{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:ui","title":"Pagination","description":"The window moves, the row does not.","dependencies":["motion"],"categories":["navigation"],"docs":"https://www.interior.dev/docs/pagination","files":[{"path":"registry/interior/pagination.tsx","type":"registry:ui","target":"components/interior/pagination.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst ROLL = { duration: 0.18, ease: EASE } as const;\nconst STILL = { duration: 0 } as const;\n\nconst slotFor = (digits: number) => Math.max(32, 18 + digits * 8);\nconst GAP = 4;\n\nconst range = (from: number, to: number) =>\n  Array.from({ length: to - from + 1 }, (_, i) => from + i);\n\nconst arrow = (can: boolean) =>\n  `flex h-8 w-8 shrink-0 items-center justify-center rounded-[9px] outline-none transition-colors duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n    can\n      ? \"text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-stone-400 dark:hover:bg-white/[0.06] dark:hover:text-stone-200\"\n      : \"text-stone-300 dark:text-white/20\"\n  }`;\n\nexport type PaginationItem = number | \"gap-l\" | \"gap-r\";\n\nexport function paginate(\n  page: number,\n  count: number,\n  siblings: number,\n  boundaries: number,\n): PaginationItem[] {\n  const total = 2 * boundaries + 2 * siblings + 3;\n  if (count <= total) return range(1, count);\n\n  const nearStart = page < boundaries + siblings + 2;\n  const nearEnd = page > count - boundaries - siblings - 1;\n\n  if (nearStart) {\n    return [\n      ...range(1, 2 * siblings + boundaries + 2),\n      \"gap-r\",\n      ...range(count - boundaries + 1, count),\n    ];\n  }\n  if (nearEnd) {\n    return [\n      ...range(1, boundaries),\n      \"gap-l\",\n      ...range(count - 2 * siblings - boundaries - 1, count),\n    ];\n  }\n  return [\n    ...range(1, boundaries),\n    \"gap-l\",\n    ...range(page - siblings, page + siblings),\n    \"gap-r\",\n    ...range(count - boundaries + 1, count),\n  ];\n}\n\nexport type UsePaginationOptions = {\n  count: number;\n  page?: number;\n  defaultPage?: number;\n  siblings?: number;\n  boundaries?: number;\n  onPageChange?: (page: number) => void;\n};\n\nexport function usePagination({\n  count,\n  page,\n  defaultPage = 1,\n  siblings = 1,\n  boundaries = 1,\n  onPageChange,\n}: UsePaginationOptions) {\n  const clampTo = useCallback(\n    (value: number) => Math.min(Math.max(1, value), Math.max(1, count)),\n    [count],\n  );\n\n  const [internal, setInternal] = useState(() => clampTo(defaultPage));\n  const controlled = page !== undefined;\n  const current = clampTo(controlled ? page : internal);\n\n  const emit = useRef(onPageChange);\n  emit.current = onPageChange;\n\n  const previous = useRef(current);\n  const direction = current >= previous.current ? 1 : -1;\n  useEffect(() => {\n    previous.current = current;\n  }, [current]);\n\n  const goTo = useCallback(\n    (value: number) => {\n      const next = clampTo(value);\n      if (next === previous.current) return;\n      if (!controlled) setInternal(next);\n      emit.current?.(next);\n    },\n    [clampTo, controlled],\n  );\n\n  const items = paginate(current, count, siblings, boundaries);\n\n  return {\n    page: current,\n    count,\n    items,\n    direction,\n    thumbIndex: items.indexOf(current),\n    canPrev: current > 1,\n    canNext: current < count,\n    goTo,\n    prev: () => goTo(current - 1),\n    next: () => goTo(current + 1),\n  };\n}\n\nexport type PaginationProps = {\n  count: number;\n  page?: number;\n  defaultPage?: number;\n  siblings?: number;\n  boundaries?: number;\n  onPageChange?: (page: number) => void;\n  label?: string;\n  className?: string;\n};\n\nfunction Chevron({ flip = false }: { flip?: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 12 12\"\n      width=\"12\"\n      height=\"12\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className={flip ? \"-scale-x-100\" : undefined}\n    >\n      <path\n        d=\"M4.75 2.75 8 6l-3.25 3.25\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function Pagination({\n  count,\n  page,\n  defaultPage,\n  siblings,\n  boundaries,\n  onPageChange,\n  label = \"Pagination\",\n  className = \"\",\n}: PaginationProps) {\n  const pagination = usePagination({\n    count,\n    page,\n    defaultPage,\n    siblings,\n    boundaries,\n    onPageChange,\n  });\n  const { items, direction, thumbIndex, canPrev, canNext } = pagination;\n  const current = pagination.page;\n\n  const reduced = useReducedMotion();\n  const digits = String(Math.max(1, count)).length;\n  const slot = slotFor(digits);\n\n  const [spoken, setSpoken] = useState(\"\");\n  useEffect(() => {\n    const t = setTimeout(\n      () => setSpoken(`Page ${current} of ${Math.max(1, count)}`),\n      500,\n    );\n    return () => clearTimeout(t);\n  }, [current, count]);\n\n  return (\n    <nav aria-label={label} className={`inline-block ${className}`}>\n      <div className=\"flex items-center\" style={{ gap: GAP }}>\n        <button\n          type=\"button\"\n          aria-label=\"Previous page\"\n          aria-disabled={!canPrev}\n          onClick={() => canPrev && pagination.prev()}\n          className={arrow(canPrev)}\n        >\n          <Chevron flip />\n        </button>\n        <div className=\"relative\">\n          <motion.span\n            aria-hidden\n            initial={false}\n            animate={{ x: thumbIndex * (slot + GAP) }}\n            transition={reduced ? STILL : CELL}\n            style={{ width: slot }}\n            className=\"absolute inset-y-0 left-0 rounded-[9px] bg-stone-800 dark:bg-stone-100\"\n          />\n          <ol className=\"relative flex\" style={{ gap: GAP }}>\n            {items.map((item) => {\n              if (typeof item !== \"number\") {\n                return (\n                  <li\n                    key={item}\n                    aria-hidden\n                    style={{ width: slot }}\n                    className=\"flex h-8 items-center justify-center text-[12.5px] text-stone-400 dark:text-stone-500\"\n                  >\n                    &hellip;\n                  </li>\n                );\n              }\n\n              const selected = item === current;\n              return (\n                <li key={`slot-${item}`} style={{ width: slot }}>\n                  <button\n                    type=\"button\"\n                    aria-label={`Page ${item}`}\n                    aria-current={selected ? \"page\" : undefined}\n                    onClick={() => pagination.goTo(item)}\n                    className={`flex h-8 w-full items-center justify-center rounded-[9px] text-[12.5px] tabular-nums outline-none transition-colors duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n                      selected\n                        ? \"font-medium text-white dark:text-stone-900\"\n                        : \"text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-stone-400 dark:hover:bg-white/[0.06] dark:hover:text-stone-200\"\n                    }`}\n                  >\n                    <motion.span\n                      key={item}\n                      initial={\n                        reduced ? false : { opacity: 0, x: 8 * direction }\n                      }\n                      animate={{ opacity: 1, x: 0 }}\n                      transition={reduced ? STILL : ROLL}\n                    >\n                      {item}\n                    </motion.span>\n                  </button>\n                </li>\n              );\n            })}\n          </ol>\n        </div>\n        <button\n          type=\"button\"\n          aria-label=\"Next page\"\n          aria-disabled={!canNext}\n          onClick={() => canNext && pagination.next()}\n          className={arrow(canNext)}\n        >\n          <Chevron />\n        </button>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {spoken}\n      </span>\n    </nav>\n  );\n}\n"}]}