|
| 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" > • </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">•</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