feat: add Pagination primitive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
141
src/design-system/primitives/Pagination/Pagination.test.tsx
Normal file
141
src/design-system/primitives/Pagination/Pagination.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
113
src/design-system/primitives/Pagination/Pagination.tsx
Normal file
113
src/design-system/primitives/Pagination/Pagination.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pageRange.map((item, idx) => {
|
||||||
|
if (item === 'ellipsis') {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${idx}`} className={styles.ellipsis} aria-hidden="true">
|
||||||
|
…
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user