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.
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.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| loop | boolean | true | The 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 attribute | Description |
|---|---|
| data-active | On 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| item* | string | - | Selector for the items that take part. Leave disabled ones out of it. |
| enabled | boolean | true | Off leaves every item in the tab order. |
| loop | boolean | false | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |