Skip to content

feat(focusgroup): DLT-3285 add v-dt-focusgroup directive for declarative roving tabindex#1187

Merged
Francis Rupert (francisrupert) merged 29 commits intonextfrom
focusgroup-directive
Apr 11, 2026
Merged

feat(focusgroup): DLT-3285 add v-dt-focusgroup directive for declarative roving tabindex#1187
Francis Rupert (francisrupert) merged 29 commits intonextfrom
focusgroup-directive

Conversation

@francisrupert
Copy link
Copy Markdown
Contributor

@francisrupert Francis Rupert (francisrupert) commented Apr 8, 2026

🛠️ Type Of Change

  • Feature

📖 Jira Ticket

DLT-3285
DLT-3042

📖 Description

New Vue directive v-dt-focusgroup that declaratively adds roving tabindex to any composite widget container. Arrow-key cycling, Home/End, looping, focus memory, disabled-item skipping, RTL support — all without writing keyboard event handlers.

Token syntax

<div role="toolbar" v-dt-focusgroup="'horizontal'" aria-label="Formatting">
  <dt-button>Bold</dt-button>
  <dt-button>Italic</dt-button>
</div>

Object syntax for advanced

<div role="listbox" v-dt-focusgroup="{ axis: 'vertical', selector: '[role=option]' }">
  <dt-button role="option">Apple</dt-button>
  <dt-button role="option">Banana</dt-button>
</div>

Role-aware defaults infer the item selector and disabled behavior from the container's role attribute (tablist[role="tab"], listbox[role="option"], etc.).

Also includes:

  • Two ESLint rules (focusgroup-requires-role, focusgroup-requires-label) as accessibility guardrails
  • Storybook stories + MDX docs with recipes (toolbar, tablist, listbox, treeview, contact list, table row nav, hovercards with custom selector)
  • Vue Utilities documentation page (/functions-and-utilities/) listing all consumer-facing directives, functions, and utilities — driven by _data/vue-utilities.json
  • Replaced inline Storybook SVG in UiKitsOverview with <dt-icon name="storybook-color">

Does NOT:

  • Adopt inside DtTabGroup or DtSegmentedControl (future work — composable layer needed)
  • Support 2D grid navigation (future work)
  • Replace the keyboard_list_navigation mixin (different pattern — activedescendant)

📦 Cross-Package Impact

Package Changes
dialtone-vue New directive, barrel export, Storybook stories + MDX
eslint-plugin-dialtone 2 new lint rules + tests
dialtone-documentation Vue Utilities page, scratch demos, nav path detection fix

💡 Context

Roving tabindex is reimplemented from scratch in 6+ Dialtone components with inconsistent feature support. Consumers building custom widgets have no primitive for this. A loose version was built in Beacon and proved the need. This directive gives every team a confident, one-line way to add keyboard cycling to toolbars, tablists, menus, contact lists, and data tables.

Benefits all users — not just accessibility or power users. Arrow-key navigation is standardized, faster, and more predictable than Tab for navigating grouped controls.

Aligned with the Open UI Scoped Focusgroup proposal so the mental model transfers when the native HTML attribute ships.

📝 Checklist

For all PRs:

  • I have ensured no private Dialpad links or info are in the code or pull request description.
  • I have reviewed my changes.
  • I have added all relevant documentation.
  • I have considered the performance impact of my change.

For all Vue changes:

  • I have added / updated unit tests.
  • I have validated components with a screen reader.
  • I have validated components keyboard navigation.

🔮 Next Steps

  • Composable layer (useFocusGroup) for DtTabGroup/DtSegmentedControl internal adoption
  • DtButtonGroup gaining toolbar keyboard nav via the directive
  • 2D grid navigation support (separate shaping effort)

🔗 Sources

📷 Screenshots / GIFs

vue-utilities.mp4
focus-group-stories.mp4

Adds v-dt-focusgroup Vue directive for declarative roving tabindex (arrow/Home/End, looping, memory, RTL, disabled-item skipping, role-aware defaults), two ESLint accessibility rules with tests, Storybook stories/MDX docs and recipes, Vue Utilities docs/site-nav updates, and package exports/Storybook registration.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Please add either the visual-test-ready or no-visual-test label to this PR depending on whether you want to run visual tests or not.
It is recommended to run visual tests if your PR changes any UI. ‼️

