Menus
components specs/components/menus.kmd
Floating list of choices anchored to a trigger — opens on demand, closes after a choice or dismiss. Material parity (`/components/menus`). Covers dropdown (anchor button), context (long-press / right-click), submenu (cascading), and exposed dropdown menu (text field with selection list).
Quando esta spec se aplica
Triggers primários
- Add a menu
Todos os triggers
- Reveal a list of actions / choices from a button
- Long-press / right-click for contextual options
- Replace a select / combobox with a styled menu
Corpo da especificação
Spec — Menus
Facet Visual of Koder Design. Material parity: https://m3.material.io/components/menus.
3 variants
| Variant | Trigger | Use |
|---|---|---|
| Dropdown | Tap / click a button (icon / overflow ⋮ / text) | List of actions or selection options |
| Context | Long-press (touch) or right-click (desktop) | Operations on the current target |
| Exposed dropdown | Tap a text field | Selection from a closed list (replaces native <select>) |
Pick by trigger type. Dropdown is the default.
Anatomy
┌──────────────────┐
│ ✂ Cut ⌘X │
Trigger ───→ │ ⏎ Copy ⌘C │
(button or field) │ 📋 Paste ⌘V │
│ ────────────── │
│ ⚙ Settings │
│ ❌ Delete › │
└──────────────────┘
- Width: min 112 dp; max 320 dp; auto-fit to longest item
- Item height: 48 px (single-line label)
- Item padding: 16 px horizontal, 12 px vertical
- Container radius: 4 px (Material 3 default)
- Container bg:
surface-container - Elevation: 2 dp (shadow + tonal overlay)
- Item label:
body-large - Leading icon: 24 px, optional
- Trailing: keyboard shortcut OR submenu chevron (›)
- Divider: inset divider between item groups (optional)
R1 — Anchor / positioning
Menu opens relative to trigger:
| Anchor | Position |
|---|---|
| Below trigger | Default for dropdowns |
| Above trigger | When below would overflow viewport |
| Right of trigger | Submenu (cascading) |
| Left of trigger | Submenu RTL |
| At cursor | Context menu (right-click) |
Position adjusts at runtime to stay inside viewport — flip / shift within 8 px of edge.
R2 — Items
| Item type | Use |
|---|---|
| Action | Label + optional icon + optional shortcut → performs action and closes menu |
| Toggle | Label + check mark on selected → toggles state, may stay open or close (document) |
| Group label | Subheader text, non-interactive |
| Divider | Separates groups (full-width inside menu) |
| Submenu | Label + trailing › → opens cascading submenu |
| Disabled | 38% opacity, not interactive |
Max ~10 items recommended; > 10 → use exposed-dropdown style with search (combobox).
R3 — Keyboard shortcut display
Trailing shortcut is label-small (11/16), text-muted, right-aligned:
| OS | Format |
|---|---|
| macOS | ⌘C (symbol) |
| Windows / Linux | Ctrl+C (text) |
| Cross-platform binding | Show OS-specific format detected at runtime |
Don't show shortcut on touch-only surfaces — useless and clutters.
R4 — Submenu (cascading)
Submenu opens to the right (or left in RTL):
- Trigger item has trailing chevron (›)
- Hover / focus on trigger item: submenu opens after 200 ms delay
- Submenu items follow same anatomy as parent menu
- Esc closes only the deepest submenu; outside click closes everything
- Max depth: 2 levels (parent → submenu → no further nesting)
R5 — Context menu
Triggered by:
- Right-click (desktop)
- Long-press (touch, ~500 ms)
- Keyboard Menu key / Shift+F10
Opens at the cursor / touch position, NOT anchored to a trigger element.
Common content:
- Cut / Copy / Paste (text fields)
- Select all / Clear (lists)
- Open / Rename / Delete (file items)
- Inspect (web)
R6 — Exposed dropdown menu (combobox replacement)
Text field that opens a menu of options on focus / tap:
┌──────────────────────────┐
│ Country ⌄ │
└──────────────────────────┘
↓ (tap)
┌──────────────────────────┐
│ Country ⌃ │
│ ────────────────────── │
│ 🇧🇷 Brazil │
│ 🇺🇸 United States │
│ 🇨🇦 Canada │
│ ... │
└──────────────────────────┘
- Trigger looks like an Outlined or Filled text field with trailing chevron
- Menu opens below; max-height ~280 dp, scrolls
- Typing in field filters options (combobox mode); pure menu mode doesn't filter
- Selecting an option fills the field text and closes the menu
Use this instead of native <select> for branded Koder UIs.
R7 — States
| State | Visual |
|---|---|
| Item rest | Base |
| Item hover | State layer 8% on item |
| Item pressed | State layer 12% |
| Item focused (keyboard) | 2 px focus ring on item, OR row tint |
| Item selected (toggle) | Trailing check icon |
| Item disabled | 38% opacity |
Open / close animation:
- Open: scale 0.95 → 1 from anchor edge + fade-in (motion-fast)
- Close: scale 1 → 0.95 + fade-out (motion-fast)
- Reduced motion: instant in / out
R8 — Dismiss behaviors
| Trigger | Result |
|---|---|
| Tap an action item | Closes menu, runs action |
| Tap a toggle item | Toggles state, may stay open |
| Tap outside menu | Closes menu, no action |
| Esc key | Closes menu |
| Scroll page (some surfaces) | Closes menu |
| Resize window | Repositions or closes |
R9 — Accessibility
- Trigger:
aria-haspopup="menu"+aria-expanded="true"when open - Menu container:
role="menu" - Each item:
role="menuitem"(action),role="menuitemcheckbox"(toggle),role="menuitemradio"(single-select group) - Submenu trigger:
aria-haspopup="menu"+aria-expanded - Group label:
role="none"on label +role="group"on items group - Divider:
role="separator" - Keyboard:
- Arrow Down: move to next item (wrap to first)
- Arrow Up: previous (wrap to last)
- Arrow Right: open submenu (if focused on submenu trigger)
- Arrow Left: close submenu
- Home / End: first / last
- Enter / Space: activate
- Esc: close
- Type-ahead: typing letter focuses next item starting with that letter
R10 — Density
| Density | Item height | Padding |
|---|---|---|
| Compact | 36 px | 12 px / 8 px |
| Default | 48 px | 16 px / 12 px |
| Comfortable | 56 px | 20 px / 16 px |
R11 — Per-preset variation
| Preset | Container | Items |
|---|---|---|
material3 | 4 px radius, 2 dp elevation, surface-container | Tonal hover |
material2 | 4 px radius, 8 dp shadow, white bg | Filled hover |
ios_cupertino | 14 px radius, blur backdrop | Solid pressed bg |
gnome | 8 px radius, surface tonal | Solid blue hover |
windows_11 | Mica backdrop, 8 px radius | Accent bar on focus |
brutalist | Sharp, thick black border, no shadow | Inverted hover |
terminal_classic | Bordered box, single line per item | > prefix on focus |
R12 — Forbidden patterns
- ❌ Menus that scroll horizontally
- ❌ Menus wider than 320 dp (too wide; use dialog or card)
- ❌ Items wrapping to multiple lines (truncate or shorten label)
- ❌ Submenu depth > 2 levels
- ❌ Menu that re-opens automatically after closing
- ❌ Menu without keyboard support
- ❌ Context menu with > 12 items at top level (overwhelming)
- ❌ Menu items as form controls (text fields, sliders) — use dialog or popover instead
- ❌ Confirmation dialogs from menu items without warning (destructive
actions should be visually marked as destructive —
errorcolor)
Cross-link
themes/elevation.kmd— 2 dp tonal + shadow recipethemes/color-roles.kmd—surface-containerbackgroundthemes/typography.kmd—body-largefor items,label-smallfor shortcutsinteraction/states.kmd— hover / focused / pressed layerscomponents/lists.kmd— sibling pattern (lists are persistent menus)components/text-fields.kmd— exposed dropdown anchorfoundations/elements.kmd— Container + Control families
Requirements (testable)
Requirement: Anchor positioning stays inside the viewport {#req-menus-anchor-positioning}
A menu SHALL open anchored below its trigger by default, and SHALL flip above (or shift horizontally) at runtime whenever the default placement would overflow the viewport, keeping the container within 8 px of any edge.
Scenario: Dropdown opens below its trigger
- GIVEN a dropdown trigger with ample space beneath it
- WHEN the trigger is activated
- THEN the menu container's top edge is positioned adjacent to the trigger's bottom edge
- AND the menu is anchored below the trigger
Scenario: Menu flips above when it would overflow the bottom
- GIVEN a dropdown trigger near the bottom of the viewport where the menu height exceeds the space below it
- WHEN the trigger is activated
- THEN the menu is rendered above the trigger instead of below
- AND the menu's bounding box stays at least 8 px inside every viewport edge
Requirement: Cascading submenu behavior and depth limit {#req-menus-submenu}
A submenu SHALL be marked by a trailing chevron on its parent item, SHALL open to the right (left in RTL) after a 200 ms hover/focus delay, and the menu SHALL NOT nest deeper than 2 levels (parent → submenu).
Scenario: Submenu opens after the hover delay
- GIVEN an open menu whose item carries a trailing chevron
- WHEN the pointer hovers that submenu-trigger item for 200 ms
- THEN the submenu opens positioned to the right of the parent item
- AND the parent item's trailing chevron remains visible
Scenario: Esc closes only the deepest submenu
- GIVEN a parent menu with an open submenu
- WHEN the Esc key is pressed once
- THEN the deepest submenu closes
- AND the parent menu remains open
Scenario: Nesting is capped at two levels
- GIVEN a submenu already opened from a parent menu
- WHEN a submenu item that would open a third-level menu is authored
- THEN no third-level menu is produced
- AND the maximum realized depth is 2
Requirement: Context menu opens at the cursor {#req-menus-context-position}
A context menu SHALL be invoked by right-click, ~500 ms long-press, or the keyboard Menu key / Shift+F10, and SHALL open at the cursor or touch position rather than anchored to a trigger element.
Scenario: Right-click opens the menu at the pointer
- GIVEN a surface that exposes a context menu
- WHEN the user right-clicks at coordinates (x, y)
- THEN the context menu opens with its top-left origin at (x, y), adjusted only to stay inside the viewport
- AND the menu is not anchored to any trigger button
Scenario: Long-press opens the context menu on touch
- GIVEN a touch surface with a context menu
- WHEN the user presses and holds for ~500 ms without moving
- THEN the context menu opens at the touch position
Requirement: Exposed dropdown replaces native select {#req-menus-exposed-dropdown}
An exposed dropdown SHALL render as a text field with a trailing chevron, SHALL open a scrolling menu of options below the field on focus/tap, and selecting an option SHALL fill the field text and close the menu.
Scenario: Selecting an option fills the field and closes
- GIVEN a closed exposed dropdown showing a trailing down-chevron
- WHEN the user taps the field and then selects the option "Brazil"
- THEN the field text becomes "Brazil"
- AND the option menu closes
Requirement: Dismiss behaviors {#req-menus-dismiss}
An open menu SHALL close when an action item is activated (also running its action), when the user taps outside the menu (running no action), and when the Esc key is pressed.
Scenario: Tapping an action item runs it and closes
- GIVEN an open menu with an enabled action item
- WHEN the action item is tapped
- THEN the item's action is invoked exactly once
- AND the menu closes
Scenario: Tapping outside closes without acting
- GIVEN an open menu
- WHEN the user taps a point outside the menu container
- THEN the menu closes
- AND no menu item action is invoked
Requirement: Accessibility roles and keyboard navigation {#req-menus-a11y}
A menu SHALL expose role="menu" on its container with role="menuitem" (action), role="menuitemcheckbox" (toggle), or role="menuitemradio" (single-select) on items, its trigger SHALL carry aria-haspopup="menu" with aria-expanded reflecting open state, and arrow-key navigation SHALL move roving focus between items wrapping at the ends.
Scenario: Trigger exposes popup state
- GIVEN a menu trigger in its closed state
- WHEN the menu is opened
- THEN the trigger reports
aria-haspopup="menu" - AND the trigger's
aria-expandedattribute reads "true"
Scenario: Arrow-down navigation wraps
- GIVEN an open menu with keyboard focus on the last item
- WHEN the Arrow Down key is pressed
- THEN focus moves to the first item
- AND that item receives the keyboard focus indicator
Scenario: Esc closes the menu from the keyboard
- GIVEN an open menu with a focused item
- WHEN the Esc key is pressed
- THEN the menu closes
- AND
aria-expandedon the trigger reads "false"
Referências
specs/foundations/elements.kmdspecs/themes/color-roles.kmdspecs/themes/elevation.kmdspecs/themes/typography.kmdspecs/interaction/states.kmdspecs/components/lists.kmdspecs/components/text-fields.kmd