import { useState, useCallback, useMemo } from 'react'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── /** A page item rendered in the pagination bar. */ export type PaginationPageItem = { type: 'page'; page: number } | { type: 'ellipsis'; key: string }; export interface UsePaginationProps { /** Total number of items in the data set. */ totalItems: number; /** Number of items per page. @default 10 */ pageSize?: number; /** Initial page (1-indexed). @default 1 */ initialPage?: number; /** * Controlled current page (1-indexed). * When provided, page state is managed externally via `onPageChange`. */ page?: number; /** Called whenever the page changes. */ onPageChange?: (page: number) => void; /** * Number of page buttons to show on each side of the current page. * @default 1 */ siblingCount?: number; } export interface UsePaginationReturn { // ── State ───────────────────────────────────────────────────────────────── /** The currently active page (1-indexed). */ currentPage: number; /** Total number of pages. */ totalPages: number; /** Index of the first item on the current page (0-indexed, useful for slicing). */ startIndex: number; /** Index of the last item on the current page (exclusive, useful for slicing). */ endIndex: number; // ── Flags ───────────────────────────────────────────────────────────────── /** Whether there is a previous page available. */ canGoPrev: boolean; /** Whether there is a next page available. */ canGoNext: boolean; /** Whether the current page is the first page. */ isFirstPage: boolean; /** Whether the current page is the last page. */ isLastPage: boolean; // ── Computed ────────────────────────────────────────────────────────────── /** * Ordered list of items to render in the pagination bar. * Each item is either a `{ type: 'page', page: number }` or * `{ type: 'ellipsis', key: string }`. */ items: PaginationPageItem[]; // ── Handlers ────────────────────────────────────────────────────────────── /** Navigate to a specific page (1-indexed, clamped to valid range). */ goTo: (page: number) => void; /** Navigate to the next page (no-op if already on the last page). */ next: () => void; /** Navigate to the previous page (no-op if already on the first page). */ prev: () => void; /** Navigate to the first page. */ first: () => void; /** Navigate to the last page. */ last: () => void; } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── /** * Headless hook for pagination logic. * * @description * Computes the current page, total pages, slice indices, and the ordered list * of page items (numbers + ellipses) to render. Supports both controlled and * uncontrolled modes. Pair with any custom pagination UI or with the * `` visual components. * * @example * ```tsx * const { currentPage, totalPages, items, next, prev, goTo, canGoPrev, canGoNext } = * usePagination({ totalItems: 200, pageSize: 20 }); * ``` */ export function usePagination({ totalItems, pageSize = 10, initialPage = 1, page: controlledPage, onPageChange, siblingCount = 1, }: UsePaginationProps): UsePaginationReturn { const [internalPage, setInternalPage] = useState(initialPage); const isControlled = controlledPage !== undefined; const currentPage = isControlled ? controlledPage! : internalPage; const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const setPage = useCallback( (p: number) => { const clamped = Math.min(Math.max(1, p), totalPages); if (!isControlled) setInternalPage(clamped); onPageChange?.(clamped); }, [isControlled, onPageChange, totalPages] ); // ── Derived ─────────────────────────────────────────────────────────────── const startIndex = (currentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, totalItems); const canGoPrev = currentPage > 1; const canGoNext = currentPage < totalPages; const isFirstPage = currentPage === 1; const isLastPage = currentPage === totalPages; // ── Page items (numbers + ellipses) ─────────────────────────────────────── const items = useMemo((): PaginationPageItem[] => { const pageSet = new Set(); pageSet.add(1); if (totalPages > 1) pageSet.add(totalPages); for ( let p = Math.max(2, currentPage - siblingCount); p <= Math.min(totalPages - 1, currentPage + siblingCount); p++ ) { pageSet.add(p); } const sorted = Array.from(pageSet).sort((a, b) => a - b); const result: PaginationPageItem[] = []; for (let i = 0; i < sorted.length; i++) { result.push({ type: 'page', page: sorted[i] }); if (i < sorted.length - 1 && sorted[i + 1] - sorted[i] > 1) { result.push({ type: 'ellipsis', key: `ellipsis-${sorted[i]}` }); } } return result; }, [currentPage, siblingCount, totalPages]); // ── Handlers ────────────────────────────────────────────────────────────── const goTo = useCallback((p: number) => setPage(p), [setPage]); const next = useCallback(() => setPage(currentPage + 1), [currentPage, setPage]); const prev = useCallback(() => setPage(currentPage - 1), [currentPage, setPage]); const first = useCallback(() => setPage(1), [setPage]); const last = useCallback(() => setPage(totalPages), [setPage, totalPages]); return { currentPage, totalPages, startIndex, endIndex, canGoPrev, canGoNext, isFirstPage, isLastPage, items, goTo, next, prev, first, last, }; }