@francisrupert Francis Rupert (francisrupert) added the no-visual-test Add this tag when the PR does not need visual testing label Apr 8, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new v-dt-focusgroup directive (implementation, constants, export), Storybook docs/stories/examples, unit tests, ESLint rules/docs enforcing role/label, package re-exports and Storybook registration, documentation/data updates for a "Vue Utilities" nav entry, and extends active-link detection to include /functions-and-utilities/ in docs nav logic.

Changes

Cohort / File(s) Summary
Routing & Nav
apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue, apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js, apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue, apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue, apps/dialtone-documentation/docs/_data/site-nav.json
Treat /functions-and-utilities/ as part of the dialtone top-level group for active-link/sidebar detection; rename nav label from "Functions" to "Vue Utilities".
Docs content & data
apps/dialtone-documentation/docs/_data/vue-utilities.json, apps/dialtone-documentation/docs/functions-and-utilities/index.md, apps/dialtone-documentation/docs/scratch.md, apps/dialtone-documentation/docs/views/UiKitsOverview.vue
Add vue-utilities.json, replace the functions page with tables bound to that data, add extensive v-dt-focusgroup docs in scratch, and swap an inline Storybook SVG/text for design-system components.
Directive implementation
packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js, packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js, packages/dialtone-vue/directives/focusgroup_directive/index.js
Implement v-dt-focusgroup with config parsing, role-aware defaults, selector resolution, roving tabindex, keyboard navigation (Arrow/Home/End), loop/memory/skip-disabled options, RTL handling, dt-focusgroup-move events, lifecycle cleanup; add constants/helpers and export the directive.
Storybook & examples
packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx, packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js, packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue, packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_recipes.story.vue
Add comprehensive MDX docs and many stories/components demonstrating syntax variants, modes, events, nesting, and integration recipes.
Tests
packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js, packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js, packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
Add directive unit tests covering parsing, discovery, keyboard navigation, disabled handling, events, RTL, lifecycle cleanup, and ESLint rule test suites.
ESLint rules & docs
packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js, packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js, packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md, packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md
Introduce two Vue-template ESLint rules that require a role and an accessible name when using v-dt-focusgroup, plus tests and documentation.
Package & Storybook integration
packages/dialtone-vue/.storybook/preview.jsx, packages/dialtone-vue/index.js
Register DtFocusgroupDirective in Storybook preview and re-export it from the package entrypoint.
Minor UI story adjustments
packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue, packages/dialtone-vue/directives/scrollbar_directive/scrollbar_directive_default.story.vue
Small layout/spacing and padding tweaks in existing directive example stories.
Story files & helpers
packages/dialtone-vue/directives/focusgroup_directive/*.story.vue, packages/dialtone-vue/directives/focusgroup_directive/*.stories.js, packages/dialtone-vue/directives/focusgroup_directive/*.mdx
Multiple new story components, story modules, and MDX docs for the focusgroup directive (examples, events, recipes).

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Dir as v-dt-focusgroup<br/>(Directive)
    participant DOM as DOM<br/>Elements
    participant App as Vue App

    User->>Dir: Key press or focus
    activate Dir
    Dir->>Dir: parseConfig() / resolveSelector() / resolveSkipDisabled()
    Dir->>DOM: querySelectorAll -> filter hidden / skipped / disabled
    DOM-->>Dir: items[]
    Dir->>Dir: compute next target (axis, loop, RTL)
    Dir->>DOM: update roving tabindex attributes
    Dir->>DOM: focus(target)
    DOM->>App: dispatch dt-focusgroup-move(detail: item,index,prev)
    deactivate Dir
    App-->>User: consumer handles selection/activation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch focusgroup-directive

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js (1)

199-214: RTL detection edge case (optional).

Line 202's || el.closest('[dir="rtl"]') could yield incorrect results if the container has explicit dir="ltr" but sits inside an RTL ancestor. getComputedStyle(el).direction alone is authoritative since it reflects inheritance correctly. The closest check appears redundant.

Low-impact since nested opposing dir attributes are rare.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js` around
lines 199 - 214, The RTL detection is using getComputedStyle(el).direction
combined with el.closest('[dir="rtl"]'), which can be wrong when the element has
an explicit dir="ltr" but is inside an RTL ancestor; change the isRTL assignment
in attach to rely solely on getComputedStyle(el).direction === 'rtl' (remove the
el.closest('[dir="rtl"]') fallback) and update the state object (state.isRTL)
initialization to use that single authoritative value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js`:
- Around line 35-38: The predicate that currently only accepts literal
accessible-name attributes (it checks !attr.directive) should be expanded to
also accept Vue bound attributes; update the condition in
focusgroup-requires-label.js so it returns true for either non-directive
attributes matching attr.key.name === 'aria-label' or 'aria-labelledby' OR for
directive/bound attributes where the attribute identifier lives on
attr.key.name.name (check attr.directive && attr.key && attr.key.name &&
(attr.key.name === 'aria-label' || attr.key.name === 'aria-labelledby') or
attr.key.name.name depending on AST shape), and add RuleTester valid cases
covering templates with :aria-label and :aria-labelledby to prevent false
positives.
- Around line 27-29: The call to
sourceCode.parserServices.defineTemplateBodyVisitor in the rule assumes
parserServices exists; add a guard that checks for sourceCode.parserServices and
sourceCode.parserServices.defineTemplateBodyVisitor before calling it (e.g., if
missing, return an empty visitor object). Update the visitor creation in the
top-level of focusgroup-requires-label (the code using sourceCode and
defineTemplateBodyVisitor and the VAttribute visitor) to early-return {} when
parserServices/defineTemplateBodyVisitor is falsy so the rule no longer throws
when vue-eslint-parser is not configured.

In `@packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js`:
- Around line 34-36: The predicate computing hasRole in
focusgroup-requires-role.js currently ignores directive attributes (attr =>
!attr.directive && attr.key.name === 'role'), which misses bound roles like
:role/v-bind:role; update the predicate used for hasRole to treat either a plain
attribute with key.name === 'role' OR a directive whose argument identifies the
role (e.g., attr.directive && attr.key.argument && attr.key.argument.name ===
'role'), so bound role attributes are counted as present; after updating the
hasRole logic, add a RuleTester valid case for a template using :role="someRole"
(or v-bind:role) to prevent regressions.

---

Nitpick comments:
In `@packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js`:
- Around line 199-214: The RTL detection is using getComputedStyle(el).direction
combined with el.closest('[dir="rtl"]'), which can be wrong when the element has
an explicit dir="ltr" but is inside an RTL ancestor; change the isRTL assignment
in attach to rely solely on getComputedStyle(el).direction === 'rtl' (remove the
el.closest('[dir="rtl"]') fallback) and update the state object (state.isRTL)
initialization to use that single authoritative value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: e16b61d6-df89-43c6-9772-177e5882a8a9

📥 Commits

Reviewing files that changed from the base of the PR and between f3e8fd7 and 2cae546.

📒 Files selected for processing (25)
  • apps/dialtone-documentation/docs/.vuepress/theme/components/Navbar.vue
  • apps/dialtone-documentation/docs/.vuepress/theme/components/Page.vue
  • apps/dialtone-documentation/docs/.vuepress/theme/composables/useSidebarItems.js
  • apps/dialtone-documentation/docs/.vuepress/theme/layouts/Layout.vue
  • apps/dialtone-documentation/docs/.vuepress/views/UiKitsOverview.vue
  • apps/dialtone-documentation/docs/_data/site-nav.json
  • apps/dialtone-documentation/docs/_data/vue-utilities.json
  • apps/dialtone-documentation/docs/functions-and-utilities/index.md
  • apps/dialtone-documentation/docs/scratch.md
  • packages/dialtone-vue/.storybook/preview.jsx
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_recipes.story.vue
  • packages/dialtone-vue/directives/focusgroup_directive/index.js
  • packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue
  • packages/dialtone-vue/directives/scrollbar_directive/scrollbar_directive_default.story.vue
  • packages/dialtone-vue/index.js
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js

Comment thread packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js`:
- Line 204: The RTL detection currently uses el.closest('[dir="rtl"]') which can
be overridden by nearer dir attributes; replace that logic so isRTL is
determined solely from getComputedStyle(el).direction === 'rtl' (remove the
el.closest check) inside the focusgroup directive where isRTL is computed, and
update any related references to rely on that computed direction. Also add a
unit/integration test that renders a parent with dir="rtl" and a nested
focusgroup element with dir="ltr" and assert horizontal navigation behaves as
LTR (i.e., left/right are not inverted) to prevent regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: dcdd7fe1-6861-42d8-a2b0-d441ab6a9271

📥 Commits

Reviewing files that changed from the base of the PR and between 2cae546 and d88af77.

📒 Files selected for processing (4)
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.stories.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js
✅ Files skipped from review due to trivial changes (1)
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.mdx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_constants.js

Comment thread packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js (1)

209-209: ⚠️ Potential issue | 🟠 Major

Use computed direction only for RTL detection

Line 209 incorrectly combines computed direction with closest('[dir="rtl"]'). In nested dir overrides (e.g., RTL parent, LTR focusgroup), this inverts horizontal navigation incorrectly. Use only computed style direction.

Suggested fix
-      const isRTL = getComputedStyle(el).direction === 'rtl' || el.closest('[dir="rtl"]') !== null;
+      const isRTL = getComputedStyle(el).direction === 'rtl';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js` at line
209, The RTL detection incorrectly combines computed style with a DOM ancestor
check; update the isRTL assignment in focusgroup.js to rely solely on the
computed style for the element (use getComputedStyle(el).direction === 'rtl')
and remove the el.closest('[dir="rtl"]') part so nested dir overrides (e.g., an
LTR focusgroup inside an RTL parent) don't invert navigation; locate and change
the isRTL declaration where it’s currently set to const isRTL =
getComputedStyle(el).direction === 'rtl' || el.closest('[dir="rtl"]') !== null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js`:
- Line 209: The RTL detection incorrectly combines computed style with a DOM
ancestor check; update the isRTL assignment in focusgroup.js to rely solely on
the computed style for the element (use getComputedStyle(el).direction ===
'rtl') and remove the el.closest('[dir="rtl"]') part so nested dir overrides
(e.g., an LTR focusgroup inside an RTL parent) don't invert navigation; locate
and change the isRTL declaration where it’s currently set to const isRTL =
getComputedStyle(el).direction === 'rtl' || el.closest('[dir="rtl"]') !== null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 44fc47ce-5b41-4811-9d8c-3b6a52cb7604

📥 Commits

Reviewing files that changed from the base of the PR and between d88af77 and bbad6c9.

📒 Files selected for processing (9)
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup_directive_events.story.vue
  • packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md
  • packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js
✅ Files skipped from review due to trivial changes (4)
  • packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-role.md
  • packages/eslint-plugin-dialtone/docs/rules/focusgroup-requires-label.md
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-label.js
  • packages/eslint-plugin-dialtone/tests/lib/rules/focusgroup-requires-role.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-role.js
  • packages/eslint-plugin-dialtone/lib/rules/focusgroup-requires-label.js
  • packages/dialtone-vue/directives/focusgroup_directive/focusgroup.test.js

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bda3cb6fdc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread packages/dialtone-vue/directives/focusgroup_directive/focusgroup.js
Copy link
Copy Markdown
Contributor

@braddialpad Brad Paugh (braddialpad) left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR looks pretty good IMO. I have one question, what is the behaviour of multi nested v-dt-focusgroup is this something that should be acknowledged, managed or handled? I believe at the moment if you have multiple nested groups and you press left arrow for example, this event will trigger in all of them. Expected behaviour?

Comment on lines +77 to +159
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are functions, not constants. Should probably just be moved to a different file to avoid confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracted into focusgroup_utils.js

@francisrupert
Copy link
Copy Markdown
Contributor Author

This PR looks pretty good IMO. I have one question, what is the behaviour of multi nested v-dt-focusgroup is this something that should be acknowledged, managed or handled? I believe at the moment if you have multiple nested groups and you press left arrow for example, this event will trigger in all of them. Expected behaviour?

Nesting is not recommended, though technicaly possible. Each v-dt-focusgroup is independent — keydown events bubble, so both handlers fire. This happens to work when the two use different axes (inner horizontal, outer vertical). But if both respond to the same key, you may get unpredictable double-moves.

Distinct axes is effectively the workaround if nested is truly needed. The treeview example I did demonstrates this: directive owns vertical (Up/Down) by default, and I created custom script to manage left/right for DtCollapsible's expand/collapse with @keydown.right/@keydown.left. That isn't a nested focusgroup though. The whole group is still a single v-dt-focusgroup.

2D grid navigation is the proper answer for most "I want to nest" cases. When someone thinks they need nested focusgroups, it's probably a grid where Up/Down and Left/Right mean different things (rows vs columns). 2D Grid could be a future token extension (e.g., v-dt-focusgroup="'grid'" with a column/row option). The token grammar enablesthis future possibility.

I've added language in the doc to discourage nested use.

@francisrupert
Copy link
Copy Markdown
Contributor Author

Francis Rupert (francisrupert) commented Apr 10, 2026

I threw in a blog post to announce this directive: https://dialtone.dialpad.com/deploy-previews/pr-1187/dialtone/whats-new/

@github-actions
Copy link
Copy Markdown
Contributor

✔️ Deploy previews ready!
😎 Dialtone documentation preview: https://dialtone.dialpad.com/deploy-previews/pr-1187/
😎 Dialtone-vue preview: https://dialtone.dialpad.com/vue/deploy-previews/pr-1187/

Copy link
Copy Markdown
Contributor

@braddialpad Brad Paugh (braddialpad) left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it's really cool. Approving with one additional question.

Does it also make sense to use this component as a "focus trap" component where we want to trap the focus within a certain area, but don't necessarily want to use arrow keyboard navigation? ex/ what we do in popover/modal

@francisrupert
Copy link
Copy Markdown
Contributor Author

Francis Rupert (francisrupert) commented Apr 10, 2026

Does it also make sense to use this component as a "focus trap" component...

Nah. This directive makes the container a single tab stop: you enter once via tab, arrows to cycle through focusable items, tabbing again leavs the container.

What does sound very reasonable: a Dialtone v-focustrap directive, and nicely slot in this new Vue Utilities section. Like https://primevue.org/focustrap/. And then of course update DtModal and DtPopover to use that – just like I plan to update DtTabGroup and DtSegmentedControl to use v-dt-focusgroup.

And to that end, we should consider combing through our products to discover what is done over and over, what should we make an equivalent available... functions, utils, composables, etc...

@francisrupert Francis Rupert (francisrupert) merged commit e1ea074 into next Apr 11, 2026
16 checks passed
@francisrupert Francis Rupert (francisrupert) deleted the focusgroup-directive branch April 11, 2026 14:38
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 15, 2026
# [3.220.0-next.1](dialtone-vue/v3.219.0...dialtone-vue/v3.220.0-next.1) (2026-04-15)

### Bug Fixes

* **Editor:** NO-JIRA remove extra blank line in editor.mdx ([18c47ef](18c47ef))
* **Split Button, Filter Pill:** DLT-3260 DLT-3261 correct misalignment at size 100 ([#1176](#1176)) ([fe2b777](fe2b777))
* **Tabs:** DLT-3251 improve accessibility ([#1171](#1171)) ([01b101e](01b101e))

### Code Refactoring

* **Combobox Multi Select:** NO-JIRA use logical css properties ([#1104](#1104)) ([a675daf](a675daf))
* **Components,recipes:** DLT-3043 DLT-3046 logical start/end naming with deprecated backward-compatible fallbacks ([#1079](#1079)) ([1d26a4e](1d26a4e))
* **Components:** DLT-3100 remove rootClass references ([#1184](#1184)) ([6531783](6531783))
* **Components:** dlt-3282 renamed show prop to open ([#1190](#1190)) ([5509d0f](5509d0f))
* **Components:** dlt-3283 refactor prop/slots to be consistent ([#1196](#1196)) ([2d22c76](2d22c76))
* **Modal:** DLT-3262 migrate DtModal to native dialog element ([#1179](#1179)) ([589edb5](589edb5))
* **Text:** DLT-3302 tone prop uses css variables instead of css utilities ([#1197](#1197)) ([75df16f](75df16f))
* **Tokens,css,link,text,breadcrumbs,input Group,split Button:** tokens, CSS foundation, deprecations, tooling DLT-3011 DLT-2961 DLT-3068 DLT-3069 DLT-3070 DLT-3071 DLT-3072 ([#1092](#1092)) ([7783090](7783090))
* **Tokens:** DLT-3013 convert color system from HSL to OKLCH ([#1060](#1060)) ([42dc418](42dc418))

### Documentation

* **Dialtone Vue:** DLT-3226 update documentation and consuming code to numeric size scale ([#1158](#1158)) ([149c763](149c763))
* **Playground:** DLT-3119 refine playground UI and integrate across doc site ([#1127](#1127)) ([ba6b599](ba6b599))

### Features

* **Avatar:** DLT-2942 updated avatar component ([#1047](#1047)) ([e50563d](e50563d))
* **Avatar:** DLT-3161 change from clickable to "interactive" ([#1182](#1182)) ([2d00fe0](2d00fe0))
* **Banner, Notice, Toast, Breadcrumbs, Checkbox, Radio, Input, Select Menu, Empty State:** visual refresh, dt-text integration, form api parity DLT-2944 DLT-2945 DLT-2949 DLT-2959 DLT-2960 DLT-2957 ([#1091](#1091)) ([16e908a](16e908a))
* **Button,tabs,split Button:** visual refresh and slot updates DLT-2946 DLT-2947 DLT-2965 ([#1090](#1090)) ([ffdd56f](ffdd56f))
* **Components:** DLT-3157 align critical/positive prop values and CSS class names ([#1177](#1177)) ([edd98b2](edd98b2))
* **Components:** DLT-3159 enforce positive boolean props ([#1198](#1198)) ([ac73bdc](ac73bdc))
* **Dialtone Css, Dialtone Tokens, Dialtone Vue:** DLT-3197 DLT-3198 DLT-3199 DLT-3200 DLT-3201 DLT-3202 DLT-3203 DLT-3204 DLT-3205 DLT-3206 DLT-3207 DLT-3208 DLT-3209 DLT-3210 DLT-3211 token-stop utility classes, migration tooling, and documentation ([#1150](#1150)) ([6237b84](6237b84))
* **Dialtone Vue:** DLT-3225 migrate component size props to numeric ordinal scale ([#1157](#1157)) ([725bd41](725bd41))
* **Filter Pill:** DLT-2941 DtFilterPill improvements ([#1110](#1110)) ([23c5689](23c5689))
* **Focusgroup:** DLT-3285 add v-dt-focusgroup directive for declarative roving tabindex ([#1187](#1187)) ([e1ea074](e1ea074))
* **Focustrap:** DLT-3297 add v-dt-focustrap directive for declarative focus trapping ([#1195](#1195)) ([6ef9a21](6ef9a21))
* **Link,button:** DLT-3012 add underline prop ([#1059](#1059)) ([1cb91ea](1cb91ea))
* **Mode Directive, Mode Island:** DLT-3067 DLT-3078 DLT-3077 add `v-dt-mode` directive ([#1109](#1109)) ([f97efca](f97efca))
* **Segmented Control:** DLT-413 new component ([#1144](#1144)) ([ad4c6c3](ad4c6c3))
* **Split Button:** DLT-3257 add show-divider prop ([#1173](#1173)) ([5bef411](5bef411))
* **Tabs:** DLT-3114 add vertical orientation variant ([#1120](#1120)) ([3290a23](3290a23))
* **Tabs:** DLT-3162 add spread prop for horizontal distribution ([#1135](#1135)) ([6e7cd04](6e7cd04))
* **Text:** export text component from dialtone-vue ([757fdb7](757fdb7))
* **Tokens:** DLT-2937 introduce spacing and layout token systems ([#1045](#1045)) ([de23e12](de23e12))
* **Tokens:** update color ramps and consumer color migration tools DLT-3004 DLT-3005 DLT-3006 DLT-3007 DLT-3008 ([#1054](#1054)) ([28cb1de](28cb1de))
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 15, 2026
# [1.12.0-next.3](eslint-plugin-dialtone/v1.12.0-next.2...eslint-plugin-dialtone/v1.12.0-next.3) (2026-04-15)

### Features

* **Focusgroup:** DLT-3285 add v-dt-focusgroup directive for declarative roving tabindex ([#1187](#1187)) ([e1ea074](e1ea074))
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 15, 2026
# [9.180.0-next.1](dialtone/v9.179.0...dialtone/v9.180.0-next.1) (2026-04-15)

### Bug Fixes

* **Doc:** NO-JIRA correct font size utility json ([#1015](#1015)) ([76cf0fa](76cf0fa))
* **Editor:** NO-JIRA remove extra blank line in editor.mdx ([18c47ef](18c47ef))
* possible merge issue in sidebarItem.vue ([5e2e91b](5e2e91b))
* **Split Button, Filter Pill:** DLT-3260 DLT-3261 correct misalignment at size 100 ([#1176](#1176)) ([fe2b777](fe2b777))
* **Stylelint:** disable hue-degree-notation rule and update OKLCH color syntax ([81f886e](81f886e))
* **Stylelint:** disable hue-degree-notation rule and update OKLCH color syntax ([8721f43](8721f43))
* **Tabs:** DLT-3251 improve accessibility ([#1171](#1171)) ([01b101e](01b101e))
* **Tokens:** DLT-3053 preserve root font size variable as reference ([#1093](#1093)) ([112ab4b](112ab4b))
* **Tokens:** handle multi-layer box shadows correctly in postcss plugin ([feea7f1](feea7f1))
* **Tokens:** NO-JIRA output line-height as unitless instead of percentages ([#1040](#1040)) ([33d8505](33d8505))
* **Tokens:** wrap css math expressions with calc() in layered build ([#966](#966)) ([1635f61](1635f61))
* update @dialpad/i18n dependencies to latest versions ([7a4b777](7a4b777))

### Code Refactoring

* **Combobox Multi Select:** NO-JIRA use logical css properties ([#1104](#1104)) ([a675daf](a675daf))
* **Components,recipes:** DLT-3043 DLT-3046 logical start/end naming with deprecated backward-compatible fallbacks ([#1079](#1079)) ([1d26a4e](1d26a4e))
* **Components:** DLT-3100 remove rootClass references ([#1184](#1184)) ([6531783](6531783))
* **Components:** dlt-3282 renamed show prop to open ([#1190](#1190)) ([5509d0f](5509d0f))
* **Components:** dlt-3283 refactor prop/slots to be consistent ([#1196](#1196)) ([2d22c76](2d22c76))
* **Css:** DLT-1876 css logical properties ([#872](#872)) ([1acd6c9](1acd6c9))
* **Modal:** DLT-3262 migrate DtModal to native dialog element ([#1179](#1179)) ([589edb5](589edb5))
* **Notice,banner,toast:** DLT-3296 decouple CSS and fix specificity violations ([#1194](#1194)) ([9579ac0](9579ac0))
* **Style:** DLT-3014 wrap all styles in css cascade layers ([#1061](#1061)) ([f8b3fed](f8b3fed))
* **Text:** DLT-2883 rename headline sizes from xxl => 2xl, etc ([#1029](#1029)) ([0ac86f3](0ac86f3))
* **Text:** DLT-3302 tone prop uses css variables instead of css utilities ([#1197](#1197)) ([75df16f](75df16f))
* **Tokens,css,link,text,breadcrumbs,input Group,split Button:** tokens, CSS foundation, deprecations, tooling DLT-3011 DLT-2961 DLT-3068 DLT-3069 DLT-3070 DLT-3071 DLT-3072 ([#1092](#1092)) ([7783090](7783090))
* **Tokens:** DLT-2884 deprecate space tokens in favor of size tokens ([#1013](#1013)) ([63f6d2f](63f6d2f))
* **Tokens:** DLT-3013 convert color system from HSL to OKLCH ([#1060](#1060)) ([42dc418](42dc418))

### Documentation

* **Components:** DLT-3178 DLT-3182 unify code examples into single code-example component ([#1147](#1147)) ([d0b6e14](d0b6e14))
* **Css Utilities:** DLT-3295 DLT-3280 mark physical utilities as deprecated ([#1193](#1193)) ([73f1479](73f1479))
* **Dialtone Vue:** DLT-3226 update documentation and consuming code to numeric size scale ([#1158](#1158)) ([149c763](149c763))
* DLT-1510 DLT-1511 DLT-1512 site redesign and ia ([#968](#968)) ([6ea7eb9](6ea7eb9))
* DLT-2908 ui kits landing page ([#1049](#1049)) ([d805b9d](d805b9d))
* DLT-3243 fenced code blocks for examples and snippets ([#1166](#1166)) ([cb169df](cb169df))
* DLT-3250 display component status badges in sidebar and page headers ([#1170](#1170)) ([7d5f5d5](7d5f5d5))
* DLT-3252 add GFM alert syntax for notices ([#1172](#1172)) ([1697911](1697911))
* DLT-3258 DLT-3259 improve sidebar search with logical keywords and fuzzy matching ([#1174](#1174)) ([2bf4639](2bf4639))
* NO-JIRA fix focus left-nav items on keypress down up ([#1106](#1106)) ([882c9b9](882c9b9))
* **Playground:** DLT-3119 refine playground UI and integrate across doc site ([#1127](#1127)) ([ba6b599](ba6b599))
* **Playground:** DLT-3242 segmented control for compact enum props ([#1165](#1165)) ([fa440d8](fa440d8))
* **Utilities:** DLT-3183 convert to vue demo fenced block ([#1167](#1167)) ([522197f](522197f))

### Features

* **Avatar:** DLT-2942 updated avatar component ([#1047](#1047)) ([e50563d](e50563d))
* **Avatar:** DLT-3161 change from clickable to "interactive" ([#1182](#1182)) ([2d00fe0](2d00fe0))
* **Banner, Notice, Toast, Breadcrumbs, Checkbox, Radio, Input, Select Menu, Empty State:** visual refresh, dt-text integration, form api parity DLT-2944 DLT-2945 DLT-2949 DLT-2959 DLT-2960 DLT-2957 ([#1091](#1091)) ([16e908a](16e908a))
* **Button,tabs,split Button:** visual refresh and slot updates DLT-2946 DLT-2947 DLT-2965 ([#1090](#1090)) ([ffdd56f](ffdd56f))
* **Components:** DLT-3157 align critical/positive prop values and CSS class names ([#1177](#1177)) ([edd98b2](edd98b2))
* **Components:** DLT-3159 enforce positive boolean props ([#1198](#1198)) ([ac73bdc](ac73bdc))
* **Css Utility:** DLT-2715 add default color for border css utilities ([#874](#874)) ([71310ab](71310ab))
* **Dialtone Css, Dialtone Tokens, Dialtone Vue:** DLT-3197 DLT-3198 DLT-3199 DLT-3200 DLT-3201 DLT-3202 DLT-3203 DLT-3204 DLT-3205 DLT-3206 DLT-3207 DLT-3208 DLT-3209 DLT-3210 DLT-3211 token-stop utility classes, migration tooling, and documentation ([#1150](#1150)) ([6237b84](6237b84))
* **Dialtone Vue:** DLT-3225 migrate component size props to numeric ordinal scale ([#1157](#1157)) ([725bd41](725bd41))
* **Eslint Plugin Dialtone:** DLT-3047 physical-to-logical naming migration tooling ([#1163](#1163)) ([d94dc4b](d94dc4b))
* **Eslint Plugin Dialtone:** DLT-3227 DLT-3228 add deprecated-tshirt-sizes ESLint rule and migration codemod ([#1159](#1159)) ([21a011f](21a011f))
* **Filter Pill:** DLT-2704 create component ([#473](#473)) ([6267445](6267445))
* **Filter Pill:** DLT-2941 DtFilterPill improvements ([#1110](#1110)) ([23c5689](23c5689))
* **Focusgroup:** DLT-3285 add v-dt-focusgroup directive for declarative roving tabindex ([#1187](#1187)) ([e1ea074](e1ea074))
* **Focustrap:** DLT-3297 add v-dt-focustrap directive for declarative focus trapping ([#1195](#1195)) ([6ef9a21](6ef9a21))
* **Link,button:** DLT-3012 add underline prop ([#1059](#1059)) ([1cb91ea](1cb91ea))
* **Mode Directive, Mode Island:** DLT-3067 DLT-3078 DLT-3077 add `v-dt-mode` directive ([#1109](#1109)) ([f97efca](f97efca))
* **Segmented Control:** DLT-413 new component ([#1144](#1144)) ([ad4c6c3](ad4c6c3))
* **Split Button:** DLT-3257 add show-divider prop ([#1173](#1173)) ([5bef411](5bef411))
* **Style:** DLT-2976 update underline thickness and offset ([#1058](#1058)) ([1e56947](1e56947))
* **Tabs:** DLT-3114 add vertical orientation variant ([#1120](#1120)) ([3290a23](3290a23))
* **Tabs:** DLT-3162 add spread prop for horizontal distribution ([#1135](#1135)) ([6e7cd04](6e7cd04))
* **Text:** export text component from dialtone-vue ([757fdb7](757fdb7))
* **Text:** new vue component DLT-2864 DLT-2863 DLT-2862 ([#995](#995)) ([196ef8a](196ef8a))
* **Tokens:** DLT-1977 add high contrast design tokens and theme ([#938](#938)) ([a1c0457](a1c0457))
* **Tokens:** DLT-2767 exploratory shell themes ([#909](#909)) ([3afcf63](3afcf63))
* **Tokens:** DLT-2781 create color-assistive themes ([#921](#921)) ([a6cc397](a6cc397))
* **Tokens:** DLT-2802 DLT-2563 layered theming system and mode island component ([#945](#945)) ([5c72c6c](5c72c6c))
* **Tokens:** DLT-2937 introduce spacing and layout token systems ([#1045](#1045)) ([de23e12](de23e12))
* **Tokens:** update color ramps and consumer color migration tools DLT-3004 DLT-3005 DLT-3006 DLT-3007 DLT-3008 ([#1054](#1054)) ([28cb1de](28cb1de))
* **Typography, Tokens:** DLT-2856 DLT-2857 next typography system ([#994](#994)) ([d744e97](d744e97))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-visual-test Add this tag when the PR does not need visual testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants