rovingFocus gives a group of related controls the keyboard behaviour the
ARIA authoring practices ask for: the group is a single tab stop, and once
focus is inside it the arrow keys move between the items. It owns the keyboard
and the tabindex bookkeeping only — it renders nothing, decides nothing about
selection, and applies no styling — which is what lets one primitive serve
SegmentedControl's radiogroup and Table's selectable rows today, and a
future Tabs unchanged.
import { rovingFocus } from 'frontile';
Construct one instance per group, then place its single modifier on every item.
<div role="radiogroup">
<button aria-checked="true" {{roving.setupItem}}>...</button>
</div>
setupItem takes no arguments. Both facts it needs are read from the
element itself, live, at the moment a decision is made:
| State | How the element declares it |
|---|---|
| selected | data-selected="true", aria-checked="true", or aria-selected="true" |
| disabled | :disabled, or aria-disabled="true" |
An element already has to declare both for the browser and for assistive technology, so asking a consumer to declare them a second time only creates a way for the two to disagree.
Covering several spellings of each is deliberate rather than defensive. A
radiogroup marks selection with aria-checked, a tab list with
aria-selected, and Frontile's own components publish data-selected.
Likewise :disabled is the form-control path, while aria-disabled is the
only option for an element that cannot be natively disabled at all — an <a>
or a LinkTo, which is exactly what a navigation tab list is built from.
Items register themselves as their modifiers run and are kept sorted by document position, not by setup order — modifier setup order is not guaranteed to match DOM order once items are added or reordered, and navigation has to follow what the user actually sees.
The constructor takes a function returning the options, not the options themselves:
roving = rovingFocus(() => ({
orientation: this.args.orientation ?? 'horizontal',
activationMode: 'automatic',
onActivate: this.activate
}));
The thunk is called at the moment a key is handled, never cached. That is the
whole point: a consumer whose orientation or activation mode is a component
argument stays reactive without having to push updates into the instance, and
without the instance having to be rebuilt when an argument changes. A snapshot
taken in the constructor would freeze @orientation at its first value, and a
control switched from horizontal to vertical would keep answering to the wrong
arrow keys.
| Option | Type | Default |
|---|---|---|
orientation |
'horizontal' | 'vertical' |
'horizontal' |
activationMode |
'automatic' | 'manual' |
'automatic' |
onActivate |
(element: HTMLElement) => void |
— |
onActivate receives the element, not a value. Keeping the primitive
ignorant of the consumer's value type is what lets a consumer hold non-string
values against their elements and get them back unserialised; SegmentedControl
keeps a Map<HTMLElement, T> for exactly that.
| Key | Behaviour |
|---|---|
Tab |
Enters or leaves the group. Only one item inside it is tabbable. |
ArrowRight / ArrowDown |
Moves to the next enabled item, wrapping past the last to the first. |
ArrowLeft / ArrowUp |
Moves to the previous enabled item, wrapping past the first to the last. |
Home / End |
Moves to the first / last enabled item. |
Which arrow pair is live depends on orientation: horizontal listens to
left/right, vertical to up/down. The other pair is left alone, so a vertical
group inside a horizontally scrolling region does not swallow left/right.
Disabled items are skipped entirely rather than focused and stepped over — the
navigation order is computed from the enabled items alone, so wrapping and
Home/End land on real targets. Any handled key calls preventDefault();
anything else is left to the browser.
Keys are only handled when they are pressed on the item itself. An item is
not always a leaf — a grid row holds real controls — and a key pressed on a
button, link or input inside one belongs to that control. Without this, typing
in a cell would move the row focus and Enter on a nested button would activate
the row instead.
automatic — the default, and what the radiogroup pattern requires — moves
selection with focus: every arrow, Home, or End focuses the new item and
immediately calls onActivate with it. Arrowing through a SegmentedControl
selects as it goes, exactly as a native radio group does.
manual moves focus only. onActivate is not called on arrow keys; it fires
when the user presses Enter or Space on the focused item. This is the mode
tabs want when switching panels is expensive — a user arrowing across six tabs
should not trigger six panel loads — and it is why the option exists ahead of a
Tabs component to use it.
In both modes the consumer decides what "activate" means. rovingFocus never
changes aria-checked, aria-selected, or anything else about the item.
Exactly one item in the group carries tabindex="0"; every other item gets
tabindex="-1", and the set is recomputed whenever an item is added, removed,
or changes state. Because both facts are read from the element rather than
passed in, nothing re-runs the modifier when they change — so each item is
watched for changes to those attributes, and the tab stop hands off on its own.
The tab stop is where focus last was, else the selected item, else the first
enabled item. That last fallback is not incidental: a radiogroup with no
selection still has to be reachable by Tab, and without it a fresh group with
nothing chosen would have no tab stop at all and would be skipped over entirely
by keyboard users.
Focus comes ahead of selection because the two only disagree when activation
does not follow focus — a manual group, or one where several items are
selected at once. There the selected item is not where the user is, and sending
Tab back to it would undo their navigation. Focus is tracked with focusin
rather than focus, so focus landing on a control inside an item still puts
that item in play.
A group in which every item is disabled has no tab stop, which is the correct outcome — there is nothing there to operate.
In an RTL container the right arrow moves towards the start of the group, so
ArrowRight and ArrowLeft swap roles. rovingFocus reads the focused item's
computed direction at the moment the key is handled, so a group inside a
dir="rtl" subtree behaves correctly without the consumer passing anything —
and a page that flips direction at runtime is picked up on the next keypress
rather than being frozen at construction time.
Only horizontal navigation is affected. Vertical order is the same in both
directions, so ArrowUp/ArrowDown are never swapped.
Arrow between the options below — focus and selection move together, Home
and End jump to the ends, the disabled option is skipped, and Tab leaves
the group in one press rather than stepping through all four.
Selected: day
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { rovingFocus } from 'frontile';
const options = [
{ value: 'day', label: 'Day', isDisabled: false },
{ value: 'week', label: 'Week', isDisabled: false },
{ value: 'month', label: 'Month', isDisabled: true },
{ value: 'year', label: 'Year', isDisabled: false }
];
export default class Example extends Component {
@tracked selected = 'day';
roving = rovingFocus(() => ({
orientation: 'horizontal',
activationMode: 'automatic',
onActivate: this.activate
}));
activate = (element: HTMLElement): void => {
this.selected = element.getAttribute('data-value') ?? this.selected;
};
isSelected = (value: string): boolean => {
return this.selected === value;
};
<template>
<div class='flex flex-col items-start gap-3'>
<div role='radiogroup' aria-label='Date range' class='flex gap-2'>
{{#each options as |option|}}
<button
type='button'
role='radio'
data-value={{option.value}}
aria-checked='{{this.isSelected option.value}}'
disabled={{option.isDisabled}}
class='rounded-md border border-neutral-soft px-3 py-1 text-label-md
aria-checked:border-primary aria-checked:text-primary
disabled:cursor-not-allowed disabled:opacity-disabled'
{{this.roving.setupItem}}
>{{option.label}}</button>
{{/each}}
</div>
<p class='text-body-sm text-neutral-strong'>Selected: {{this.selected}}</p>
</div>
</template>
}
rovingFocushandleskeydownon the items themselves, so it only reacts once focus is already inside the group. Selecting with the pointer stays the consumer's job — wire your ownclickhandler alongsidesetupItem, asSegmentedControldoes.