Pular para o conteúdo

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

Todos os triggers

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):

ElementConstValueNotes
Separator slot widthSEP_W16.0one / between two visible items
Separator/ellipsis glyph heightSEP_GLYPH_H10.0drawn centred in its slot
Collapse-ellipsis slot widthELLIPSIS_W22.0the standing in for hidden crumbs
Per-crumb horizontal paddingCRUMB_PAD_H4.0added on each side of the content
Minimum row heightMIN_ROW_H24.0comfortable 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), coloured palette.on_surface_dim. It is decorative — it contributes no accessibility node.
  • Ellipsis (): three filled circles of radius 1.6, spaced 6.0 apart, coloured palette.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.

The last crumb in the trail is the current page. It is:

  • a non-interactive SemRole::Label (every other crumb is a SemRole::Link);
  • not clickable — a press/release on it fires nothing;
  • not a Tab stopcollect_focusables never emits its id;
  • its Crumb::id is 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:

  1. n ≥ 3 (there is a middle to hide), and
  2. the constraint's max.width is finite, and
  3. 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):

StateTriggerPaint
Hoverpointer over a link slot8% on-surface wash (palette.hover_overlay) filling a RoundedRect at the shape's radius_hoverstates.kmd R1
Pressedmid press→release on a linksame wash as hover (hovered is also set to the pressed index)
Focusedlink holds keyboard focus2px 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

  • Movehovered = the link whose slot contains the pointer (or none).
  • Left down on a link → capture the pointer, request_focus on that link's id, and mark it pressed. (Ignored while the event ctx is_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::Pointer over a link slot, Cursor::Default elsewhere.

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 (like button.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::Group labelled "Breadcrumb", bounded to the trail, carrying the widget id, with the visible slots as children in reading order.
  • Each link: SemRole::Link with the crumb's accessible label, its bounds, its focused flag, and its node id (the crumb's Tab-stop id).
  • Current page: SemRole::Label with its label and bounds — not a Link (this is the structural aria-current="page").
  • Ellipsis (when collapsed): SemRole::Label with 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 traitThis widgetStatus
Leading home icon.home(icon) paints crumb 0's visual (link to index 0)✅ R1
Crumb links + / separatorsLinks + diagonal-/ chrome separators✅ R1
Current page as non-link textLast crumb = SemRole::Label, not clickable/focusable✅ R2
Overflow handlingCollapse middle → first … current ellipsis✅ R3 (see Não-escopo for menu)
Hover / focus / press states8% wash + 2px focus ring on links✅ R4
Keyboard reachableTab through links, Enter/Space activate✅ R5
Accessible name on the containerGroup "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:

  1. role="navigation" + aria-label="Primary" (navigation.kmd R10). No navigation role exists in the neutral SemRole table, so the container exports as a Group. A dedicated navigation role and an explicit aria-current="page" attribute on the current crumb (today conveyed only structurally, by using Label instead of Link) wait on a semantics-model addition. — slice-2 / semantics-model work.

  2. Arrow-key roving movement between destinations (navigation.kmd R10). This slice ships Tab + Enter/Space (the task's keyboard contract); Left/Right roving-tabindex within the trail is deferred. — slice-2.

  3. Finer-grained overflow + a clickable ellipsis dropdown. This slice collapses the whole middle to a single first … current ellipsis, and that ellipsis is decorative (a non-interactive Label) — 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.

  4. navigation.kmd R1–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.

  • 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_navigate is not invoked for any index
  • AND the last crumb is exported with role SemRole::Label, not SemRole::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.width of 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
  • 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.width smaller than its full width
  • THEN is_collapsed() returns false because n < 3

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.

  • 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_navigate is invoked with index 1
  • 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_navigate is not invoked

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.

  • GIVEN a Breadcrumb whose link at index 0 currently holds keyboard focus
  • WHEN an Enter key event is delivered
  • THEN on_navigate is invoked with index 0
  • GIVEN a Breadcrumb whose link at index 1 currently holds keyboard focus
  • WHEN a Space key event is delivered
  • THEN on_navigate is invoked with index 1

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.

  • 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 hovered is 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 hovered resolves 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

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::Group labelled "Breadcrumb"
  • AND the children in reading order are Link(home), Link(Docs), Label(Current)
  • AND no separator contributes any semantics node
  • 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