Interaction States
The three-state interaction model for TUI-style components — highlight, selected, and hover.
Three-state model
@diffgazer/ui components use three distinct interaction states inspired by terminal UIs (the VS Code list model):
The keyboard owns the cursor: only arrow keys (and typeahead) move the highlight and aria-activedescendant, and only that cursor fires onHighlightChange. The pointer owns a separate, purely cosmetic hover. Clicking still commits — it moves the cursor and activates the item — but travelling never does, so reading the menu with the mouse can never yank the keyboard cursor around.
Hover is deliberately not CSS :hover. It is JS state set by pointermove events gated on real coordinate deltas — an event whose clientX/clientY equal the previous ones is ignored, which kills stationary-cursor artifacts (browser :hover sticks to a resting pointer, and Safari re-fires synthetic pointermoves on re-render). The hover clears on pointerleave and on any navigation keydown, and re-arms only when the pointer actually travels again. A hovered row shows a right-pointing chevron in the indicator slot; the ▌ glyph belongs exclusively to the keyboard cursor, and when a row is both hovered and highlighted the keyboard treatment wins. Idle rows render an empty indicator slot. The chevron reveal is a conditional mount inside a fixed-width w-5 slot: the slot always occupies the same width, so revealing the chevron never shifts the label column, and the glyph appears instantly rather than fading in — the TUI-appropriate treatment. The delta gate also means a scroll under a stationary pointer would not move the hover to the new row beneath it (no pointerleave fires, and the resting-cursor re-fire is exactly what the gate blocks); no current menu surface scrolls, but a scrollable variant must clear the hover in its container's onScroll, the same one-line pattern as the navigation-keydown clear. Rows that already carry a directional glyph — the submenu trigger's trailing chevron, the back row's ‹ — take the hover background only, never a second chevron.
State precedence
This comes from getItemState() in menu-item-variants.ts. A disabled item that owns the menu highlight renders disabledFocused, so APG-style disabled menu items can still be the keyboard cursor without looking enabled. Otherwise, disabled wins over selection — and over hover: disabled rows never take hover styling. A highlighted enabled item always shows highlight visuals even if it is also the selected or hovered item. This ensures the keyboard cursor is always visible.
The same disabledFocused state applies to submenu triggers. Disabled submenu triggers and checkbox items keep data-highlighted when they own the virtual focus, while their disabled styling and activation guards remain in effect.
Visual mapping (CVA variants)
The menuItemBase CVA maps each state to Tailwind classes:
Danger items use bg-error text-error-foreground for both focused and selected states, and keep their text-error palette under hover.
Sync on click
When selection is enabled (selectedId or defaultSelectedId), activating a MenuItem sets both selection and highlight. Plain command menus do not persist selected state; activation calls onSelect and leaves the keyboard highlight model in charge of the active item.
autoFocus
Pass autoFocus to Menu when it is the primary interaction target on the page. This focuses the container div on mount so keyboard events (ArrowUp/Down, Enter) work immediately without requiring the user to click or tab into the menu first.
Navigation direction
Diffgazer web persists menu highlight state across route changes as an app-level pattern. The direction of navigation determines whether the highlight resets or restores:
- Forward (parent → child): Parent clears the child's stored highlight before navigating. The child menu starts from its first item.
- Back (child → parent): Parent's highlight is restored automatically from the persistent store.
This gives TUI-like behavior: entering a menu always starts from the top, but returning to it picks up where you left off.
Accessibility
The interaction state model maps directly to ARIA semantics:
- Container:
role="menu",tabIndex={0},aria-activedescendantpointing to the highlighted item - Items:
role="menuitem",data-value={id},data-highlightedfor keyboard/virtual focus,data-selectedfor selection,data-hoveredfor the cosmetic pointer hover,aria-disabledfor disabled items - Selectable menu items:
role="menuitemradio"witharia-checkedfor the selected item - Screen readers: announce the active descendant as the user arrows through items -- the highlight state drives
aria-activedescendant - Follows WAI-ARIA Menu Pattern (APG)
The model maps cleanly onto the APG listbox pattern: aria-activedescendant always reflects the keyboard cursor and nothing else. Hover is cosmetic and never enters the accessibility tree, so what a screen reader announces is exactly what the keyboard user sees, while aria-checked reflects selection only when the menu is explicitly selectable.