Pular para o conteúdo

Split button

components specs/components/split-button.kmd

Material 3 Expressive composite button: primary action + separate menu trigger side-by-side, divided. Trailing chevron rotates + shape morphs when menu opens. 5 sizes × 4 styles. Anchored to host `buttons.kmd`.

Quando esta spec se aplica

Triggers primários

Todos os triggers

Corpo da especificação

Spec — Split button

Companion: buttons.kmd (base button variants). Trailing morph via motion.kmd R9 + shape-library.kmd R2.

Princípios

  1. Two distinct actions — primary tap = default action; trailing tap = open menu. NEVER one combined.
  2. Visual separation — divisor line entre leading + trailing.
  3. Shape morph on menu open — trailing morphs (square → pill) + chevron rotates 180°.
  4. 5 sizes, 4 styles — XS/S/M/L/XL × elevated/filled/tonal/outlined.

R1 — Anatomia

┌──────────────────┬─────┐
│  Save             │  ▼  │   ← leading (primary action) | divisor | trailing (menu)
└──────────────────┴─────┘
                          ╱
                         ╱  menu opens →
                  ┌────────────────┐
                  │ Save as...     │
                  │ Export         │
                  │ Save & close   │
                  └────────────────┘

Slots:

SlotFunction
LeadingPrimary action (text + optional icon). Tap = executa default.
Divisor1dp line; color outline-variant per color-roles.kmd.
TrailingMenu trigger (chevron ). Tap = abre menu (cross-link menus.kmd).

Both slots are focusable nodes (a11y separately reachable).

R2 — Sizes

SizeHeightPadding (L+R per slot)Min width (leading)Min width (trailing)
XS2886028
S32107232
M40168840
L482010448
XL562412056

Trailing min-width ensures chevron + tap target ≥ 28dp (S+).

R3 — Styles

Same as buttons.kmd base variants:

StyleBackgroundBorderElevation
Elevatedsurface tintnoneshadow + tonal
Filledprimarynonenone
Tonalsecondary-containernonenone
Outlinedtransparentoutline 1dpnone

Both slots share the same style (no mixed-style split buttons).

R4 — Menu open state morph

When user taps trailing → menu opens:

  1. Shape morph trailing: from square corner → pill corner (per shape-library.kmd Pill ↔ Squircle morph).
  2. Chevron rotation: rotates 180° → via spring motion-spatial-fast.
  3. Trailing color shift: optional tint to indicate active state (per preset).
  4. Menu appears: cross-link menus.kmd for menu styling.

On menu close (selection OR ESC OR outside-tap): reverse morph.

R5 — Surface bindings

SurfaceAPI
FlutterKoderSplitButton({onPrimary, menuItems, size, style}) em koder_kit/ (futuro)
Web<koder-split-button size="md" style="filled"> em koder_web_kit
Compose AndroidKoderSplitButton (futuro)
SwiftUI iOSidem (futuro)
CLI / TUIPlain action; menu via slash command (<action> default, <action>:menu to open)

R6 — Acessibilidade

  • Leading: role="button" aria-label="<primary action>".
  • Trailing: role="button" aria-haspopup="menu" aria-expanded="<state>" aria-label="<primary action> options".
  • Keyboard: Tab cycles leading → trailing; Space/Enter on each.
  • Arrow Down on trailing also opens menu.
  • ESC closes menu; focus returns to trailing.
  • Menu items per menus.kmd R6.

R7 — i18n

Inherits from buttons.kmd + menus.kmd. Trailing label localized as " options" pattern.

Keyen-USpt-BR
split_button.trailing.label"{action} options""Opções de {action}"

R8 — Reduced-motion

Shape morph + chevron rotation: instant (no spring). Menu open instant (no slide).

R9 — Per-preset variation

PresetSplit button style
material3 / material_expressiveDefault morph + rotation
material2No morph; chevron rotation only
terminal_classic[action ▾] text-only; trailing literal
brutalistSharp corners; no shape morph; no rotation animation
cyberpunk_neonDefault + glow on active state
minimalist_monoSingle bottom-border; no fill; no morph

T-suite

  • T1 Mount: render with primary + 3 menu items → leading + divisor + trailing visible.
  • T2 Primary tap: tap leading → onPrimary callback; menu NOT opened.
  • T3 Menu open: tap trailing → menu visible; trailing shape morphs; chevron rotates.
  • T4 Menu close: tap outside → menu closes; trailing reverts.
  • T5 Keyboard: Tab to leading + Enter → onPrimary; Tab to trailing + Down → menu open.
  • T6 All sizes XS-XL: render each → height correct; trailing tap-target ≥ 28dp.
  • T7 All styles 4: filled/elevated/tonal/outlined render correctly.
  • T8 Reduced-motion: morph + rotation instant.
  • T9 A11y: leading aria-label distinct from trailing.
  • N1 Click trailing accidentally → only opens menu (não executes primary action).

Requirements (testable)

Requirement: Two distinct actions {#req-split-button-two-actions}

The split button SHALL expose two separately actionable regions — a leading slot that executes the primary/default action and a trailing slot that opens the options menu — and a tap on one region SHALL NOT trigger the behavior of the other. There is never a single combined action.

Scenario: Leading tap runs the primary action

  • GIVEN a split button rendered with a primary action and 3 menu items
  • WHEN the user taps the leading slot
  • THEN the onPrimary callback fires exactly once
  • AND the options menu remains closed

Scenario: Trailing tap only opens the menu

  • GIVEN a split button rendered with a primary action and 3 menu items
  • WHEN the user taps the trailing slot
  • THEN the options menu becomes visible
  • AND the onPrimary callback does NOT fire

Requirement: Menu-open morph and reversal {#req-split-button-menu-morph}

When the trailing slot opens the menu the split button SHALL morph the trailing shape from square corner to pill corner and rotate the chevron 180° (), and on menu close (selection, ESC, or outside tap) it SHALL reverse the morph and chevron rotation.

Scenario: Opening the menu morphs the trailing slot

  • GIVEN a closed split button in the material_expressive preset
  • WHEN the user taps the trailing slot to open the menu
  • THEN the trailing corner shape morphs from square to pill
  • AND the chevron rotates 180° from to
  • AND aria-expanded on the trailing slot reads true

Scenario: Closing the menu reverts the trailing slot

  • GIVEN a split button with its menu open and trailing morphed to pill
  • WHEN the user taps outside the menu
  • THEN the menu closes
  • AND the trailing shape reverts to square with the chevron back to

Requirement: Size variants {#req-split-button-sizes}

The split button SHALL render in all five sizes (XS/S/M/L/XL) with the height defined in R2 (28/32/40/48/56dp), and for every size the trailing tap target SHALL be at least 28dp so the chevron remains reliably tappable.

Scenario: Each size renders at its defined height with a valid trailing target

  • GIVEN the split button size set {XS, S, M, L, XL}
  • WHEN each size is rendered
  • THEN the component height equals the R2 value for that size (28/32/40/48/56dp)
  • AND the trailing slot tap target is ≥ 28dp

Requirement: Style variants share one style {#req-split-button-styles}

The split button SHALL render in all four styles (elevated/filled/tonal/outlined) matching the buttons.kmd base variants, and both the leading and trailing slots SHALL use the same style — mixed-style split buttons are not permitted.

Scenario: Both slots render with the same requested style

  • GIVEN a split button requested with style filled
  • WHEN the component renders
  • THEN the leading slot uses the filled background (primary)
  • AND the trailing slot uses the same filled style
  • AND the two slots are separated by a 1dp divisor in outline-variant

Scenario: All four styles render

  • GIVEN the style set {elevated, filled, tonal, outlined}
  • WHEN each style is rendered
  • THEN each produces the R3 background/border/elevation for that style
  • AND both slots share that style in every case

Requirement: Accessibility and keyboard operation {#req-split-button-a11y}

The split button SHALL expose the leading and trailing slots as separately focusable buttons with distinct accessible labels (trailing carrying aria-haspopup="menu" and a live aria-expanded), SHALL let Tab move focus leading → trailing with Space/Enter activating each, SHALL open the menu on Arrow Down over the trailing slot, and SHALL close the menu on ESC returning focus to the trailing slot.

Scenario: Leading and trailing carry distinct roles and labels

  • GIVEN a split button whose primary action is "Save"
  • WHEN the accessibility tree is inspected
  • THEN the leading slot exposes role="button" with aria-label="Save"
  • AND the trailing slot exposes role="button", aria-haspopup="menu", and aria-label="Save options"
  • AND the leading aria-label is distinct from the trailing aria-label

Scenario: Keyboard reaches and activates both slots

  • GIVEN keyboard focus is outside the split button
  • WHEN the user presses Tab, then Enter, then Tab, then Arrow Down
  • THEN the first Tab focuses the leading slot and Enter fires onPrimary
  • AND the second Tab focuses the trailing slot and Arrow Down opens the menu

Scenario: ESC closes the menu and restores focus

  • GIVEN the menu is open with focus inside it
  • WHEN the user presses ESC
  • THEN the menu closes
  • AND focus returns to the trailing slot

Requirement: Reduced-motion suppresses animation {#req-split-button-reduced-motion}

When reduced-motion is active the split button SHALL apply the shape morph and chevron rotation instantly with no spring, and the menu SHALL appear instantly with no slide.

Scenario: Reduced-motion opens without animated transitions

  • GIVEN reduced-motion is enabled
  • WHEN the user opens the menu from the trailing slot
  • THEN the trailing shape morph and chevron rotation complete instantly with no spring
  • AND the menu appears instantly with no slide animation

Referências