diff --git a/apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue b/apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue index 9fff8c47d6..e27872444b 100644 --- a/apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue +++ b/apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue @@ -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 diff --git a/apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue b/apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue index 8d3057cde7..752a9b2143 100644 --- a/apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue +++ b/apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue @@ -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'; diff --git a/apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js b/apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js index 1852017a63..85c879e966 100644 --- a/apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js +++ b/apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js @@ -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'; diff --git a/apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue b/apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue index a9d125a0c4..8209b1797d 100644 --- a/apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue +++ b/apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue @@ -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'; diff --git a/apps/dialtone-documentation/docs/.vuepress/views/UiKitsOverview.vue b/apps/dialtone-documentation/docs/.vuepress/views/UiKitsOverview.vue index 48a74815b2..d20de89a30 100644 --- a/apps/dialtone-documentation/docs/.vuepress/views/UiKitsOverview.vue +++ b/apps/dialtone-documentation/docs/.vuepress/views/UiKitsOverview.vue @@ -25,37 +25,8 @@ - - View in Storybook + + Storybook diff --git a/apps/dialtone-documentation/docs/_data/site-nav.json b/apps/dialtone-documentation/docs/_data/site-nav.json index 8717a0134d..2932b3be46 100644 --- a/apps/dialtone-documentation/docs/_data/site-nav.json +++ b/apps/dialtone-documentation/docs/_data/site-nav.json @@ -923,7 +923,7 @@ ], "/functions-and-utilities/": [ { - "text": "Functions", + "text": "Vue Utilities", "link": "/functions-and-utilities/", "icon": "code" } diff --git a/apps/dialtone-documentation/docs/_data/vue-utilities.json b/apps/dialtone-documentation/docs/_data/vue-utilities.json new file mode 100644 index 0000000000..d31fc5d02f --- /dev/null +++ b/apps/dialtone-documentation/docs/_data/vue-utilities.json @@ -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" + } + ] +} diff --git a/apps/dialtone-documentation/docs/dialtone/whats-new/posts/2026-4-15.md b/apps/dialtone-documentation/docs/dialtone/whats-new/posts/2026-4-15.md new file mode 100644 index 0000000000..692c745a1f --- /dev/null +++ b/apps/dialtone-documentation/docs/dialtone/whats-new/posts/2026-4-15.md @@ -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.' +--- + + + +## 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 + + Bold + Italic + Underline + +``` + +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 + + + + + + Ashanti Trevor + + + + Outgoing call + + + 2 minutes 10 seconds + + + 3:23 pm + + + + + + + + Marcus Chen + + + + Incoming call + + + 14 minutes 32 seconds + + + 1:47 pm + + + + + + + Priya Sharma + + + + Missed call + + + + 11:05 am + + + + +``` + +## Configuration + +### Token syntax for common cases + +```html + +
...
+ + +
...
+ + +
...
+``` + +### Object syntax for advanced cases + +```html + +...
+``` + +### Zero config + +```html + +
...
+``` + +## 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 +
+``` + +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 +
+``` + +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. + + + + diff --git a/apps/dialtone-documentation/docs/functions-and-utilities/index.md b/apps/dialtone-documentation/docs/functions-and-utilities/index.md index 64fbe23430..c152258f38 100644 --- a/apps/dialtone-documentation/docs/functions-and-utilities/index.md +++ b/apps/dialtone-documentation/docs/functions-and-utilities/index.md @@ -1,17 +1,73 @@ --- -title: Functions -description: Reusable JavaScript helper functions for common development tasks +title: Vue Utilities +description: Consumer-facing directives and utilities exported by Dialtone Vue. thumb: true +keywords: ["directive", "function", "utility", "utilities", "plugin", "v-dt-focusgroup", "v-dt-mode", "v-dt-tooltip", "v-dt-scrollbar", "keyboard navigation", "roving tabindex", "dark mode", "date", "time", "localization", "i18n", "lazy show", "DtLazyShow", "accessibility", "a11y", "internationalization", "theme"] --- -
- - - -
+## Directives + +Behavioral plugins that attach to any element — add keyboard navigation, color modes, tooltips, and more. + + + + + + + + + + + + + + + + +
DirectiveDescriptionDocs
{{ item.name }}{{ item.description }}Storybook
+ +## Functions + +Stateless helpers for formatting and transforming data. + + + + + + + + + + + + + + + + +
FunctionDescriptionDocs
{{ item.name }}{{ item.description }}Storybook
+ +## Utilities + +Foundational modules for rendering optimization and internationalization. + + + + + + + + + + + + + + + + +
UtilityDescriptionDocs
{{ item.name }}{{ item.description }}Storybook
+ + diff --git a/apps/dialtone-documentation/docs/scratch.md b/apps/dialtone-documentation/docs/scratch.md index 0191836dd9..dba3566aa4 100644 --- a/apps/dialtone-documentation/docs/scratch.md +++ b/apps/dialtone-documentation/docs/scratch.md @@ -7,6 +7,7 @@ layout: Blank - + Scratchpad @@ -150,6 +151,343 @@ const checkRadioDisabled = ref(false); +
+ +# Focusgroup directive + +Declarative roving tabindex for composite widgets. Manages arrow-key navigation, +`tabindex` management, looping, focus memory, and disabled-item skipping — following +the [Open UI focusgroup proposal](https://open-ui.org/components/scoped-focusgroup.explainer/). + +The directive handles **focus movement only**. Activation and selection (toggling +`aria-selected`, `aria-checked`, etc.) remain the consumer's responsibility. + +## Usage + +Import and install the directive: + +```js +import { DtFocusgroupDirective } from "@dialpad/dialtone-vue"; +app.use(DtFocusgroupDirective); +``` + +### Basic usage + +Just add `v-dt-focusgroup` and any focusable child will be managed by the focusgroup. + +> [!WARNING] Always pair with an ARIA role +> The directive manages keyboard focus but does not set any ARIA attributes. For screen readers to announce the widget correctly, you must provide a `role` (`toolbar`, `tablist`, `listbox`, `radiogroup`, `menu`), an accessible name (`aria-label`), and `aria-orientation` when the axis differs from the role's default. Without a role, the container is opaque to assistive technology — arrow-key cycling works, but the user has no context for what they're navigating. + +```vue demo + + Button + Button + Button + +``` + +### Token syntax + +```vue demo + + Bold + Italic + Underline + +``` + +### Object syntax + +```vue demo + + Apple + Banana + +``` + +```vue demo + + Apple + Banana + +``` + +### Vertical toolbar + +```vue demo + + Bold + Italic + Underline + +``` + +### noloop — focus stops at boundaries + +```vue demo + + First + Previous + Next + Last + +``` + +### nomemory — re-entry always starts at first item + +```vue demo + + Cut + Copy + Paste + +``` + +### Disabled items — skipped by default + +```vue demo + + Pen + Eraser (disabled) + Highlighter + +``` + +### noskipdisabled — disabled items remain focusable + +```vue demo + + Mac + Windows (disabled) + Linux + +``` + +### dt-focusgroup-move event — selection follows focus + +```vue demo + + One + Two + Three + +``` + +### Item opt-out + +Add `data-dt-focusgroup-skip` to exclude an element from arrow-key navigation +(e.g., text inputs that need their own arrow keys): + +```vue demo + + Bold + + Code + Skipped Text link + Code + +``` + +### Mixed focusable elements + +```vue demo + + Button + Link + + +``` + +### Nesting depth + +Items do not need to be direct children. The directive uses `querySelectorAll` +on the container, finding items at any nesting depth in DOM order: + +```vue demo + + + btn + btn + btn + + + btn + btn + + + text link a + text link b + + +``` + +## Recipes + +Real-world patterns showing how `v-dt-focusgroup` composes with Dialtone components. + +### Table with row navigation + +```vue demo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Office List
OfficeCountryEmployeesContact
Austin, TXUnited States48Henna FerryButton 1Button 2
BangaloreIndia13Arun ChaddaButton 1Button 2
San Francisco, CAUnited States108Shane HolmesButton 1Button 2
Vancouver, BCCanada76Kendal LewisButton 1Button 2
+``` + +### Inbox + +```vue demo + + + + + + Ashanti Trevor + + + + Outgoing call + + + 2 minutes 10 seconds + + + 3:23 pm + + + + + + + + Marcus Chen + + + + Incoming call + + + 14 minutes 32 seconds + + + 1:47 pm + + + + + + + Priya Sharma + + + + Missed call + + + 0 seconds + + + 11:05 am + + + + +``` + +### Contact List, with custom selector + + +```vue demo + + + + + + + + + + + + + + + + + + +``` + +
Disabled Button diff --git a/packages/dialtone-vue/.storybook/preview.jsx b/packages/dialtone-vue/.storybook/preview.jsx index f98a46cd26..b104c55014 100644 --- a/packages/dialtone-vue/.storybook/preview.jsx +++ b/packages/dialtone-vue/.storybook/preview.jsx @@ -49,6 +49,7 @@ import { DialtoneDocsPage } from './DialtoneDocsPage.jsx'; import { DtTooltipDirective } from '@/directives/tooltip_directive'; import { DtScrollbarDirective } from '@/directives/scrollbar_directive'; import { DtModeDirective } from '@/directives/mode_directive'; +import { DtFocusgroupDirective } from '@/directives/focusgroup_directive'; import { DtStack } from '@/components/stack'; import { faker } from '@faker-js/faker'; @@ -135,6 +136,7 @@ setup((app) => { app.use(DtTooltipDirective); app.use(DtScrollbarDirective); app.use(DtModeDirective); + app.use(DtFocusgroupDirective); app.component('DtStack', DtStack); // global seed, to make sure results are reproducible on percy and don't change on every reload too. faker.seed(6687422389464139); diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js new file mode 100644 index 0000000000..279bcf325e --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js @@ -0,0 +1,269 @@ +import { EVENT_KEYNAMES } from '@/common/constants'; +import { + parseConfig, + configsEqual, + resolveSelector, + resolveSkipDisabled, +} from './focusgroup_utils.js'; + +/** + * v-dt-focusgroup directive — declarative roving tabindex for composite widgets. + * + * Implements the Open UI focusgroup pattern as a Vue custom directive. + * Manages arrow-key navigation, tabindex, looping, memory, and disabled-item + * handling. Focus only — activation/selection is the consumer's responsibility. + * + * @example + * // Token syntax (Open UI style) + *
+ * + * // Object syntax + *
+ * + * // Selection follows focus via dt-focusgroup-move event + *
+ * + * @see https://open-ui.org/components/scoped-focusgroup.explainer/ + */ +export const DtFocusgroupDirective = { + name: 'dt-focusgroup-directive', + install (app) { + const instances = new WeakMap(); + + app.directive('dt-focusgroup', { + mounted (el, binding) { + const config = parseConfig(binding.value); + const state = attach(el, config); + instances.set(el, state); + }, + + updated (el, binding) { + const newConfig = parseConfig(binding.value); + const state = instances.get(el); + if (state && configsEqual(state.config, newConfig)) return; + cleanup(state, el); + const freshState = attach(el, newConfig); + instances.set(el, freshState); + }, + + unmounted (el) { + cleanup(instances.get(el), el); + instances.delete(el); + }, + }); + + // ── Item discovery ────────────────────────────────────── + + function getItems (el, selector) { + return Array.from(el.querySelectorAll(selector)) + .filter(item => { + if (item.hasAttribute('data-dt-focusgroup-skip')) return false; + if (item.closest('[hidden]')) return false; + // Exclude items inside collapsed containers (e.g., DtCollapsible), + // but only if the aria-hidden ancestor is inside this focusgroup container + const hiddenAncestor = item.closest('[aria-hidden="true"]'); + if (hiddenAncestor && el.contains(hiddenAncestor)) return false; + return true; + }); + } + + function isDisabled (item) { + return item.disabled === true || + item.getAttribute('aria-disabled') === 'true'; + } + + // ── Navigation ────────────────────────────────────────── + + function findNext (items, fromIndex, direction, loop, skipDisabled) { + const len = items.length; + // Clamp fallback to valid range (fromIndex can be -1 or len for Home/End) + const fallback = Math.max(0, Math.min(fromIndex, len - 1)); + for (let i = 1; i <= len; i++) { + const index = loop + ? (fromIndex + i * direction + len) % len + : fromIndex + i * direction; + + if (index < 0 || index >= len) return fallback; + if (skipDisabled && isDisabled(items[index])) continue; + return index; + } + return fallback; + } + + // Maps arrow keys to [axis, ltr-direction]. RTL reverses horizontal direction. + const ARROW_KEY_MAP = { + [EVENT_KEYNAMES.arrowright]: ['horizontal', 1], + [EVENT_KEYNAMES.arrowleft]: ['horizontal', -1], + [EVENT_KEYNAMES.arrowdown]: ['vertical', 1], + [EVENT_KEYNAMES.arrowup]: ['vertical', -1], + }; + + function resolveDirection (key, axis, isRTL) { + const mapping = ARROW_KEY_MAP[key]; + if (!mapping) return null; + + const [keyAxis, ltrDir] = mapping; + const axisAllowed = axis === 'both' || axis === keyAxis; + if (!axisAllowed) return null; + + return (keyAxis === 'horizontal' && isRTL) ? -ltrDir : ltrDir; + } + + // ── Tabindex management ───────────────────────────────── + + function setRovingTabindex (items, focusedIndex) { + items.forEach((item, i) => { + item.setAttribute('tabindex', i === focusedIndex ? '0' : '-1'); + }); + } + + // ── Focus movement ────────────────────────────────────── + + function moveTo (el, state, items, currentIndex, targetIndex) { + setRovingTabindex(items, targetIndex); + state._internalMove = true; + items[targetIndex].focus(); + queueMicrotask(() => { state._internalMove = false; }); + state.lastFocusedIndex = targetIndex; + el.dispatchEvent(new CustomEvent('dt-focusgroup-move', { + bubbles: true, + detail: { + item: items[targetIndex], + index: targetIndex, + previousItem: items[currentIndex], + previousIndex: currentIndex, + }, + })); + } + + // ── Core handlers ─────────────────────────────────────── + + function resolveHomeEnd (key, items, skipDisabled) { + if (key === EVENT_KEYNAMES.home) return findNext(items, -1, 1, false, skipDisabled); + if (key === EVENT_KEYNAMES.end) return findNext(items, items.length, -1, false, skipDisabled); + return null; + } + + function handleKeydown (event, el, state) { + const items = getItems(el, state.selector); + if (!items.length) return; + + const currentIndex = items.indexOf(document.activeElement); + if (currentIndex === -1) return; + + // Home / End — always preventDefault to avoid page scroll + const homeEndIndex = resolveHomeEnd(event.key, items, state.skipDisabled); + if (homeEndIndex !== null) { + event.preventDefault(); + if (homeEndIndex !== currentIndex) moveTo(el, state, items, currentIndex, homeEndIndex); + return; + } + + // Arrow keys — always preventDefault when axis matches to prevent page scroll + const direction = resolveDirection(event.key, state.config.axis, state.isRTL); + if (direction === null) return; + event.preventDefault(); + + const nextIndex = findNext(items, currentIndex, direction, state.config.loop, state.skipDisabled); + if (nextIndex !== currentIndex) { + moveTo(el, state, items, currentIndex, nextIndex); + } + } + + function handleFocusin (event, el, state) { + // Skip when focus was moved by the directive itself (avoid double work) + if (state._internalMove) return; + + const items = getItems(el, state.selector); + const index = items.indexOf(event.target); + if (index !== -1) { + state.lastFocusedIndex = index; + setRovingTabindex(items, index); + } + } + + function handleFocusout (event, el, state) { + if (state.config.memory) return; + // relatedTarget is where focus is going — if still inside, ignore + if (event.relatedTarget && el.contains(event.relatedTarget)) return; + // Focus left the container — reset tabindex to first enabled item + const items = getItems(el, state.selector); + if (items.length) { + let resetIndex = 0; + if (state.skipDisabled) { + const enabledIndex = items.findIndex(item => !isDisabled(item)); + if (enabledIndex !== -1) resetIndex = enabledIndex; + } + setRovingTabindex(items, resetIndex); + state.lastFocusedIndex = resetIndex; + } + } + + // ── Lifecycle ─────────────────────────────────────────── + + function attach (el, config) { + const selector = resolveSelector(el, config); + const skipDisabled = resolveSkipDisabled(el, config); + // getComputedStyle is the source of truth; closest('[dir]') fallback for jsdom (which doesn't compute direction) + const isRTL = getComputedStyle(el).direction === 'rtl' || el.closest('[dir]')?.getAttribute('dir') === 'rtl'; + + const state = { + config, + selector, + skipDisabled, + isRTL, + lastFocusedIndex: 0, + _internalMove: false, + onKeydown: null, + onFocusin: null, + onFocusout: null, + }; + + // Set initial tabindex + const items = getItems(el, selector); + if (!items.length && process.env.NODE_ENV !== 'production') { + const role = el.getAttribute('role'); + // eslint-disable-next-line no-console + console.warn( + `[DtFocusgroupDirective] No items found for selector "${selector}"` + + (role ? ` (inferred from role="${role}")` : '') + + '. Check that items match the selector, or provide an explicit selector via ' + + 'v-dt-focusgroup="{ selector: \'...\' }".', + ); + } + if (items.length) { + let initialIndex = 0; + if (skipDisabled) { + const enabledIndex = items.findIndex(item => !isDisabled(item)); + if (enabledIndex !== -1) initialIndex = enabledIndex; + } + setRovingTabindex(items, initialIndex); + } + + // Bind handlers + state.onKeydown = (event) => handleKeydown(event, el, state); + state.onFocusin = (event) => handleFocusin(event, el, state); + state.onFocusout = (event) => handleFocusout(event, el, state); + + el.addEventListener('keydown', state.onKeydown); + el.addEventListener('focusin', state.onFocusin); + el.addEventListener('focusout', state.onFocusout); + + return state; + } + + function cleanup (state, el) { + if (!state) return; + if (state.onKeydown) el.removeEventListener('keydown', state.onKeydown); + if (state.onFocusin) el.removeEventListener('focusin', state.onFocusin); + if (state.onFocusout) el.removeEventListener('focusout', state.onFocusout); + state.onKeydown = null; + state.onFocusin = null; + state.onFocusout = null; + } + }, +}; + +export default DtFocusgroupDirective; diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx new file mode 100644 index 0000000000..e3798b3d18 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx @@ -0,0 +1,383 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as FocusgroupDirectiveStories from './focusgroup.stories.js'; + + + +# Focusgroup directive + +Declarative roving tabindex for composite widgets. Manages arrow-key navigation, +`tabindex` management, looping, focus memory, and disabled-item skipping — following +the [Open UI focusgroup proposal](https://open-ui.org/components/scoped-focusgroup.explainer/). + +The directive handles **focus movement only**. Activation and selection (toggling +`aria-selected`, `aria-checked`, etc.) remain the consumer's responsibility. + +## Usage + +Import and install the directive: + +```js +import { DtFocusgroupDirective } from "@dialpad/dialtone-vue"; +app.use(DtFocusgroupDirective); +``` + +### Important: always pair with ARIA + +The directive manages keyboard focus but does not set any ARIA attributes. For +screen readers to announce the widget correctly, you must provide: + +- A `role` (`toolbar`, `tablist`, `listbox`, `radiogroup`, `menu`, `menubar` — or any other role; these have smart defaults) +- An accessible name (`aria-label` or `aria-labelledby`) +- `aria-orientation` when the axis differs from the role's default + +Without a role, the container is opaque to assistive technology — arrow-key +cycling works, but the user has no context for what they're navigating. + +Two ESLint rules in `eslint-plugin-dialtone` catch these omissions: + +- `dialtone/focusgroup-requires-role` — warns when `v-dt-focusgroup` is used without a `role` +- `dialtone/focusgroup-requires-label` — warns when `v-dt-focusgroup` is used without `aria-label` or `aria-labelledby` + +Enable them in your ESLint config: + +```js +rules: { + 'dialtone/focusgroup-requires-role': 'warn', + 'dialtone/focusgroup-requires-label': 'warn', +} +``` + +### Token syntax — horizontal toolbar + + + +### Object syntax + + + +### Vertical toolbar + + + +### No value (defaults) + + + +### Disable looping + +Use `noloop` so focus stops at the first and last items instead of cycling: + + + +### Disable memory + +Use `nomemory` so re-entry via Tab always starts at the first item instead of +restoring the last focused item: + + + +### Disabled items + +By default, disabled items (`aria-disabled="true"` or native `disabled`) are +skipped during arrow-key navigation: + + + +For `role="tablist"`, disabled items remain focusable so screen reader users can +discover them. This is the WAI-ARIA Tabs convention and the directive applies it +automatically. Override with explicit `skipdisabled` if needed. + + + +### Selection follows focus + +The directive dispatches a `dt-focusgroup-move` event when focus moves via arrow +keys. Use it to implement selection-follows-focus. See the +[Events story](?path=/story/directives-focusgroup--events) for the full interactive example. + + + +### Item opt-out + +Add `data-dt-focusgroup-skip` to exclude an element from arrow-key navigation +(e.g., text inputs that need their own arrow keys): + + + +### Mixed focusable elements + +The directive cycles through any focusable element matched by the selector, +regardless of element type: + + + +### Nesting depth + +Items do not need to be direct children. The directive uses `querySelectorAll` +on the container, finding items at any nesting depth in DOM order: + + + +## Tokens + +Space-separated string tokens configure the directive behavior: + + + + + + + + + + + + + + + + +
TokenDescriptionDefault
horizontalLeft/Right arrow keys onlyboth
verticalUp/Down arrow keys onlyboth
bothAll four arrow keys (Left/Up = prev, Right/Down = next)both
loopFocus loops from last to first and vice versaloop
noloopFocus stops at boundariesloop
memoryRe-entry restores last focused itemmemory
nomemoryRe-entry always starts at first itemmemory
skipdisabledArrow keys skip disabled itemsRole-aware
noskipdisabledDisabled items remain focusable (e.g., tabs)Role-aware
+ +## Object config + +The object syntax accepts the same options as named properties: + + + + + + + + + + + + +
PropertyTypeDefaultDescription
axis'horizontal' | 'vertical' | 'both''both'Which arrow keys respond
loopbooleantrueLoop at boundaries
memorybooleantrueRemember last focused item
selectorstring | nullRole-awareCSS selector for focusable items
skipDisabledboolean | nullRole-awareWhether to skip disabled items
+ +## Role-aware defaults + +When no explicit selector or skipDisabled is provided, the directive infers +defaults from the container's `role` attribute: + + + + + + + + + + + + + +
Container roleDefault selectorSkip disabled?
tablist[role="tab"]No (disabled tabs remain focusable)
radiogroup[role="radio"]Yes
listbox[role="option"]Yes
menu / menubar[role="menuitem"], ...Yes
toolbarAll focusable elementsYes
(other)All focusable elementsYes
+ +## Events + +### dt-focusgroup-move + +Dispatched on the container as a native `CustomEvent` when the directive moves +focus via arrow key, Home, or End. **Not** dispatched on click or Tab re-entry. + +```html +
+``` + +The `event.detail` payload: + + + + + + + + + + + +
PropertyTypeDescription
itemHTMLElementThe newly focused element
indexnumberIndex of the newly focused item
previousItemHTMLElementThe previously focused element
previousIndexnumberIndex of the previously focused item
+ +## Keyboard support + + + + + + + + + + + +
KeyBehavior
Arrow Right / Arrow DownMove to next item (when axis allows)
Arrow Left / Arrow UpMove to previous item (when axis allows)
HomeMove to first enabled item
EndMove to last enabled item
+ +Arrow direction is RTL-aware: in right-to-left contexts, Left arrow moves +forward and Right arrow moves backward on the horizontal axis. + +## Limitations + +- **Nesting**: Nesting multiple `v-dt-focusgroup` directives is **not recommended**. Each instance is independent — keydown events bubble, so both handlers fire. This can cause unpredictable double-moves if both respond to the same arrow keys. If you must nest, use distinct axes (e.g., outer `vertical`, inner `horizontal`). Most cases where nesting seems necessary are better served by 2D grid navigation (see below). +- **2D grid navigation**: Not supported. All navigation is 1D (linear DOM order). A future `grid` token on this directive will address cases where Up/Down and Left/Right need to mean different things (rows vs columns). +- **`aria-activedescendant`**: The directive uses roving tabindex (actual DOM focus moves between items). It does not support the `aria-activedescendant` pattern where the container retains focus and items are "virtually" focused. Use the `keyboard_list_navigation` mixin for that model. +- **RTL**: Direction is resolved at mount time. If `dir` changes dynamically without remounting, the directive won't pick it up. +- **Dynamic items**: The directive re-queries items on every keystroke, so dynamically added/removed items are handled correctly during navigation. However, the initial `tabindex` setup only runs on mount — items added after mount (e.g., async data) won't have `tabindex="-1"` until the first keyboard interaction. +- **Escape key**: Not handled. The directive manages arrow-key cycling only. Consumers who need Escape behavior (e.g., closing a menu) should add their own `@keydown.escape` handler. + +## Accessibility: `aria-orientation` + +The directive manages focus behavior but does **not** set `aria-orientation`. +This follows the established pattern used by Radix, React Aria, and Headless UI: +the focus primitive handles keyboard mechanics, the widget sets ARIA semantics. + +**You must set `aria-orientation` yourself** when the axis differs from the +role's implicit default. Without it, screen readers will announce incorrect +navigation cues (e.g., "use Left/Right" when Up/Down is what works). + + + + + + + + + + + + +
RoleImplicit defaultWhen to add aria-orientation
toolbarhorizontalAdd aria-orientation="vertical" when using axis: 'vertical'
tablisthorizontalAdd aria-orientation="vertical" when using axis: 'vertical'
listboxverticalAdd aria-orientation="horizontal" when using axis: 'horizontal'
menuverticalAdd aria-orientation="horizontal" when using axis: 'horizontal'
radiogroupundefinedAlways set it explicitly
+ +**Rule of thumb:** if you specify `axis`, check whether it matches the role's +default. If it doesn't, add `aria-orientation`. + +## Recipes + +Real-world patterns showing how `v-dt-focusgroup` composes with Dialtone components. + +### Table with row navigation + +Use `selector` to target `tbody tr` elements. Up/Down cycles rows; Tab reaches +interactive content within a row. + +```html + + + + + + + + + + + + + + + + + + + + +
OfficeCountryContact
Austin, TXUnited StatesHenna Ferry
BangaloreIndiaArun Chadda
+``` + +### Inbox + +A vertical list using `DtRecipeContactRow`. Up/Down cycles all focusable elements. + +```html + + + + + + +``` + +### Contact list + +A vertical list of rich content items built with Dialtone primitives. Up/Down cycles all focusable elements. + +```html + + + + + + Ashanti Trevor + + + Outgoing call + • 2m 10s + + + 3:23 pm + + + + + + + + Marcus Chen + + + Incoming call + • 14m 32s + + + 1:47 pm + + + +``` + +### Contact list with hovercards (custom selector) + +When items are wrapped in complex components like `DtHovercard`, use a custom +`selector` with a data attribute to target only the intended focusable items. + +```html + + + + + + + + + + +``` diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js new file mode 100644 index 0000000000..5351379191 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js @@ -0,0 +1,163 @@ +import { createTemplateFromVueFile } from '@/common/storybook_utils.js'; +import DtButton from '@/components/button/button.vue'; +import DtLink from '@/components/link/link.vue'; +import DtInput from '@/components/input/input.vue'; +import DtSelectMenu from '@/components/select_menu/select_menu.vue'; +import FocusgroupDirectiveEventsTemplate from './focusgroup_directive_events.story.vue'; +import FocusgroupDirectiveRecipesTemplate from './focusgroup_directive_recipes.story.vue'; + +export const argsData = {}; +export const argTypesData = {}; + +export default { + title: 'Directives/Focusgroup', + args: argsData, + argTypes: argTypesData, + excludeStories: /.*Data$/, +}; + +// ── Helper: inline story with single source of truth ──────── +const inline = (components, template) => ({ + render: () => ({ components, template }), + parameters: { + options: { showPanel: false }, + controls: { disable: true }, + docs: { source: { code: template, language: 'html' } }, + }, +}); + +// ── Default: horizontal toolbar ───────────────────────────── +export const Default = inline({ DtButton }, `\ + + Bold + Italic + Underline + + Strikethrough (disabled) + + Code +`); + +// ── Object syntax ─────────────────────────────────────────── +export const ObjectSyntax = inline({ DtButton }, `\ + + Apple + Banana +`); + +// ── Vertical toolbar ──────────────────────────────────────── +export const Vertical = inline({ DtButton }, `\ + + Bold + Italic + Underline +`); + +// ── No value (defaults: both axes, loop, memory) ──────────── +export const Defaults = inline({ DtButton }, `\ + + A + B + C +`); + +// ── Noloop ────────────────────────────────────────────────── +export const Noloop = inline({ DtButton }, `\ + + First + Previous + Next + Last +`); + +// ── Nomemory ──────────────────────────────────────────────── +export const Nomemory = inline({ DtButton }, `\ + + Cut + Copy + Paste +`); + +// ── Disabled items ────────────────────────────────────────── +export const DisabledSkipped = inline({ DtButton }, `\ + + Pen + Eraser (disabled) + Highlighter +`); + +export const DisabledFocusable = inline({ DtButton }, `\ + + Mac + Windows (disabled) + Linux +`); + +// ── Item opt-out ──────────────────────────────────────────── +export const OptOut = inline({ DtButton, DtInput, DtLink }, `\ + + Bold + + Code + Skipped Text link + Code +`); + +// ── Mixed focusable elements ──────────────────────────────── +export const Mixed = inline({ DtButton, DtLink, DtSelectMenu }, `\ + + Button + Link + +`); + +// ── Nesting depth ─────────────────────────────────────────── +export const Nesting = inline({ DtButton, DtLink }, `\ + + + btn + btn + btn + + + btn + btn + + + text link a + text link b + +`); + +// ── Events (complex — .story.vue with data/methods) ───────── +const EventsTemplate = (args, { argTypes }) => + createTemplateFromVueFile(args, argTypes, FocusgroupDirectiveEventsTemplate); + +export const Events = { + render: EventsTemplate, + parameters: { + options: { showPanel: false }, + controls: { disable: true }, + }, +}; + +// ── Recipes (complex — .story.vue with data/methods) ──────── +const RecipesTemplate = (args, { argTypes }) => + createTemplateFromVueFile(args, argTypes, FocusgroupDirectiveRecipesTemplate); + +export const Recipes = { + render: RecipesTemplate, + parameters: { + options: { showPanel: false }, + controls: { disable: true }, + }, +}; diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js new file mode 100644 index 0000000000..981456c198 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js @@ -0,0 +1,734 @@ +import { mount } from '@vue/test-utils'; +import { DtFocusgroupDirective } from './focusgroup.js'; +import { FOCUSGROUP_DEFAULTS } from './focusgroup_constants.js'; +import { + parseConfig, + configsEqual, + resolveSelector, + resolveSkipDisabled, +} from './focusgroup_utils.js'; + +// ── Helpers ───────────────────────────────────────────────── + +const PLUGINS = [DtFocusgroupDirective]; + +function mountToolbar (directive = true, options = {}) { + return mount({ + template: ` +
+ + + +
+ `, + ...options, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); +} + +function mountWithConfig (config, template) { + return mount({ + data () { return { config }; }, + template, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); +} + +function getItems (loopper) { + return [ + loopper.find('[data-qa="item-0"]'), + loopper.find('[data-qa="item-1"]'), + loopper.find('[data-qa="item-2"]'), + ]; +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('DtFocusgroupDirective', () => { + let loopper; + + afterEach(() => { + loopper?.unmount(); + }); + + // ── Config Parsing ────────────────────────────────────── + + describe('parseConfig', () => { + it('should return defaults for undefined', () => { + expect(parseConfig(undefined)).toEqual(FOCUSGROUP_DEFAULTS); + }); + + it('should return defaults for true', () => { + expect(parseConfig(true)).toEqual(FOCUSGROUP_DEFAULTS); + }); + + it('should parse "inline loop" tokens', () => { + const config = parseConfig('horizontal loop'); + expect(config.axis).toBe('horizontal'); + expect(config.loop).toBe(true); + }); + + it('should parse "vertical noloop nomemory" tokens', () => { + const config = parseConfig('vertical noloop nomemory'); + expect(config.axis).toBe('vertical'); + expect(config.loop).toBe(false); + expect(config.memory).toBe(false); + }); + + it('should parse "horizontal loop nomemory noskipdisabled" tokens', () => { + const config = parseConfig('horizontal loop nomemory noskipdisabled'); + expect(config.axis).toBe('horizontal'); + expect(config.loop).toBe(true); + expect(config.memory).toBe(false); + expect(config.skipDisabled).toBe(false); + }); + + it('should parse object config', () => { + const config = parseConfig({ axis: 'horizontal', loop: false, selector: '[role="tab"]' }); + expect(config.axis).toBe('horizontal'); + expect(config.loop).toBe(false); + expect(config.selector).toBe('[role="tab"]'); + expect(config.memory).toBe(true); // default preserved + }); + }); + + // ── Config Equality ───────────────────────────────────── + + describe('configsEqual', () => { + it('should return true for identical configs', () => { + const a = parseConfig('horizontal loop'); + const b = parseConfig('horizontal loop'); + expect(configsEqual(a, b)).toBe(true); + }); + + it('should return false when axis differs', () => { + const a = parseConfig('horizontal'); + const b = parseConfig('vertical'); + expect(configsEqual(a, b)).toBe(false); + }); + }); + + // ── Selector Resolution ───────────────────────────────── + + describe('resolveSelector', () => { + it('should use explicit selector when provided', () => { + const el = document.createElement('div'); + expect(resolveSelector(el, { selector: '.my-item' })).toBe('.my-item'); + }); + + it.each([ + ['tablist', '[role="tab"]'], + ['listbox', '[role="option"]'], + ['radiogroup', '[role="radio"]'], + ['menu', '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'], + ])('should use role-aware default for role="%s"', (role, expectedSelector) => { + const el = document.createElement('div'); + el.setAttribute('role', role); + expect(resolveSelector(el, { selector: null })).toBe(expectedSelector); + }); + + it('should fall back to all focusable elements for unknown role', () => { + const el = document.createElement('div'); + el.setAttribute('role', 'region'); + expect(resolveSelector(el, { selector: null })).toContain('button'); + }); + }); + + // ── Disabled Item Resolution ──────────────────────────── + + describe('resolveSkipDisabled', () => { + it('should use explicit skipDisabled when provided', () => { + const el = document.createElement('div'); + expect(resolveSkipDisabled(el, { skipDisabled: false })).toBe(false); + }); + + it.each([ + ['tablist', false], + ['radiogroup', true], + ])('for role="%s", should default skipDisabled to %s', (role, expected) => { + const el = document.createElement('div'); + el.setAttribute('role', role); + expect(resolveSkipDisabled(el, { skipDisabled: null })).toBe(expected); + }); + + it('should default to true for unknown role', () => { + const el = document.createElement('div'); + expect(resolveSkipDisabled(el, { skipDisabled: null })).toBe(true); + }); + }); + + // ── Presentation ──────────────────────────────────────── + + describe('Presentation', () => { + it('should set tabindex="0" on first item and "-1" on others', () => { + loopper = mountToolbar(); + const [item0, item1, item2] = getItems(loopper); + + expect(item0.attributes('tabindex')).toBe('0'); + expect(item1.attributes('tabindex')).toBe('-1'); + expect(item2.attributes('tabindex')).toBe('-1'); + }); + + it('should find items at any nesting depth', () => { + loopper = mount({ + template: ` +
+
+
+ +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const [item0, item1, item2] = getItems(loopper); + expect(item0.attributes('tabindex')).toBe('0'); + expect(item1.attributes('tabindex')).toBe('-1'); + expect(item2.attributes('tabindex')).toBe('-1'); + }); + + it('should use role-aware selector for tablist', () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + expect(loopper.find('[data-qa="item-0"]').attributes('tabindex')).toBe('0'); + expect(loopper.find('[data-qa="item-1"]').attributes('tabindex')).toBe('-1'); + // Non-tab button should not have tabindex set by the directive + expect(loopper.find('[data-qa="non-tab"]').attributes('tabindex')).toBeUndefined(); + }); + }); + + // ── Arrow Key Navigation ──────────────────────────────── + + describe('Arrow key navigation', () => { + it('should move focus to next item on ArrowRight (default axis: both)', async () => { + loopper = mountToolbar(); + const [item0, item1] = getItems(loopper); + + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(item1.element); + expect(item1.attributes('tabindex')).toBe('0'); + expect(item0.attributes('tabindex')).toBe('-1'); + }); + + it('should move focus to previous item on ArrowLeft', async () => { + loopper = mountToolbar(); + const [item0, item1] = getItems(loopper); + + item1.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowLeft' }); + + expect(document.activeElement).toBe(item0.element); + }); + + it('should move focus on ArrowDown (default axis: both)', async () => { + loopper = mountToolbar(); + const [item0, item1] = getItems(loopper); + + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowDown' }); + + expect(document.activeElement).toBe(item1.element); + }); + + it('should move focus on ArrowUp', async () => { + loopper = mountToolbar(); + const [item0, item1] = getItems(loopper); + + item1.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowUp' }); + + expect(document.activeElement).toBe(item0.element); + }); + }); + + // ── Axis Restriction ──────────────────────────────────── + + describe('Axis restriction', () => { + it('should ignore ArrowUp/Down when axis is inline', async () => { + loopper = mountWithConfig('horizontal', ` +
+ + + +
+ `); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowDown' }); + + expect(document.activeElement).toBe(item0.element); + }); + + it('should ignore ArrowLeft/Right when axis is block', async () => { + loopper = mountWithConfig('vertical', ` +
+ + + +
+ `); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(item0.element); + }); + + it('should respond to ArrowDown when axis is block', async () => { + loopper = mountWithConfig('vertical', ` +
+ + + +
+ `); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowDown' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-1"]').element); + }); + }); + + // ── Wrapping ──────────────────────────────────────────── + + describe('Wrapping', () => { + it('should loop from last to first when loop is true (default)', async () => { + loopper = mountToolbar(); + const [item0, , item2] = getItems(loopper); + + item2.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(item0.element); + }); + + it('should loop from first to last going backwards', async () => { + loopper = mountToolbar(); + const [item0, , item2] = getItems(loopper); + + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowLeft' }); + + expect(document.activeElement).toBe(item2.element); + }); + + it('should NOT loop when noloop is set', async () => { + loopper = mountWithConfig('noloop', ` +
+ + + +
+ `); + + const item2 = loopper.find('[data-qa="item-2"]'); + item2.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(item2.element); + }); + }); + + // ── Home / End ────────────────────────────────────────── + + describe('Home / End keys', () => { + it('should focus first item on Home', async () => { + loopper = mountToolbar(); + const [item0, , item2] = getItems(loopper); + + item2.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'Home' }); + + expect(document.activeElement).toBe(item0.element); + }); + + it('should focus last item on End', async () => { + loopper = mountToolbar(); + const [item0, , item2] = getItems(loopper); + + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'End' }); + + expect(document.activeElement).toBe(item2.element); + }); + }); + + // ── Disabled Items ────────────────────────────────────── + + describe('Disabled items', () => { + it('should skip aria-disabled items during navigation', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-2"]').element); + }); + + it('should skip native disabled items during navigation', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-2"]').element); + }); + + it('should NOT skip disabled items for role="tablist" (noskipdisabled default)', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-1"]').element); + }); + + it('should respect explicit skipdisabled override on tablist', async () => { + loopper = mountWithConfig('horizontal skipdisabled', ` +
+ + + +
+ `); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-2"]').element); + }); + + it('should skip disabled items on Home key', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item2 = loopper.find('[data-qa="item-2"]'); + item2.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'Home' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-1"]').element); + }); + }); + + // ── Opt-out ───────────────────────────────────────────── + + describe('Item opt-out', () => { + it('should exclude items with data-dt-focusgroup-skip', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-2"]').element); + }); + }); + + // ── Memory ────────────────────────────────────────────── + + describe('Memory', () => { + it('should update tabindex when an item receives focus via click', async () => { + loopper = mountToolbar(); + const [item0, , item2] = getItems(loopper); + + // Simulate click focus on third item — focusin event bubbles from item to container + item2.element.focus(); + item2.element.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await loopper.vm.$nextTick(); + + expect(item2.attributes('tabindex')).toBe('0'); + expect(item0.attributes('tabindex')).toBe('-1'); + }); + }); + + // ── dt-focusgroup-move Event ──────────────────────────── + + describe('dt-focusgroup-move event', () => { + it('should dispatch event with correct detail on arrow key navigation', async () => { + loopper = mountToolbar(); + const container = loopper.find('[data-qa="container"]'); + const [item0] = getItems(loopper); + + const moveHandler = vi.fn(); + container.element.addEventListener('dt-focusgroup-move', moveHandler); + + item0.element.focus(); + await container.trigger('keydown', { key: 'ArrowRight' }); + + expect(moveHandler).toHaveBeenCalledTimes(1); + }); + + it('should include item, index, previousItem, previousIndex in event detail', async () => { + loopper = mountToolbar(); + const container = loopper.find('[data-qa="container"]'); + const [item0] = getItems(loopper); + + let detail; + container.element.addEventListener('dt-focusgroup-move', (e) => { detail = e.detail; }); + + item0.element.focus(); + await container.trigger('keydown', { key: 'ArrowRight' }); + + expect(detail).toEqual({ + item: loopper.find('[data-qa="item-1"]').element, + index: 1, + previousItem: item0.element, + previousIndex: 0, + }); + }); + + it('should dispatch event on Home/End navigation', async () => { + loopper = mountToolbar(); + const container = loopper.find('[data-qa="container"]'); + const [, , item2] = getItems(loopper); + + const moveHandler = vi.fn(); + container.element.addEventListener('dt-focusgroup-move', moveHandler); + + item2.element.focus(); + await container.trigger('keydown', { key: 'Home' }); + + expect(moveHandler).toHaveBeenCalledTimes(1); + expect(moveHandler.mock.calls[0][0].detail.index).toBe(0); + }); + + it('should NOT dispatch event when focus does not change', async () => { + loopper = mountWithConfig('noloop', ` +
+ + +
+ `); + + const container = loopper.find('[data-qa="container"]'); + const moveHandler = vi.fn(); + container.element.addEventListener('dt-focusgroup-move', moveHandler); + + loopper.find('[data-qa="item-0"]').element.focus(); + await container.trigger('keydown', { key: 'ArrowLeft' }); + + expect(moveHandler).not.toHaveBeenCalled(); + }); + }); + + // ── nomemory / focusout ────────────────────────────────── + + describe('nomemory focusout reset', () => { + it('should reset tabindex to first item when focus leaves container with nomemory', async () => { + loopper = mountWithConfig('horizontal nomemory', ` +
+ + + +
+ `); + + const container = loopper.find('[data-qa="container"]'); + const item2 = loopper.find('[data-qa="item-2"]'); + + // Focus third item via arrow keys + loopper.find('[data-qa="item-0"]').element.focus(); + await container.trigger('keydown', { key: 'ArrowRight' }); + await container.trigger('keydown', { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(item2.element); + + // Simulate focus leaving the container + container.element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body })); + await loopper.vm.$nextTick(); + + // tabindex should reset to first item + expect(loopper.find('[data-qa="item-0"]').attributes('tabindex')).toBe('0'); + expect(item2.attributes('tabindex')).toBe('-1'); + }); + + it('should NOT reset tabindex when focus leaves container with memory (default)', async () => { + loopper = mountToolbar(); + const container = loopper.find('[data-qa="container"]'); + const item2 = loopper.find('[data-qa="item-2"]'); + + // Focus third item + loopper.find('[data-qa="item-0"]').element.focus(); + await container.trigger('keydown', { key: 'ArrowRight' }); + await container.trigger('keydown', { key: 'ArrowRight' }); + + // Simulate focus leaving + container.element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body })); + await loopper.vm.$nextTick(); + + // tabindex should stay on last focused item + expect(item2.attributes('tabindex')).toBe('0'); + }); + }); + + // ── RTL ───────────────────────────────────────────────── + + describe('RTL support', () => { + it('should reverse ArrowRight/ArrowLeft in RTL context', async () => { + loopper = mount({ + template: ` +
+ + + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const item0 = loopper.find('[data-qa="item-0"]'); + item0.element.focus(); + + // In RTL, ArrowLeft should go forward (next), not backward + await loopper.find('[data-qa="container"]').trigger('keydown', { key: 'ArrowLeft' }); + + expect(document.activeElement).toBe(loopper.find('[data-qa="item-1"]').element); + }); + }); + + // ── Dynamic config update ─────────────────────────────── + + describe('Dynamic config update', () => { + it('should apply new axis when config changes', async () => { + loopper = mount({ + data () { return { config: 'horizontal' }; }, + template: ` +
+ + +
+ `, + }, { + global: { plugins: PLUGINS }, + attachTo: document.body, + }); + + const container = loopper.find('[data-qa="container"]'); + loopper.find('[data-qa="item-0"]').element.focus(); + + // ArrowDown should be ignored with horizontal axis + await container.trigger('keydown', { key: 'ArrowDown' }); + expect(document.activeElement).toBe(loopper.find('[data-qa="item-0"]').element); + + // Change to vertical + await loopper.setData({ config: 'vertical' }); + + // ArrowDown should now work + loopper.find('[data-qa="item-0"]').element.focus(); + await container.trigger('keydown', { key: 'ArrowDown' }); + expect(document.activeElement).toBe(loopper.find('[data-qa="item-1"]').element); + }); + }); + + // ── Lifecycle ─────────────────────────────────────────── + + describe('Lifecycle', () => { + it('should clean up listeners on unmount', async () => { + loopper = mountToolbar(); + const container = loopper.find('[data-qa="container"]').element; + const spy = vi.spyOn(container, 'removeEventListener'); + + loopper.unmount(); + + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('focusin', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('focusout', expect.any(Function)); + + // Prevent double-unmount in afterEach + loopper = null; + }); + }); +}); diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js new file mode 100644 index 0000000000..0244fcd666 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js @@ -0,0 +1,64 @@ +/** + * Default focusable element selector — all natively focusable elements + * plus anything with an explicit tabindex, excluding opt-outs. + */ +export const FOCUSABLE_SELECTOR = + ':is(button, [href], input, select, textarea, [tabindex]):not([data-dt-focusgroup-skip])'; + +const MENU_DEFAULTS = Object.freeze({ + selector: '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]', + skipDisabled: true, +}); + +/** + * Default configuration for v-dt-focusgroup. + * Matches Open UI spec defaults: both axes, loop on, memory on, skip disabled on. + */ +export const FOCUSGROUP_DEFAULTS = Object.freeze({ + axis: 'both', + loop: true, + memory: true, + selector: null, + skipDisabled: null, // null = use role-aware default +}); + +/** + * Role-aware defaults for container roles. + * Each entry provides a default item selector and skipDisabled behavior + * matching the WAI-ARIA authoring practices for that pattern. + */ +export const ROLE_DEFAULTS_MAP = Object.freeze({ + tablist: { + selector: '[role="tab"]', + skipDisabled: false, // WAI-ARIA Tabs: disabled tabs remain focusable for discoverability + }, + radiogroup: { + selector: '[role="radio"]', + skipDisabled: true, + }, + listbox: { + selector: '[role="option"]', + skipDisabled: true, + }, + menu: MENU_DEFAULTS, + menubar: MENU_DEFAULTS, + toolbar: { + selector: FOCUSABLE_SELECTOR, + skipDisabled: true, + }, +}); + +// Maps string tokens to config key + value +export const TOKEN_MAP = Object.freeze({ + horizontal: { key: 'axis', value: 'horizontal' }, + vertical: { key: 'axis', value: 'vertical' }, + both: { key: 'axis', value: 'both' }, + loop: { key: 'loop', value: true }, + noloop: { key: 'loop', value: false }, + memory: { key: 'memory', value: true }, + nomemory: { key: 'memory', value: false }, + skipdisabled: { key: 'skipDisabled', value: true }, + noskipdisabled: { key: 'skipDisabled', value: false }, +}); + +export const CONFIG_KEYS = ['axis', 'loop', 'memory', 'selector', 'skipDisabled']; diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue new file mode 100644 index 0000000000..d161d5c0d8 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_recipes.story.vue b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_recipes.story.vue new file mode 100644 index 0000000000..076a5b621f --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_recipes.story.vue @@ -0,0 +1,742 @@ + + + + diff --git a/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_utils.js b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_utils.js new file mode 100644 index 0000000000..66ab699f20 --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/focusgroup_utils.js @@ -0,0 +1,102 @@ +import { + FOCUSGROUP_DEFAULTS, + FOCUSABLE_SELECTOR, + ROLE_DEFAULTS_MAP, + TOKEN_MAP, + CONFIG_KEYS, +} from './focusgroup_constants.js'; + +/** + * Parse a v-dt-focusgroup binding value into a normalized config object. + * + * Accepts: + * - undefined / null / true → defaults + * - String of space-separated tokens: 'horizontal nomemory skipdisabled' + * - Object: { axis: 'horizontal', loop: true, memory: false, selector: '[role="tab"]' } + * + * @param {*} value - The directive binding value + * @returns {{ axis: string, loop: boolean, memory: boolean, selector: string|null, skipDisabled: boolean|null }} + */ +function parseObjectConfig (config, value) { + for (const key of CONFIG_KEYS) { + if (value[key] !== undefined) config[key] = value[key]; + } +} + +function parseStringConfig (config, value) { + const tokens = value.split(/\s+/); + for (const token of tokens) { + const mapping = TOKEN_MAP[token]; + if (mapping) { + config[mapping.key] = mapping.value; + } else if (token && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn( + `[DtFocusgroupDirective] Unknown token "${token}". ` + + `Valid tokens: ${Object.keys(TOKEN_MAP).join(', ')}.`, + ); + } + } +} + +export function parseConfig (value) { + const config = { ...FOCUSGROUP_DEFAULTS }; + + if (!value || value === true) return config; + + if (typeof value === 'object') { + parseObjectConfig(config, value); + } else if (typeof value === 'string') { + parseStringConfig(config, value); + } + + return config; +} + +/** + * Shallow comparison of two parsed config objects. + * Used by the updated hook to avoid teardown/reattach when config is semantically identical. + */ +export function configsEqual (a, b) { + return CONFIG_KEYS.every(key => a[key] === b[key]); +} + +/** + * Resolve the final item selector for a focusgroup container. + * + * Priority: explicit config.selector > role-aware default > fallback (all focusable). + * + * @param {HTMLElement} el - The focusgroup container element + * @param {{ selector: string|null }} config - Parsed focusgroup config + * @returns {string} CSS selector string + */ +export function resolveSelector (el, config) { + if (config.selector) return config.selector; + + const role = el.getAttribute('role'); + if (role && ROLE_DEFAULTS_MAP[role]) { + return ROLE_DEFAULTS_MAP[role].selector; + } + + return FOCUSABLE_SELECTOR; +} + +/** + * Resolve whether disabled items should be skipped during navigation. + * + * Priority: explicit config.skipDisabled > role-aware default > true. + * + * @param {HTMLElement} el - The focusgroup container element + * @param {{ skipDisabled: boolean|null }} config - Parsed focusgroup config + * @returns {boolean} + */ +export function resolveSkipDisabled (el, config) { + if (config.skipDisabled !== null) return config.skipDisabled; + + const role = el.getAttribute('role'); + if (role && ROLE_DEFAULTS_MAP[role]) { + return ROLE_DEFAULTS_MAP[role].skipDisabled; + } + + return true; +} diff --git a/packages/dialtone-vue/directives/focusgroup_directive/index.js b/packages/dialtone-vue/directives/focusgroup_directive/index.js new file mode 100644 index 0000000000..b8ce78049b --- /dev/null +++ b/packages/dialtone-vue/directives/focusgroup_directive/index.js @@ -0,0 +1 @@ +export { default as DtFocusgroupDirective } from './focusgroup.js'; diff --git a/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue b/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue index 4d468b0c25..630005ba6f 100644 --- a/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue +++ b/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue @@ -1,6 +1,6 @@