Manual UI Behaviors activeDescendant

activeDescendant

elements man ui/behaviors/active-descendant

A virtual highlight in a list while DOM focus stays on another element: a combobox input, a command palette's input, a select-only button. The behavior sets aria-activedescendant on the focus element and data-highlighted on the item, giving the item an id when it has none. It never sets aria-selected: selection is the template's.

Signatures
function activeDescendant(focusEl: HTMLElement, list: HTMLElement, opts?: ActiveDescendantOptions): ActiveDescendant; function scrollIntoNearest(item: HTMLElement, list: HTMLElement, toTop?: boolean): void;

Start it when the list exists (the popup's oninsert) and release it when the list goes. scrollIntoNearest is the scroll it uses, exported for a list that highlights its own items: nearest edge inside the list's scroll container, never the page, keeping the container's scroll-padding.

Options

Option Type Default What it does
items string "[role=option]"; "[role=gridcell]" with layout grid, "[role=treeitem]" with layout tree Selector for the items, matched inside the list.
order (items: HTMLElement[]) => HTMLElement[] DOM order Puts the items in the order the keys walk them, for a list ranked with CSS order.
commandKeys boolean false Command palette keys: Ctrl+N and Ctrl+J move down, Ctrl+P and Ctrl+K up, Meta+Arrow to the ends, Alt+Arrow to the next or previous group.
groups string "[role=group]" With commandKeys: the selector for item groups.
loop boolean false Wrap from the last item to the first.
orientation "vertical" | "horizontal" "vertical" Which arrows move. A horizontal list follows reading direction.
dir "ltr" | "rtl" the list's computed direction Reading direction for a horizontal list.
homeEnd boolean false on a text input, true otherwise Home and End jump to the ends. On a text input they move the caret by default.
page number 0 (off) PageUp and PageDown move this many items.
typeahead boolean false Letters jump to a matching item, for a select-only combobox.
focusDisabled boolean false Disabled items can be highlighted, never selected.
autoHighlight boolean false Keep the first item highlighted whenever nothing is; every search change highlights the first match.
pointer boolean true The highlight follows the mouse.
clearOnLeave boolean false The mouse leaving the list, or moving onto a disabled item or a gap, clears the highlight. Ignored with autoHighlight.
enabled () => boolean always Keys are handled only while this returns true: the popup is open. Otherwise they pass through untouched, so ArrowDown can open it.
current () => HTMLElement | null none The item to highlight on start, such as the selected option.
highlightOnFocus boolean true when focusEl is the list Focusing focusEl with nothing highlighted highlights current() or the first item, without selecting it.
onHighlight (el: HTMLElement | null) => void none The highlight changed, to the item or to nothing.
onSelect (el: HTMLElement, e: Event) => void none Enter on the highlighted item, or a click on an item.
multiple boolean false Multi-select listbox keys, below. Ignored on a text input.
onSelectRange (items: HTMLElement[], e: KeyboardEvent) => void none A range key selected these items, in DOM order, with multiple.
selectionFollowsFocus boolean false With multiple: plain arrows, Home, End and Page keys select the item they move to and unselect every other; Ctrl+Arrow moves without selecting and Ctrl+Space toggles.
onSelectOnly (el: HTMLElement, e: KeyboardEvent) => void onSelectRange([el]) Select only this item, with selectionFollowsFocus.
layout "list" | "grid" | "tree" "list" The popup's shape. Grid: items are cells in rows, arrows move by cell, Home and End to the row's ends, Ctrl+Home and Ctrl+End to the grid's, Page keys page rows. Tree: Right opens a closed node or enters an open one, Left closes an open node or goes to the parent.
rows string "[role=row]" With layout grid: the selector for rows.
onExpand (el: HTMLElement, open: boolean, e: KeyboardEvent) => void sets aria-expanded itself With layout tree: a node opens or closes.
onMove (el: HTMLElement, dir: 1 | -1, e: KeyboardEvent) => void none Alt+Down and Alt+Up on the highlighted item: the template moves it one place.
onDelete (el: HTMLElement, e: KeyboardEvent) => void none Delete or Backspace on the highlighted item of a list that is not a text input.

Keys

Keys Action
ArrowDown, ArrowUp Next and previous item. From nothing, Down goes to the first item and Up to the last.
Home, End First and last item, with homeEnd.
PageDown, PageUp page items forward or back.
Enter Selects the highlighted item.
Space Selects too on a focus element that is not a text input, except while a typeahead search is running.
Letters Jump to a matching item, with typeahead.
ArrowLeft, ArrowRight On an editable combobox with a vertical list: clear the highlight and move the caret.
Typing, Backspace, Delete On an editable combobox: clear the highlight, unless autoHighlight.
Shift+ArrowDown, Shift+ArrowUp With multiple: move and toggle the next item (onSelect).
Ctrl+Space With multiple: toggle the highlighted item.
Ctrl+ArrowDown, Ctrl+ArrowUp With multiple: move without selecting.
Shift+Space With multiple: onSelectRange from the last item selected to the highlight.
Ctrl+Shift+Home, Ctrl+Shift+End With multiple: onSelectRange from the highlight to an end, moving the highlight there.
Ctrl+A With multiple: onSelectRange with every item.
Alt+ArrowDown, Alt+ArrowUp onMove with the highlighted item.

Cmd works for Ctrl. Disabled items (disabled, aria-disabled, data-disabled) are skipped unless focusDisabled. An IME composition is never intercepted.

Pointer: moving a mouse over an item highlights it (on move, not on enter, so a list scrolling under a still pointer does not steal the highlight; touch and pen moves are scrolls). A press inside the list keeps focus on the input, and a click on an enabled item selects it. The highlighted item scrolls into view inside the list; the first item scrolls the list to its top so a group heading above it shows. When the highlighted item is removed, hidden or disabled (the list filtered), the highlight clears, or moves to the first item with autoHighlight.

The handle

Member What it does
items() Usable items in order: visible, and enabled unless focusDisabled.
highlighted() The highlighted item, or null.
highlight(el, { scroll }) Highlights el, or clears with null, and scrolls it into view unless scroll: false.
first(), last(), next(), prev() Move the highlight.
sync() Re-checks the highlight after the items changed. Runs on its own on DOM changes.
release() Stops and clears the highlight.

In a template of your own

app/shared/templates/picker/index.ehtml
import { activeDescendant, type ActiveDescendant } from "@elements/ui/behaviors"; interface PickerState { input?: HTMLElement; ad?: ActiveDescendant; } function matches(option: string, query: string): boolean { return option.toLowerCase().includes(query.toLowerCase()); } <Picker (options: string[], value: string = "", private query: string = "", private ui: PickerState = {})> <input role="combobox" aria-expanded="true" aria-autocomplete="list" value={query} oninsert={(e) => ui.input = e.target as HTMLElement}> <ul role="listbox" oninsert={(e) => ui.ad = activeDescendant(ui.input!, e.target as HTMLElement, { autoHighlight: true, onSelect: (el) => { value = el.textContent ?? ""; query = value; }, })} onremove={() => ui.ad?.release()}> <li e:for={option of options.filter((o) => matches(o, query))} role="option" aria-selected={option === value ? "true" : "false"}>{option}</li> </ul> </Picker>

Writes

aria-activedescendant on the focus element; data-highlighted and, when it has none, an id on the highlighted item. With layout tree and no onExpand, aria-expanded on the node.

Used by

Combobox (opens in a new tab), DropdownMenu (opens in a new tab), ContextMenu (opens in a new tab), Menubar (opens in a new tab), Select (opens in a new tab), TagsInput (opens in a new tab). scrollIntoNearest: Command (opens in a new tab).