Skip to content

Commit 403d887

Browse files
committed
blog post announcing v-dt-focusgroup directive
1 parent 8003cf0 commit 403d887

File tree

1 file changed

+210
-0
lines changed
  • apps/dialtone-documentation/docs/dialtone/whats-new/posts

1 file changed

+210
-0
lines changed
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
---
2+
heading: 'Introducing v-dt-focusgroup: Declarative Keyboard Navigation'
3+
author: Francis Rupert
4+
posted: '2026-4-15'
5+
excerpt: '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.'
6+
---
7+
8+
<BlogPost :author="$frontmatter.author" :posted="parse($frontmatter.posted, 'y-M-d', new Date())" :heading="$frontmatter.heading" :excerpt="$frontmatter.excerpt">
9+
10+
## TLDR
11+
12+
- New directive `v-dt-focusgroup` adds roving tabindex to any container element.
13+
- Arrow keys cycle focus between items. Home/End jump to first/last. Configurable looping, memory, and disabled-item handling.
14+
- Role-aware defaults infer the right behavior from the container's ARIA role.
15+
- Focus only — selection/activation remains the consumer's responsibility.
16+
- Aligned with the upcoming [Open UI focusgroup proposal](https://open-ui.org/components/scoped-focusgroup.explainer/).
17+
- [Storybook docs](https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focusgroup--docs)
18+
19+
## The Motivation
20+
21+
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.
22+
23+
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.
24+
25+
## The Solution
26+
27+
One directive. One attribute. Zero event handlers. Many config options.
28+
29+
```vue demo
30+
<dt-stack role="toolbar" v-dt-focusgroup="'horizontal'" direction="row" gap="100" aria-label="Text formatting">
31+
<dt-button importance="outlined" kind="muted">Bold</dt-button>
32+
<dt-button importance="outlined" kind="muted">Italic</dt-button>
33+
<dt-button importance="outlined" kind="muted">Underline</dt-button>
34+
</dt-stack>
35+
```
36+
37+
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.
38+
39+
## Who Benefits
40+
41+
This isn't just an accessibility feature, arrow-key navigation makes grouped controls faster and more predictable for **everyone**:
42+
43+
- **Keyboard users** navigate without Tab-hammering through every item
44+
- **Screenreaders** can properly announce the focus group and each item
45+
- **Mouse users** who occasionally reach for the keyboard get a consistent, expected interaction
46+
- **Product teams** ship accessible experiences without writing keyboard logic
47+
- **Dialtone** has one implementation to test and maintain
48+
49+
## Example
50+
51+
Focus on the first item and use your up/down arrow keys.
52+
53+
```vue demo
54+
<dt-stack v-dt-focusgroup="'vertical'" role="list" aria-label="Contacts">
55+
<dt-stack role="listitem" tabindex="0" gap="100" class="d-p-100 d-w-800 h:d-bgc-moderate-opaque fv:d-bgc-moderate-opaque d-bar8">
56+
<dt-stack direction="row" gap="100" class="d-w100p">
57+
<dt-avatar full-name="Ashanti Trevor" />
58+
<dt-stack class="d-fl1">
59+
<dt-text kind="body" :size="200" strength="bold" > Ashanti Trevor </dt-text>
60+
<dt-stack direction="row" gap="50">
61+
<dt-stack direction="row" gap="100">
62+
<dt-icon name="phone-outgoing" size="200" class="d-fc-tertiary" />
63+
<dt-text kind="body" :size="100" tone="tertiary" > Outgoing call </dt-text>
64+
</dt-stack>
65+
<dt-text kind="body" :size="100" tone="tertiary" > &bull; </dt-text>
66+
<dt-text kind="body" :size="100" tone="tertiary" > 2 minutes 10 seconds </dt-text>
67+
</dt-stack>
68+
</dt-stack>
69+
<dt-text kind="body" :size="200" tone="tertiary" numeric> 3:23 pm </dt-text>
70+
<dt-badge kind="count" type="bulletin" text="6" />
71+
</dt-stack>
72+
</dt-stack>
73+
<dt-stack role="listitem" tabindex="0" gap="100" class="d-p-100 d-w-800 h:d-bgc-moderate-opaque fv:d-bgc-moderate-opaque d-bar8">
74+
<dt-stack direction="row" gap="100" class="d-w100p">
75+
<dt-avatar full-name="Marcus Chen" />
76+
<dt-stack class="d-fl1">
77+
<dt-text kind="body" :size="200" strength="bold"> Marcus Chen </dt-text>
78+
<dt-stack direction="row" gap="50">
79+
<dt-stack direction="row" gap="100">
80+
<dt-icon name="phone-incoming" size="200" class="d-fc-tertiary" />
81+
<dt-text kind="body" :size="100" tone="tertiary"> Incoming call </dt-text>
82+
</dt-stack>
83+
<dt-text kind="body" :size="100" tone="tertiary">&bull;</dt-text>
84+
<dt-text kind="body" :size="100" tone="tertiary"> 14 minutes 32 seconds </dt-text>
85+
</dt-stack>
86+
</dt-stack>
87+
<dt-text kind="body" :size="200" tone="tertiary" numeric> 1:47 pm </dt-text>
88+
</dt-stack>
89+
</dt-stack>
90+
<dt-stack role="listitem" tabindex="0" gap="100" class="d-p-100 d-w-800 h:d-bgc-moderate-opaque fv:d-bgc-moderate-opaque d-bar8">
91+
<dt-stack direction="row" gap="100" class="d-w100p">
92+
<dt-avatar full-name="Priya Sharma" />
93+
<dt-stack class="d-fl1">
94+
<dt-text kind="body" :size="200" strength="bold"> Priya Sharma </dt-text>
95+
<dt-stack direction="row" gap="50">
96+
<dt-stack direction="row" gap="100">
97+
<dt-icon name="phone-missed" size="200" class="d-fc-critical" />
98+
<dt-text kind="body" :size="100" tone="critical"> Missed call </dt-text>
99+
</dt-stack>
100+
</dt-stack>
101+
</dt-stack>
102+
<dt-text kind="body" :size="200" tone="tertiary" numeric> 11:05 am </dt-text>
103+
<dt-badge kind="count" type="bulletin" text="3" />
104+
</dt-stack>
105+
</dt-stack>
106+
</dt-stack>
107+
```
108+
109+
## Configuration
110+
111+
### Token syntax for common cases
112+
113+
```html
114+
<!-- Vertical menu -->
115+
<div role="menu" v-dt-focusgroup="'vertical'" aria-label="Actions">...</div>
116+
117+
<!-- Horizontal tabs, no memory (re-entry starts at selected tab) -->
118+
<div role="tablist" v-dt-focusgroup="'horizontal nomemory'" aria-label="Tabs">...</div>
119+
120+
<!-- No looping — focus stops at boundaries -->
121+
<div role="toolbar" v-dt-focusgroup="'horizontal noloop'" aria-label="Pagination">...</div>
122+
```
123+
124+
### Object syntax for advanced cases
125+
126+
```html
127+
<!-- Custom selector for table row navigation -->
128+
<table v-dt-focusgroup="{ axis: 'vertical', selector: 'tbody tr', memory: false }">...</table>
129+
```
130+
131+
### Zero config
132+
133+
```html
134+
<!-- Both axes, looping, memory — all defaults -->
135+
<div role="radiogroup" v-dt-focusgroup aria-label="Options">...</div>
136+
```
137+
138+
## Role-Aware Defaults
139+
140+
The directive infers the item selector and disabled behavior from the container's `role`:
141+
142+
| Role | Items found automatically | Disabled behavior |
143+
|---|---|---|
144+
| `tablist` | `[role="tab"]` | Focusable (discoverable) |
145+
| `listbox` | `[role="option"]` | Skipped |
146+
| `radiogroup` | `[role="radio"]` | Skipped |
147+
| `menu` | `[role="menuitem"]` | Skipped |
148+
| `toolbar` | All focusable elements | Skipped |
149+
150+
No selector configuration needed for standard ARIA patterns.
151+
152+
## Selection Follows Focus
153+
154+
The directive handles focus movement only. Selection is the consumer's responsibility — wired through the `dt-focusgroup-move` event:
155+
156+
```html
157+
<div
158+
role="tablist"
159+
v-dt-focusgroup="'horizontal nomemory'"
160+
aria-label="Tabs"
161+
@dt-focusgroup-move="selectedTab = $event.detail.index"
162+
>
163+
```
164+
165+
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.
166+
167+
## Treeview Pattern
168+
169+
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:
170+
171+
```html
172+
<div
173+
role="tree"
174+
v-dt-focusgroup="'vertical'"
175+
aria-label="Sidebar"
176+
@keydown.right.prevent="expandOrEnter"
177+
@keydown.left.prevent="collapseOrParent"
178+
>
179+
```
180+
181+
The two don't collide — `axis: 'vertical'` means the directive ignores Left/Right entirely.
182+
183+
## What It Does NOT Do
184+
185+
- **No 2D grid navigation.** All navigation is 1D (DOM order). Grid patterns are a separate problem with different mechanics.
186+
- **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.
187+
- **No `aria-orientation`.** The directive manages keyboard behavior, not ARIA semantics. Set `aria-orientation` yourself when the axis differs from the role's default.
188+
- **No nested focusgroup awareness.** Each `v-dt-focusgroup` is independent. Use distinct axes to avoid conflicts when nesting.
189+
190+
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.
191+
192+
## ESLint Guardrails
193+
194+
Two new rules in `eslint-plugin-dialtone` catch the most common accessibility mistakes:
195+
196+
```js
197+
rules: {
198+
'dialtone/focusgroup-requires-role': 'warn',
199+
'dialtone/focusgroup-requires-label': 'warn',
200+
}
201+
```
202+
203+
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.
204+
205+
</BlogPost>
206+
207+
<script setup>
208+
import BlogPost from '@baseComponents/BlogPost.vue';
209+
import { parse } from 'date-fns';
210+
</script>

0 commit comments

Comments
 (0)