Primitives / Interaction
Finding and moving
Typeahead is type-to-find for a host that has no roving tab stop, or
whose focus is managed elsewhere. The Model slice is { query, generation }:
printable keys extend the query, a timer of timeoutMs on Effect's clock
clears it (generation lets a superseded timer change nothing), and Cleared
drops it on purpose. Which item a query picks is the pure
Typeahead.match(texts, enabled, query, current): one character, or one
character repeated, starts after the current item so repeated presses cycle;
a longer query starts at it, since the user is refining. Case and leading
whitespace are ignored and disabled items are skipped. The Behavior,
Typeahead.behavior(Declared)(Slots)<Model, Message>({ host, items, text, current }),
gives the host OnKeyDownFocus: a printable key with no ctrl, alt, or meta
extends the query, focuses the match by id, and dispatches Typed; with no
match the key is still recorded and focus stays put; a space with an empty
query is left to the host.
ListNavigation is what a list host takes when it wants both: arrows,
Home, End, PageUp and PageDown by page, and typeahead, in one placement.
It exists for two reasons. The resolver allows one owner per event on a slot,
so RovingTabindex and Typeahead cannot both own the host's
OnKeyDownFocus; and under virtual a typed key must move the pointer and
extend the query in one transition, which two placements cannot do. Its Model
slice is { current, query, generation }, its args are RovingTabindex's
plus timeoutMs and page, and its Behavior takes { container, item, items,
text, direction?, typeahead?, commit? }. typeahead: false leaves printable keys to
the container (a search field); arrows still move the pointer. commit(input, id)
runs on unmodified Enter when current is an enabled item and returns that
parent Message from the same handler, so the field does not grow a second key
listener. Typed { char, match } carries the item the query now picks, so
update sets current and query together.
const Nav = Bundle.declare(ListNavigation.bundle, 'nav')
const navArgs = { orientation: 'vertical', loop: false, virtual: false, timeoutMs: 500, page: 10 } as const
const Keys = ListNavigation.behavior(Nav, navArgs)(ListSlots)<ListModel, ListMessage>({
container: 'list',
item: 'option',
items: model => describeFruits(model.fruits),
text: (model, index) => model.fruits[index]?.label ?? '',
})GridNavigation is the two-dimensional counterpart for cells laid out in
rows of columns (a calendar grid, a swatch picker, an emoji palette). Its
Model slice and item attributes are RovingTabindex's, so the two are
interchangeable on a view; only the pure move differs. Left and right step
within the row and up and down within the column, skipping disabled cells;
under wrap a horizontal key continues into the next row and a vertical key
into the next column, otherwise the key is consumed at the edge. Home and End
are the row's first and last enabled cell, Ctrl+Home and Ctrl+End the grid's.
RTL swaps left and right, and virtual works as it does for RovingTabindex.
const Cells = Bundle.declare(GridNavigation.bundle, 'cells')
const gridArgs = { columns: 7, wrap: false, virtual: false } as const
const DayKeys = GridNavigation.behavior(Cells, gridArgs)(CalendarSlots)<CalendarModel, CalendarMessage>({
container: 'grid',
item: 'day',
items: model => describeDays(model.days),
})TreeNavigation is the tree counterpart, after the WAI-ARIA tree pattern,
for a file explorer, a page's layers, or a nested menu: Up and Down through the
rows that are showing, Right opens a row or steps into it, Left closes it or
steps out to its parent, Home and End go to the first and last row. The view
gives every row, open or not, in tree order, each with its parent and
whether it is a branch; TreeNavigation.shown(rows, model, args) is the
rows that are showing, with their level and place among their siblings. The
Model slice is { current, toggled }: openness is stored as the rows toggled
away from openByDefault, so a layers panel that starts open and a file tree
that starts closed are one bundle with a different arg. The Behavior writes
each showing row's id (through domId, by default the row's own),
role="treeitem", aria-level, aria-posinset, aria-setsize,
aria-expanded on a branch, aria-disabled, a roving tabindex, and
OnFocus; the container's keys move focus by id, or open and close the
current row in place. Focus in the tree follows its stop when a transition it
did not see moves it or removes the focused row (the FollowTabStop Mount in
foldkit-primitives/dom, which never takes focus from outside the container).
The level is also the custom property --fk-tree-level, 1 at the top, so one
rule indents any depth: padding-inline-start: calc(var(--fk-tree-level) * 1rem).
const Layers = Bundle.declare(TreeNavigation.bundle, 'layers')
const TreeKeys = TreeNavigation.behavior(Layers, { openByDefault: true })(TreeSlots)<TreeModel, TreeMessage>({
container: 'tree',
item: 'row',
rows: model =>
model.folders.map(folder => ({ id: folder.id, parent: folder.parent, branch: folder.hasChildren })),
})The pure move(shown, current, key, modifiers, model, args, direction?)
answers with Focus, Open, or Close.
FocusScope is the one entry here that is a Mount, not a Bundle: which
element has focus is a DOM fact, so nothing crosses to the Model. The Mount
lives in foldkit-primitives/dom;
FocusScope.behavior(Slots)<Input, Message>({ container, contain?, restore?, initialFocus? })
attaches it to a container slot. On insert the container focuses
initialFocus, else its first tabbable descendant, else itself. With contain
(default), Tab from the last tabbable wraps to the first, Shift+Tab from the
first wraps to the last, and focus that lands outside comes straight back; Tab
in the middle is the browser's. On unmount, with restore (default), focus
returns to the element that had it, if it is still in the document. A native
<dialog> does all of this itself; this is for a custom overlay, a menu, or a
command palette.