{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-pagination",
  "type": "registry:ui",
  "title": "Data Pagination",
  "description": "Total-driven pagination control — prev/next, numbered pages with ellipsis, an optional page-size selector and an 'X–Y of N' summary. One component wires a whole table footer.",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/data-pagination.tsx",
      "type": "registry:ui",
      "content": "// @toreview\n\"use client\"\n\n/**\n * @fileoverview DataPagination — a total-driven, batteries-included pagination\n * control. A fully additive sibling to the `Pagination` compound (NOT a rename\n * or overload of it): give it `total` + `pageSize` (or a precomputed\n * `pageCount`) and it renders prev/next controls, numbered page links with\n * ellipsis truncation, an optional page-size `<select>`, and an optional\n * \"X–Y of N\" summary in a single prop call.\n *\n * It is built ENTIRELY on existing primitives — it composes the `Pagination*`\n * parts and drives them with the {@link usePagination} hook (zero new range /\n * ellipsis logic, zero new runtime deps). Use the `Pagination` compound when you\n * need full hand-wired control; reach for `DataPagination` for the common\n * total-driven case (e.g. a table footer).\n *\n * @module packages/ui/components/ui/data-pagination\n * @layer core\n *\n * @component\n * @example\n * import { DataPagination } from '@saasflare/ui';\n * <DataPagination total={248} pageSize={20} showSummary onPageChange={fetchPage} />\n */\n\nimport * as React from \"react\"\n\nimport { cn } from \"@saasflare/ui\"\nimport {\n  useSaasflareProps,\n  type SaasflareComponentProps,\n} from \"@saasflare/ui\"\nimport {\n  usePagination,\n  paginationSummary,\n  type PaginationSummaryRange,\n} from \"@saasflare/ui\"\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationItem,\n  PaginationLink,\n  PaginationPrevious,\n  PaginationNext,\n  PaginationEllipsis,\n} from \"@saasflare/ui\"\nimport { NativeSelect, NativeSelectOption } from \"@saasflare/ui\"\n\n// React-style dev warnings: the consumer's bundler replaces process.env.NODE_ENV.\ndeclare const process: { readonly env: { readonly NODE_ENV?: string } }\n\n/**\n * Where the page-size selector + range summary sit relative to the page numbers.\n *\n * - `\"split\"` — summary on the leading edge, numbers centred, size selector on\n *   the trailing edge (the canonical table-footer layout).\n * - `\"center\"` — everything grouped and centred.\n * - `\"end\"` — everything grouped on the trailing edge.\n *\n * @example\n * <DataPagination total={248} pageSize={20} layout=\"end\" />\n */\nexport type DataPaginationLayout = \"split\" | \"center\" | \"end\"\n\n/** Size token for the number links + controls. */\ntype DataPaginationSize = \"xs\" | \"sm\" | \"md\" | \"lg\"\n\n/** Maps the public {@link DataPaginationSize} onto the square icon size used for number links. */\nconst NUMBER_SIZE: Record<DataPaginationSize, \"icon-xs\" | \"icon-sm\" | \"icon\" | \"icon-lg\"> = {\n  xs: \"icon-xs\",\n  sm: \"icon-sm\",\n  md: \"icon\",\n  lg: \"icon-lg\",\n} as const\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const\n\n/** i18n / label overrides for {@link DataPagination}. */\nexport interface DataPaginationLabels {\n  /** Accessible label for the \"previous\" control. Default: `\"Previous\"`. */\n  previous?: string\n  /** Accessible label for the \"next\" control. Default: `\"Next\"`. */\n  next?: string\n  /** Builder for the summary string. Receives `{ from, to, total }`. Default: `({ from, to, total }) => `${from}–${to} of ${total}``. */\n  summary?: (range: PaginationSummaryRange) => string\n  /** Builder for the per-number aria-label. Default: `(p) => `Go to page ${p}``. */\n  page?: (page: number) => string\n  /** Label/prefix for the page-size select. Default: `\"Rows per page\"`. */\n  pageSize?: string\n}\n\n/**\n * Props for {@link DataPagination} — a total-driven, batteries-included\n * pagination control. Mutually-exclusive total inputs: provide EITHER `total`\n * (item count, with `pageSize`) OR `pageCount` (precomputed page count).\n * `pageCount` wins if both are given.\n */\nexport interface DataPaginationProps\n  extends Omit<React.ComponentProps<\"nav\">, \"onChange\" | keyof SaasflareComponentProps>,\n    SaasflareComponentProps {\n  /** Total number of ITEMS across all pages. Used with `pageSize` to derive page count + the \"X–Y of N\" summary. Ignored if `pageCount` is set. */\n  total?: number\n  /** Precomputed total number of PAGES. Use when the backend returns a page count directly (no item total). Takes precedence over `total`/`pageSize`. */\n  pageCount?: number\n  /** Items per page. Required when using `total`; drives summary math + page-count derivation. Default: `10`. */\n  pageSize?: number\n  /** Controlled active page (1-indexed). When provided, the component is controlled and you MUST update it from `onPageChange`. */\n  page?: number\n  /** Initial page for uncontrolled mode. Default: `1`. Ignored when `page` is provided. */\n  defaultPage?: number\n  /** Fired with the next 1-indexed page when the user navigates. Always clamped to `[1, derivedPageCount]`. */\n  onPageChange?: (page: number) => void\n  /** Sibling pages shown each side of the active page (forwarded to {@link usePagination}). Default: `1`. */\n  siblings?: number\n  /** Boundary pages pinned at each end (forwarded to {@link usePagination}). Default: `1`. */\n  boundaries?: number\n  /** Show the prev/next caret controls. Default: `true`. */\n  showControls?: boolean\n  /** Show numbered page links + ellipsis. When `false`, renders only controls + summary (compact mode). Default: `true`. */\n  showNumbers?: boolean\n  /** Render the \"X–Y of N\" summary text. Auto-disabled (with a dev warning) when neither `total` nor an item count is known. Default: `false`. */\n  showSummary?: boolean\n  /** Render a page-size `<select>` built on {@link NativeSelect}. Requires `onPageSizeChange`. Default: `false`. */\n  showPageSize?: boolean\n  /** Selectable page sizes for the size selector. Default: `[10, 20, 50, 100]`. */\n  pageSizeOptions?: readonly number[]\n  /**\n   * Fired with the chosen page size. The component does NOT internally manage\n   * `pageSize` (controlled-only) — keeps a single source of truth with the\n   * consumer. Changing `pageSize` does NOT auto-correct the active page when it\n   * exceeds the new page count; reset the page yourself (e.g. `setPage(1)`).\n   */\n  onPageSizeChange?: (pageSize: number) => void\n  /** Visual placement of summary/size-selector vs the page numbers. Default: `\"split\"` (summary left, numbers center, size right). */\n  layout?: DataPaginationLayout\n  /** Button-variant size token for prev/next + number links (forwarded to PaginationLink). Default: `\"md\"` (icon-square numbers + `md` controls). */\n  size?: DataPaginationSize\n  /** Accessible label for the nav landmark. Default: `\"pagination\"`. */\n  \"aria-label\"?: string\n  /** i18n / label overrides. */\n  labels?: DataPaginationLabels\n}\n\n/** Default summary string builder: `\"1–20 of 248\"`. */\nfunction defaultSummary(range: PaginationSummaryRange): string {\n  return `${range.from}–${range.to} of ${range.total}`\n}\n\n/** Default per-number aria-label builder. */\nfunction defaultPageLabel(page: number): string {\n  return `Go to page ${page}`\n}\n\n/**\n * Total-driven pagination control. Composes the existing `Pagination` compound\n * + the {@link usePagination} hook into a single semantic component with an\n * optional page-size selector and \"X–Y of N\" summary. Controlled or\n * uncontrolled. Inherits the `surface`/`radius`/`animated`/`iconWeight` axes\n * from {@link SaasflareShell} and forwards them into the compound parts so the\n * whole control is visually coherent.\n *\n * For full, hand-wired control over each link, use the lower-level `Pagination`\n * compound directly.\n *\n * @component\n * @layer core\n *\n * @example\n * // Uncontrolled, total-driven\n * <DataPagination total={248} pageSize={20} showSummary onPageChange={fetchPage} />\n *\n * @example\n * // Controlled with page-size selector (table footer)\n * <DataPagination\n *   total={count} pageSize={size} page={page}\n *   showSummary showPageSize pageSizeOptions={[10, 25, 50]}\n *   onPageChange={setPage} onPageSizeChange={(s) => { setSize(s); setPage(1) }}\n * />\n *\n * @example\n * // Compact: controls + summary only, no numbers\n * <DataPagination pageCount={9} page={p} showNumbers={false} showSummary onPageChange={setP} />\n */\nexport function DataPagination({\n  total,\n  pageCount,\n  pageSize = 10,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  siblings = 1,\n  boundaries = 1,\n  showControls = true,\n  showNumbers = true,\n  showSummary = false,\n  showPageSize = false,\n  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n  onPageSizeChange,\n  layout = \"split\",\n  size = \"md\",\n  className,\n  surface,\n  radius,\n  animated,\n  iconWeight,\n  labels,\n  \"aria-label\": ariaLabel = \"pagination\",\n  ...props\n}: DataPaginationProps): React.JSX.Element {\n  const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n  const sizeSelectId = React.useId()\n\n  // Whether we actually know an item total (needed for the summary). Only the\n  // `total` input carries item-level information; `pageCount` is page-level only.\n  const hasItemTotal = pageCount == null && total != null\n\n  // Derived page count (pure): pageCount wins; otherwise ceil(items / pageSize).\n  const safePageSize = Math.max(1, Math.floor(pageSize))\n  const derivedPageCount =\n    pageCount != null\n      ? Math.max(1, Math.floor(pageCount))\n      : Math.max(1, Math.ceil(Math.max(0, total ?? 0) / safePageSize))\n\n  const { activePage, range, setPage, next, previous } = usePagination({\n    total: derivedPageCount,\n    page,\n    initialPage: defaultPage,\n    siblings,\n    boundaries,\n    onChange: onPageChange,\n  })\n\n  const isFirst = activePage <= 1\n  const isLast = activePage >= derivedPageCount\n\n  // ---- dev-only warnings (additive, no runtime cost in production) ----------\n  if (process.env.NODE_ENV !== \"production\") {\n    if (showSummary && !hasItemTotal) {\n      warnOnce(\n        \"data-pagination-summary-no-total\",\n        \"<DataPagination showSummary /> needs an item `total` to render the \\\"X–Y of N\\\" range. \" +\n          \"Only `pageCount` was provided (page-level), so the summary is hidden. \" +\n          \"Pass `total` + `pageSize` to enable it.\",\n      )\n    }\n    if (showPageSize && !onPageSizeChange) {\n      warnOnce(\n        \"data-pagination-pagesize-no-handler\",\n        \"<DataPagination showPageSize /> requires `onPageSizeChange` — `pageSize` is controlled-only, \" +\n          \"so the size selector is inert without it.\",\n      )\n    }\n  }\n\n  const resolvedShowSummary = showSummary && hasItemTotal\n  const summaryRange = resolvedShowSummary\n    ? paginationSummary(activePage, safePageSize, total ?? 0)\n    : null\n  const summaryText = summaryRange\n    ? (labels?.summary ?? defaultSummary)(summaryRange)\n    : null\n\n  const pageLabel = labels?.page ?? defaultPageLabel\n  const previousLabel = labels?.previous ?? \"Previous\"\n  const nextLabel = labels?.next ?? \"Next\"\n  const pageSizeLabel = labels?.pageSize ?? \"Rows per page\"\n\n  const handleNumberClick = (event: React.MouseEvent<HTMLAnchorElement>, n: number): void => {\n    event.preventDefault()\n    setPage(n)\n  }\n\n  const handlePrevious = (event: React.MouseEvent<HTMLAnchorElement>): void => {\n    event.preventDefault()\n    if (!isFirst) previous()\n  }\n\n  const handleNext = (event: React.MouseEvent<HTMLAnchorElement>): void => {\n    event.preventDefault()\n    if (!isLast) next()\n  }\n\n  const handlePageSizeChange = (event: React.ChangeEvent<HTMLSelectElement>): void => {\n    onPageSizeChange?.(Number(event.target.value))\n  }\n\n  const numberSize = NUMBER_SIZE[size]\n  const disabledLinkClass = \"pointer-events-none opacity-50\"\n\n  // ---- the inner Pagination <nav> (owns role=navigation + the axis attrs) ---\n  const nav = (\n    <Pagination\n      className={cn(\"mx-0 w-auto\", className)}\n      aria-label={ariaLabel}\n      surface={sf.surface}\n      radius={sf.radius}\n      animated={sf.animated}\n      iconWeight={sf.iconWeight}\n      {...props}\n    >\n      <PaginationContent>\n        {showControls ? (\n          <PaginationItem>\n            <PaginationPrevious\n              href=\"#\"\n              aria-label={previousLabel}\n              aria-disabled={isFirst || undefined}\n              data-disabled={isFirst || undefined}\n              tabIndex={isFirst ? -1 : undefined}\n              className={cn(isFirst && disabledLinkClass)}\n              surface={sf.surface}\n              radius={sf.radius}\n              iconWeight={sf.iconWeight}\n              onClick={handlePrevious}\n            >\n              <span className=\"hidden sm:block\">{previousLabel}</span>\n            </PaginationPrevious>\n          </PaginationItem>\n        ) : null}\n\n        {showNumbers\n          ? range.map((item, index) =>\n              item === \"dots\" ? (\n                <PaginationItem key={`dots-${index}`}>\n                  <PaginationEllipsis />\n                </PaginationItem>\n              ) : (\n                <PaginationItem key={item}>\n                  <PaginationLink\n                    href=\"#\"\n                    size={numberSize}\n                    isActive={item === activePage}\n                    aria-label={pageLabel(item)}\n                    surface={sf.surface}\n                    radius={sf.radius}\n                    iconWeight={sf.iconWeight}\n                    onClick={(event) => handleNumberClick(event, item)}\n                  >\n                    {item}\n                  </PaginationLink>\n                </PaginationItem>\n              ),\n            )\n          : null}\n\n        {showControls ? (\n          <PaginationItem>\n            <PaginationNext\n              href=\"#\"\n              aria-label={nextLabel}\n              aria-disabled={isLast || undefined}\n              data-disabled={isLast || undefined}\n              tabIndex={isLast ? -1 : undefined}\n              className={cn(isLast && disabledLinkClass)}\n              surface={sf.surface}\n              radius={sf.radius}\n              iconWeight={sf.iconWeight}\n              onClick={handleNext}\n            >\n              <span className=\"hidden sm:block\">{nextLabel}</span>\n            </PaginationNext>\n          </PaginationItem>\n        ) : null}\n      </PaginationContent>\n    </Pagination>\n  )\n\n  // ---- summary + size selector (presentational siblings, no nav semantics) --\n  const summaryNode = summaryText ? (\n    <p\n      data-slot=\"data-pagination-summary\"\n      aria-live=\"polite\"\n      className=\"text-sm text-muted-foreground\"\n    >\n      {summaryText}\n    </p>\n  ) : null\n\n  const sizeNode = showPageSize ? (\n    <div data-slot=\"data-pagination-page-size\" className=\"flex items-center gap-2\">\n      <label htmlFor={sizeSelectId} className=\"text-sm whitespace-nowrap text-muted-foreground\">\n        {pageSizeLabel}\n      </label>\n      <NativeSelect\n        id={sizeSelectId}\n        size=\"sm\"\n        value={String(safePageSize)}\n        disabled={!onPageSizeChange}\n        aria-label={pageSizeLabel}\n        surface={sf.surface}\n        radius={sf.radius}\n        iconWeight={sf.iconWeight}\n        onChange={handlePageSizeChange}\n      >\n        {pageSizeOptions.map((option) => (\n          <NativeSelectOption key={option} value={String(option)}>\n            {option}\n          </NativeSelectOption>\n        ))}\n      </NativeSelect>\n    </div>\n  ) : null\n\n  // If there are no surrounding pieces, the nav stands alone (still semantic).\n  if (!summaryNode && !sizeNode) return nav\n\n  const layoutClass =\n    layout === \"split\"\n      ? \"justify-between\"\n      : layout === \"end\"\n        ? \"justify-end\"\n        : \"justify-center\"\n\n  return (\n    <div\n      data-slot=\"data-pagination\"\n      data-layout={layout}\n      className={cn(\"flex w-full flex-wrap items-center gap-4\", layoutClass)}\n    >\n      {layout === \"split\" ? (\n        <>\n          {summaryNode ?? <span aria-hidden className=\"hidden sm:block\" />}\n          {nav}\n          {sizeNode ?? <span aria-hidden className=\"hidden sm:block\" />}\n        </>\n      ) : (\n        <>\n          {summaryNode}\n          {nav}\n          {sizeNode}\n        </>\n      )}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// dev-only one-shot warning registry (module-scoped; stripped to a no-op in\n// production by the NODE_ENV guard at the call sites above)\n// ---------------------------------------------------------------------------\nconst warnedKeys = new Set<string>()\n\n/** Logs `message` to `console.warn` at most once per `key` (dev only). */\nfunction warnOnce(key: string, message: string): void {\n  if (warnedKeys.has(key)) return\n  warnedKeys.add(key)\n  // eslint-disable-next-line no-console\n  console.warn(message)\n}\n",
      "target": "components/ui/data-pagination.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/data-pagination.json"
  }
}
