Pagination
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/pagination.jsonIt does not fetch, and it does not slice. It is told the page and the total
and it says which page was asked for — because whether paging means an OFFSET
or an Array.prototype.slice is the product’s business, and a component that
guesses gets it wrong for the other one.
const [page, setPage] = useState(1)const [pageSize, setPageSize] = useState(25)const [from, to] = pageRange(page, pageSize, total)
<span>{from}-{to} of {total}</span><Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} labels={{ region: 'Pages', previous: 'Previous', next: 'Next', page: (n) => `Page ${n}` }}/>The row keeps a steady width. This is the part that takes the arithmetic. A window of a fixed number of pages looks right in the middle and comes up short at the ends, where one of the gaps is not needed — the row loses a place, and the “next” button moves under the pointer as you use it. So the run of pages is grown to whatever fills the row, given how many gaps it has.
A gap never stands for a single page. 1 … 3 hides only page 2, which the
row had room for. Near an end the run slides onto the edge instead — and it
slides rather than dropping the page, because leaving it out silently is worse
than the gap: the row would say 1 is followed by 3 and give no sign that
anything was missing.
The gap is not a button and not hidden either. Drawn as a disabled button it would give a keyboard stops that lead nowhere; hidden from a screen reader it would announce a jump from 2 to 40 with no reason for it.
The current page is announced. aria-current="page", not colour alone —
and not aria-selected, which belongs to a listbox.
labels is required. These are the only words the control has, and a
default here would be English shipped inside a primitive. page takes the
number, because “Page 3” is a sentence only the product’s language can build.
How many rows to a page is PageSize, next door. The two usually sit together and are needed apart often enough to be separate: a list that scrolls for ever wants “how many to load at a time” and no page buttons, and a table with a fixed page size wants the buttons and no choice.
The arithmetic is exported for a product that draws its own: pageCount(total, pageSize) is at least 1, because “page 1 of 0” reads as broken; pageRange(page, pageSize, total) gives [from, to] with to clamped, so the last page says what it holds; pageWindow(page, pages, around?) gives the numbers and 'gap' entries the control draws.
Props
Read from the types in ui/pagination.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
| Prop | Type | Default | |
|---|---|---|---|
pagerequired | number | The current page, counting from one. Clamped into range rather than drawing a hole. | |
pageSizerequired | number | How many rows a page holds. | |
totalrequired | number | How many rows there are altogether - rows, not pages. | |
onPageChangerequired | (page: number) => void | Called with the page to go to, from the arrows and the numbered buttons. | |
around | number | 1 | How many pages either side of the current one. |
labelsrequired | PaginationLabels | What a screen reader hears for the region and its buttons. Required rather than defaulted: a default here would be English shipped inside a primitive, and these are the only words the control has. | |
className | string |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 12 rules applied and passed, and none failed. The accessibility report has every item.
Left to a person, where axe could not decide: label-content-name-mismatch.
History
Each release that changed ui/pagination.tsx - the file a product copies - newest first.