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

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

@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

Copy link
Copy Markdown

@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

Copy link
Copy Markdown

@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".

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
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