feat: add Pagination primitive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 12:39:46 +01:00
parent 45d56262ea
commit ab5b792648
3 changed files with 333 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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(<Pagination page={5} totalPages={20} onPageChange={vi.fn()} />)
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(<Pagination page={5} totalPages={20} onPageChange={vi.fn()} />)
const activeBtn = screen.getByRole('button', { name: 'Page 5' })
expect(activeBtn).toHaveAttribute('aria-current', 'page')
})
it('shows ellipsis when pages are far apart', () => {
render(<Pagination page={10} totalPages={20} onPageChange={vi.fn()} />)
const ellipses = screen.getAllByText('…')
expect(ellipses.length).toBeGreaterThanOrEqual(1)
})
it('disables prev button on first page', () => {
render(<Pagination page={1} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
})
it('does not disable next button on first page', () => {
render(<Pagination page={1} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Next page' })).not.toBeDisabled()
})
it('disables next button on last page', () => {
render(<Pagination page={10} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('does not disable prev button on last page', () => {
render(<Pagination page={10} totalPages={10} onPageChange={vi.fn()} />)
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(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
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(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
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(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
await user.click(screen.getByRole('button', { name: 'Page 6' }))
expect(onPageChange).toHaveBeenCalledWith(6)
})
it('applies custom className', () => {
const { container } = render(
<Pagination page={1} totalPages={5} onPageChange={vi.fn()} className="custom-class" />
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('renders with siblingCount=0 showing only current page between first and last', () => {
render(<Pagination page={10} totalPages={20} onPageChange={vi.fn()} siblingCount={0} />)
expect(screen.getByRole('button', { name: 'Page 10' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Page 20' })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,113 @@
import styles from './Pagination.module.css'
import type { HTMLAttributes } from 'react'
interface PaginationProps extends HTMLAttributes<HTMLElement> {
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 (
<nav
aria-label="Pagination"
className={`${styles.pagination} ${className ?? ''}`}
{...rest}
>
<button
className={styles.navBtn}
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
>
&lt;
</button>
{pageRange.map((item, idx) => {
if (item === 'ellipsis') {
return (
<span key={`ellipsis-${idx}`} className={styles.ellipsis} aria-hidden="true">
&hellip;
</span>
)
}
const isActive = item === page
return (
<button
key={item}
className={`${styles.pageBtn} ${isActive ? styles.active : ''}`}
onClick={() => onPageChange(item)}
aria-current={isActive ? 'page' : undefined}
aria-label={`Page ${item}`}
disabled={isActive}
>
{item}
</button>
)
})}
<button
className={styles.navBtn}
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
>
&gt;
</button>
</nav>
)
}