Component contract
Renders pagination controls. Stateless. Parent owns the page state and re-fetches on change.
page: number— 1-indexed.pageSize: numbertotal: number— total row count across all pages.onPageChange: (page) => voidonPageSizeChange?: (size) => void— when omitted, page-size selector is not rendered.pageSizeOptions?: number[]— defaults to [25, 50, 100].maxPageButtons?: number— defaults to 7. Ellipsis for ranges that exceed.showFirstLast?: boolean— defaults to true when total pages > maxPageButtons.label?: string— singular for the "showing X–Y of Z [label]" line; defaults to "results".
Interaction surface
-
Renders status text "Showing X–Y of Z [label]".
Where X = (page-1)*pageSize + 1, Y = min(page*pageSize, total), Z = total. When total is 0, status reads "No [label]". Status is always rendered when component is rendered.
-
Page buttons render the current page + neighbors + ellipsis.
For total pages ≤ maxPageButtons, all page numbers render. Beyond that, render: 1 ... [page-2] [page-1] [page] [page+1] [page+2] ... [last]. Ellipsis is non-clickable, marked
aria-hidden. -
Prev / next disabled at boundaries.
Prev disabled on page 1; next disabled on the last page. Disabled state is real
disabled+aria-disabled="true", not just visual. -
First / last shortcuts when far from edges.
When showFirstLast is true and the user is past page 5, render explicit "first" and "last" controls in addition to ellipsis.
-
Page-size change resets to page 1.
Changing page size from 25 to 100 calls both
onPageSizeChange(100)andonPageChange(1)in the same tick. Otherwise the user could be on page 5 of 25-row pages then jump to page 5 of 100-row pages and skip ahead.
Failure modes
Status text says "Showing 21–40 of ?"
Trigger: total is undefined or null because the parent's API doesn't return it.
Total is REQUIRED by contract. If the parent's API doesn't have it, the parent must compute or estimate it before rendering the component. The component does not render "?" or "many". The harness asserts: render with total=undefined → component throws or renders explicit dev-mode error, not silent "?".
Page-size change leaves you on a page that doesn't exist
Trigger: user is on page 5 of 25-row pages (rows 101–125), changes pageSize to 100. New total pages is 2. Without page reset, user is on a non-existent page 5.
Page-size change MUST also call onPageChange(1). Harness: page=5, pageSize=25, total=125. Change pageSize to 100. Assert onPageChange(1) was called AND onPageSizeChange(100) was called.
Empty list shows pagination controls anyway
Trigger: total is 0 but prev/next/page-numbers still render.
When total is 0, render the status line ("No results") only. Hide the page buttons, prev/next, and ellipsis. The page-size selector is also hidden when there are 0 results (no point in choosing). Harness: render total=0, assert no ui-pagination-page-button elements visible.
Ellipsis is clickable
Trigger: ellipsis is rendered as a <button> with no handler, or as a focusable element.
Ellipsis is a non-interactive <span> with aria-hidden="true". It is not focusable. The harness asserts: ellipsis has no tabindex, no onClick, screen-reader skips it.
Disabled prev still navigates
Trigger: visually disabled but click handler still fires.
On page 1, clicking prev must NOT call onPageChange. The harness asserts: page=1, click prev, onPageChange NOT called. Same for next on the last page.
Page button shows current page as clickable
Trigger: current page button is rendered like every other page button — clickable, fires onPageChange(currentPage), causes a redundant re-fetch.
The current page is rendered as aria-current="page" + visual highlight + NOT clickable (no onClick wired, or click is a no-op). Harness: render page=3, click the "3" button, onPageChange NOT called.
Total count is wrong because parent races
Trigger: parent fetches in parallel with the previous fetch, the older total wins.
Out of scope for the component — but the harness, when running a branch story, asserts the total displayed in the status string matches the API response that produced the current rows. A mismatch is logged at the branch level. Component-level assertion: when total prop changes, status text updates within the same render.
Far-from-edges first/last not rendered
Trigger: showFirstLast is true but the user on page 50 of 100 only sees prev/next/some-numbers, no "first" or "last" jump.
At page 50 of 100, the visible buttons should be: First, ..., 48, 49, 50, 51, 52, ..., Last. The harness asserts: render at page 50/100, both ui-pagination-first and ui-pagination-last buttons are visible.
Accessibility
- Wrapping element is
<nav aria-label="Pagination">. - Page buttons are
<button>. Current page hasaria-current="page". - Disabled prev/next uses both
disabledattribute andaria-disabled="true". - Ellipsis is
<span aria-hidden="true">…</span>. - Status text "Showing X–Y of Z" is in a region with
aria-live="polite"so screen readers announce changes. - Color contrast: page button text ≥ 4.5:1, current page indicator ≥ 3:1 against its background.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-pagination | Outer <nav> | Component identity |
ui-pagination-status | "Showing X–Y of Z" | Status text region |
ui-pagination-prev | Prev button | Always rendered when total > 0 |
ui-pagination-next | Next button | Always rendered when total > 0 |
ui-pagination-first | First button | Visible only when showFirstLast=true and far from page 1 |
ui-pagination-last | Last button | Visible only when showFirstLast=true and far from last page |
ui-pagination-page-button | Each page number | data-page identifies which page |
ui-pagination-current-page | The active page | aria-current=page; not clickable |
ui-pagination-ellipsis | Range ellipsis | aria-hidden; non-interactive |
ui-pagination-page-size | Page-size selector | Visible only when onPageSizeChange provided |
Agent test plan
Standalone probes against /admin-test/ui-pagination-fixture with variants: small (total=10), medium (total=200), large (total=10000), single page (total=5), empty (total=0), no-page-size-selector.
Probe list
- status-text-correct-mid-list: page=2, pageSize=25, total=125 → text "Showing 26–50 of 125 results"
- status-text-last-page: page=5, pageSize=25, total=110 → text "Showing 101–110 of 110 results"
- status-text-empty: total=0 → text "No results"
- prev-disabled-on-first-page: page=1 → prev has disabled AND aria-disabled=true
- prev-noop-on-first-page: page=1, click prev, onPageChange NOT called
- next-disabled-on-last-page: page=5/5 → next has disabled AND aria-disabled=true
- current-page-not-clickable: page=3, click "3" button, onPageChange NOT called
- current-page-aria-current: page=3 → that button has aria-current=page
- page-size-change-resets-page: page=5, pageSize=25, change to 100 → onPageChange(1) AND onPageSizeChange(100)
- empty-hides-controls: total=0 → no ui-pagination-page-button visible, no prev/next visible
- ellipsis-not-interactive: large total → ui-pagination-ellipsis has aria-hidden=true, no tabindex
- first-last-far-from-edge: page=50, total=2500 → ui-pagination-first AND ui-pagination-last visible
- first-not-rendered-near-start: page=2, total=200 → ui-pagination-first not visible (or marked redundant)
- max-page-buttons-respected: total=2500, maxPageButtons=7 → at most 7 numbered buttons visible (excluding first/last)
- aria-live-on-status: status region has aria-live=polite
- color-contrast-buttons: page button text ≥ 4.5:1
- color-contrast-current: current-page indicator ≥ 3:1
- axe-clean-serious: no serious axe violations