{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "pagination", "title": "Integrated Pagination", "description": "Thin item-total Pagination wrapper with mutually exclusive client and route-navigation modes, normalized controlled or uncontrolled values, compact boundary and sibling ranges, and explicit primitive-part class ownership.", "registryDependencies": [ "pagination" ], "files": [ { "path": "registry/ui/pagination.tsx", "content": "\"use client\";\n\nimport type { ClassValue } from \"clsx\";\nimport type { ComponentProps, KeyboardEvent } from \"react\";\nimport { useEffect, useState } from \"react\";\nimport {\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n PaginationNext,\n PaginationPrevious,\n Pagination as PaginationRoot,\n} from \"@/components/ui/pagination\";\nimport { cn } from \"@/lib/utils\";\n\ntype PaginationRootProps = ComponentProps;\n\ninterface PaginationSharedProps\n extends Omit<\n PaginationRootProps,\n \"children\" | \"className\" | \"dangerouslySetInnerHTML\" | \"role\"\n > {\n /** Number of pages always shown at each outer edge, capped at 100. @default 1 */\n boundaryCount?: number;\n /** Compose owns the complete generated child structure. */\n children?: never;\n /** Class override for the navigation root. */\n className?: ClassValue;\n /** Class override for PaginationContent. */\n contentClassName?: ClassValue;\n /** Raw HTML conflicts with Compose-owned descendants. */\n dangerouslySetInnerHTML?: never;\n /** The primitive slot marker is Compose-owned. */\n \"data-slot\"?: never;\n /** Disables every generated destination. @default false */\n disabled?: boolean;\n /** Class override for PaginationEllipsis. */\n ellipsisClassName?: ClassValue;\n /** Hides the complete landmark when the derived page count is one. */\n hideOnSinglePage?: boolean;\n /** Class override for every PaginationItem wrapper. */\n itemClassName?: ClassValue;\n /** Class override for numbered PaginationLink leaves only. */\n linkClassName?: ClassValue;\n /** Class override for PaginationNext. */\n nextClassName?: ClassValue;\n /** Positive number of items represented by each page. @default 10 */\n pageSize?: number;\n /** Class override for PaginationPrevious. */\n previousClassName?: ClassValue;\n /** The navigation landmark role is Compose-owned. */\n role?: never;\n /** Number of pages shown on each side of the current value, capped at 100. @default 1 */\n siblingCount?: number;\n /** Non-negative total number of items. */\n total: number;\n}\n\n/** Client-controlled pagination. The caller owns value and must not pass defaultValue or getPageHref. */\nexport interface PaginationClientControlledProps {\n /** Initial values belong only to the client-uncontrolled variant. */\n defaultValue?: never;\n /** Route hrefs belong only to the navigation variant. */\n getPageHref?: never;\n /** Receives normalized user-activated values; prop changes never emit. */\n onValueChange: (value: number) => void;\n /** Caller-owned current page. */\n value: number;\n}\n\n/** Client-uncontrolled pagination. Compose owns value after defaultValue and still reports every user change. */\nexport interface PaginationClientUncontrolledProps {\n /** Initial current page. @default 1 */\n defaultValue?: number;\n /** Route hrefs belong only to the navigation variant. */\n getPageHref?: never;\n /** Receives normalized user-activated values. */\n onValueChange: (value: number) => void;\n /** Controlled values belong only to the client-controlled and navigation variants. */\n value?: never;\n}\n\n/** Route-navigation pagination. Genuine links require value and must not be combined with client state callbacks. */\nexport interface PaginationNavigationProps {\n /** Initial client state is mutually exclusive with route navigation. */\n defaultValue?: never;\n /** Pure, deterministic, non-throwing href mapper for every rendered target page. */\n getPageHref: (page: number) => string;\n /** Client callbacks are mutually exclusive with native route navigation. */\n onValueChange?: never;\n /** Route-derived current page. */\n value: number;\n}\n\nexport type PaginationProps = PaginationSharedProps &\n (\n | PaginationClientControlledProps\n | PaginationClientUncontrolledProps\n | PaginationNavigationProps\n );\n\nconst MAX_WINDOW_COUNT = 100;\n\nconst normalizeSafeInteger = (\n value: number,\n fallback: number,\n minimum: number\n): number =>\n Number.isFinite(value)\n ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(minimum, Math.trunc(value)))\n : fallback;\n\nconst normalizePageSize = (value: number): number =>\n normalizeSafeInteger(value, 10, 1);\n\nconst normalizeTotal = (value: number): number =>\n normalizeSafeInteger(value, 0, 0);\n\nconst normalizeCurrent = (value: number, pageCount: number): number => {\n const integer = normalizeSafeInteger(value, 1, 1);\n return Math.min(integer, pageCount);\n};\n\nconst normalizeWindowCount = (value: number): number =>\n Math.min(MAX_WINDOW_COUNT, normalizeSafeInteger(value, 1, 0));\n\ninterface PageInterval {\n end: number;\n start: number;\n}\n\ntype PageRangeItem = number | \"ellipsis\";\n\nconst getPageRange = (\n pageCount: number,\n currentValue: number,\n boundaryCount: number,\n siblingCount: number\n): PageRangeItem[] => {\n const intervals: PageInterval[] = [];\n\n if (boundaryCount > 0) {\n intervals.push({ end: Math.min(boundaryCount, pageCount), start: 1 });\n }\n\n intervals.push({\n end: Math.min(pageCount, currentValue + siblingCount),\n start: Math.max(1, currentValue - siblingCount),\n });\n\n if (boundaryCount > 0) {\n intervals.push({\n end: pageCount,\n start: Math.max(1, pageCount - boundaryCount + 1),\n });\n }\n\n intervals.sort((left, right) => left.start - right.start);\n\n const merged: PageInterval[] = [];\n for (const interval of intervals) {\n const previous = merged.at(-1);\n\n if (previous && interval.start <= previous.end + 2) {\n previous.end = Math.max(previous.end, interval.end);\n } else {\n merged.push({ ...interval });\n }\n }\n\n const range: PageRangeItem[] = [];\n for (const [index, interval] of merged.entries()) {\n if (index > 0) {\n range.push(\"ellipsis\");\n }\n for (let page = interval.start; page <= interval.end; page += 1) {\n range.push(page);\n }\n }\n\n return range;\n};\n\nexport const Pagination = ({\n boundaryCount = 1,\n children: _ignoredChildren,\n className,\n contentClassName,\n dangerouslySetInnerHTML: _ignoredDangerouslySetInnerHTML,\n \"data-slot\": _ignoredDataSlot,\n defaultValue,\n disabled = false,\n ellipsisClassName,\n getPageHref,\n hideOnSinglePage = false,\n itemClassName,\n linkClassName,\n nextClassName,\n onValueChange,\n pageSize = 10,\n previousClassName,\n role: _ignoredRole,\n siblingCount = 1,\n total,\n value,\n ...rootProps\n}: PaginationProps) => {\n const pageCount = Math.max(\n 1,\n Math.ceil(normalizeTotal(total) / normalizePageSize(pageSize))\n );\n const [uncontrolledValue, setUncontrolledValue] = useState(() =>\n normalizeCurrent(defaultValue ?? 1, pageCount)\n );\n const isNavigation = typeof getPageHref === \"function\";\n const currentValue = normalizeCurrent(value ?? uncontrolledValue, pageCount);\n const pageRange = getPageRange(\n pageCount,\n currentValue,\n normalizeWindowCount(boundaryCount),\n normalizeWindowCount(siblingCount)\n );\n\n useEffect(() => {\n if (!isNavigation && value === undefined) {\n setUncontrolledValue((previousValue) =>\n normalizeCurrent(previousValue, pageCount)\n );\n }\n }, [isNavigation, pageCount, value]);\n\n const activate = (target: number) => {\n if (target === currentValue) {\n return;\n }\n\n if (value === undefined) {\n setUncontrolledValue(target);\n }\n onValueChange?.(target);\n };\n\n const getControlProps = (target: number, unavailable = false) => {\n const isUnavailable = disabled || unavailable;\n\n if (isNavigation) {\n return {\n \"aria-disabled\": isUnavailable ? true : undefined,\n href: isUnavailable ? undefined : getPageHref(target),\n role: \"link\",\n tabIndex: isUnavailable ? -1 : undefined,\n };\n }\n\n return {\n \"aria-disabled\": isUnavailable ? true : undefined,\n onClick: () => {\n if (!isUnavailable) {\n activate(target);\n }\n },\n onKeyDown: (event: KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n if (!isUnavailable) {\n activate(target);\n }\n }\n },\n role: \"button\",\n tabIndex: isUnavailable ? -1 : 0,\n };\n };\n\n if (hideOnSinglePage && pageCount === 1) {\n return null;\n }\n\n return (\n \n \n \n \n \n\n {pageRange.map((rangeItem, index) => (\n \n {rangeItem === \"ellipsis\" ? (\n \n ) : (\n \n {rangeItem}\n \n )}\n \n ))}\n\n \n \n \n \n \n );\n};\n\nexport default Pagination;\n", "type": "registry:component", "target": "components/easy/pagination.tsx" } ], "type": "registry:component" }