Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3a97fb4
initi focusgroup directive
francisrupert Apr 7, 2026
3fce532
initial doc, variants story, and dev warnings
francisrupert Apr 7, 2026
b2247e3
contacts list example
francisrupert Apr 7, 2026
1cbff70
rename wrap to loop and inline/block to horizontal/vertical
francisrupert Apr 7, 2026
d1905be
focusgroup lints
francisrupert Apr 7, 2026
fda84a8
add usage examples
francisrupert Apr 7, 2026
0fbde34
add focusout event listener assertion
francisrupert Apr 7, 2026
29c54f6
add focusout handler to reset tabindex when focus leaves container
francisrupert Apr 7, 2026
fa3d176
reorganize focusgroup documentation and add recipes section
francisrupert Apr 7, 2026
fe3d59c
replace inline storybook svg with dt-icon
francisrupert Apr 8, 2026
85059f3
add functions-and-utilities to design system path detection
francisrupert Apr 8, 2026
8dd9d1c
add vue utilities index page with directives, functions, and utilitieโ€ฆ
francisrupert Apr 8, 2026
12501d8
simplify configsEqual to use CONFIG_KEYS array and remove memory reseโ€ฆ
francisrupert Apr 8, 2026
58aa527
move preventDefault to arrow key handler and improve RTL detection
francisrupert Apr 8, 2026
a35ebfd
Merge branch 'next' into focusgroup-directive
francisrupert Apr 8, 2026
737e722
filter out aria-hidden items from focusgroup navigation
francisrupert Apr 8, 2026
d2f300b
focusgroup examples and reorganize documentation sections
francisrupert Apr 8, 2026
523bdbc
add section headings and GitHub source links to focusgroup story examโ€ฆ
francisrupert Apr 9, 2026
9adebfb
housekeeping
francisrupert Apr 9, 2026
60b321a
refactor focusgroup story to use inline template and Canvas component
francisrupert Apr 9, 2026
2cae546
refactor focusgroup story to use inline templates with Canvas componeโ€ฆ
francisrupert Apr 9, 2026
4ca19dd
replace d-btn--disabled class with disabled attribute in focusgroup Dโ€ฆ
francisrupert Apr 9, 2026
d88af77
fix aria-hidden filtering to only exclude items within focusgroup conโ€ฆ
francisrupert Apr 9, 2026
bbad6c9
prevent page scroll on arrow navigation and reset tabindex to first eโ€ฆ
francisrupert Apr 9, 2026
bda3cb6
add comment explaining RTL detection fallback and fix closest selectoโ€ฆ
francisrupert Apr 9, 2026
3127679
add keywords metadata to functions and utilities documentation page
francisrupert Apr 9, 2026
8930687
extract focusgroup utility functions
francisrupert Apr 10, 2026
8003cf0
update focusgroup documentation to clarify nesting limitation
francisrupert Apr 10, 2026
403d887
blog post announcing v-dt-focusgroup directive
francisrupert Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
const isActiveLink = (link) => {
// For Design System, check all related paths (same as useSidebarItems.js)
if (link === '/dialtone/') {
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/', '/dialtone/'];
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/', '/dialtone/', '/functions-and-utilities/'];
return designSystemPaths.some(p => route.path.includes(p));
}
// For other links, use simple path matching
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const route = useRoute();
*/
function detectTopLevelGroup(path) {
// Map routes to top-level groups
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/'];
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/', '/functions-and-utilities/'];

if (designSystemPaths.some(p => path.includes(p))) {
return 'dialtone';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { usePageData } from 'vuepress/client';
*/
function detectTopLevelGroup(path) {
// Map routes to top-level groups
const designSystemPaths = ['/design/', '/components/', '/utilities/', '/tokens/', '/guides/', '/about/'];
const designSystemPaths = ['/design/', '/components/', '/utilities/', '/tokens/', '/guides/', '/about/', '/functions-and-utilities/'];

if (designSystemPaths.some(p => path.includes(p))) {
return 'dialtone';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const isMobile = ref(false);
*/
function detectTopLevelGroup(path) {
// Map routes to top-level groups
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/'];
const designSystemPaths = ['/components/', '/utilities/', '/tokens/', '/guides/', '/about/', '/functions-and-utilities/'];

if (designSystemPaths.some(p => path.includes(p))) {
return 'dialtone';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,8 @@
</div>
</dt-stack>
<dt-stack direction="row" gap="50" class="d-ai-center">
<svg
class="d-icon d-icon--size-200"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M4.61824 20.4293L4.00072 3.92089C3.98032 3.37568 4.397 2.91362 4.93974
2.87959L18.9352 2.00199C19.4877 1.96735 19.9635 2.38859 19.998 2.94286C19.9993
2.96374 20 2.98466 20 3.00558V20.9945C20 21.5498 19.5513 22 18.9978 22C18.9828
22 18.9678 21.9997 18.9528 21.999L5.57482 21.3962C5.0538 21.3727 4.6378 20.9522
4.61824 20.4293Z"
fill="#FF4785"
/>
<path
d="M15.8555 4.42657L15.9531 2.14956L17.9154 2L17.9999 4.34821C18.0028
4.42993 17.9369 4.49849 17.8527 4.50135C17.8166 4.50257 17.7813 4.49135
17.7529 4.46968L16.9962 3.89144L16.1003 4.55068C16.0331 4.6001 15.9374
4.58735 15.8864 4.5222C15.865 4.49478 15.854 4.46096 15.8555 4.42657ZM13.346
9.44092C13.346 9.82708 16.0275 9.642 16.3875 9.37075C16.3875 6.74106 14.9328
5.3592 12.2692 5.3592C9.60547 5.3592 8.11304 6.76256 8.11304 8.8676C8.11304
12.5339 13.2137 12.604 13.2137 14.6038C13.2137 15.1652 12.9304 15.4985 12.3069
15.4985C11.4946 15.4985 11.1735 15.096 11.2112 13.7278C11.2112 13.4309 8.11304
13.3384 8.01859 13.7278C7.77806 17.0436 9.90773 18 12.3447 18C14.7062 18 16.5575
16.779 16.5575 14.5687C16.5575 10.6393 11.3813 10.7446 11.3813 8.79743C11.3813
8.00804 11.9858 7.90279 12.3447 7.90279C12.7226 7.90279 13.4026 7.96739 13.346
9.44092Z"
fill="white"
/>
</svg>
<span class="d-fs-125 d-fw-semibold d-fc-secondary">View in Storybook</span>
<dt-icon name="storybook-color" size="200" />
<dt-text kind="label" size="200" strength="medium" tone="secondary">Storybook</dt-text>
</dt-stack>
</dt-stack>
</a>
Expand Down
2 changes: 1 addition & 1 deletion apps/dialtone-documentation/docs/_data/site-nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,7 @@
],
"/functions-and-utilities/": [
{
"text": "Functions",
"text": "Vue Utilities",
"link": "/functions-and-utilities/",
"icon": "code"
}
Expand Down
43 changes: 43 additions & 0 deletions apps/dialtone-documentation/docs/_data/vue-utilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"directives": [
{
"name": "v-dt-focusgroup",
"description": "Roving tabindex for composite widgets โ€” arrow-key cycling, looping, memory, and disabled-item handling",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focusgroup--docs"
},
{
"name": "v-dt-mode",
"description": "Scope descendant design tokens to a light, dark, or inverted color palette",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-mode--docs"
},
{
"name": "v-dt-tooltip",
"description": "Attach a tooltip to any element without a wrapper component",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-tooltip--docs"
},
{
"name": "v-dt-scrollbar",
"description": "Replace native scrollbars with a styled overlay that auto-hides",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-scrollbar--docs"
}
],
"functions": [
{
"name": "Date and Time",
"description": "Format dates, relative timestamps, and durations with i18n locale support",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/functions-date-and-time--docs"
}
],
"utilities": [
{
"name": "DtLazyShow",
"description": "Defer child rendering until first shown โ€” reduces initial mount cost for popovers and modals",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/utilities-lazy-show--docs"
},
{
"name": "Localization",
"description": "Singleton i18n manager that localizes strings across all Dialtone components",
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/utilities-localization--docs"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
---
heading: 'Introducing v-dt-focusgroup: Declarative Keyboard Navigation'
author: Francis Rupert
posted: '2026-4-15'
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.'
---

<BlogPost :author="$frontmatter.author" :posted="parse($frontmatter.posted, 'y-M-d', new Date())" :heading="$frontmatter.heading" :excerpt="$frontmatter.excerpt">

## 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](https://open-ui.org/components/scoped-focusgroup.explainer/).
- [Storybook docs](https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focusgroup--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.

```vue demo
<dt-stack role="toolbar" v-dt-focusgroup="'horizontal'" direction="row" gap="100" aria-label="Text formatting">
<dt-button importance="outlined" kind="muted">Bold</dt-button>
<dt-button importance="outlined" kind="muted">Italic</dt-button>
<dt-button importance="outlined" kind="muted">Underline</dt-button>
</dt-stack>
```

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.

```vue demo
<dt-stack v-dt-focusgroup="'vertical'" role="list" aria-label="Contacts">
<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">
<dt-stack direction="row" gap="100" class="d-w100p">
<dt-avatar full-name="Ashanti Trevor" />
<dt-stack class="d-fl1">
<dt-text kind="body" :size="200" strength="bold" > Ashanti Trevor </dt-text>
<dt-stack direction="row" gap="50">
<dt-stack direction="row" gap="100">
<dt-icon name="phone-outgoing" size="200" class="d-fc-tertiary" />
<dt-text kind="body" :size="100" tone="tertiary" > Outgoing call </dt-text>
</dt-stack>
<dt-text kind="body" :size="100" tone="tertiary" > &bull; </dt-text>
<dt-text kind="body" :size="100" tone="tertiary" > 2 minutes 10 seconds </dt-text>
</dt-stack>
</dt-stack>
<dt-text kind="body" :size="200" tone="tertiary" numeric> 3:23 pm </dt-text>
<dt-badge kind="count" type="bulletin" text="6" />
</dt-stack>
</dt-stack>
<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">
<dt-stack direction="row" gap="100" class="d-w100p">
<dt-avatar full-name="Marcus Chen" />
<dt-stack class="d-fl1">
<dt-text kind="body" :size="200" strength="bold"> Marcus Chen </dt-text>
<dt-stack direction="row" gap="50">
<dt-stack direction="row" gap="100">
<dt-icon name="phone-incoming" size="200" class="d-fc-tertiary" />
<dt-text kind="body" :size="100" tone="tertiary"> Incoming call </dt-text>
</dt-stack>
<dt-text kind="body" :size="100" tone="tertiary">&bull;</dt-text>
<dt-text kind="body" :size="100" tone="tertiary"> 14 minutes 32 seconds </dt-text>
</dt-stack>
</dt-stack>
<dt-text kind="body" :size="200" tone="tertiary" numeric> 1:47 pm </dt-text>
</dt-stack>
</dt-stack>
<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">
<dt-stack direction="row" gap="100" class="d-w100p">
<dt-avatar full-name="Priya Sharma" />
<dt-stack class="d-fl1">
<dt-text kind="body" :size="200" strength="bold"> Priya Sharma </dt-text>
<dt-stack direction="row" gap="50">
<dt-stack direction="row" gap="100">
<dt-icon name="phone-missed" size="200" class="d-fc-critical" />
<dt-text kind="body" :size="100" tone="critical"> Missed call </dt-text>
</dt-stack>
</dt-stack>
</dt-stack>
<dt-text kind="body" :size="200" tone="tertiary" numeric> 11:05 am </dt-text>
<dt-badge kind="count" type="bulletin" text="3" />
</dt-stack>
</dt-stack>
</dt-stack>
```

## Configuration

### Token syntax for common cases

```html
<!-- 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

```html
<!-- Custom selector for table row navigation -->
<table v-dt-focusgroup="{ axis: 'vertical', selector: 'tbody tr', memory: false }">...</table>
```

### Zero config

```html
<!-- 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:

```html
<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:

```html
<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:

```js
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.

</BlogPost>

<script setup>
import BlogPost from '@baseComponents/BlogPost.vue';
import { parse } from 'date-fns';
</script>
Loading
Loading