← All stories

COMPONENT · ui-pagination

ui-pagination

Component Tier 1 (primitive) Used by every paginated list (mandate: every list endpoint is paginated)

The footer that turns "we have N rows" into a navigable list. Page numbers, prev/next, optional first/last, page-size selector, "Showing X–Y of Z" status. Mandatory mechanics: always render the total count, never silently round to "many", never leave the user wondering what page they're on.

Component contract

Renders pagination controls. Stateless. Parent owns the page state and re-fetches on change.

  • page: number — 1-indexed.
  • pageSize: number
  • total: number — total row count across all pages.
  • onPageChange: (page) => void
  • onPageSizeChange?: (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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Page-size change resets to page 1.

    Changing page size from 25 to 100 calls both onPageSizeChange(100) and onPageChange(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 has aria-current="page".
  • Disabled prev/next uses both disabled attribute and aria-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-testWherePurpose
ui-paginationOuter <nav>Component identity
ui-pagination-status"Showing X–Y of Z"Status text region
ui-pagination-prevPrev buttonAlways rendered when total > 0
ui-pagination-nextNext buttonAlways rendered when total > 0
ui-pagination-firstFirst buttonVisible only when showFirstLast=true and far from page 1
ui-pagination-lastLast buttonVisible only when showFirstLast=true and far from last page
ui-pagination-page-buttonEach page numberdata-page identifies which page
ui-pagination-current-pageThe active pagearia-current=page; not clickable
ui-pagination-ellipsisRange ellipsisaria-hidden; non-interactive
ui-pagination-page-sizePage-size selectorVisible 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