Behaviors

The keyboard, focus and state logic under Koala components, as small hooks you can build your own on.

Where behavior lives#

Koala components get dialogs, menus, popovers and tabs from Radix primitives. The patterns Radix leaves open, an input that drives a list, a group with one tab stop, a value a parent may own, live in a few hooks in lib/. Combobox, Command, Select Search, Multi Select, List and the editor menus all run on them, so every list answers the same keys in the same way.

Building a component Koala does not ship? Reach for a Radix primitive first, then for these hooks, and keep only the look in your own tv recipe. Never write keyboard or ARIA logic inside the component.

A value the parent may own#

Every Koala component takes value or defaultValue. One hook decides which applies, and calls onValueChange only when the value really changes.

rating.tsxTSX
import { useControllableState } from "@/lib/controllable"export function Rating({ value, defaultValue = 0, onValueChange }) {  const [rating, setRating] = useControllableState({    value,    defaultValue,    onChange: onValueChange,  })  // setRating(3), or setRating((n) => n + 1)}

An input that drives a list#

Focus stays in the input while the highlight moves through the rows. The arrows wrap, Home and End jump to the ends, PageUp and PageDown move ten rows, Cmd+arrow jumps on macOS and Enter picks the lit row. Nothing moves while an input method is composing a word, and a finger sliding over the list on touch never lights a row.

environment-picker.tsxTSX
import { useActiveDescendant } from "@/lib/listbox"const listRef = React.useRef(null)const inputRef = React.useRef(null)const list = useActiveDescendant({ listRef, inputRef, item: '[role="option"]' })<input ref={inputRef} role="combobox" aria-controls="envs" onKeyDown={list.handleKeyDown} /><div ref={listRef} id="envs" role="listbox">  {environments.map((env) => (    <div key={env.id} id={env.id} role="option" onPointerMove={list.highlightOnPointer} onClick={() => pick(env)}>      {env.name}    </div>  ))}</div>

One tab stop for a group#

Tab enters the group on the selected item and leaves it in one press; the arrows move inside. When the focused item is removed, focus moves to the one now in its place.

file-list.tsxTSX
import { useRovingFocus } from "@/lib/roving-focus"const roving = useRovingFocus({ item: "[data-file]:not([aria-disabled=true])" })<ul ref={roving.ref} onFocus={roving.onFocus} onKeyDown={roving.onKeyDown}>  {files.map((file) => <li key={file.id} data-file tabIndex={-1}>{file.name}</li>)}</ul>

API reference#

useControllableState

A value a parent may own (controlled) or leave to the component. Returns [value, setValue]. From @/lib/controllable.

PropTypeDefaultDescription
value*T | undefined-The controlled value. Once one has been passed, a later undefined is that value, not a switch back to uncontrolled.
defaultValue*T-The starting value while uncontrolled.
onChange(next: T) => void-Called when the value actually changes, never for the same value.

useActiveDescendant

A highlight that moves through rows while focus stays in an input, with aria-activedescendant. From @/lib/listbox.

PropTypeDefaultDescription
listRef*RefObject<HTMLElement | null>-Holds the rows. They are read on every move, so filtering needs no bookkeeping.
inputRef*RefObject<HTMLElement | null> | (() => HTMLElement | null)-Keeps focus and carries aria-activedescendant.
item*string-Selector for every row. Rows with data-disabled or hidden are skipped.
loopbooleantrueThe arrows wrap from the last row to the first and back.
scrollContainer(row: HTMLElement) => HTMLElement | null-The element that scrolls to show a row, when it is not listRef.
onActiveChange(row: HTMLElement | null, details: { instant: boolean }) => void-Runs after the highlight moves: extra ARIA, a sliding highlight.
Data attributeDescription
data-activeOn the highlighted row. Style the highlight from it.

useRovingFocus

One tab stop for a group of items: Tab enters and leaves, the arrows move. From @/lib/roving-focus.

PropTypeDefaultDescription
item*string-Selector for the items that take part. Leave disabled ones out of it.
enabledbooleantrueOff leaves every item in the tab order.
loopbooleanfalseThe arrows wrap at the ends.
orientation"vertical" | "horizontal""vertical"Up/Down, or Left/Right (swapped in right-to-left text).
initial(items: HTMLElement[]) => HTMLElement | undefined-The item that holds the stop before any has been focused.

listMoveFor / moveIndex

The list keys as plain functions, for lists that keep the highlighted index in state. From @/lib/listbox.

PropTypeDefaultDescription
listMoveFor(event)"next" | "previous" | "first" | "last" | "page-down" | "page-up" | null-The move a key asks for. null while an IME is composing.
moveIndex(current, count, move, options?)number-Where the move lands. -1 for an empty list. options: loop, pageSize.
isPickKey(event)boolean-The Enter that picks the highlighted row, never one that ends an IME word.