Introducing v-dt-focusgroup: Declarative Keyboard Navigation

Francis Rupert avatar
Francis Rupert

New Vue directive for roving tabindex. Add arrow-key cycling, looping, memory, and disabled-item handling to any composite widget with a single attribute — no keyboard event handlers required.

TLDR

  • New directive v-dt-focusgroup adds 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 existing keyboard_list_navigation mixin.
  • No aria-orientation. The directive manages keyboard behavior, not ARIA semantics. Set aria-orientation yourself when the axis differs from the role's default.
  • No nested focusgroup awareness. Each v-dt-focusgroup is 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.

documentation last updated Wednesday, September 16, 2026