TLDR
- New directive
v-dt-focusgroupadds roving tabindex to any container element. - Arrow keys cycle focus between items. Home/End jump to first/last. Configurable looping, memory, and disabled-item handling.
- Role-aware defaults infer the right behavior from the container's ARIA role.
- Focus only — selection/activation remains the consumer's responsibility.
- Aligned with the upcoming Open UI focusgroup proposal.
- Storybook docs
The Motivation
Keyboard navigation is just as much an accessibility requirement as it is a usability improvement for everyone. Arrow-key cycling through grouped controls is faster than Tab-hammering, more predictable than mouse-only interaction, and expected by anyone who's used a native desktop or web app. When a toolbar, tab list, or sidebar doesn't respond to arrow keys, it can feel broken.
Many composite UI features across Dialpad products need this behavior: toolbars, tab lists, contact lists, sidebar navigation, data tables. Consumers building custom widgets had no Dialtone primitive for this.
The Solution
One directive. One attribute. Zero event handlers. Many config options.
Arrow Left/Right cycles through the buttons. Home/End jump to first/last. Tab enters and exits the group as a single stop. That's it.
Who Benefits
This isn't just an accessibility feature, arrow-key navigation makes grouped controls faster and more predictable for everyone:
- Keyboard users navigate without Tab-hammering through every item
- Screenreaders can properly announce the focus group and each item
- Mouse users who occasionally reach for the keyboard get a consistent, expected interaction
- Product teams ship accessible experiences without writing keyboard logic
- Dialtone has one implementation to test and maintain
Example
Focus on the first item and use your up/down arrow keys.
Configuration
Token syntax for common cases
<!-- Vertical menu -->
<div role="menu" v-dt-focusgroup="'vertical'" aria-label="Actions">...</div>
<!-- Horizontal tabs, no memory (re-entry starts at selected tab) -->
<div role="tablist" v-dt-focusgroup="'horizontal nomemory'" aria-label="Tabs">...</div>
<!-- No looping — focus stops at boundaries -->
<div role="toolbar" v-dt-focusgroup="'horizontal noloop'" aria-label="Pagination">...</div>
Object syntax for advanced cases
<!-- Custom selector for table row navigation -->
<table v-dt-focusgroup="{ axis: 'vertical', selector: 'tbody tr', memory: false }">...</table>
Zero config
<!-- Both axes, looping, memory — all defaults -->
<div role="radiogroup" v-dt-focusgroup aria-label="Options">...</div>
Role-Aware Defaults
The directive infers the item selector and disabled behavior from the container's role:
| Role | Items found automatically | Disabled behavior |
|---|---|---|
tablist | [role="tab"] | Focusable (discoverable) |
listbox | [role="option"] | Skipped |
radiogroup | [role="radio"] | Skipped |
menu | [role="menuitem"] | Skipped |
toolbar | All focusable elements | Skipped |
No selector configuration needed for standard ARIA patterns.
Selection Follows Focus
The directive handles focus movement only. Selection is the consumer's responsibility — wired through the dt-focusgroup-move event:
<div
role="tablist"
v-dt-focusgroup="'horizontal nomemory'"
aria-label="Tabs"
@dt-focusgroup-move="selectedTab = $event.detail.index"
>
This keeps the directive's scope narrow and predictable. It never toggles aria-selected, aria-checked, or any other state — that's yours to own.
Treeview Pattern
The directive composes cleanly with consumer-owned behavior. For example, in a sidebar treeview, the directive owns Up/Down cycling while the consumer handles Left/Right for expand/collapse:
<div
role="tree"
v-dt-focusgroup="'vertical'"
aria-label="Sidebar"
@keydown.right.prevent="expandOrEnter"
@keydown.left.prevent="collapseOrParent"
>
The two don't collide — axis: 'vertical' means the directive ignores Left/Right entirely.
What It Does NOT Do
- No 2D grid navigation. All navigation is 1D (DOM order). Grid patterns are a separate problem with different mechanics.
- No
aria-activedescendant. The directive uses roving tabindex (actual DOM focus moves). For virtual focus patterns (combobox dropdowns), use the existingkeyboard_list_navigationmixin. - No
aria-orientation. The directive manages keyboard behavior, not ARIA semantics. Setaria-orientationyourself when the axis differs from the role's default. - No nested focusgroup awareness. Each
v-dt-focusgroupis independent. Use distinct axes to avoid conflicts when nesting.
These boundaries are intentional. A focused tool that does one thing well is more trustworthy than a Swiss army knife that does many things unpredictably.
ESLint Guardrails
Two new rules in eslint-plugin-dialtone catch the most common accessibility mistakes:
rules: {
'dialtone/focusgroup-requires-role': 'warn',
'dialtone/focusgroup-requires-label': 'warn',
}
These warn when v-dt-focusgroup is used without a role or aria-label — the two attributes screen readers need to announce the widget correctly.
