Accordion component
components specs/components/accordion.kmd
A vertical stack of collapsible disclosure items — each a header (a caller-composed label + a rotating chevron) over a body region shown only while the item is expanded. Two modes: multi-expand (any number open, the default) or single-expand (radio-like — opening one collapses the others). Click or Enter/Space on a header toggles its region; the headers are the keyboard roving unit (Up/Down/Home/End). Exports as an ARIA accordion: a Group of Button headers carrying expanded/collapsed, with a region panel under every open item. Grown from the gov.br GVB-01 disclosure pattern + Material accordion conventions, anchored on cards.kmd.
Quando esta spec se aplica
Triggers primários
- Render an accordion / disclosure-group widget
Todos os triggers
- Add a collapsible / disclosure / 'accordion' section list to a Koder UI
- Show one panel at a time (radio-like single-expand) vs many
- Give a header a rotating expand/collapse chevron over a body region
Corpo da especificação
Accordion
Status: v0.1.0 — Draft. Opened from the KDS × gov.br gap analysis (
tools/design-gen/docs/govbr-ds-gap-analysis.md, GVB-01 — disclosure) asstack#391. This spec is written faithful to the shipped Kroma widget (engines/sdk/kroma/src/widget/accordion.rs, kroma#185 on the widget-extensibility substrate kroma#201, height-tween kroma#213), not inferred from a design mock. Every dimension, colour token, keyboard rule and a11y node below is grounded in the widget's code — the consts, theevent/semanticsmethods and the public API. Behaviour the ticket names but the widget does not yet do is listed under Não-escopo / follow-up, not asserted as present.Normative anchor: there is (was) no
accordion.kmd; the widget was built on the closest normative spec —specs/components/cards.kmd(a card is a container of related content clickable as a whole; an accordion is its collapsible sibling). The load-bearing borrowings are cards.kmd R2 (the whole header is the hit area; 2 px focus ring; pointer cursor) and R7 (the header is the operable heading; the panel's controls are reachable by Tab separately; reading order = visual order).
Why it exists
Long forms and settings screens pile up sections the user does not need all at
once. An accordion collapses each into a one-line header they can open on
demand — the gov.br accordion / disclosure pattern (GVB-01). Its whole value
is that a closed panel costs one row and is absent from the tab order and the
a11y tree until opened, so a keyboard or screen-reader user is not dragged
through content they collapsed.
R1 — Anatomy
The widget is a vertical stack of items (Vec<AccordionItem>), each item
composed of, top to bottom:
- Header — a full-width row, fixed 48 px tall (
HEADER_HEIGHT), the entire row being the hit area (cards.kmd R2). It contains:- a caller-composed label child (
Box<dyn Widget>— aText, or aRowof icon +Text), inset left by 16 px (HEADER_PAD_H) and vertically centred in the row (the row's content box is bounded byHEADER_PAD_V= 12 px top/bottom); - a chevron at the right edge, inset by
HEADER_PAD_H, drawn as a 2 px-stroke vector path (font-free — no glyph): pointing right when collapsed, down when expanded (CHEVRON_ARM= 4 px half-arm); - a 1 px hairline divider along the header's bottom edge
(
palette.field_outline), the structural rule between items.
- a caller-composed label child (
- Body / panel region — a full-width, raised card surface
(
palette.card) shown only while the item is drawn open, with the caller-composed body child (Box<dyn Widget>) inset 16 px on every side (BODY_PAD).
The shell owns no font: a font is used only by the caller's label child in
paint; the chevron and dividers are vector paths. When the available width is
unbounded, the accordion falls back to 320 px (DEFAULT_WIDTH); normally it
fills its column (ctx.max.width).
Layout stacks items with no inter-item gap: three collapsed 48 px headers
measure exactly 144 px; opening a panel over a 40 px-tall body adds
40 + 2×16 = 72 px (measured in the widget's own tests).
R2 — Expansion modes
Two policies (ExpandMode):
- Multi (
ExpandMode::Multi, the default) — every header is an independent toggle; any number of panels may be open at once. - Single (
.single()/ExpandMode::Single) — radio-like: opening one item collapses every other one, so at most one panel is open.
Items may be seeded open with .expanded_item(...). In single mode, after the
first user toggle exactly one item is open; the exclusion is applied both on
user toggle (toggle) and on the controlled set_expanded.
R3 — States
Per header:
- Collapsed — chevron points right; no panel region is rendered (it contributes a zero-height rect at the baseline, never hit-tested).
- Expanded — chevron points down; the panel region is drawn as a raised
card. The logical
expandedflag flips immediately on toggle — a11y, focus and hit-testing read it at once; only the drawn panel height lags during the open/close animation (R6). - Hover — an 8 % on-surface wash (
palette.hover_overlay, interaction/states.kmd R1) fills the header row, on an enabled header only. - Focus — a 2 px focus ring (
palette.focus_ring) is stroked around the outer header container, inset −2 px, radiusshape.radius_btn + 2(cards.kmd R2 / states.kmd R2). The ring is the keyboard-nav cue. - Active / pressed — a
PointerDownon an enabled header captures the pointer and requests focus; the toggle fires onPointerUponly if the release lands back on the pressed header (a press that drifts off cancels). - Disabled — two granularities: the whole accordion (
set_disabled) or a single item (.disabled_item(...)/set_item_disabled). A disabled header: is dropped from the Tab order and keyboard roving, ignores pointer input (no toggle, no hover wash), shows its chevron dimmed to 38 % alpha (on_surface.with_alpha(0.38)), reportsenabled = falseto a11y, and yields aCursor::Default(an enabled header yieldsCursor::Pointer).
R4 — Accessibility (ARIA accordion mapping)
semantics() exports, reusing the existing Button + Group roles — no new
SemRole:
- Container — a
SemRole::Groupnamed"Accordion",node_id = self.id,enabled = !disabled. - Per item, a header button — a
SemRole::Buttonwhose:name= the item's accessiblelabelstring;value="expanded"or"collapsed"— thearia-expandedanalogue, tracking the logical state;focused= whether it holds keyboard focus;node_id= the item's stableheader_id;enabled=!item.disabled && !accordion.disabled.
- Per OPEN item, a panel region — a
SemRole::Groupnamed"{label} panel"(therole="region"/aria-controlstarget) wrapping the body's semantics subtree. - A collapsed panel exports no node — a hidden panel is absent from the a11y tree, matching the DOM.
The header buttons are stable focus stops: header_id depends only on the
item's index (id + 1 + i), so it is invariant across expand/collapse
re-layouts.
R5 — Keyboard
The headers are the roving unit (the ARIA accordion contract). When a header holds focus:
- Up / Down — move focus to the previous / next enabled header. This slice clamps at the ends — Up on the first (or Down on the last) is a no-op (no wrap-around; see Não-escopo).
- Home / End — jump focus to the first / last enabled header.
- Enter / Space — toggle the focused header's panel.
Tab order (collect_focusables) visits every enabled header AND every
focusable inside an expanded body, in visual order — so an open panel's own
controls stay reachable (cards.kmd R7). A disabled item's header and (when
closed) its body contribute nothing. When focus is not on a header, key
events are forwarded to the open bodies (a focused control inside a panel keeps
its keys). A fully-disabled accordion contributes no focus stops.
R6 — Motion (Verge tokens)
The panel's height tweens open/closed over motion-medium
(duration::MEDIUM) instead of popping (kroma#213): ease-decelerate
opening, ease-accelerate closing — arriving vs leaving, per
easing-duration.kmd R3, off one clock. The body child is laid out at its full
natural height and the region is scaled by an open_fraction in 0.0..=1.0; a
settled-closed panel lands on exactly zero height.
Under reduced motion the panel snaps (physics.kmd R1) — the substrate collapses the duration; there is no reduced-motion branch in the widget.
cards.kmd R8 forbids hover-driven height change (feed shift); the accordion's height change is user-initiated (a click/keypress), which is allowed.
R7 — Verge shape / elevation tokens
Colours and geometry are read from the active theme, not hardcoded — a runtime theme switch re-themes the accordion:
- header surface
palette.surface; hover washpalette.hover_overlay; chevronpalette.on_surface(0.38 α disabled); dividerpalette.field_outline; focus ringpalette.focus_ring; panel = raised cardpalette.card. - the focus ring's corner radius =
shape.radius_btn + 2(Verge defaultradius_btn= 12 px dark / 6 px light, perspecs/themes/verge.kmdR4). - defaults are
Palette::dark()+Shape::verge()untilset_palette/set_shapeadopt the active DS (kroma#189/#194).
R8 — Content-overflow pattern
An expanded panel is painted inside a clip layer bounded to the region rect
(push_layer → fill card → paint body → pop_layer). Because the body child is
laid out at its full natural height while the region height animates, mid-
tween the child is taller than the region — the clip is what stops it
spilling over the item below (a bounded-region reveal, content-overflow.kmd
R-scroll family). A body that itself overflows composes its own strategy
(scroll region / show-more) per content-overflow.kmd; the shell does not
truncate for it.
R9 — Public API (grounded)
Builder: Accordion::new(id) (multi mode) → .item(header, label, body) /
.expanded_item(...) / .disabled_item(...) → .single() or
.mode(ExpandMode) → .on_toggle(Box<dyn FnMut(usize, bool)>).
Controlled / reconciler surface: set_expanded(i, val) (updates state without
firing on_toggle, applying single-mode exclusion), is_expanded(i),
expanded_indices(), set_disabled(d), set_item_disabled(i, d),
set_on_toggle(f), clear_callbacks(). The toggle callback is fired
(index, expanded) on every user toggle.
R10 — gov.br parity (GVB-01)
The widget realises the gov.br Padrão Digital disclosure / accordion GVB-01
line, blended with Material's accordion conventions and anchored on cards.kmd:
the collapsible section list, the single-vs-multi open policy, the rotating
disclosure chevron, and the ARIA accordion keyboard + aria-expanded contract
are all present (R1–R5). Unlike skip-link.kmd, the styling here is not
byte-measured against @govbr-ds/core — it paints one Verge/M3 header + panel;
measured token parity against the gov.br .br-* accordion CSS is a follow-up
(Não-escopo).
Não-escopo / follow-up
Named here, not silently dropped — each is a distinct follow-up ticket, not an implied part of this spec:
- Density presets (cards.kmd R1 comfortable/compact/spacious padding) — this slice paints one fixed padding set (48 px header, 16 px pads).
- Per-preset styling (cards.kmd R6 — material2 corners, windows_95 bevel, brutalist border, …) — one Verge/M3 look only.
- Chevron rotation tween — the chevron flips instantly; only the panel height tweens (R6). Animating the rotation is open.
- Disabled-header label dim — the shell dims the state cues it owns (the chevron → 38 %) but cannot recolour/dim the caller-composed label child; a full disabled-label dim needs the host or a follow-up that threads a disabled flag into the child.
- Wrap-around arrow nav — Up/Down clamp at the ends (R5); wrapping (as an option) is the follow-up.
- cards.kmd R4 "cards within cards" — a caller concern; the shell does not enforce that a body compose list-items/sections rather than a nested card.
- A dedicated
DisclosureGroupSemRole — the widget reusesGroup+Button; a first-class disclosure role is deferred. - Measured gov.br
.br-*token parity (R10) — visual parity against@govbr-ds/coreis unmeasured here.
Cross-link
specs/components/cards.kmd— the normative anchor (R2 hit-area/focus-ring, R7 operable heading + Tab-reachable panel, R8 no hover height change).specs/interaction/states.kmd— the 8 % hover wash + 2 px focus ring.specs/patterns/content-overflow.kmd— the bounded-region clip + body overflow.specs/themes/verge.kmd— the shape / colour / radius tokens (R7).specs/motion/easing-duration.kmd+specs/motion/physics.kmd— the arriving/leaving easings and reduced-motion snap (R6).specs/i18n/contract.kmd— the header label / accessible name floor.
Requirements (testable)
Requirement: Header toggles its panel region {#req-accordion-toggle}
A click or Enter/Space on an enabled header SHALL toggle its item between collapsed and expanded, flipping the logical expanded flag immediately (a11y, focus and hit-testing read the new value at once), rendering the raised card panel region only while the item is expanded and rotating the chevron to point down when expanded and right when collapsed.
Scenario: Clicking a collapsed header opens its panel
- GIVEN an accordion whose first item is collapsed
- WHEN a
PointerDownon the first header is followed by aPointerUpreleased back on that same header - THEN the item's logical
expandedflag readstrueimmediately - AND the panel region for that item is rendered as a raised
palette.cardsurface - AND the header's chevron points down
Scenario: A press that drifts off the header cancels the toggle
- GIVEN an enabled collapsed header
- WHEN a
PointerDowncaptures the header but thePointerUpis released off the pressed header - THEN the item stays collapsed
- AND no panel region is rendered for it
Scenario: Enter on a focused header toggles it
- GIVEN a collapsed header that holds keyboard focus
- WHEN the Enter key is pressed
- THEN the item becomes expanded
- AND its chevron points down
Requirement: Single-expand mode keeps at most one panel open {#req-accordion-single-mode}
In ExpandMode::Single the accordion SHALL be radio-like: opening one item collapses every other item, so at most one panel is open at any time, with the exclusion applied on both user toggle and the controlled set_expanded; the default ExpandMode::Multi SHALL allow any number of panels open at once.
Scenario: Opening a second item collapses the first in single mode
- GIVEN a single-mode accordion with item 0 expanded
- WHEN the user toggles item 1 open
- THEN item 1 is expanded
- AND item 0 is collapsed
- AND exactly one item reports
expanded
Scenario: Multi mode leaves other panels open
- GIVEN a default multi-mode accordion with item 0 expanded
- WHEN the user toggles item 1 open
- THEN both item 0 and item 1 are expanded
Requirement: Disabled headers are inert and dropped from navigation {#req-accordion-disabled}
A disabled header — whether the whole accordion (set_disabled) or a single item (set_item_disabled) — SHALL ignore pointer input (no toggle, no hover wash), be dropped from the Tab order and keyboard roving, dim its chevron to 38 % alpha, report enabled = false to a11y, and yield Cursor::Default; an enabled header SHALL yield Cursor::Pointer.
Scenario: A disabled item ignores clicks
- GIVEN an accordion whose item 1 is disabled and collapsed
- WHEN a click lands on item 1's header
- THEN item 1 stays collapsed
- AND no hover wash is drawn on its row
- AND its chevron is rendered at 38 % alpha
Scenario: A disabled header is skipped by roving
- GIVEN focus on an enabled header immediately above a disabled header, followed by an enabled header
- WHEN the Down arrow key is pressed
- THEN focus moves past the disabled header to the next enabled header
- AND the disabled header never holds focus
Requirement: Keyboard roving over headers with clamped arrows {#req-accordion-keyboard}
The headers SHALL be the roving keyboard unit: Up/Down SHALL move focus to the previous/next enabled header and clamp at the ends (no wrap-around), Home/End SHALL jump to the first/last enabled header, Enter/Space SHALL toggle the focused header, and Tab order SHALL visit every enabled header plus every focusable inside an expanded body in visual order.
Scenario: Down arrow clamps at the last header
- GIVEN focus on the last enabled header of the accordion
- WHEN the Down arrow key is pressed
- THEN focus stays on the last header (no wrap to the first)
Scenario: Tab reaches a control inside an open panel
- GIVEN an accordion with an expanded panel that contains a focusable control
- WHEN Tab advances from that panel's header
- THEN focus lands on the control inside the expanded body
- AND the traversal order follows visual order
Requirement: ARIA accordion semantics mapping {#req-accordion-a11y}
semantics() SHALL export a SemRole::Group container named "Accordion", a SemRole::Button per item whose name is the item label and whose value is "expanded" or "collapsed" tracking the logical state (the aria-expanded analogue), and a SemRole::Group panel named "{label} panel" only for each open item; a collapsed panel SHALL export no node.
Scenario: An open item exports an expanded button and a panel node
- GIVEN an accordion with item 0 expanded and item 1 collapsed
- WHEN
semantics()is exported - THEN item 0's header button reports
value = "expanded" - AND a
Groupnode named"{item 0 label} panel"is present - AND item 1's header button reports
value = "collapsed" - AND no panel node exists for item 1
Referências
specs/components/cards.kmdspecs/interaction/states.kmdspecs/patterns/content-overflow.kmdspecs/themes/verge.kmdspecs/motion/easing-duration.kmdspecs/motion/physics.kmdspecs/i18n/contract.kmd