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).
Quando esta spec se aplica
Triggers primários
- Render a Koder pagination control
Todos os triggers
- Render a pagination / page control (prev · numbered · next)
- Let the user move between pages of a list or table
- Show a windowed page run with an ellipsis for a large page count
- Integrate a page control under a data table
Corpo da especificação
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 tocheckbox.rs/radio.rs), not inferred. The windowing model is MUIusePagination(boundaryCount/siblingCount); the a11y / keyboard contract is borrowed fromnavigation.kmdR10/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:
prev— a left-pointing chevron button. Always present as a cell; disabled (non-interactive) on page 1.- The windowed page run — one numbered button per shown page, with an ellipsis cell standing in for each elided run (§R2).
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(MUIboundaryCount) — pages pinned at each end. Default 1, clamped to a minimum of 1 (.boundary()applies.max(1)).siblings(MUIsiblingCount) — 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 · window | page_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 page — accent fill (
palette.accent) behind the digit, which paints inpalette.accent_on. This is the "selected" style. Re-activating the current page is a no-op ("disabled-as-current") — it fires noon_change— but the cell stays focusable and in the tab order so a screen reader can land on it and read itsaria-currentstate (§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), perstates.kmdR2 /navigation.kmdR11. 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 exportedGroupreportsenabled = 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):
| Key | Action |
|---|---|
| Tab / Shift+Tab | Visit 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) |
| Home | Focus the first control |
| End | Focus the last control |
| Enter / Space | Activate 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.
- Cursor —
Cursor::Pointerover an enabled interactive cell,Cursor::Defaultelsewhere (including over a disabled prev/next or an ellipsis).
R6 — Accessibility / semantics
The control exports (navigation.kmd R10 semantics):
- A
SemRole::Groupcontainer labelled "Pagination", carrying the widget's geometry and node id, withenabled = !disabled. - One
SemRole::Buttonchild per control:prev→ label "Previous page",enabledreflecting the page-1 state.next→ label "Next page",enabledreflecting the last-page state.- each page → label = the page number as a string; the current page carries
value = "current"— thearia-current="page"analogue.
- Each ellipsis is a non-interactive
SemRole::Labellabelled "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:
- the windowing output for a large range and for a range that fits (§R2);
prev/nextare disabled and dropped from the focus order at the bounds, and the current page carriesvalue = "current"(§R3, §R6);- Enter on a focused control fires
on_changewith the new page, and the arrows / Home / End rove focus between controls (§R4); - a pointer click on a page cell navigates + fires
on_change, while a disabledprevignores the click (§R5); - (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
Paletteis adopted throughWidget::set_palette(kroma#194);paintreadsaccent,accent_on,on_surface,on_surface_dim,hover_overlayandfocus_ringlive, so a runtime theme switch re-themes the control. The default is dark (bit-for-bit the legacyvergeconstants). Cached page glyphs bake their colour at construction, soset_paletterebuilds 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 throughset_shape(kroma#278). Tokens perspecs/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
prevonto 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-currentcell chosen here (§R3).
Cross-link
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 run1 … 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()yields1 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()yields1 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
prevbutton is inspected and clicked - THEN
prevreports enabled = false and its chevron paints at alpha 0.38 - AND
previs 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
nextbutton is inspected and activated - THEN
nextreports enabled = false - AND
nextis absent from the collected focus stops - AND no
on_changeis 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_changecallback 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_changeis 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::Grouplabelled "Pagination" - AND the page-5 button carries
value = "current" - AND each ellipsis is a non-interactive
SemRole::Labellabelled "More pages" with no node id
Scenario: Controlled set_current is silent
- GIVEN a pagination control on page 3 with an
on_changehandler installed - WHEN
set_current(7)is called programmatically - THEN
current()returns 7 - AND
on_changeis not fired
Referências
specs/components/data-table.kmdspecs/components/navigation.kmdspecs/themes/verge.kmdspecs/themes/light-dark.kmdspecs/accessibility/conformance-br.kmd