+```
+
+## 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.
+
+
+
+
+
Directive
+
Description
+
Docs
+
+
+
+
+
{{ item.name }}
+
{{ item.description }}
+
Storybook
+
+
+
+
+## Functions
+
+Stateless helpers for formatting and transforming data.
+
+
+
+
+
Function
+
Description
+
Docs
+
+
+
+
+
{{ item.name }}
+
{{ item.description }}
+
Storybook
+
+
+
+
+## Utilities
+
+Foundational modules for rendering optimization and internationalization.
+
+
+
+
+
Utility
+
Description
+
Docs
+
+
+
+
+
{{ 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
+
+ *
+ * @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:
+
+
+
+
Token
Description
Default
+
+
+
horizontal
Left/Right arrow keys only
both
+
vertical
Up/Down arrow keys only
both
+
both
All four arrow keys (Left/Up = prev, Right/Down = next)
both
+
loop
Focus loops from last to first and vice versa
loop
+
noloop
Focus stops at boundaries
loop
+
memory
Re-entry restores last focused item
memory
+
nomemory
Re-entry always starts at first item
memory
+
skipdisabled
Arrow keys skip disabled items
Role-aware
+
noskipdisabled
Disabled items remain focusable (e.g., tabs)
Role-aware
+
+
+
+## Object config
+
+The object syntax accepts the same options as named properties:
+
+
+
+
Property
Type
Default
Description
+
+
+
axis
'horizontal' | 'vertical' | 'both'
'both'
Which arrow keys respond
+
loop
boolean
true
Loop at boundaries
+
memory
boolean
true
Remember last focused item
+
selector
string | null
Role-aware
CSS selector for focusable items
+
skipDisabled
boolean | null
Role-aware
Whether 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 role
Default selector
Skip disabled?
+
+
+
tablist
[role="tab"]
No (disabled tabs remain focusable)
+
radiogroup
[role="radio"]
Yes
+
listbox
[role="option"]
Yes
+
menu / menubar
[role="menuitem"], ...
Yes
+
toolbar
All focusable elements
Yes
+
(other)
All focusable elements
Yes
+
+
+
+## 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:
+
+
+
+
Property
Type
Description
+
+
+
item
HTMLElement
The newly focused element
+
index
number
Index of the newly focused item
+
previousItem
HTMLElement
The previously focused element
+
previousIndex
number
Index of the previously focused item
+
+
+
+## Keyboard support
+
+
+
+
Key
Behavior
+
+
+
Arrow Right / Arrow Down
Move to next item (when axis allows)
+
Arrow Left / Arrow Up
Move to previous item (when axis allows)
+
Home
Move to first enabled item
+
End
Move 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).
+
+
+
+
Role
Implicit default
When to add aria-orientation
+
+
+
toolbar
horizontal
Add aria-orientation="vertical" when using axis: 'vertical'
+
tablist
horizontal
Add aria-orientation="vertical" when using axis: 'vertical'
+
listbox
vertical
Add aria-orientation="horizontal" when using axis: 'horizontal'
+
menu
vertical
Add aria-orientation="horizontal" when using axis: 'horizontal'
+
radiogroup
undefined
Always 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
+
+
+
+
+ Inbox
+
+
+ Vertical list using DtRecipeContactRow. Up/Down cycles all focusable elements.
+
+
+
+
+
+
+
+
+
+
+ Contact list — rich content
+
+
+ Vertical list of rich content items built with Dialtone primitives.
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
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 @@
diff --git a/packages/dialtone-vue/index.js b/packages/dialtone-vue/index.js
index f27a14bbba..3311f19c65 100644
--- a/packages/dialtone-vue/index.js
+++ b/packages/dialtone-vue/index.js
@@ -71,6 +71,7 @@ export * from './components/combobox_with_popover';
export * from './directives/tooltip_directive';
export * from './directives/scrollbar_directive';
export * from './directives/mode_directive';
+export * from './directives/focusgroup_directive';
/// Recipes
export * from './recipes/buttons/callbar_button';
diff --git a/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md b/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md
new file mode 100644
index 0000000000..609185059c
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md
@@ -0,0 +1,35 @@
+# focusgroup-requires-label
+
+Warns when `v-dt-focusgroup` is used on an element without `aria-label` or `aria-labelledby`. Screen readers need an accessible name to identify the widget.
+
+## Rule Details
+
+### What the rule flags
+
+- Elements with `v-dt-focusgroup` that have no `aria-label` or `aria-labelledby` attribute (static or dynamic)
+
+### What the rule does NOT flag
+
+- Elements with `v-dt-focusgroup` that have a static `aria-label` or `aria-labelledby`
+- Elements with `v-dt-focusgroup` that have a dynamic `:aria-label` or `:aria-labelledby` binding
+- Elements without `v-dt-focusgroup`
+
+## Examples
+
+### Invalid
+
+```vue
+
+
+
+
+```
+
+### Valid
+
+```vue
+
+
+
+
+```
diff --git a/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md b/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md
new file mode 100644
index 0000000000..1dd5e3bac8
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md
@@ -0,0 +1,35 @@
+# focusgroup-requires-role
+
+Warns when `v-dt-focusgroup` is used on an element without a `role` attribute. Screen readers need a role to announce the widget correctly.
+
+## Rule Details
+
+### What the rule flags
+
+- Elements with `v-dt-focusgroup` that have no `role` attribute (static or dynamic)
+
+### What the rule does NOT flag
+
+- Elements with `v-dt-focusgroup` that have a static `role` attribute
+- Elements with `v-dt-focusgroup` that have a dynamic `:role` binding
+- Elements without `v-dt-focusgroup`
+
+## Examples
+
+### Invalid
+
+```vue
+
+
+
+
+```
+
+### Valid
+
+```vue
+
+
+
+
+```
diff --git a/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js b/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js
new file mode 100644
index 0000000000..3393853c26
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js
@@ -0,0 +1,52 @@
+/**
+ * @fileoverview Warns when v-dt-focusgroup is used without an accessible label on the same element.
+ * @author Dialtone
+ */
+'use strict';
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description:
+ 'Warns when v-dt-focusgroup is used on an element without aria-label or aria-labelledby. ' +
+ 'Screen readers need an accessible name to identify the widget.',
+ recommended: false,
+ url: 'https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ missingLabel:
+ 'v-dt-focusgroup requires an accessible name via "aria-label" or "aria-labelledby" ' +
+ 'so screen readers can identify the widget.',
+ },
+ },
+
+ create (context) {
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
+ return sourceCode.parserServices.defineTemplateBodyVisitor({
+ VAttribute (node) {
+ if (!node.directive) return;
+ if (node.key.name.name !== 'dt-focusgroup') return;
+
+ const element = node.parent;
+ const hasLabel = element.attributes.some(
+ attr =>
+ (!attr.directive && (
+ attr.key.name === 'aria-label' ||
+ attr.key.name === 'aria-labelledby'
+ )) ||
+ (attr.directive && attr.key.name.name === 'bind' && (
+ attr.key.argument?.name === 'aria-label' ||
+ attr.key.argument?.name === 'aria-labelledby'
+ )),
+ );
+
+ if (!hasLabel) {
+ context.report({ node, messageId: 'missingLabel' });
+ }
+ },
+ });
+ },
+};
diff --git a/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js b/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js
new file mode 100644
index 0000000000..aa9a68948b
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js
@@ -0,0 +1,46 @@
+/**
+ * @fileoverview Warns when v-dt-focusgroup is used without a role attribute on the same element.
+ * @author Dialtone
+ */
+'use strict';
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description:
+ 'Warns when v-dt-focusgroup is used on an element without a role attribute. ' +
+ 'Screen readers need a role to announce the widget correctly.',
+ recommended: false,
+ url: 'https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ missingRole:
+ 'v-dt-focusgroup requires a "role" attribute (e.g. toolbar, tablist, listbox, radiogroup, menu) ' +
+ 'so screen readers can announce the widget correctly.',
+ },
+ },
+
+ create (context) {
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
+ return sourceCode.parserServices.defineTemplateBodyVisitor({
+ VAttribute (node) {
+ if (!node.directive) return;
+ if (node.key.name.name !== 'dt-focusgroup') return;
+
+ const element = node.parent;
+ const hasRole = element.attributes.some(
+ attr =>
+ (!attr.directive && attr.key.name === 'role') ||
+ (attr.directive && attr.key.name.name === 'bind' && attr.key.argument?.name === 'role'),
+ );
+
+ if (!hasRole) {
+ context.report({ node, messageId: 'missingRole' });
+ }
+ },
+ });
+ },
+};
diff --git a/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js b/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
new file mode 100644
index 0000000000..05d3825678
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
@@ -0,0 +1,55 @@
+/**
+ * @fileoverview Tests for focusgroup-requires-label rule.
+ * @author Dialtone
+ */
+'use strict';
+
+const rule = require('../../../lib/rules/focusgroup-requires-label');
+const RuleTester = require('eslint').RuleTester;
+// eslint-disable-next-line n/no-extraneous-require
+const vueParser = require('vue-eslint-parser');
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parser: vueParser,
+ ecmaVersion: 'latest',
+ },
+});
+
+ruleTester.run('focusgroup-requires-label', rule, {
+ valid: [
+ {
+ name: 'Element with aria-label and v-dt-focusgroup',
+ code: '',
+ },
+ {
+ name: 'Element with aria-labelledby and v-dt-focusgroup',
+ code: '',
+ },
+ {
+ name: 'Element without v-dt-focusgroup (no rule needed)',
+ code: '',
+ },
+ {
+ name: 'Dynamic :aria-label binding',
+ code: '',
+ },
+ {
+ name: 'Dynamic :aria-labelledby binding',
+ code: '',
+ },
+ ],
+
+ invalid: [
+ {
+ name: 'v-dt-focusgroup without aria-label or aria-labelledby',
+ code: '',
+ errors: [{ messageId: 'missingLabel' }],
+ },
+ {
+ name: 'Bare v-dt-focusgroup without label',
+ code: '',
+ errors: [{ messageId: 'missingLabel' }],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js b/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js
new file mode 100644
index 0000000000..00a97a2c8f
--- /dev/null
+++ b/packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js
@@ -0,0 +1,55 @@
+/**
+ * @fileoverview Tests for focusgroup-requires-role rule.
+ * @author Dialtone
+ */
+'use strict';
+
+const rule = require('../../../lib/rules/focusgroup-requires-role');
+const RuleTester = require('eslint').RuleTester;
+// eslint-disable-next-line n/no-extraneous-require
+const vueParser = require('vue-eslint-parser');
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parser: vueParser,
+ ecmaVersion: 'latest',
+ },
+});
+
+ruleTester.run('focusgroup-requires-role', rule, {
+ valid: [
+ {
+ name: 'Element with role and v-dt-focusgroup',
+ code: '',
+ },
+ {
+ name: 'Element with role="tablist" and v-dt-focusgroup',
+ code: '',
+ },
+ {
+ name: 'Element without v-dt-focusgroup (no rule needed)',
+ code: '',
+ },
+ {
+ name: 'Bare v-dt-focusgroup with role',
+ code: '',
+ },
+ {
+ name: 'Dynamic :role binding',
+ code: '',
+ },
+ ],
+
+ invalid: [
+ {
+ name: 'v-dt-focusgroup without role',
+ code: '',
+ errors: [{ messageId: 'missingRole' }],
+ },
+ {
+ name: 'Bare v-dt-focusgroup without role',
+ code: '',
+ errors: [{ messageId: 'missingRole' }],
+ },
+ ],
+});