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.

ts
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.

ts
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).

ts
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.