diff --git a/src/design-system/primitives/Pagination/Pagination.module.css b/src/design-system/primitives/Pagination/Pagination.module.css new file mode 100644 index 0000000..4b19ba5 --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.module.css @@ -0,0 +1,79 @@ +.pagination { + display: inline-flex; + align-items: center; + gap: 2px; +} + +/* Prev / Next nav buttons — ghost style */ +.navBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.navBtn:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.navBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Individual page number buttons */ +.pageBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.pageBtn:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.pageBtn:disabled:not(.active) { + opacity: 0.4; + cursor: not-allowed; +} + +/* Active page */ +.active { + background: var(--amber); + color: white; + cursor: default; +} + +/* Ellipsis */ +.ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; + user-select: none; +} diff --git a/src/design-system/primitives/Pagination/Pagination.test.tsx b/src/design-system/primitives/Pagination/Pagination.test.tsx new file mode 100644 index 0000000..826c206 --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Pagination, getPageRange } from './Pagination' + +describe('getPageRange', () => { + it('returns all pages when totalPages is small', () => { + expect(getPageRange(1, 5, 1)).toEqual([1, 2, 3, 4, 5]) + }) + + it('returns only page 1 when totalPages is 1', () => { + expect(getPageRange(1, 1, 1)).toEqual([1]) + }) + + it('adds left ellipsis when current page is far from start', () => { + const range = getPageRange(10, 20, 1) + expect(range[0]).toBe(1) + expect(range[1]).toBe('ellipsis') + }) + + it('adds right ellipsis when current page is far from end', () => { + const range = getPageRange(1, 20, 1) + const lastEllipsisIdx = range.lastIndexOf('ellipsis') + expect(lastEllipsisIdx).toBeGreaterThan(-1) + expect(range[range.length - 1]).toBe(20) + }) + + it('shows first and last page always', () => { + const range = getPageRange(5, 20, 1) + expect(range[0]).toBe(1) + expect(range[range.length - 1]).toBe(20) + }) + + it('renders pages around current with siblingCount=1', () => { + // page 5, totalPages 20, siblingCount 1 → 1 ... 4 5 6 ... 20 + const range = getPageRange(5, 20, 1) + expect(range).toContain(4) + expect(range).toContain(5) + expect(range).toContain(6) + }) + + it('renders wider sibling window with siblingCount=2', () => { + // page 10, totalPages 20, siblingCount 2 → 1 ... 8 9 10 11 12 ... 20 + const range = getPageRange(10, 20, 2) + expect(range).toContain(8) + expect(range).toContain(9) + expect(range).toContain(10) + expect(range).toContain(11) + expect(range).toContain(12) + }) + + it('does not duplicate page 1 when siblings reach the start', () => { + const range = getPageRange(2, 20, 1) + const ones = range.filter(x => x === 1) + expect(ones).toHaveLength(1) + }) + + it('does not duplicate last page when siblings reach the end', () => { + const range = getPageRange(19, 20, 1) + const twenties = range.filter(x => x === 20) + expect(twenties).toHaveLength(1) + }) +}) + +describe('Pagination', () => { + it('renders prev and next buttons', () => { + render() + expect(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument() + }) + + it('marks the current page with aria-current', () => { + render() + const activeBtn = screen.getByRole('button', { name: 'Page 5' }) + expect(activeBtn).toHaveAttribute('aria-current', 'page') + }) + + it('shows ellipsis when pages are far apart', () => { + render() + const ellipses = screen.getAllByText('…') + expect(ellipses.length).toBeGreaterThanOrEqual(1) + }) + + it('disables prev button on first page', () => { + render() + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled() + }) + + it('does not disable next button on first page', () => { + render() + expect(screen.getByRole('button', { name: 'Next page' })).not.toBeDisabled() + }) + + it('disables next button on last page', () => { + render() + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled() + }) + + it('does not disable prev button on last page', () => { + render() + expect(screen.getByRole('button', { name: 'Previous page' })).not.toBeDisabled() + }) + + it('fires onPageChange with page - 1 when prev is clicked', async () => { + const user = userEvent.setup() + const onPageChange = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Previous page' })) + expect(onPageChange).toHaveBeenCalledWith(4) + }) + + it('fires onPageChange with page + 1 when next is clicked', async () => { + const user = userEvent.setup() + const onPageChange = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Next page' })) + expect(onPageChange).toHaveBeenCalledWith(6) + }) + + it('fires onPageChange with the clicked page number', async () => { + const user = userEvent.setup() + const onPageChange = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Page 6' })) + expect(onPageChange).toHaveBeenCalledWith(6) + }) + + it('applies custom className', () => { + const { container } = render( + + ) + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('renders with siblingCount=0 showing only current page between first and last', () => { + render() + expect(screen.getByRole('button', { name: 'Page 10' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Page 20' })).toBeInTheDocument() + }) +}) diff --git a/src/design-system/primitives/Pagination/Pagination.tsx b/src/design-system/primitives/Pagination/Pagination.tsx new file mode 100644 index 0000000..9bcf75a --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.tsx @@ -0,0 +1,113 @@ +import styles from './Pagination.module.css' +import type { HTMLAttributes } from 'react' + +interface PaginationProps extends HTMLAttributes { + page: number + totalPages: number + onPageChange: (page: number) => void + siblingCount?: number + className?: string +} + +export function getPageRange( + page: number, + totalPages: number, + siblingCount: number +): (number | 'ellipsis')[] { + if (totalPages <= 1) return [1] + + // If total pages fit in a compact window, just return all pages with no ellipsis + const totalPageNumbers = siblingCount * 2 + 5 // 1(first) + 1(last) + 1(current) + 2*siblings + 2(ellipsis slots) + if (totalPages <= totalPageNumbers) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + const range: (number | 'ellipsis')[] = [] + + const left = Math.max(2, page - siblingCount) + const right = Math.min(totalPages - 1, page + siblingCount) + + // First page always shown + range.push(1) + + // Left ellipsis: gap between 1 and left boundary hides at least 1 page + if (left > 2) { + range.push('ellipsis') + } + + // Middle pages + for (let i = left; i <= right; i++) { + range.push(i) + } + + // Right ellipsis: gap between right boundary and last page hides at least 1 page + if (right < totalPages - 1) { + range.push('ellipsis') + } + + // Last page always shown + range.push(totalPages) + + return range +} + +export function Pagination({ + page, + totalPages, + onPageChange, + siblingCount = 1, + className, + ...rest +}: PaginationProps) { + const pageRange = getPageRange(page, totalPages, siblingCount) + + return ( + + ) +}