Skip to content

Keyboard Navigation

How @diffgazer/ui's headless-friendly component philosophy works with keyboard handling.

Philosophy

@diffgazer/ui components are headless-friendly primitives. They render the UI layer and expose controlled props/events/ARIA hooks so keyboard behavior can be composed at the app level.

Some components include local keyboard behavior, and some package-mode entries import hooks from @diffgazer/keys. Others expose control points such as highlighted state, selection state, and onKeyDown. Check each component's dependency metadata before choosing copy mode or package mode.

Why this approach

  • Simple and predictable -- state and interactions stay explicit
  • Declared navigation policy -- built-in keyboard behavior and external hooks are documented per component
  • No conflicts -- component behavior doesn't fight app-level shortcuts
  • Full control -- you decide which keys do what, in which contexts
  • Composes with @diffgazer/keys -- same primitives work with your own handlers or keys recipes

Without keyboard support

Not every context needs full keyboard navigation. A menu in a toolbar might only need click support, but verify the component's own behavior before removing keyboard handlers:

tsx
import { useState } from "react"
import { Menu, MenuItem } from "@/components/ui/menu"

function BasicMenu() {
  const [selected, setSelected] = useState<string | null>(null)

  return (
    <Menu selectedId={selected} onSelect={setSelected} aria-label="File menu">
      <MenuItem id="file-new">New File</MenuItem>
      <MenuItem id="file-open">Open File</MenuItem>
      <MenuItem id="file-save">Save</MenuItem>
    </Menu>
  )
}

Items are clickable and selectable. Components with built-in navigation can still respond to keys; components without it need an onKeyDown handler if keyboard navigation is required.

Controlling keyboard state

Menu includes local arrow-key, j/k, Home/End, Enter/Space, and typeahead behavior. Control highlighted only when the app needs to coordinate highlight state outside the menu:

tsx
import { useState } from "react"
import { Menu, MenuItem } from "@/components/ui/menu"

function KeyboardMenu() {
  const [selected, setSelected] = useState<string | null>(null)
  const [highlighted, setHighlighted] = useState<string | null>(null)

  return (
    <Menu
      selectedId={selected}
      onSelect={setSelected}
      highlighted={highlighted}
      onHighlightChange={setHighlighted}
      aria-label="File menu"
    >
      <MenuItem id="file-new">New File</MenuItem>
      <MenuItem id="file-open">Open File</MenuItem>
      <MenuItem id="file-save">Save</MenuItem>
      <MenuItem id="file-close">Close</MenuItem>
    </Menu>
  )
}

Use onKeyDown only to compose extra app-level shortcuts. Call your handler first and leave the event unprevented when the component's local keyboard behavior should still run.

Keyboard-Aware Components

The following components expose keyboard integration points:

ComponentProps
MenuonKeyDown, highlighted, onHighlightChange, selectedId, onSelect
NavigationListonKeyDown, highlighted, onHighlightChange, onEnter, onNavigationBoundaryReached, autoFocus, focused, selectedId, onSelect
CheckboxGrouponKeyDown, highlighted, onHighlightChange, onChange
RadioGrouponKeyDown, highlighted, onHighlightChange, onChange, onEnter, onNavigationBoundaryReached(direction, event, key), orientation
ToggleGrouponKeyDown, highlighted, onHighlightChange, onChange
TabsList + TabsonKeyDown, loop, onNavigationBoundaryReached (TabsList) + controlled value/onChange (Tabs)
Select + SelectContentcontrolled highlighted/onHighlightChange + onKeyDown on content
CommandPalette stackcontrolled highlighted/onHighlightChange + onKeyDown on input
  • j / k -- vim aliases for ArrowDown/ArrowUp in the list composites that own the whole keyboard: Menu, NavigationList, CheckboxGroup, RadioGroup, DiffView, and an open Select option list. Where typeahead is enabled, j/k never start a query -- they move the highlight on an empty buffer and extend a query already in progress, so first-letter jumps to items starting with j or k are unavailable. Composites whose focus lives in a text field (CommandPalette input, Select's search input) stay arrows-only so the characters type.
  • highlight state / highlighted -- controls which item appears highlighted
  • onKeyDown -- receives keyboard events for app-level handlers
  • selectedId -- the currently selected item; for CommandPalette, use highlighted because command palettes track highlight rather than selection
  • onSelect -- called when an item is selected (via click or Enter/Space)
  • onEnter -- Diffgazer preview/commit extension that separates Enter activation from Space selection in components that support it
  • onNavigationBoundaryReached -- lets an app hand focus to an adjacent zone when non-wrapping navigation hits an edge. The callback receives direction, native event, and key so vertical app zones can ignore horizontal APG arrow keys.
  • autoFocus -- moves focus into a composite component when it becomes the active interaction target
  • focused (NavigationList only) -- whether item highlight visuals are shown while the list is active

Accessibility

Components ship with proper ARIA attributes regardless of whether you add keyboard handling. Roles, labels, and states are built in.

For components without built-in keyboard behavior, compose onKeyDown or @diffgazer/keys hooks so users can navigate and activate items without a mouse.