Breadcrumb component
components specs/components/breadcrumb.kmd
The location-trail widget: a row of location crumbs separated by a `/` glyph, every crumb but the last an activatable link, the last the current page (a non-interactive label — not a link, not clickable). An optional leading home icon paints crumb 0's visual (still a link to index 0). When the trail overflows its width constraint the middle crumbs collapse to an ellipsis (`first … current`). Font-free shell: the caller supplies each crumb's content widget, so the trail is unit-testable without a font. Backed by the Kroma widget engines/sdk/kroma/src/widget/breadcrumb.rs (kroma#184 / GVB-02). Exports a Group of Links + a final Label.
Quando esta spec se aplica
Triggers primários
- Render a breadcrumb location trail
Todos os triggers
- Add a breadcrumb / location trail (home > section > current page)
- Show where the user is in a hierarchy with clickable ancestor links
- Collapse a breadcrumb trail that is wider than its constraint
Corpo da especificação
Breadcrumb
Status: v0.1.0 — Draft. Opened from the KDS × gov.br DS parity gap (stack#392, GVB-25). Behaviour below is MEASURED from the shipping Kroma widget
engines/sdk/kroma/src/widget/breadcrumb.rs(kroma#184 / GVB-02) — every constant, semantic role, keyboard path, and API symbol is grounded in that source, not inferred. What the widget does not yet do lives in Não-escopo / follow-up, not in the normative body.
Why it exists
A breadcrumb answers "where am I, and how do I get back up?" — a horizontal
trail of the current location's ancestors, each an escape hatch to a level
above. It is a distinct pattern from the primary-navigation containers
(navigation.kmd's bar / rail / drawer): those are the app's global menu; a
breadcrumb is the location trail for the page you are on. It complements
back-behavior.kmd — back walks the Navigator stack one step at a time; the
breadcrumb lets the user jump directly to any ancestor level.
R1 — Anatomy
The trail is a single left-to-right row of visible slots, laid out in reading order, interleaving a separator before every slot but the first:
[home] / Docs / Guides / Current page
^link ^link ^link ^current (Label — not a link)
Slot geometry (from the widget consts — all in logical px):
| Element | Const | Value | Notes |
|---|---|---|---|
| Separator slot width | SEP_W | 16.0 | one / between two visible items |
| Separator/ellipsis glyph height | SEP_GLYPH_H | 10.0 | drawn centred in its slot |
| Collapse-ellipsis slot width | ELLIPSIS_W | 22.0 | the … standing in for hidden crumbs |
| Per-crumb horizontal padding | CRUMB_PAD_H | 4.0 | added on each side of the content |
| Minimum row height | MIN_ROW_H | 24.0 | comfortable target even for short content |
- A crumb's slot width =
content.width + 2 * CRUMB_PAD_H. - Row height =
max(MIN_ROW_H, SEP_GLYPH_H, …every crumb's content height). - Natural full width =
Σ slot_widths + SEP_W * (n − 1). - Crumb content is cross-centred vertically within the row; the content
offset is
(slot.x + CRUMB_PAD_H, (row_h − content_h) / 2).
Font-free shell
The widget paints only the chrome it owns — the / separators, the collapse
ellipsis, and the per-link hover/focus/press affordances. The crumb content is
a widget the caller supplies (Text in real use, FillBox in tests), so the
shell paints no glyphs of its own and is unit-testable without a font. Link-vs-
current text styling (accent colour, weight) is therefore the caller's
concern — it lives in the content widget, not the shell.
The separator + ellipsis are chrome, not content
- Separator (
/): a diagonal hairline,Stroke::new(1.5), drawn from(cx−3, midy+5)to(cx+3, midy−5), colouredpalette.on_surface_dim. It is decorative — it contributes no accessibility node. - Ellipsis (
…): three filled circles of radius1.6, spaced6.0apart, colouredpalette.on_surface_dim. Present only when the trail collapsed.
Optional home icon
.home(icon) sets an optional leading icon that replaces the painted visual of
crumb 0 (measured at the icon's natural size instead of crumb 0's content).
Crumb 0 stays a link to index 0 — the home icon is a paint substitution, not
a semantic change. With no home icon, crumb 0 paints its own content like any
other link.
R2 — The last crumb is the current page (non-link)
The last crumb in the trail is the current page. It is:
- a non-interactive
SemRole::Label(every other crumb is aSemRole::Link); - not clickable — a press/release on it fires nothing;
- not a Tab stop —
collect_focusablesnever emits its id; - its
Crumb::idis unused (it is not a link).
This is the structural conveyance of aria-current="page": the current location
is marked by being a Label rather than a Link, not by an ARIA attribute (see
Não-escopo).
R3 — Overflow collapse (first … current)
When the natural full width exceeds a finite width constraint, the trail collapses its middle. The collapse fires iff all three hold:
n ≥ 3(there is a middle to hide), and- the constraint's
max.widthis finite, and full_width > max.width.
On collapse the visible sequence becomes exactly [crumb 0, ellipsis, crumb n−1]
— i.e. first … current. Consequences:
- Only the first crumb stays a visible link; every hidden middle crumb drops out of the Tab order and the semantics tree.
- The current page (last crumb) is always preserved, still a Label.
is_collapsed()reports whether the last layout collapsed;crumb_count()reports the total crumb count regardless of collapse.
On a wide/unbounded constraint nothing collapses and every non-current crumb is a link. (Narrow viewports — e.g. mobile — are the practical trigger, but the rule is width-driven, not device-class-driven.)
R4 — States (per interaction/states.kmd)
Per-link affordances, painted live from self.palette (so a runtime theme switch
re-themes the trail; default palette is dark):
| State | Trigger | Paint |
|---|---|---|
| Hover | pointer over a link slot | 8% on-surface wash (palette.hover_overlay) filling a RoundedRect at the shape's radius_hover — states.kmd R1 |
| Pressed | mid press→release on a link | same wash as hover (hovered is also set to the pressed index) |
| Focused | link holds keyboard focus | 2px focus ring (Stroke::new(2.0), palette.focus_ring) around the rounded slot — states.kmd R2 |
Only links get these affordances — the current-page Label and the separators/ ellipsis are never washed, ringed, or hovered. Hover/press/focus never apply to a collapsed-away crumb (it has no visible slot to hit).
R5 — Pointer & keyboard
Pointer
- Move →
hovered= the link whose slot contains the pointer (or none). - Left down on a link → capture the pointer,
request_focuson that link's id, and mark itpressed. (Ignored while the event ctxis_forced().) - Left up → release capture; fire
on_navigate(index)iff the release lands back on the same link it pressed. A release that drifts off the link cancels — nothing fires. - Pressing/releasing the current page or a separator does nothing.
- Cursor:
Cursor::Pointerover a link slot,Cursor::Defaultelsewhere.
Keyboard (navigation.kmd R10 keyboard contract)
- Tab steps through the visible links in reading order — the current page and any collapsed-away crumbs are skipped (they are not focusables).
- Enter or Space activates the focused link →
on_navigate(index), reading the live focus from the event ctx (likebutton.rs). - Arrow-key roving within the trail is not implemented (Não-escopo).
R6 — Accessibility
The widget exports a semantics subtree via semantics():
- Container:
SemRole::Grouplabelled"Breadcrumb", bounded to the trail, carrying the widget id, with the visible slots as children in reading order. - Each link:
SemRole::Linkwith the crumb's accessiblelabel, its bounds, itsfocusedflag, and its node id (the crumb's Tab-stop id). - Current page:
SemRole::Labelwith itslabeland bounds — not a Link (this is the structuralaria-current="page"). - Ellipsis (when collapsed):
SemRole::Labelwith label"…". - Separators: decorative — no node is emitted.
A crumb's accessible label is supplied by the caller at Crumb::new(id, label, content) and is what a screen reader announces / a collector matches on —
independent of the content widget's painted glyphs.
R7 — Public API (Kroma)
Grounded in the widget's public surface:
// A single crumb: focus id (a Tab stop only when it is a link), accessible
// label, and the content widget that paints its visual.
Crumb::new(id: u64, label: impl Into<String>, content: Box<dyn Widget>)
// Build a trail over crumbs (the LAST is the current page).
Breadcrumb::new(id: u64, crumbs: Vec<Crumb>)
.home(icon: Box<dyn Widget>) // optional leading home icon (crumb 0 stays a link)
.on_navigate(Box<dyn FnMut(usize)>) // fired with the crumb index on activation
// Reconciler config-update: rebind a fresh action each rebuild.
bc.set_on_navigate(f: Box<dyn FnMut(usize)>)
// Introspection.
bc.is_collapsed() -> bool // did the last layout collapse the middle?
bc.crumb_count() -> usize // total crumbs in the trail
// Theme reactivity (trait methods — dispatched through dyn Widget).
bc.set_palette(palette) // adopt the DS palette (kroma#194)
bc.set_shape(shape) // adopt the DS geometry (kroma#278)
An empty trail (crumbs = []) lays out to Size::ZERO and exports its children-
less Group.
R8 — gov.br DS parity
The gov.br design system ships a breadcrumb (br-breadcrumb): home icon + crumb
links + /-style separators, the current page rendered as non-link text, and a
nav landmark with an accessible name. This spec's widget covers:
| gov.br trait | This widget | Status |
|---|---|---|
| Leading home icon | .home(icon) paints crumb 0's visual (link to index 0) | ✅ R1 |
Crumb links + / separators | Links + diagonal-/ chrome separators | ✅ R1 |
| Current page as non-link text | Last crumb = SemRole::Label, not clickable/focusable | ✅ R2 |
| Overflow handling | Collapse middle → first … current ellipsis | ✅ R3 (see Não-escopo for menu) |
| Hover / focus / press states | 8% wash + 2px focus ring on links | ✅ R4 |
| Keyboard reachable | Tab through links, Enter/Space activate | ✅ R5 |
| Accessible name on the container | Group "Breadcrumb" | ⚠️ Group, not a navigation landmark — Não-escopo |
aria-current="page" | conveyed structurally (Label, not Link) | ⚠️ no ARIA attribute — Não-escopo |
Não-escopo / follow-up
The widget's own module doc names these as deferred (not dropped); they are not implemented and MUST NOT be described as if they exist:
role="navigation"+aria-label="Primary"(navigation.kmdR10). Nonavigationrole exists in the neutralSemRoletable, so the container exports as aGroup. A dedicated navigation role and an explicitaria-current="page"attribute on the current crumb (today conveyed only structurally, by usingLabelinstead ofLink) wait on a semantics-model addition. — slice-2 / semantics-model work.Arrow-key roving movement between destinations (
navigation.kmdR10). This slice ships Tab + Enter/Space (the task's keyboard contract); Left/Right roving-tabindex within the trail is deferred. — slice-2.Finer-grained overflow + a clickable ellipsis dropdown. This slice collapses the whole middle to a single
first … currentellipsis, and that ellipsis is decorative (a non-interactiveLabel) — it does not open a menu. Dropping crumbs one-at-a-time as width shrinks, and a clickable ellipsis that opens a dropdown/menu of the hidden crumbs (the "collapse into a dropdown on mobile" part of the ticket), are not built here. — slice-2.navigation.kmdR1–R9 / R11–R13 (bar/rail/drawer anatomy, tonal selected block, density, presets) are container concerns and do not apply to a location-trail breadcrumb — intentionally out of scope, not a follow-up.
Cross-link
engines/sdk/kroma/src/widget/breadcrumb.rs— the canonical widget + its locking tests (click_navigates_link_and_current_page_is_not_a_link,tab_and_enter_activate_the_focused_link,overflow_collapses_middle_crumbs_to_an_ellipsis).specs/components/navigation.kmd— the primary-nav containers; only its a11y/ keyboard intent (R10) maps to the breadcrumb.specs/navigation/back-behavior.kmd— back walks the stack; the breadcrumb jumps directly to an ancestor level.specs/interaction/states.kmd— R1 hover wash, R2 focus ring (R4 above).specs/themes/verge.kmd— the palette/shape tokens the chrome reads.
Requirements (testable)
Requirement: Last crumb is the non-interactive current page {#req-breadcrumb-current-page-label}
The last crumb in the trail SHALL be the current page: it SHALL be exported as a SemRole::Label (never a SemRole::Link), SHALL NOT be clickable (a press/release on it fires nothing), and SHALL NOT be a Tab stop (collect_focusables never emits its id).
Scenario: Pressing the current page fires no navigation
- GIVEN a Breadcrumb whose crumbs are
[home, Docs, Current] - WHEN the pointer presses and releases on the last crumb (Current)
- THEN
on_navigateis not invoked for any index - AND the last crumb is exported with role
SemRole::Label, notSemRole::Link
Scenario: The current page is skipped in the Tab order
- GIVEN a Breadcrumb whose crumbs are
[home, Docs, Current] - WHEN the focusable ids are collected via
collect_focusables - THEN the last crumb's id is absent from the collected focusables
- AND every crumb before the last is present as a focusable link
Requirement: Overflow collapses the middle to first … current {#req-breadcrumb-overflow-collapse}
When and only when n >= 3, the constraint's max.width is finite, and the natural full width exceeds max.width, the trail SHALL collapse to exactly [crumb 0, ellipsis, crumb n-1], dropping every hidden middle crumb from both the Tab order and the semantics tree while preserving the current page as a Label; on any wide or unbounded constraint it SHALL NOT collapse.
Scenario: A trail wider than a finite constraint collapses its middle
- GIVEN a Breadcrumb with 4 crumbs whose natural full width is 400px
- WHEN it is laid out under a finite
max.widthof 200px - THEN
is_collapsed()returns true - AND the visible sequence is exactly first crumb, ellipsis, current crumb
- AND every hidden middle crumb is absent from the focusables and the semantics children
Scenario: An unbounded constraint keeps every crumb a link
- GIVEN a Breadcrumb with 4 crumbs
- WHEN it is laid out under an unbounded (infinite)
max.width - THEN
is_collapsed()returns false - AND every non-current crumb is exported as a
SemRole::Link
Scenario: A two-crumb trail never collapses
- GIVEN a Breadcrumb with 2 crumbs
- WHEN it is laid out under a finite
max.widthsmaller than its full width - THEN
is_collapsed()returns false becausen < 3
Requirement: Pointer activation requires press and release on the same link {#req-breadcrumb-pointer-activation}
A left press on a link SHALL capture the pointer, request focus on that link, and mark it pressed; the release SHALL fire on_navigate(index) if and only if it lands back on the same link that was pressed, and SHALL fire nothing when the release drifts off that link.
Scenario: Press and release on the same link navigates
- GIVEN a Breadcrumb with a link crumb at index 1
- WHEN a left-button press lands on that link's slot and the release lands on the same slot
- THEN
on_navigateis invoked with index 1
Scenario: A release that drifts off the link cancels
- GIVEN a Breadcrumb with a link crumb at index 1
- WHEN a left-button press lands on that link's slot but the release lands outside it
- THEN
on_navigateis not invoked
Requirement: Keyboard reaches links via Tab and activates with Enter or Space {#req-breadcrumb-keyboard}
Tab SHALL step through the visible links in reading order, skipping the current page and any collapsed-away crumbs; a focused link SHALL be activated by Enter or Space, invoking on_navigate(index) for the crumb that currently holds focus.
Scenario: Enter activates the focused link
- GIVEN a Breadcrumb whose link at index 0 currently holds keyboard focus
- WHEN an Enter key event is delivered
- THEN
on_navigateis invoked with index 0
Scenario: Space activates the focused link
- GIVEN a Breadcrumb whose link at index 1 currently holds keyboard focus
- WHEN a Space key event is delivered
- THEN
on_navigateis invoked with index 1
Requirement: Interaction affordances apply only to links {#req-breadcrumb-link-only-states}
Hover wash, pressed wash, and the focus ring SHALL be painted only on link slots; the current-page Label, the separators, the collapse ellipsis, and any collapsed-away crumb SHALL never receive a hover, press, or focus affordance.
Scenario: Hovering a link washes it but hovering the current page does not
- GIVEN a Breadcrumb with a link at index 0 and the current page at the last index
- WHEN the pointer moves over the link slot at index 0
- THEN
hoveredis set to index 0 and its slot paints the hover wash - WHEN the pointer moves over the current-page slot
- THEN no hover wash is painted and
hoveredresolves to none
Scenario: A collapsed-away crumb receives no affordance
- GIVEN a Breadcrumb that has collapsed its middle to
first … current - WHEN the pointer moves across the region occupied by the ellipsis
- THEN no hidden middle crumb is marked hovered
- AND the ellipsis itself paints no hover, press, or focus affordance
Requirement: Accessibility subtree exports links, current label, and no separator nodes {#req-breadcrumb-a11y-semantics}
The semantics() subtree SHALL export a SemRole::Group labelled "Breadcrumb" containing the visible slots in reading order, each non-current crumb as a SemRole::Link carrying its caller-supplied accessible label, focus flag and node id, the current page as a SemRole::Label, and the separators SHALL emit no accessibility node.
Scenario: Container and roles are exported in reading order
- GIVEN a Breadcrumb whose crumbs are
[home, Docs, Current] - WHEN
semantics()is queried - THEN the container is a
SemRole::Grouplabelled "Breadcrumb" - AND the children in reading order are Link(home), Link(Docs), Label(Current)
- AND no separator contributes any semantics node
Scenario: Each link announces its caller-supplied label
- GIVEN a crumb built as
Crumb::new(id, "Docs", content) - WHEN
semantics()exports that crumb's node - THEN the exported Link's accessible label is "Docs" regardless of the content widget's painted glyphs
Referências
engines/sdk/kroma/src/widget/breadcrumb.rsspecs/components/navigation.kmdspecs/navigation/back-behavior.kmdspecs/interaction/states.kmdspecs/themes/verge.kmd