Skip to content

Pagination component

components specs/components/pagination.kmd

A font-optional page control — a `‹ prev` button, a windowed run of numbered page buttons with an ellipsis when the range is large (`1 … 4 5 6 … 20`), and a `next ›` button. The current page renders in the accent-filled style and re-activating it is a no-op ("disabled-as-current"); prev is disabled on page 1 and next on the last page. Modelled after MUI `usePagination` (`boundaryCount` / `siblingCount`) for the windowing, and the gov.br DS `.br-pagination` for parity. Opened from the KDS × gov.br gap analysis (stack#393, GVB-25); implemented as the Kroma widget `engines/sdk/kroma/src/widget/pagination.rs` (kroma#184, GVB-03).

When this spec applies

Primary triggers

All triggers

Specification body

Pagination

Status: v0.1.0 — Draft. Behaviour below is MEASURED from the shipped Kroma widget engines/sdk/kroma/src/widget/pagination.rs (kroma#184 — GVB-03, on the widget-extensibility substrate kroma#201, sibling to checkbox.rs / radio.rs), not inferred. The windowing model is MUI usePagination (boundaryCount / siblingCount); the a11y / keyboard contract is borrowed from navigation.kmd R10/R11 (pagination is the secondary-nav control). Anything the widget does not yet do lives in §Não-escopo / follow-up below — it is not described as if it exists.

Why it exists

A list or table longer than one screen needs a way to move between pages. The pagination control is the standard affordance: a compact row of a previous button, a numbered run that stays short even over thousands of pages by eliding the middle (1 … 4 5 6 … 20), and a next button. It is the secondary-nav counterpart to the primary-nav trio in navigation.kmd, and it is the page control a data table reaches for (specs/components/data-table.kmd R5).

R1 — Anatomy

The control is a single horizontal row of square cells, laid out left to right in this order:

  1. prev — a left-pointing chevron button. Always present as a cell; disabled (non-interactive) on page 1.
  2. The windowed page run — one numbered button per shown page, with an ellipsis cell standing in for each elided run (§R2).
  3. next — a right-pointing chevron button. Always present as a cell; disabled on the last page.

Geometry (measured constants, pagination.rs):

  • Each cell is a CELL = 36.0 px square (a comfortable click target for prev/next and each page).
  • Adjacent cells are separated by CELL_GAP = 4.0 px.
  • The laid-out size is width = Σcells + gaps (the trailing gap is trimmed), height = CELL. A 5-page control (prev + 5 pages + next) lays out at ≈276 px wide.

The prev/next chevrons and the ellipsis are drawn as vector paths (chevron arm 5.0 px, 2.0 px stroke; ellipsis = three dots radius 1.2 px spaced 4 px), so they render with no font. Only the page digits need a font (§R7).

R2 — Windowing (MUI usePagination model)

Which pages and ellipses appear is resolved by page_items() — the font-free, deterministic unit-testable heart of the widget. Two knobs shape the window:

  • boundary (MUI boundaryCount) — pages pinned at each end. Default 1, clamped to a minimum of 1 (.boundary() applies .max(1)).
  • siblings (MUI siblingCount) — pages shown on each side of the current page. Default 1.

The output is: the pinned boundary pages, then a left gap, then the sibling window around the current page, then a right gap, then the pinned end pages. Each gap resolves to one of:

  • an ellipsis (PageItem::Ellipsis) when the hidden run is longer than one page, or
  • a single bridging page when the gap is exactly one page (no ellipsis for a gap of one — a hiding a single page would waste the same width the page itself takes), or
  • nothing when the window already reaches the boundary.

Measured examples (from the widget's own tests):

total · current · windowpage_items()
20 · page 5 · (1s/1b)1 … 4 5 6 … 20
20 · page 1 · (1s/1b)1 2 3 4 5 … 20 (no leading ellipsis)
5 · page 3 · (1s/1b)1 2 3 4 5 (fits entirely — no ellipsis)

total is clamped to ≥ 1 at construction and on set_total; current is clamped into 1..=total.

R3 — States

Every cell paints one of the widget's defined states, all read live from the active Palette (§R8):

  • Current pageaccent fill (palette.accent) behind the digit, which paints in palette.accent_on. This is the "selected" style. Re-activating the current page is a no-op ("disabled-as-current") — it fires no on_change — but the cell stays focusable and in the tab order so a screen reader can land on it and read its aria-current state (§R6). This is a deliberate choice over hard-disabling the current page (see §Não-escopo).
  • prev disabled on page 1 / next disabled on the last page — the chevron dims to alpha 0.38 and the cell is dropped from the focus order (not a Tab stop, ignores pointer and keyboard).
  • Hover — an enabled, non-current cell under the pointer fills with palette.hover_overlay. A disabled cell never shows hover.
  • Focus — the focused control draws a 2.0 px focus ring OUTSIDE the cell (inset −2 px on every side, palette.focus_ring), per states.kmd R2 / navigation.kmd R11. Only the one focused control shows it.
  • Whole-control disabled (set_disabled(true)) — every cell drops out of the focus order, ignores all input, and the digits dim to alpha 0.38. The exported Group reports enabled = false.

This slice uses the shared hover/pressed overlay; the precise gov.br / Material 8 %/12 % state-layer alphas and the selected-state animation are deferred (§Não-escopo).

R4 — Keyboard

The control borrows the navigation.kmd R10 contract for a nav control. Every enabled control is exposed as a focus stop (collect_focusables), and the widget draws its own cells, moving focus by requesting focus on a neighbouring cell id (the self-drawing group pattern shared with RadioGroup — no per-cell child widget):

KeyAction
Tab / Shift+TabVisit each enabled control in paint order (prev, each shown page including the current, next)
← / ↑Rove focus to the previous control (clamps at the first — no wrap)
→ / ↓Rove focus to the next control (clamps at the last — no wrap)
HomeFocus the first control
EndFocus the last control
Enter / SpaceActivate the focused control

The focus order is: prev (omitted on page 1), each shown page (the current page included), then next (omitted on the last page). Activating prev steps back one page; next steps forward one; a page cell jumps to that page; each firing on_change(page). Re-activating the current page fires nothing (§R3). A key event reaches the whole tree, but the widget acts only when one of its own controls holds the focus.

R5 — Pointer

  • Move — sets the hovered cell (only an enabled, interactive cell hovers).
  • Left press (when not a forced/synthetic event) — presses and focuses the cell and captures the pointer, consuming the event.
  • Left release — fires the activation only if the release lands back on the pressed cell (a press that drags off and releases elsewhere is cancelled); the capture is released either way.
  • CursorCursor::Pointer over an enabled interactive cell, Cursor::Default elsewhere (including over a disabled prev/next or an ellipsis).

R6 — Accessibility / semantics

The control exports (navigation.kmd R10 semantics):

  • A SemRole::Group container labelled "Pagination", carrying the widget's geometry and node id, with enabled = !disabled.
  • One SemRole::Button child per control:
    • prev → label "Previous page", enabled reflecting the page-1 state.
    • next → label "Next page", enabled reflecting the last-page state.
    • each page → label = the page number as a string; the current page carries value = "current" — the aria-current="page" analogue.
  • Each ellipsis is a non-interactive SemRole::Label labelled "More pages" (no node id, never a Tab stop).

Every button reports its enabled state and, when focused, its focused flag, so the introspection/snapshot oracle (kroma#088) and an AT both see the live state.

R7 — Font-optional / testability

The windowing (page_items), the current/disabled state, the keyboard traversal, the on_change firing, the semantics and the hit-testing are all font-free and driven by plain page numbers — they are unit-testable with no font mounted. A font is used only in paint, to glyph-render the page digits; without one, the cells still lay out, traverse, hit-test and report semantics — they simply paint no digits, while the accent fill, chevrons and ellipsis dots (all vector paths) still render. A surface claiming this component MUST prove, headlessly:

  1. the windowing output for a large range and for a range that fits (§R2);
  2. prev/next are disabled and dropped from the focus order at the bounds, and the current page carries value = "current" (§R3, §R6);
  3. Enter on a focused control fires on_change with the new page, and the arrows / Home / End rove focus between controls (§R4);
  4. a pointer click on a page cell navigates + fires on_change, while a disabled prev ignores the click (§R5);
  5. (with a GPU) the current-page accent highlight moves to the clicked cell — the salient visible state change.

R8 — Theming (active DS)

Every colour and radius is read from the active design system, not hardcoded:

  • The Palette is adopted through Widget::set_palette (kroma#194); paint reads accent, accent_on, on_surface, on_surface_dim, hover_overlay and focus_ring live, so a runtime theme switch re-themes the control. The default is dark (bit-for-bit the legacy verge constants). Cached page glyphs bake their colour at construction, so set_palette rebuilds them (or they would pin the old theme's colour). Light/dark both apply (specs/themes/light-dark.kmd).
  • The cell corner radius comes from Shape (radius_btn), adopted through set_shape (kroma#278). Tokens per specs/themes/verge.kmd.

R9 — Public API / controlled mode

Pagination::new(id, total, on_change) builds the control (total clamped ≥ 1, starting on page 1, default 1 sibling / 1 boundary, no font). Builder setters: .page(p), .siblings(s), .boundary(b), .font(bytes, size_px). Runtime setters for a reconciler / host-composed update: set_font, set_current, set_total, set_disabled, set_on_change, and the reader current().

Controlled mode: set_current(p) moves the current page without firing on_change (the host owns the source of truth and pushes the page in); only user navigation (pointer/keyboard activation) fires on_change. set_total re-clamps the current page into the new range.

R10 — gov.br parity

This control is the KDS answer to the gov.br DS .br-pagination (epic govbr-ds-parity, GVB-25). It delivers, at parity, the page-run navigation: prev/next with disabled bounds, a numbered run, the ellipsis for large ranges, current-page marking, and full keyboard + screen-reader support. The gov.br per-page selector, go-to-page field and result counter are not yet part of this widget — see §Não-escopo. A gov.br surface MUST also meet specs/accessibility/conformance-br.kmd (eMAG) for the overall page.

Data-table integration

The data table (specs/components/data-table.kmd R5) chooses pagination for the 50–500-row band. It composes this control: the table owns the page state and row slice, feeds total and set_current, and reacts to on_change(page). The widget itself is a standalone, self-contained leaf — it holds no table coupling; the integration is composition by the host surface, not a feature of the widget.

Não-escopo / follow-up

The ticket scope named three affordances the shipped widget does not implement — they are follow-ups, not present behaviour:

  • Per-page (rows-per-page) select — the "N per page" dropdown is not part of the widget; a host composes a separate select and drives set_total.
  • Go-to-page field — the "jump to page" input is not implemented; only prev/next stepping and clicking a shown page navigate.
  • Result counter — the "showing X–Y of Z" / "página X de Y" text is not rendered by the widget.

Deferred in the widget's own module doc (named, not silently dropped):

  • Selected-state animation — the active cell swaps style with no motion.
  • Density variants and per-preset styling (the kit's R12/R13).
  • The precise 8 %/12 % hover/pressed state-layer alphas — this slice uses the shared hover overlay.
  • Smart focus retargeting when an activated control becomes disabled (e.g. clicking prev onto page 1) — focus is left on the now-disabled control for the host to fix.
  • Wrap-around arrow navigation — this slice clamps at the ends (Home/End reach the extremes).
  • A hard-disabled current page (dropped from the focus order) as an alternative to the focusable aria-current cell chosen here (§R3).
  • specs/components/data-table.kmd — the primary consumer (R5 decision tree).
  • specs/components/navigation.kmd — the nav-control contract this borrows (R10 keyboard/semantics, R11 states/focus ring).
  • specs/themes/verge.kmd · specs/themes/light-dark.kmd — the token / theme source (§R8).
  • specs/accessibility/conformance-br.kmd — eMAG obligations for a gov.br surface (§R10).

Requirements (testable)

Requirement: Windowed page run with ellipsis {#req-pagination-windowing}

The control SHALL resolve the visible page run deterministically via page_items(), pinning boundary pages (default 1, minimum 1) at each end and showing siblings pages (default 1) on each side of the current page, eliding each hidden run longer than one page to a single ellipsis, bridging a gap of exactly one page with that page (no ellipsis), and omitting an ellipsis entirely when the window already reaches the boundary; total SHALL be clamped to at least 1 and current into 1..=total.

Scenario: Large range elides the middle

  • GIVEN a pagination control with total 20, boundary 1 and siblings 1
  • WHEN the current page is set to 5
  • THEN page_items() yields the run 1 … 4 5 6 … 20
  • AND an ellipsis stands in for each hidden run longer than one page

Scenario: Range that fits shows no ellipsis

  • GIVEN a pagination control with total 5, boundary 1 and siblings 1
  • WHEN the current page is set to 3
  • THEN page_items() yields 1 2 3 4 5
  • AND no ellipsis item is present

Scenario: First page suppresses the leading ellipsis

  • GIVEN a pagination control with total 20, boundary 1 and siblings 1
  • WHEN the current page is set to 1
  • THEN page_items() yields 1 2 3 4 5 … 20
  • AND there is no leading ellipsis before the sibling window

Requirement: Boundary buttons disabled and dropped from focus order {#req-pagination-bounds}

The prev button SHALL be disabled on page 1 and the next button SHALL be disabled on the last page; a disabled bound button SHALL dim its chevron to alpha 0.38, SHALL be dropped from the focus order (not a Tab stop), and SHALL ignore pointer and keyboard input.

Scenario: prev is inert on the first page

  • GIVEN a pagination control positioned on page 1
  • WHEN the prev button is inspected and clicked
  • THEN prev reports enabled = false and its chevron paints at alpha 0.38
  • AND prev is absent from the collected focus stops
  • AND the click fires no on_change

Scenario: next is inert on the last page

  • GIVEN a pagination control with total 20 positioned on page 20
  • WHEN the next button is inspected and activated
  • THEN next reports enabled = false
  • AND next is absent from the collected focus stops
  • AND no on_change is fired

Requirement: Current page is the accent no-op {#req-pagination-current-noop}

The current page cell SHALL paint in the accent-filled style (palette.accent behind a digit in palette.accent_on) and SHALL remain focusable and in the tab order, but re-activating it SHALL be a no-op that fires no on_change ("disabled-as-current").

Scenario: Re-activating the current page changes nothing

  • GIVEN a pagination control on page 5 with the page-5 cell focused
  • WHEN Enter is pressed on the focused current-page cell
  • THEN no on_change callback is fired
  • AND the current page remains 5
  • AND the page-5 cell stays present in the focus order

Requirement: Keyboard traversal and activation {#req-pagination-keyboard}

Every enabled control (prev, each shown page including the current, next) SHALL be a focus stop in paint order; Tab / Shift+Tab SHALL visit them, ← / ↑ SHALL rove focus to the previous control and → / ↓ to the next (both clamping at the ends with no wrap), Home / End SHALL focus the first / last control, and Enter / Space SHALL activate the focused control, firing on_change with the target page for a navigating activation.

Scenario: Arrow keys rove focus and clamp at the first control

  • GIVEN a pagination control with the first control (prev) focused
  • WHEN the ← key is pressed
  • THEN focus stays on the first control (no wrap-around)
  • WHEN the → key is pressed
  • THEN focus moves to the next control in paint order

Scenario: Enter on a page cell navigates

  • GIVEN a pagination control on page 5 with a non-current page cell focused
  • WHEN Enter is pressed on that focused cell
  • THEN on_change is fired with that cell's page number
  • AND current() reflects the new page

Requirement: Accessibility semantics and controlled mode {#req-pagination-a11y}

The control SHALL export a SemRole::Group labelled "Pagination" (enabled = !disabled) containing one SemRole::Button per control — prev labelled "Previous page", next labelled "Next page", each page labelled by its number with the current page carrying value = "current" (the aria-current="page" analogue) — and each ellipsis as a non-interactive SemRole::Label labelled "More pages"; set_current(p) SHALL move the current page WITHOUT firing on_change.

Scenario: Semantics expose current page and ellipsis

  • GIVEN a pagination control with total 20 on page 5
  • WHEN the exported semantics tree is read
  • THEN the container is a SemRole::Group labelled "Pagination"
  • AND the page-5 button carries value = "current"
  • AND each ellipsis is a non-interactive SemRole::Label labelled "More pages" with no node id

Scenario: Controlled set_current is silent

  • GIVEN a pagination control on page 3 with an on_change handler installed
  • WHEN set_current(7) is called programmatically
  • THEN current() returns 7
  • AND on_change is not fired

References