"use client"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { type ComponentPropsWithRef, type ReactElement, type ReactNode, useId, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { PresenceGate, type PresenceGateRenderProps } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; type PaginationItem = number | "ellipsis-start" | "ellipsis-end"; /** * The pages to show: `boundaries` pages at each end, `siblings` either side of * the current page, and an ellipsis for every gap of two or more pages. The * result keeps one length while the current page moves, so the row never * changes width as you page through. */ export function paginationRange( page: number, totalPages: number, siblings = 1, boundaries = 1, ): PaginationItem[] { const range = (start: number, end: number) => Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index); // Boundaries, siblings, the current page and two ellipses. const slots = boundaries * 2 + siblings * 2 + 3; if (totalPages <= slots) return range(1, totalPages); const left = Math.max(page - siblings, boundaries); const right = Math.min(page + siblings, totalPages - boundaries); const showStart = left > boundaries + 2; const showEnd = right < totalPages - (boundaries + 1); if (!showStart && showEnd) { return [ ...range(1, siblings * 2 + boundaries + 2), "ellipsis-end", ...range(totalPages - boundaries + 1, totalPages), ]; } if (showStart && !showEnd) { return [ ...range(1, boundaries), "ellipsis-start", ...range(totalPages - (boundaries + 1 + siblings * 2), totalPages), ]; } return [ ...range(1, boundaries), "ellipsis-start", ...range(left, right), "ellipsis-end", ...range(totalPages - boundaries + 1, totalPages), ]; } export interface PaginationProps extends Omit, "onChange" | "children"> { totalPages: number; page?: number; defaultPage?: number; onPageChange?: (page: number) => void; /** Pages shown either side of the current one. */ siblings?: number; /** Pages always shown at each end. */ boundaries?: number; /** Pages an ellipsis jumps when pressed. */ jump?: number; /** * Render pages as links. Each control still calls `onPageChange`, so a * client router can update state while the href keeps the page shareable. */ getHref?: (page: number) => string; /** Render your router's Link, spreading these props onto it. */ renderLink?: (props: ComponentPropsWithRef<"a">) => ReactElement; } const CONTROL = "relative inline-flex h-8 min-w-8 items-center justify-center rounded-full px-2 text-sm tabular-nums sm:h-9 sm:min-w-9 outline-none transition-[color,background-color,transform] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.94] motion-reduce:active:scale-100"; export function Pagination({ totalPages, page, defaultPage = 1, onPageChange, siblings = 1, boundaries = 1, jump = 5, getHref, renderLink, className, "aria-label": ariaLabel = "Pagination", ...props }: PaginationProps) { const id = useId(); const reduce = useReducedMotion(); const total = Math.max(1, Math.floor(totalPages)); const clamp = (value: number) => Math.min(total, Math.max(1, Math.round(value))); const [internal, setInternal] = useState(() => clamp(defaultPage)); const current = clamp(page ?? internal); const go = (next: number) => { const target = clamp(next); if (target === current) return; if (page === undefined) setInternal(target); onPageChange?.(target); }; // One control: a link when hrefs are given, otherwise a button. const control = ( target: number, { label, current: isCurrent = false, disabled = false, className: controlClass, children, gate, }: { label: string; current?: boolean; disabled?: boolean; className?: string; children: ReactNode; /** Releases a control the moment it starts leaving. */ gate?: PresenceGateRenderProps["gate"]; }, ) => { const shared = { ...gate, "aria-label": label, "aria-current": isCurrent ? ("page" as const) : undefined, className: cn(CONTROL, controlClass), children, }; // A disabled control is always a button: a link cannot be disabled. if (getHref && !disabled) { const linkProps: ComponentPropsWithRef<"a"> = { ...shared, href: getHref(target), onClick: (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; go(target); }, }; return renderLink ? renderLink(linkProps) : ; } return (