/** * Pagination Component * * Reusable pagination component with page navigation, page size selector, * and keyboard navigation support. * * Features: * - Previous/Next buttons (disabled at boundaries) * - Page numbers with ellipsis for large page counts * - Page size selector dropdown * - "Showing X-Y of Z results" text * - Keyboard navigation (arrow keys) * - WCAG AA accessibility * * @layer UI */ import * as React from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from './button'; import { cn } from '@/lib/utils'; export interface PaginationProps { currentPage: number; pageSize: number; totalItems: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; className?: string; } const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const MAX_VISIBLE_PAGES = 5; /** * Calculates visible page numbers with ellipsis logic * * Examples: * - 1-5 pages: [1, 2, 3, 4, 5] * - Current page 1 of 10: [1, 2, 3, ..., 10] * - Current page 5 of 10: [1, ..., 4, 5, 6, ..., 10] * - Current page 10 of 10: [1, ..., 8, 9, 10] */ function calculateVisiblePages(currentPage: number, totalPages: number): (number | 'ellipsis')[] { if (totalPages <= MAX_VISIBLE_PAGES) { return Array.from({ length: totalPages }, (_, i) => i + 1); } const pages: (number | 'ellipsis')[] = []; // Always show first page pages.push(1); // Calculate range around current page const rangeStart = Math.max(2, currentPage - 1); const rangeEnd = Math.min(totalPages - 1, currentPage + 1); // Add ellipsis after first page if needed if (rangeStart > 2) { pages.push('ellipsis'); } // Add pages in range for (let i = rangeStart; i <= rangeEnd; i++) { pages.push(i); } // Add ellipsis before last page if needed if (rangeEnd < totalPages - 1) { pages.push('ellipsis'); } // Always show last page if (totalPages > 1) { pages.push(totalPages); } return pages; } export const Pagination = React.forwardRef( ({ currentPage, pageSize, totalItems, onPageChange, onPageSizeChange, className }, ref) => { const totalPages = Math.ceil(totalItems / pageSize); const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; const endItem = Math.min(currentPage * pageSize, totalItems); const hasPrevious = currentPage > 1; const hasNext = currentPage < totalPages; // Keyboard navigation React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; // Don't handle keyboard events in input fields } if (event.key === 'ArrowLeft' && hasPrevious) { event.preventDefault(); onPageChange(currentPage - 1); } else if (event.key === 'ArrowRight' && hasNext) { event.preventDefault(); onPageChange(currentPage + 1); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [currentPage, hasPrevious, hasNext, onPageChange]); const visiblePages = calculateVisiblePages(currentPage, totalPages); return (
{/* Results text */}
Showing {startItem} to{' '} {endItem} of{' '} {totalItems} results
{/* Pagination controls */}
{/* Page size selector */}
{/* Page navigation */}
{/* Previous button */} {/* Page numbers */}
{visiblePages.map((page, index) => { if (page === 'ellipsis') { return ( ); } const isCurrentPage = page === currentPage; return ( ); })}
{/* Next button */}
); } ); Pagination.displayName = 'Pagination';