Manual UI Behaviors rovingFocus

rovingFocus

elements man ui/behaviors/roving-focus

One Tab stop for a group of items, arrow keys between them: toolbars, tabs, radio groups, menus, chips. The current item has tabindex="0" and every other item tabindex="-1". The behavior owns tabindex on the items, so a template never binds it.

Signature
function rovingFocus(container: HTMLElement, opts: RovingOptions): Roving; type Orientation = "horizontal" | "vertical" | "both";

Options

Option Type Default What it does
items string required Selector for the items, matched inside the container.
orientation Orientation "horizontal" Which arrows move. Left and Right flip under rtl.
loop boolean true Wrap from the last item to the first.
dir "ltr" | "rtl" the container's computed direction Reading direction.
homeEnd boolean true Home and End jump to the first and last item.
page number 0 (off) PageUp and PageDown move this many items.
typeahead boolean false Letters jump to a matching item (typeahead (opens in a new tab)).
focusDisabled boolean false Disabled items stay focusable, as in a menu.
current () => HTMLElement | null the first item The item that holds the tab stop when focus is outside: the selected tab.
onFocusItem (el: HTMLElement) => void none Called after an item receives focus by keyboard. Automatic activation goes here.

Keys

Keys Action
ArrowRight, ArrowLeft Next and previous item in a horizontal or both group; flipped under rtl.
ArrowDown, ArrowUp Next and previous item in a vertical or both group.
Home, End First and last item, with homeEnd.
PageDown, PageUp page items forward or back, stopping at the ends.
Letters The next item whose text starts with what was typed, with typeahead.

Disabled items (disabled, aria-disabled="true", data-disabled) are skipped unless focusDisabled. Hidden items hold -1, so an item shown later by a class or style change joins without a second Tab stop, and a group started while hidden takes its stop when it is shown. Items added later are picked up on the next key press or focus; a stop that becomes disabled or hidden hands the stop to the next usable item. A text field or another arrow-key widget inside an item keeps its arrows, Home, End and letters. When the focused item is removed (a deleted chip, a closed tab), focus moves to the item that took its place, or the one before it, instead of falling to the body.

The handle

Member What it does
items() The usable items in DOM order: visible and, unless focusDisabled, enabled.
focus(el) Moves the tab stop to el and focuses it.
sync() Re-applies tabindex after the items changed. Runs on its own on DOM changes.
setCurrent(el) Moves the tab stop to el without focusing it, when a selection changed from outside.
release() Stops and puts back the tabindex it changed.

In a template of your own

app/shared/templates/chip-group/index.ehtml
import { rovingFocus, type Roving } from "@elements/ui/behaviors"; interface ChipGroupState { roving?: Roving; } <ChipGroup (options: string[], value: string = "", private ui: ChipGroupState = {})> <div role="radiogroup" class="chip-group" oninsert={(e) => ui.roving = rovingFocus(e.target as HTMLElement, { items: "[role=radio]", current: () => (e.target as HTMLElement).querySelector("[aria-checked=true]"), onFocusItem: (el) => value = el.dataset.value ?? "", })} onremove={() => ui.roving?.release()}> <button e:for={option of options} type="button" role="radio" class="chip" data-value={option} aria-checked={option === value ? "true" : "false"} onclick={() => value = option}>{option}</button> </div> </ChipGroup>

Tab lands on the checked chip, arrows move between chips, and onFocusItem selects as focus moves.

Writes

tabindex on every item, put back on release.

Used by

DataTable (opens in a new tab), Kanban (opens in a new tab), Menubar (opens in a new tab), RichText (opens in a new tab), Tabs (opens in a new tab), ToggleGroup (opens in a new tab).