{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:ui","title":"Pagination","description":"Page navigation with previous/next links and ellipsis gaps.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json"],"files":[{"path":"registry/dashral/pagination.tsx","type":"registry:file","target":"components/dashral/pagination.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { buttonVariants, type Button } from \"./button\"\n\ntype PaginationProps = Omit<React.ComponentProps<\"nav\">, \"children\"> & {\n  /** The current page, from 1. */\n  page: number\n  /** How many pages there are. */\n  total: number\n  /** Called with the page to go to. Leave it out and give hrefPattern for plain links. */\n  onPageChange?: (page: number) => void\n  /**\n   * Each page's URL, with {page} where the number goes: \"?page={page}\". Links work without JavaScript (and search\n   * engines follow them); being a string, it can come from a server component.\n   */\n  hrefPattern?: string\n  /** Your router's link for hrefPattern, e.g. Next.js Link (from a client component); a plain <a> by default. */\n  linkAs?: React.ElementType\n  /** Pages shown on each side of the current one before an ellipsis. */\n  siblings?: number\n  /** The Previous and Next buttons. */\n  showPrevNext?: boolean\n  /** Arrows around \"Page 2 of 7\", for a narrow card. Phones always get this; compact keeps it on wider screens. */\n  compact?: boolean\n  /**\n   * The look: quiet links with an outlined current page (default), every page in its own box with the current one\n   * filled (outline), or the pages in one pill with a sliding highlight (pill).\n   */\n  variant?: PaginationVariant\n}\n\ntype PaginationVariant = \"default\" | \"outline\" | \"pill\"\n\n/** The variant, for the parts (content, links and the sliding thumb) without passing it through each. */\nconst VariantContext = React.createContext<PaginationVariant>(\"default\")\n\n/**\n * Page links in one tag: Previous, the page numbers with ellipses for the gaps, and Next. Buttons with onPageChange,\n * links with hrefPattern. The current page's outline slides to the page you open.\n */\nfunction Pagination({\n  page,\n  total,\n  onPageChange,\n  hrefPattern,\n  linkAs = \"a\",\n  siblings = 1,\n  showPrevNext = true,\n  compact = false,\n  variant = \"default\",\n  className,\n  ...props\n}: PaginationProps) {\n  const { t, locale } = useLocale()\n  const number = new Intl.NumberFormat(locale)\n  const go = (to: number) => {\n    const target = Math.min(total, Math.max(1, to))\n    return hrefPattern\n      ? { as: linkAs, href: hrefPattern.replaceAll(\"{page}\", String(target)) }\n      : { as: \"button\" as const, type: \"button\" as const, onClick: () => onPageChange?.(target) }\n  }\n  // Arrows only when compact: the words \"Previous\" and \"Next\" don't fit.\n  const arrow = compact ? \"size-9 px-0 sm:px-0 [&>span]:hidden\" : undefined\n  const numbers = compact ? \"hidden\" : \"max-sm:hidden\"\n  return (\n    <VariantContext.Provider value={variant}>\n      <nav aria-label={t(\"pagination\")} data-slot=\"pagination\" data-variant={variant} className={cn(\"mx-auto flex w-full justify-center\", className)} {...props}>\n        <PaginationContent>\n          {showPrevNext && (\n            <li>\n              <PaginationPrevious {...go(page - 1)} disabled={page <= 1} className={arrow} />\n            </li>\n          )}\n          {getPageRange(page, total, siblings).map((item, i) =>\n            item === \"ellipsis\" ? (\n              <li key={`gap-${i}`} className={numbers}>\n                <PaginationEllipsis />\n              </li>\n            ) : (\n              <li key={item} className={numbers}>\n                <PaginationLink {...go(item)} isActive={item === page} aria-label={t(\"page\", { page: item })}>\n                  {number.format(item)}\n                </PaginationLink>\n              </li>\n            )\n          )}\n          {/* Where you are, in words, when there's no room for the numbers. */}\n          <li className={cn(\"px-2 text-sm text-muted-foreground tabular-nums\", !compact && \"sm:hidden\")}>\n            {t(\"pageOf\", { page, total })}\n          </li>\n          {showPrevNext && (\n            <li>\n              <PaginationNext {...go(page + 1)} disabled={page >= total} className={arrow} />\n            </li>\n          )}\n        </PaginationContent>\n      </nav>\n    </VariantContext.Provider>\n  )\n}\n\n/** The row of links. The current page's outline slides to the page you open (it just moves with reduced motion). */\nfunction PaginationContent({ className, children, ...props }: React.ComponentProps<\"ul\">) {\n  const variant = React.useContext(VariantContext)\n  const ref = React.useRef<HTMLUListElement>(null)\n  const [thumb, setThumb] = React.useState<{ x: number; y: number; width: number; height: number } | null>(null)\n\n  // Measure the current page's link; again when the row resizes or the page changes.\n  React.useLayoutEffect(() => {\n    const list = ref.current\n    if (!list) return\n    const measure = () => {\n      const link = list.querySelector<HTMLElement>(\"[data-slot=pagination-link][data-active=true]\")\n      setThumb(link ? { x: link.offsetLeft, y: link.offsetTop, width: link.offsetWidth, height: link.offsetHeight } : null)\n    }\n    measure()\n    const resize = new ResizeObserver(measure)\n    resize.observe(list)\n    const changes = new MutationObserver(measure)\n    changes.observe(list, { attributes: true, subtree: true, attributeFilter: [\"data-active\"], childList: true })\n    return () => {\n      resize.disconnect()\n      changes.disconnect()\n    }\n  }, [])\n\n  return (\n    <ul\n      ref={ref}\n      data-slot=\"pagination-content\"\n      data-measured={thumb ? \"\" : undefined}\n      className={cn(\"relative flex flex-row items-center gap-1\", variant === \"pill\" && \"gap-0.5 rounded-full border bg-muted p-1 dark:bg-muted/60\", className)}\n      {...props}\n    >\n      {thumb && (\n        <li\n          aria-hidden\n          data-slot=\"pagination-thumb\"\n          className={cn(\n            \"pointer-events-none absolute top-0 left-0 transition-[translate,width,height] duration-(--duration-considered) ease-standard motion-reduce:transition-none\",\n            {\n              default: \"rounded-md border bg-card shadow-xs dark:border-input dark:bg-input/30\",\n              outline: \"rounded-md bg-primary shadow-xs\",\n              pill: \"rounded-full bg-background shadow-sm ring-1 ring-black/5 dark:bg-accent dark:ring-white/10\",\n            }[variant]\n          )}\n          style={{ translate: `${thumb.x}px ${thumb.y}px`, width: thumb.width, height: thumb.height }}\n        />\n      )}\n      {children}\n    </ul>\n  )\n}\n\ntype PaginationLinkProps = {\n  isActive?: boolean\n  /** A button, a plain link, or your router's link. */\n  as?: React.ElementType\n  disabled?: boolean\n} & Pick<React.ComponentProps<typeof Button>, \"size\"> &\n  Omit<React.ComponentProps<\"a\">, \"type\"> & { type?: \"button\" }\n\n/**\n * A page link. The current page's outline is PaginationContent's sliding thumb (before it's measured, the link draws\n * its own). A number that changes — the window of pages moving — arrives with a soft blur.\n */\nfunction PaginationLink({ className, isActive, size = \"icon\", as: Comp = \"a\", disabled, children, ...props }: PaginationLinkProps) {\n  const variant = React.useContext(VariantContext)\n  const text = typeof children === \"number\" || typeof children === \"string\" ? String(children) : null\n  return (\n    <Comp\n      aria-current={isActive ? \"page\" : undefined}\n      aria-disabled={disabled || undefined}\n      disabled={Comp === \"button\" ? disabled : undefined}\n      data-slot=\"pagination-link\"\n      data-active={isActive || undefined}\n      className={cn(\n        buttonVariants({ variant: \"ghost\", size }),\n        \"relative tabular-nums aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n        variant === \"outline\" && \"border border-input bg-card shadow-xs hover:bg-accent dark:bg-input/30\",\n        variant === \"pill\" && \"rounded-full hover:bg-background/60 dark:hover:bg-accent/60\",\n        isActive &&\n          {\n            default:\n              \"hover:bg-transparent not-in-data-measured:border not-in-data-measured:bg-card not-in-data-measured:shadow-xs dark:not-in-data-measured:border-input dark:not-in-data-measured:bg-input/30\",\n            outline:\n              \"border-transparent bg-transparent text-primary-foreground shadow-none hover:bg-transparent hover:text-primary-foreground not-in-data-measured:bg-primary dark:bg-transparent\",\n            pill: \"hover:bg-transparent not-in-data-measured:bg-background not-in-data-measured:shadow-xs dark:not-in-data-measured:bg-accent\",\n          }[variant],\n        className\n      )}\n      {...props}\n    >\n      {text === null ? (\n        children\n      ) : (\n        <span\n          key={text}\n          className=\"animate-in fade-in-0 zoom-in-90 blur-in-[2px] duration-(--duration-standard) ease-spring motion-reduce:animate-none\"\n        >\n          {text}\n        </span>\n      )}\n    </Comp>\n  )\n}\n\nfunction PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {\n  const { t } = useLocale()\n  return (\n    <PaginationLink\n      aria-label={t(\"previousPage\")}\n      size=\"default\"\n      className={cn(\"gap-1 px-2.5 sm:ps-2.5\", className)}\n      {...props}\n    >\n      <ChevronLeftIcon className=\"rtl:-scale-x-100\" />\n      <span className=\"hidden sm:block\">{t(\"previous\")}</span>\n    </PaginationLink>\n  )\n}\n\nfunction PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {\n  const { t } = useLocale()\n  return (\n    <PaginationLink\n      aria-label={t(\"nextPage\")}\n      size=\"default\"\n      className={cn(\"gap-1 px-2.5 sm:pe-2.5\", className)}\n      {...props}\n    >\n      <span className=\"hidden sm:block\">{t(\"next\")}</span>\n      <ChevronRightIcon className=\"rtl:-scale-x-100\" />\n    </PaginationLink>\n  )\n}\n\nfunction PaginationEllipsis({ className, ...props }: React.ComponentProps<\"span\">) {\n  const { t } = useLocale()\n  return (\n    <span\n      data-slot=\"pagination-ellipsis\"\n      className={cn(\"flex size-9 items-center justify-center\", className)}\n      {...props}\n    >\n      <MoreHorizontalIcon className=\"size-4\" />\n      <span className=\"sr-only\">{t(\"morePages\")}</span>\n    </span>\n  )\n}\n\n/**\n * Page numbers to render, with \"ellipsis\" gaps — e.g. getPageRange(6, 20) → [1, \"ellipsis\", 5, 6, 7, \"ellipsis\", 20].\n */\nfunction getPageRange(current: number, total: number, siblings = 1): (number | \"ellipsis\")[] {\n  const range = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i)\n  if (total <= 5 + siblings * 2) return range(1, total)\n\n  const left = Math.max(current - siblings, 2)\n  const right = Math.min(current + siblings, total - 1)\n  return [\n    1,\n    ...(left > 2 ? ([\"ellipsis\"] as const) : []),\n    ...range(left, right),\n    ...(right < total - 1 ? ([\"ellipsis\"] as const) : []),\n    total,\n  ]\n}\n\nexport { Pagination, getPageRange, type PaginationProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}