{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "paginator",
  "title": "Paginator",
  "description": "A page control from a total and a current page, with the page-window logic.",
  "dependencies": [
    "lucide-react@^1.33.0"
  ],
  "registryDependencies": [
    "https://guillermo-rebolledo.github.io/materialcn/r/button.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/materialcn-theme.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/pagination.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/select.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/utils.json"
  ],
  "files": [
    {
      "path": "src/components/ui/paginator.tsx",
      "content": "import { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"./button\"\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n} from \"./pagination\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"./select\"\nimport { ELIDED, buildRange } from \"./paginator-utils\"\nimport type { PaginatorProps } from \"./paginator.types\"\n\n/**\n * Previous and next differ only in direction, so they are one control rather\n * than a mirrored pair — the labels are the part that has to stay in step, and\n * two copies is how they stop being.\n *\n * The word is hidden below `medium`: on a phone the chevron alone is the\n * affordance, and the accessible name carries the rest.\n */\nfunction Step({\n  direction,\n  enabled,\n  label,\n  onActivate,\n}: {\n  direction: \"previous\" | \"next\"\n  enabled: boolean\n  label: string\n  onActivate: () => void\n}) {\n  const previous = direction === \"previous\"\n  const Chevron = previous ? ChevronLeftIcon : ChevronRightIcon\n  const word = previous ? \"Previous\" : \"Next\"\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"sm\"\n      aria-label={label}\n      disabled={!enabled}\n      onClick={onActivate}\n    >\n      {previous && <Chevron data-icon=\"inline-start\" />}\n      <span className=\"m3-medium:inline hidden\">{word}</span>\n      {!previous && <Chevron data-icon=\"inline-end\" />}\n    </Button>\n  )\n}\n\n/**\n * Navigation across a range of pages.\n *\n * The current page stays a `button` rather than becoming a `span`: it has to be\n * non-interactive *and* it has to announce \"current page\" when a keyboard user\n * arrives on it, and a span would deliver the first at the cost of the second.\n * `aria-disabled` with no handler is what gives both.\n *\n * Every control carries a full label — \"Go to page 4 of 20\", not \"4\". A bare\n * number read out of context is not navigation, it is a number.\n */\nfunction Paginator({\n  className,\n  hasNextPage,\n  onPageChange,\n  onPageSizeChange,\n  page,\n  pageSize,\n  pageSizeOptions,\n  siblingCount = 1,\n  totalPages,\n  ...props\n}: PaginatorProps) {\n  const indeterminate = totalPages === undefined\n  const of = indeterminate ? \"\" : ` of ${totalPages}`\n  const canGoBack = page > 1\n  const canGoForward = indeterminate ? hasNextPage : page < totalPages\n\n  return (\n    <Pagination\n      className={cn(\"flex-wrap items-center gap-m3-md\", className)}\n      {...props}\n    >\n      <PaginationContent>\n        <PaginationItem>\n          <Step\n            direction=\"previous\"\n            label={`Go to previous page${of}`}\n            enabled={canGoBack}\n            onActivate={() => onPageChange(page - 1)}\n          />\n        </PaginationItem>\n\n        {!indeterminate &&\n          buildRange(page, totalPages, siblingCount).map((slot, index) =>\n            slot === ELIDED ? (\n              <PaginationItem key={`elided-${index}`}>\n                <PaginationEllipsis />\n              </PaginationItem>\n            ) : (\n              <PaginationItem key={slot}>\n                <Button\n                  variant={slot === page ? \"tonal\" : \"ghost\"}\n                  size=\"sm\"\n                  className=\"min-w-10 px-m3-sm\"\n                  aria-current={slot === page ? \"page\" : undefined}\n                  aria-disabled={slot === page || undefined}\n                  aria-label={\n                    slot === page\n                      ? `Page ${slot}${of}, current page`\n                      : `Go to page ${slot}${of}`\n                  }\n                  onClick={\n                    slot === page ? undefined : () => onPageChange(slot)\n                  }\n                >\n                  {slot}\n                </Button>\n              </PaginationItem>\n            )\n          )}\n\n        {indeterminate && (\n          <PaginationItem>\n            <span\n              data-slot=\"pagination-position\"\n              className=\"text-m3-body-md text-m3-on-surface-variant px-m3-sm\"\n            >\n              Page {page}\n            </span>\n          </PaginationItem>\n        )}\n\n        <PaginationItem>\n          <Step\n            direction=\"next\"\n            label={`Go to next page${of}`}\n            enabled={canGoForward}\n            onActivate={() => onPageChange(page + 1)}\n          />\n        </PaginationItem>\n      </PaginationContent>\n\n      {pageSizeOptions && pageSize !== undefined && (\n        <div className=\"flex items-center gap-m3-sm\">\n          <span\n            id=\"paginator-page-size\"\n            className=\"text-m3-body-md text-m3-on-surface-variant\"\n          >\n            Per page\n          </span>\n          <Select\n            value={String(pageSize)}\n            onValueChange={(value) => onPageSizeChange?.(Number(value))}\n          >\n            <SelectTrigger\n              aria-labelledby=\"paginator-page-size\"\n              className=\"w-24\"\n            >\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {pageSizeOptions.map((option) => (\n                <SelectItem key={option} value={String(option)}>\n                  {option}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      )}\n    </Pagination>\n  )\n}\n\nexport { Paginator }\nexport type { PaginatorProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/paginator-utils.ts",
      "content": "/**\n * Lives beside the component rather than inside it, for the reason\n * `calendar-utils.ts` does: a module is a React Fast Refresh boundary only when\n * every export is a component or keeps a stable identity across\n * re-evaluations. A plain function exported from the component file is a fresh\n * object each time the module runs, which demotes every edit to a full reload.\n */\n\n/** An elided run of pages, rather than a page. */\nexport const ELIDED = \"elided\" as const\n\nexport type PageSlot = number | typeof ELIDED\n\n/**\n * The slots to draw: page numbers, with `ELIDED` where a run is hidden.\n *\n * The length is always `siblingCount * 2 + 5` once the total exceeds it —\n * first, last, current, the siblings either side, and two elisions — so a range\n * of 9 pages and a range of 9,000 occupy the same width and no control moves\n * under the pointer as the user steps through.\n */\nexport function buildRange(\n  page: number,\n  totalPages: number,\n  siblingCount: number,\n): PageSlot[] {\n  const slots = siblingCount * 2 + 5\n  if (totalPages <= slots)\n    return Array.from({ length: totalPages }, (_, i) => i + 1)\n\n  const left = Math.max(page - siblingCount, 1)\n  const right = Math.min(page + siblingCount, totalPages)\n  const elideLeft = left > 2\n  const elideRight = right < totalPages - 1\n\n  // Both elisions present is the steady state. When the window reaches an end,\n  // the run that would have been elided is shown instead, which keeps the slot\n  // count identical rather than letting the row shrink at the extremes.\n  const run = slots - 2\n  if (!elideLeft && elideRight) {\n    return [...Array.from({ length: run }, (_, i) => i + 1), ELIDED, totalPages]\n  }\n  if (elideLeft && !elideRight) {\n    return [\n      1,\n      ELIDED,\n      ...Array.from({ length: run }, (_, i) => totalPages - run + 1 + i),\n    ]\n  }\n  return [\n    1,\n    ELIDED,\n    ...Array.from({ length: right - left + 1 }, (_, i) => left + i),\n    ELIDED,\n    totalPages,\n  ]\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/paginator.types.ts",
      "content": "import type * as React from \"react\"\n\ntype Common = Omit<React.ComponentProps<\"nav\">, \"onChange\"> & {\n  /** The current page, 1-based. */\n  page: number\n  onPageChange: (page: number) => void\n  /**\n   * How many pages to show either side of the current one. The rendered width\n   * is `siblingCount * 2 + 5` slots whatever the total, so a range of 9 pages\n   * and a range of 9,000 occupy the same space and nothing shifts as the user\n   * moves through them.\n   */\n  siblingCount?: number\n  /** Offer a choice of how many rows appear on a page. */\n  pageSize?: number\n  pageSizeOptions?: number[]\n  onPageSizeChange?: (size: number) => void\n}\n\n/** The total is known, so the range can be drawn. */\ntype Determinate = Common & {\n  totalPages: number\n  hasNextPage?: never\n}\n\n/**\n * The total is not known ahead of time — a cursor-paged API, a search that\n * counts lazily. There is no range to draw, so it falls back to previous and\n * next, and the caller says whether a next page exists.\n */\ntype Indeterminate = Common & {\n  totalPages?: undefined\n  hasNextPage: boolean\n}\n\nexport type PaginatorProps = Determinate | Indeterminate\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}