{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:ui","title":"Pagination","description":"A page-number control with Previous/Next, ellipsis collapsing for long runs, and optional First/Last jump buttons; the current-page pill slides between numbers and the numbers glide when the visible run shifts.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/pagination/Pagination.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { buildPageRange } from \"./pagination-range\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { DURATIONS, EASINGS } from \"../../internals/motion/tokens\";\nimport \"./pagination.css\";\n\nexport interface PaginationProps {\n\t/**\n\t * The current page, 1-based. Pass it to control the component from\n\t * outside; omit it entirely to let the control own its own page and\n\t * report every change through `onPageChange`.\n\t */\n\tpage?: number;\n\t/** Total number of pages. */\n\tcount: number;\n\t/** Called with the new page whenever it changes, however the change happened. */\n\tonPageChange?: (page: number) => void;\n\t/** Pages shown on each side of the current page. Defaults to `1`. */\n\tsiblingCount?: number;\n\t/** Pages always shown at each end of the run. Defaults to `1`. */\n\tboundaryCount?: number;\n\t/** Shows First/Last jump buttons alongside Previous/Next. Defaults to `false`. */\n\tshowEdges?: boolean;\n\t/** Disables every control in the nav. */\n\tdisabled?: boolean;\n\t/** Accessible name for the `<nav>` landmark. Defaults to `\"Pagination\"`. */\n\tlabel?: string;\n\t/** Overrides the Previous button's content. */\n\tpreviousLabel?: ReactNode;\n\t/** Overrides the Next button's content. */\n\tnextLabel?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the select cue through the sound controller. Off by default;\n\t * only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nconst SLIDE_MS = DURATIONS.base;\n\nconst pageButtonBase =\n\t\"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\";\nconst navButtonBase =\n\t\"inline-flex shrink-0 items-center gap-1 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\";\n\n/**\n * The nav element arrives through the ref channel rather than a `ref` prop,\n * per PORTING.md — the Svelte source declares `ref = $bindable(null)`.\n *\n * Rest props are not spread: the Svelte source reads only these props off\n * `$props()` and has no `...restProps`, so the port carries no wider\n * attribute surface than the component it mirrors.\n */\nexport const Pagination = forwardRef<HTMLElement, PaginationProps>(\n\t(\n\t\t{\n\t\t\tpage: pageProp,\n\t\t\tcount,\n\t\t\tonPageChange,\n\t\t\tsiblingCount = 1,\n\t\t\tboundaryCount = 1,\n\t\t\tshowEdges = false,\n\t\t\tdisabled = false,\n\t\t\tlabel = \"Pagination\",\n\t\t\tpreviousLabel,\n\t\t\tnextLabel,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\tconst playCue = useSoundCue(sound);\n\t\t// The Svelte source's `page` is `$bindable(1)`: a consumer can bind it,\n\t\t// or leave it alone and let the component keep writing its own copy.\n\t\t// React has no such channel, so the prop is controlled when it is\n\t\t// passed and this local copy takes over when it is not. Either way\n\t\t// `onPageChange` fires with the same value.\n\t\tconst [uncontrolledPage, setUncontrolledPage] = useState(1);\n\t\tconst isControlled = pageProp !== undefined;\n\t\tconst page = isControlled ? pageProp : uncontrolledPage;\n\n\t\tconst items = buildPageRange(page, count, siblingCount, boundaryCount);\n\n\t\t// Floored, not just clamped, and used everywhere this component reasons\n\t\t// about \"which page\" rather than trusting the raw props: `count` can\n\t\t// arrive fractional (`totalItems / pageSize` without `Math.ceil`).\n\t\t// Without a floored value here, `handleLast` would call `goTo(count)`\n\t\t// and set `page` itself to that same fractional value —\n\t\t// `buildPageRange`'s own flooring would still save the *rendered*\n\t\t// sequence, but `item === page` in the markup below would compare an\n\t\t// integer against a value that can now never match again, so\n\t\t// `aria-current` and the pill styling would stay wrong for the rest of\n\t\t// the session. See `pagination-range.ts` for why the pure function\n\t\t// floors independently of this — both layers guard the same invariant\n\t\t// on purpose, the same redundancy the boundary buttons already rely on\n\t\t// below.\n\t\tconst safeCount = Math.max(0, Math.floor(count));\n\t\tconst safePage = Math.min(Math.max(Math.floor(page), 1), Math.max(safeCount, 1));\n\n\t\tconst isFirst = safePage <= 1;\n\t\tconst isLast = safePage >= safeCount;\n\n\t\t// The current-page pill slides from the old page to the new one — but it\n\t\t// must not fly in on first paint, from wherever an unplaced box sits. So\n\t\t// the slide is armed only once the page has really moved, and the flag is\n\t\t// a `data-*` attribute the CSS selects on rather than a class (nothing\n\t\t// else keys off it, and it stays out of the merged class string).\n\t\t//\n\t\t// Armed off `safePage`, not from inside `goTo()`: a controlled\n\t\t// `Pagination` whose `page` prop is changed from outside never calls\n\t\t// `goTo`, and its pill should slide just the same. The ref seeds the\n\t\t// baseline with the page the component started on.\n\t\tconst [popArmed, setPopArmed] = useState(false);\n\t\tconst armedRef = useRef(false);\n\t\tconst lastPageRef = useRef(safePage);\n\n\t\t/*\n\t\t * ---------------------------------------------------------------------\n\t\t * The sliding pill\n\t\t * ---------------------------------------------------------------------\n\t\t *\n\t\t * One `aria-hidden` box under the numbers, moved with `translate()` alone\n\t\t * to the current page's button (every page button is the same size, so\n\t\t * position is all it needs). When the run of numbers itself shifts — a new\n\t\t * window after a jump, an ellipsis moving — the numbers glide to their new\n\t\t * places (a FLIP on the same duration), so the pill and its number arrive\n\t\t * together.\n\t\t *\n\t\t * A progressive enhancement, never the only signal: the current button\n\t\t * keeps `aria-current=\"page\"` and its own `bg-accent`, which the CSS only\n\t\t * hides once the pill has actually been placed (`data-indicator`). A\n\t\t * JS-off render, a forced-colors user and a screen reader all still get it.\n\t\t */\n\t\tconst reduced = useReducedMotion();\n\t\tconst flipDuration = reduced ? 0 : SLIDE_MS;\n\n\t\tconst navRef = useRef<HTMLElement | null>(null);\n\t\tconst composedRef = useComposedRefs(navRef, forwardedRef);\n\t\tconst indicatorRef = useRef<HTMLSpanElement | null>(null);\n\t\tconst [indicatorPlaced, setIndicatorPlaced] = useState(false);\n\n\t\tfunction placeIndicator(animate: boolean) {\n\t\t\tconst el = indicatorRef.current;\n\t\t\tconst nav = navRef.current;\n\t\t\tconst current = nav?.querySelector<HTMLElement>('button[aria-current=\"page\"]');\n\t\t\tif (!el || !current || current.offsetWidth === 0) {\n\t\t\t\tsetIndicatorPlaced(false);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Sum offsets up to the nav rather than trusting `offsetLeft` alone:\n\t\t\t// while the numbers glide, each `<li>` carries a transform, which\n\t\t\t// makes it the button's `offsetParent` (offset ≈ 0). Offsets ignore\n\t\t\t// transforms, so the sum is the button's final place — where the pill\n\t\t\t// must land, together with the number gliding there.\n\t\t\tlet x = 0;\n\t\t\tlet y = 0;\n\t\t\tfor (let n: HTMLElement | null = current; n && n !== nav; ) {\n\t\t\t\tx += n.offsetLeft;\n\t\t\t\ty += n.offsetTop;\n\t\t\t\tn = n.offsetParent as HTMLElement | null;\n\t\t\t}\n\t\t\tconst transform = `translate(${x}px, ${y}px)`;\n\t\t\tel.style.width = `${current.offsetWidth}px`;\n\t\t\tel.style.height = `${current.offsetHeight}px`;\n\t\t\tif (el.style.transform === transform) {\n\t\t\t\tsetIndicatorPlaced(true);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (animate && el.style.transform) {\n\t\t\t\tel.style.transform = transform;\n\t\t\t} else {\n\t\t\t\t// Snap: suspend the transition, write, force a reflow, restore.\n\t\t\t\tconst previous = el.style.transition;\n\t\t\t\tel.style.transition = \"none\";\n\t\t\t\tel.style.transform = transform;\n\t\t\t\tvoid el.offsetWidth;\n\t\t\t\tel.style.transition = previous;\n\t\t\t}\n\t\t\tsetIndicatorPlaced(true);\n\t\t}\n\n\t\t// The numbers' glide. Each `<li>` registers itself by its key; after\n\t\t// every commit the run is re-measured, and an item that moved is played\n\t\t// back from where it was last seen, while an item that just appeared\n\t\t// fades in a beat later. Nothing animates on the first commit.\n\t\t//\n\t\t// Positions are kept relative to the nav, not the viewport: they are\n\t\t// stored at one commit and compared at the next, and a page scroll in\n\t\t// between must not read as every number having moved.\n\t\tconst itemEls = useRef(new Map<string, HTMLLIElement>());\n\t\tconst itemRects = useRef(new Map<string, { left: number; top: number }>());\n\t\tconst itemAnims = useRef(new Map<string, Animation>());\n\t\tconst mountedRef = useRef(false);\n\n\t\tfunction measureItems() {\n\t\t\tconst nav = navRef.current;\n\t\t\tconst origin = nav?.getBoundingClientRect();\n\t\t\tconst rects = new Map<string, { left: number; top: number }>();\n\t\t\tfor (const [key, el] of itemEls.current) {\n\t\t\t\tconst rect = el.getBoundingClientRect();\n\t\t\t\trects.set(key, {\n\t\t\t\t\tleft: rect.left - (origin?.left ?? 0),\n\t\t\t\t\ttop: rect.top - (origin?.top ?? 0),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn rects;\n\t\t}\n\n\t\tconst itemsKey = items.join(\",\");\n\n\t\t// Arm, glide, then re-place the pill once the DOM has the new buttons.\n\t\t// Slides once armed; the first placement snaps.\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tconst nav = navRef.current;\n\t\t\tif (safePage !== lastPageRef.current) {\n\t\t\t\tlastPageRef.current = safePage;\n\t\t\t\tarmedRef.current = true;\n\t\t\t\t// Written straight onto the nav as well as into state: the pill is\n\t\t\t\t// moved below, in this same pass, and its CSS transition only\n\t\t\t\t// applies once `data-armed` is on the element. Waiting for the\n\t\t\t\t// re-render would make the very first page change snap.\n\t\t\t\tnav?.setAttribute(\"data-armed\", \"true\");\n\t\t\t\tsetPopArmed(true);\n\t\t\t}\n\n\t\t\tconst firstCommit = !mountedRef.current;\n\t\t\tmountedRef.current = true;\n\t\t\tconst origin = nav?.getBoundingClientRect();\n\t\t\tconst nextRects = new Map<string, { left: number; top: number }>();\n\t\t\tfor (const [key, el] of itemEls.current) {\n\t\t\t\t// An item still mid-glide starts its next glide from where it is\n\t\t\t\t// on screen right now, not from where the last glide was heading:\n\t\t\t\t// the running animation's current offset is read before cancelling.\n\t\t\t\tconst running = itemAnims.current.get(key);\n\t\t\t\tlet driftX = 0;\n\t\t\t\tlet driftY = 0;\n\t\t\t\tif (running) {\n\t\t\t\t\tconst live = el.getBoundingClientRect();\n\t\t\t\t\trunning.cancel();\n\t\t\t\t\tconst settled = el.getBoundingClientRect();\n\t\t\t\t\tdriftX = live.left - settled.left;\n\t\t\t\t\tdriftY = live.top - settled.top;\n\t\t\t\t}\n\t\t\t\titemAnims.current.delete(key);\n\t\t\t\tconst rect = el.getBoundingClientRect();\n\t\t\t\tconst left = rect.left - (origin?.left ?? 0);\n\t\t\t\tconst top = rect.top - (origin?.top ?? 0);\n\t\t\t\tnextRects.set(key, { left, top });\n\t\t\t\tif (firstCommit || !flipDuration || typeof el.animate !== \"function\") continue;\n\t\t\t\tconst prev = itemRects.current.get(key);\n\t\t\t\tif (prev) {\n\t\t\t\t\tconst dx = prev.left + driftX - left;\n\t\t\t\t\tconst dy = prev.top + driftY - top;\n\t\t\t\t\tif (!dx && !dy) continue;\n\t\t\t\t\titemAnims.current.set(\n\t\t\t\t\t\tkey,\n\t\t\t\t\t\tel.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], {\n\t\t\t\t\t\t\tduration: flipDuration,\n\t\t\t\t\t\t\teasing: EASINGS.out,\n\t\t\t\t\t\t})\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\titemAnims.current.set(\n\t\t\t\t\t\tkey,\n\t\t\t\t\t\tel.animate([{ opacity: 0 }, { opacity: 1 }], {\n\t\t\t\t\t\t\tduration: DURATIONS.fast,\n\t\t\t\t\t\t\tdelay: 60,\n\t\t\t\t\t\t\teasing: \"linear\",\n\t\t\t\t\t\t\tfill: \"backwards\",\n\t\t\t\t\t\t})\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\titemRects.current = nextRects;\n\n\t\t\tplaceIndicator(armedRef.current);\n\t\t\t// eslint-disable-next-line react-hooks/exhaustive-deps -- placeIndicator reads refs only\n\t\t}, [safePage, itemsKey, flipDuration]);\n\n\t\t// Resizes, font loads and zoom move the buttons without a page change:\n\t\t// follow them with a snap, never a slide, and re-baseline the numbers so\n\t\t// the next glide does not start from a stale layout.\n\t\tuseEffect(() => {\n\t\t\tconst nav = navRef.current;\n\t\t\tif (!nav || typeof ResizeObserver === \"undefined\") return;\n\t\t\tconst ro = new ResizeObserver(() => {\n\t\t\t\tconst gliding = [...itemAnims.current.values()].some((a) => a.playState === \"running\");\n\t\t\t\tif (!gliding) itemRects.current = measureItems();\n\t\t\t\tplaceIndicator(false);\n\t\t\t});\n\t\t\tro.observe(nav);\n\t\t\treturn () => ro.disconnect();\n\t\t\t// eslint-disable-next-line react-hooks/exhaustive-deps -- placeIndicator and measureItems read refs only\n\t\t}, []);\n\n\t\tfunction registerItem(key: string) {\n\t\t\treturn (el: HTMLLIElement | null) => {\n\t\t\t\tif (el) itemEls.current.set(key, el);\n\t\t\t\telse itemEls.current.delete(key);\n\t\t\t};\n\t\t}\n\n\t\tfunction goTo(next: number) {\n\t\t\tif (disabled) return;\n\t\t\tconst clamped = Math.max(1, Math.min(Math.floor(next), Math.max(safeCount, 1)));\n\t\t\tif (clamped === page) return;\n\t\t\tif (!isControlled) setUncontrolledPage(clamped);\n\t\t\tplayCue(\"select\");\n\t\t\tonPageChange?.(clamped);\n\t\t}\n\n\t\t// Each handler re-checks the boundary itself rather than trusting the\n\t\t// `disabled` attribute below — a synthetic click bypasses the native\n\t\t// guard, as does `fireEvent.click` in tests.\n\t\tfunction handlePrevious() {\n\t\t\tif (disabled || isFirst) return;\n\t\t\tgoTo(safePage - 1);\n\t\t}\n\t\tfunction handleNext() {\n\t\t\tif (disabled || isLast) return;\n\t\t\tgoTo(safePage + 1);\n\t\t}\n\t\tfunction handleFirst() {\n\t\t\tif (disabled || isFirst) return;\n\t\t\tgoTo(1);\n\t\t}\n\t\tfunction handleLast() {\n\t\t\tif (disabled || isLast) return;\n\t\t\tgoTo(safeCount);\n\t\t}\n\n\t\treturn (\n\t\t\t<nav\n\t\t\t\tref={composedRef}\n\t\t\t\taria-label={label}\n\t\t\t\tdata-armed={popArmed ? \"true\" : undefined}\n\t\t\t\tdata-indicator={indicatorPlaced ? \"\" : undefined}\n\t\t\t\tclassName={cn(\"ft-pagination\", className)}\n\t\t\t>\n\t\t\t\t<ul className=\"flex items-center gap-1\">\n\t\t\t\t\t{showEdges && (\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName={navButtonBase}\n\t\t\t\t\t\t\t\tdisabled={disabled || isFirst}\n\t\t\t\t\t\t\t\taria-label=\"First page\"\n\t\t\t\t\t\t\t\ttitle=\"First page\"\n\t\t\t\t\t\t\t\tonClick={handleFirst}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t« First\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={navButtonBase}\n\t\t\t\t\t\t\tdisabled={disabled || isFirst}\n\t\t\t\t\t\t\taria-label=\"Previous page\"\n\t\t\t\t\t\t\ttitle=\"Previous page\"\n\t\t\t\t\t\t\tonClick={handlePrevious}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{previousLabel ?? \"‹ Previous\"}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\n\t\t\t\t\t{items.map((item, i) => {\n\t\t\t\t\t\tconst key = item === \"ellipsis\" ? `ellipsis-${i}` : `page-${item}`;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<li key={key} ref={registerItem(key)}>\n\t\t\t\t\t\t\t\t{item === \"ellipsis\" ? (\n\t\t\t\t\t\t\t\t\t// Decorative only: it stands for a run of hidden pages, not a\n\t\t\t\t\t\t\t\t\t// control, so it must not take focus or be reachable by Tab.\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-muted-foreground flex size-8 items-center justify-center select-none\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t…\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\tpageButtonBase,\n\t\t\t\t\t\t\t\t\t\t\titem === safePage\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-accent text-accent-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:bg-accent hover:text-accent-foreground\"\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\taria-current={item === safePage ? \"page\" : undefined}\n\t\t\t\t\t\t\t\t\t\taria-label={`Go to page ${item}`}\n\t\t\t\t\t\t\t\t\t\ttitle={`Go to page ${item}`}\n\t\t\t\t\t\t\t\t\t\tonClick={() => goTo(item)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{item}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={navButtonBase}\n\t\t\t\t\t\t\tdisabled={disabled || isLast}\n\t\t\t\t\t\t\taria-label=\"Next page\"\n\t\t\t\t\t\t\ttitle=\"Next page\"\n\t\t\t\t\t\t\tonClick={handleNext}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{nextLabel ?? \"Next ›\"}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\n\t\t\t\t\t{showEdges && (\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName={navButtonBase}\n\t\t\t\t\t\t\t\tdisabled={disabled || isLast}\n\t\t\t\t\t\t\t\taria-label=\"Last page\"\n\t\t\t\t\t\t\t\ttitle=\"Last page\"\n\t\t\t\t\t\t\t\tonClick={handleLast}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tLast »\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t)}\n\t\t\t\t</ul>\n\n\t\t\t\t<span ref={indicatorRef} className=\"ft-pagination-indicator\" aria-hidden=\"true\"></span>\n\t\t\t</nav>\n\t\t);\n\t}\n);\n\nPagination.displayName = \"Pagination\";\n","type":"registry:ui","target":"@ui/fancy/components/pagination/Pagination.tsx"},{"path":"react/src/components/pagination/index.ts","content":"\"use client\";\n\n// `pagination-range.ts` is deliberately NOT re-exported here: this file is\n// the folder's barrel, and the package barrel re-exports it wholesale\n// straight onto the public npm surface. `buildPageRange`/`PageItem` stay an\n// internal implementation detail — import them from\n// `./pagination-range.js` directly (as the colocated test does) if you need\n// them outside this component.\nexport { Pagination } from \"./Pagination\";\nexport type { PaginationProps } from \"./Pagination\";\n","type":"registry:ui","target":"@ui/fancy/components/pagination/index.ts"},{"path":"react/src/components/pagination/pagination-range.ts","content":"\"use client\";\n\n// Pure page-range math for Pagination — no DOM, no framework, trivially\n// unit-testable and safe to call during SSR.\n//\n// The visible pages are the union of three windows: `boundaryCount` pages\n// pinned at each end of the run, and a `2 * siblingCount + 1`-wide window\n// centred on `page`. That sibling window has a *fixed* width: near an edge\n// it is shifted (not truncated) to stay inside `[1, count]`, which is what\n// pulls extra numbers into view next to page 1 instead of shrinking the\n// control — the same reason the sequence for `page=1, count=12,\n// siblingCount=1, boundaryCount=1` is `[1, 2, 3, \"ellipsis\", 12]` rather\n// than the narrower `[1, 2, \"ellipsis\", 12]`. Once the window has room to\n// sit fully away from both edges (the \"middle\" of the range), its width\n// stops changing, so the sequence's length stops changing too.\n//\n// Any run of hidden pages between two visible ones collapses to a single\n// \"ellipsis\" entry, *except* a run of exactly one page — that page is shown\n// instead, since a single-page ellipsis costs the same width as the digit\n// it would have hidden.\n\nexport type PageItem = number | \"ellipsis\";\n\n/**\n * Builds the visible page sequence for a pagination control, with\n * `\"ellipsis\"` markers standing in for runs of two or more hidden pages.\n *\n * @param page - The current page (1-based). Clamped into `[1, count]`.\n * @param count - Total number of pages. `count <= 0` returns `[]`.\n * @param siblingCount - Pages shown on each side of `page`.\n * @param boundaryCount - Pages always shown at each end of the run.\n */\nexport function buildPageRange(\n\tpage: number,\n\tcount: number,\n\tsiblingCount: number,\n\tboundaryCount: number\n): PageItem[] {\n\t// Floored, not just clamped: `visible` is keyed by integer page numbers,\n\t// and the final walk below steps through integers only. A fractional\n\t// `count` (a consumer computing `totalItems / pageSize` instead of\n\t// `Math.ceil(...)`, say) or a fractional `page` would seed `visible` with\n\t// a key the walk can never match, silently dropping real pages instead of\n\t// erroring — flooring here is what keeps every input an integer before\n\t// any of that arithmetic runs.\n\tconst safeCount = Math.floor(count);\n\tif (safeCount <= 0) return [];\n\n\tconst safeSiblingCount = Math.max(0, Math.floor(siblingCount));\n\tconst safeBoundaryCount = Math.max(0, Math.floor(boundaryCount));\n\tconst current = Math.min(Math.max(Math.floor(page), 1), safeCount);\n\n\t// Pages that must always render, collected once as a Set — order doesn't\n\t// matter here, only membership; the final walk below puts them back in\n\t// ascending order and fills the gaps.\n\tconst visible = new Set<number>();\n\n\tfor (let p = 1; p <= Math.min(safeBoundaryCount, safeCount); p++) visible.add(p);\n\tfor (let p = Math.max(1, safeCount - safeBoundaryCount + 1); p <= safeCount; p++) visible.add(p);\n\n\tconst windowWidth = Math.min(safeSiblingCount * 2 + 1, safeCount);\n\tlet windowStart = current - safeSiblingCount;\n\tif (windowStart < 1) windowStart = 1;\n\tif (windowStart + windowWidth - 1 > safeCount) windowStart = safeCount - windowWidth + 1;\n\tconst windowEnd = windowStart + windowWidth - 1;\n\tfor (let p = windowStart; p <= windowEnd; p++) visible.add(p);\n\n\tconst items: PageItem[] = [];\n\tlet p = 1;\n\twhile (p <= safeCount) {\n\t\tif (visible.has(p)) {\n\t\t\titems.push(p);\n\t\t\tp++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet runEnd = p;\n\t\twhile (runEnd + 1 <= safeCount && !visible.has(runEnd + 1)) runEnd++;\n\t\tconst runLength = runEnd - p + 1;\n\n\t\t// A single hidden page is cheaper to just show than to elide — the\n\t\t// ellipsis would take the same one slot of width anyway.\n\t\titems.push(runLength === 1 ? p : \"ellipsis\");\n\t\tp = runEnd + 1;\n\t}\n\n\treturn items;\n}\n","type":"registry:ui","target":"@ui/fancy/components/pagination/pagination-range.ts"},{"path":"react/src/components/pagination/pagination.css","content":"/*\n * The current-page pill reads the app's semantic `bg-accent` token (the\n * mockup's neutral highlight, not the brand purple) — Pagination never\n * needs the purple accent for its own surface. The focus ring is the one\n * place this component reaches for the shared nav accent, matching\n * ToggleGroupItem's identical `--ft-toggle-group-accent` pattern: no\n * semantic token owns \"focus ring purple\", so it is declared locally\n * with a light-dark() fallback, retintable from higher up the tree via\n * `--ft-accent`.\n */\n.ft-pagination {\n\t--ft-nav-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n\n.ft-pagination button:focus-visible {\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-nav-accent) 35%, transparent);\n}\n\n/*\n * The nav is the page buttons' `offsetParent` (no `<ul>`/`<li>` in between\n * is positioned), so `offsetLeft`/`offsetTop` and the pill's own\n * `left: 0; top: 0` share one origin. `isolation` lets the pill sit at\n * `z-index: -1` — above the nav's background, under the buttons.\n */\n.ft-pagination {\n\tposition: relative;\n\tisolation: isolate;\n}\n\n.ft-pagination-indicator {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tz-index: -1;\n\tpointer-events: none;\n\tborder-radius: var(--radius-md, 0.375rem);\n\tbackground: var(--ft-pagination-indicator-color, var(--color-accent));\n\topacity: 0;\n}\n\n.ft-pagination[data-indicator] .ft-pagination-indicator {\n\topacity: 1;\n}\n\n/* The pill has taken over: the current button's own fill steps aside. */\n.ft-pagination[data-indicator] button[aria-current=\"page\"] {\n\tbackground: transparent;\n}\n\n/*\n * 300ms = tokens.DURATIONS.base, cubic-bezier(0.16, 1, 0.3, 1) =\n * tokens.EASINGS.out — the same curve and length as the numbers' own\n * glide, so the pill lands with its number. Gated: under reduced\n * motion the pill still follows the page, it just arrives.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-pagination[data-armed=\"true\"] .ft-pagination-indicator {\n\t\ttransition: transform\n\t\t\tvar(\n\t\t\t\t--ft-pagination-slide-duration,\n\t\t\t\tvar(--ft-pagination-pop-duration, var(--ft-duration-base, 300ms))\n\t\t\t)\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/pagination/pagination.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/pagination?framework=react"}}