Skip to content

Commit 6ef9a21

Browse files
feat(focustrap): DLT-3297 add v-dt-focustrap directive for declarative focus trapping (#1195)
1 parent 9579ac0 commit 6ef9a21

11 files changed

Lines changed: 1380 additions & 0 deletions

File tree

apps/dialtone-documentation/docs/_data/vue-utilities.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
"description": "Roving tabindex for composite widgets — arrow-key cycling, looping, memory, and disabled-item handling",
66
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focusgroup--docs"
77
},
8+
{
9+
"name": "v-dt-focustrap",
10+
"description": "Trap Tab/Shift+Tab within a container — initial focus, boundary wrapping, and focus restoration for dialogs and overlays",
11+
"storybook": "https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focustrap--docs"
12+
},
813
{
914
"name": "v-dt-mode",
1015
"description": "Scope descendant design tokens to a light, dark, or inverted color palette",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
heading: 'Introducing v-dt-focustrap: Declarative Focus Trapping'
3+
author: Francis Rupert
4+
posted: '2026-4-21'
5+
excerpt: 'New Vue directive for focus trapping. Trap Tab/Shift+Tab within dialogs, popovers, and overlays with a single attribute — configurable initial focus, boundary wrapping, and focus restoration built in.'
6+
---
7+
8+
<BlogPost :author="$frontmatter.author" :posted="parse($frontmatter.posted, 'y-M-d', new Date())" :heading="$frontmatter.heading" :excerpt="$frontmatter.excerpt">
9+
10+
## TLDR
11+
12+
- New directive `v-dt-focustrap` traps Tab/Shift+Tab within a container element.
13+
- Configurable initial focus: auto (first focusable), CSS selector, element ref, or disabled.
14+
- Focus restoration on deactivation — returns focus to the element that triggered the overlay.
15+
- Reactive — bind to your open/close state and the directive handles the full lifecycle.
16+
- Companion to `v-dt-focusgroup` — they solve different problems and compose together.
17+
- [Storybook docs](https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focustrap--docs)
18+
19+
## The Motivation
20+
21+
When a dialog, popover, or drawer opens, Tab must not escape the container. Users who rely on keyboard navigation will Tab past the overlay into page content behind it — confusing, disorienting, and a WCAG 2.4.3 failure.
22+
23+
Focus trapping in Dialtone has been handled by the Modal mixin, which 5+ components wire up differently. Some call `focusFirstElement()` on mount, some on transition-end, some conditionally. Some restore focus, some don't. There's no shared primitive for new components — every team re-implements the same logic with subtle differences.
24+
25+
## The Solution
26+
27+
One directive. One attribute. Zero event handlers.
28+
29+
```vue demo
30+
<div
31+
v-dt-focustrap
32+
role="dialog"
33+
aria-label="Settings"
34+
class="d-w-500 d-p-300 d-bar8 d-bc-default d-ba d-bgc-primary"
35+
>
36+
<dt-stack gap="200">
37+
<dt-text as="p" kind="body" :size="200">Tab and Shift+Tab cycle within this container. Focus never escapes.</dt-text>
38+
<dt-input label="Name" placeholder="Jane Doe" />
39+
<dt-input label="Email" placeholder="jane@example.com" />
40+
<dt-stack direction="row" gap="100">
41+
<dt-button>Save</dt-button>
42+
<dt-button kind="muted" importance="clear">Cancel</dt-button>
43+
</dt-stack>
44+
</dt-stack>
45+
</div>
46+
```
47+
48+
Tab cycles through the inputs and buttons. Shift+Tab wraps backward. Focus never leaves the container. That's it.
49+
50+
## Who Benefits
51+
52+
- **Keyboard users** stay inside the overlay without escaping into page content behind it
53+
- **Screen reader users** get the expected dialog focus behavior — no silent focus loss
54+
- **Product teams** ship accessible overlays without writing focus management logic
55+
- **Dialtone** has one implementation to test, maintain, and improve
56+
57+
## Configuration
58+
59+
### Boolean binding — activate when truthy
60+
61+
```html
62+
<div v-dt-focustrap="isOpen" role="dialog" aria-label="Settings">
63+
```
64+
65+
### Object binding — full configuration
66+
67+
```html
68+
<div v-dt-focustrap="{ active: isOpen, initialFocus: '#name-input', restoreFocus: true }" role="dialog">
69+
```
70+
71+
### Always active
72+
73+
```html
74+
<div v-dt-focustrap role="alertdialog" aria-label="Confirm">
75+
```
76+
77+
## Options
78+
79+
| Option | Type | Default | Description |
80+
| --- | --- | --- | --- |
81+
| `active` | `boolean` | `true` | Whether the trap is active. Reactive. |
82+
| `initialFocus` | `'auto' \| string \| HTMLElement \| false` | `'auto'` | Where to place focus on activation. `'auto'` = first focusable. CSS selector = `el.querySelector()`. `false` = don't move focus. |
83+
| `restoreFocus` | `boolean` | `true` | Restore focus to the previously-focused element on deactivation. |
84+
85+
## Focustrap vs. Focusgroup
86+
87+
These directives solve different problems. Use both when a widget needs both behaviors.
88+
89+
| | `v-dt-focustrap` | `v-dt-focusgroup` |
90+
| --- | --- | --- |
91+
| **Purpose** | Prevent focus from *leaving* a container | Move focus *within* a container via arrow keys |
92+
| **Key handled** | `Tab` / `Shift+Tab` | Arrow keys, `Home`, `End` |
93+
| **Typical use** | Dialogs, modals, popovers, drawers | Toolbars, tab lists, menus, listboxes |
94+
| **Tab behavior** | Cycles Tab at container boundaries | Single Tab stop — Tab exits the group |
95+
| **Composable?** | Yes — use on the dialog container | Yes — use on a toolbar or menu inside the dialog |
96+
97+
## What It Does NOT Do
98+
99+
- **Escape key.** Not handled. Add your own `@keydown.escape` handler.
100+
- **Click outside.** Not handled. Use a click-outside directive or manual listener.
101+
- **`aria-modal` or `role`.** Not set. You must provide the appropriate ARIA semantics yourself.
102+
- **Scroll lock.** Not managed. Use CSS `overflow: hidden` on `<body>` if needed.
103+
104+
These boundaries are intentional. A focused tool that does one thing well is more trustworthy than one that tries to manage the full overlay lifecycle.
105+
106+
## Replacing a Custom Focus Trap
107+
108+
If your product feature has its own focus trap logic, you can replace it:
109+
110+
```html
111+
<!-- Before: manual focus trap -->
112+
<div ref="dialog" @keydown.tab="trapFocus" role="dialog">
113+
114+
<!-- After: directive handles everything -->
115+
<div v-dt-focustrap="isOpen" role="dialog">
116+
```
117+
118+
Remove your `@keydown.tab` handler, your `querySelectorAll` calls for Tab trapping, and your `previousActiveElement` save/restore logic. Keep your Escape and click-outside handlers — those are yours to own.
119+
120+
</BlogPost>
121+
122+
<script setup>
123+
import BlogPost from '@baseComponents/BlogPost.vue';
124+
import { parse } from 'date-fns';
125+
</script>

packages/dialtone-vue/.storybook/preview.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { DtTooltipDirective } from '@/directives/tooltip_directive';
5050
import { DtScrollbarDirective } from '@/directives/scrollbar_directive';
5151
import { DtModeDirective } from '@/directives/mode_directive';
5252
import { DtFocusgroupDirective } from '@/directives/focusgroup_directive';
53+
import { DtFocustrapDirective } from '@/directives/focustrap_directive';
5354
import { DtStack } from '@/components/stack';
5455
import { faker } from '@faker-js/faker';
5556

@@ -137,6 +138,7 @@ setup((app) => {
137138
app.use(DtScrollbarDirective);
138139
app.use(DtModeDirective);
139140
app.use(DtFocusgroupDirective);
141+
app.use(DtFocustrapDirective);
140142
app.component('DtStack', DtStack);
141143
// global seed, to make sure results are reproducible on percy and don't change on every reload too.
142144
faker.seed(6687422389464139);
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { getTabbableElements, getFirstFocusCandidate } from './focustrap_utils.js';
2+
import { FOCUSTRAP_DEFAULTS, FOCUSTRAP_STATE_KEY } from './focustrap_constants.js';
3+
4+
/**
5+
* v-dt-focustrap directive — trap Tab/Shift+Tab within a container element.
6+
*
7+
* Manages initial focus, Tab boundary wrapping, and focus restoration.
8+
* Does NOT handle Escape or click-outside — that's the component's responsibility.
9+
*
10+
* @example
11+
* // Boolean binding — activate when truthy
12+
* <div role="dialog" v-dt-focustrap="isOpen" aria-label="Settings">
13+
*
14+
* // Object binding — full configuration
15+
* <div role="dialog" v-dt-focustrap="{ active: isOpen, initialFocus: '#name-input' }">
16+
*
17+
* // Always active (no binding value)
18+
* <div role="alertdialog" v-dt-focustrap aria-label="Confirm">
19+
*
20+
* @see https://dialtone.dialpad.com/vue/next/?path=/docs/directives-focustrap--docs
21+
*/
22+
export const DtFocustrapDirective = {
23+
name: 'dt-focustrap-directive',
24+
25+
install (app) {
26+
app.directive('dt-focustrap', {
27+
mounted (el, binding) {
28+
const config = resolveConfig(binding.value);
29+
el[FOCUSTRAP_STATE_KEY] = createState();
30+
31+
if (config.active) {
32+
activate(el, config);
33+
}
34+
},
35+
36+
updated (el, binding) {
37+
const prev = resolveConfig(binding.oldValue);
38+
const next = resolveConfig(binding.value);
39+
const state = el[FOCUSTRAP_STATE_KEY];
40+
41+
if (!state) return;
42+
43+
if (!prev.active && next.active) {
44+
activate(el, next);
45+
} else if (prev.active && !next.active) {
46+
deactivate(el);
47+
}
48+
},
49+
50+
unmounted (el) {
51+
const state = el[FOCUSTRAP_STATE_KEY];
52+
if (state?.active) {
53+
deactivate(el);
54+
}
55+
cleanup(el);
56+
delete el[FOCUSTRAP_STATE_KEY];
57+
},
58+
});
59+
},
60+
};
61+
62+
// ── Config resolution ───────────────────────────────────────
63+
64+
function resolveConfig (value) {
65+
if (value == null || value === true) {
66+
return { ...FOCUSTRAP_DEFAULTS, active: true };
67+
}
68+
if (value === false) {
69+
return { ...FOCUSTRAP_DEFAULTS, active: false };
70+
}
71+
if (typeof value === 'object') {
72+
return { ...FOCUSTRAP_DEFAULTS, ...value };
73+
}
74+
return { ...FOCUSTRAP_DEFAULTS, active: Boolean(value) };
75+
}
76+
77+
// ── State management ────────────────────────────────────────
78+
79+
function createState () {
80+
return {
81+
active: false,
82+
onKeydown: null,
83+
previousActiveElement: null,
84+
restoreFocus: true,
85+
addedTabindex: false,
86+
};
87+
}
88+
89+
// ── Activate / Deactivate ───────────────────────────────────
90+
91+
function activate (el, config) {
92+
const state = el[FOCUSTRAP_STATE_KEY];
93+
if (!state || state.active) return;
94+
95+
state.active = true;
96+
state.restoreFocus = config.restoreFocus;
97+
state.previousActiveElement = document.activeElement;
98+
99+
// Bind Tab keydown handler
100+
state.onKeydown = (event) => handleKeydown(event, el);
101+
el.addEventListener('keydown', state.onKeydown);
102+
103+
// Set initial focus
104+
setInitialFocus(el, config);
105+
}
106+
107+
function deactivate (el) {
108+
const state = el[FOCUSTRAP_STATE_KEY];
109+
if (!state || !state.active) return;
110+
111+
state.active = false;
112+
cleanup(el);
113+
114+
// Restore focus using the config captured at activation time
115+
if (state.restoreFocus && state.previousActiveElement) {
116+
try {
117+
state.previousActiveElement.focus({ preventScroll: true });
118+
} catch {
119+
// Element no longer in DOM or not focusable
120+
}
121+
}
122+
state.previousActiveElement = null;
123+
}
124+
125+
function cleanup (el) {
126+
const state = el[FOCUSTRAP_STATE_KEY];
127+
if (!state) return;
128+
if (state.onKeydown) {
129+
el.removeEventListener('keydown', state.onKeydown);
130+
state.onKeydown = null;
131+
}
132+
if (state.addedTabindex) {
133+
el.removeAttribute('tabindex');
134+
state.addedTabindex = false;
135+
}
136+
}
137+
138+
// ── Initial focus ───────────────────────────────────────────
139+
140+
function resolveInitialFocusTarget (el, initialFocus) {
141+
if (initialFocus === 'auto' || initialFocus == null) {
142+
const elements = getTabbableElements(el, { includeNegativeTabIndex: true });
143+
return getFirstFocusCandidate(elements);
144+
}
145+
if (typeof initialFocus === 'string') return el.querySelector(initialFocus);
146+
if (initialFocus instanceof HTMLElement) return initialFocus;
147+
return null;
148+
}
149+
150+
function focusOrFallback (el, target) {
151+
if (target) {
152+
target.focus({ preventScroll: true });
153+
return;
154+
}
155+
if (!el.hasAttribute('tabindex')) {
156+
el.setAttribute('tabindex', '-1');
157+
const state = el[FOCUSTRAP_STATE_KEY];
158+
if (state) state.addedTabindex = true;
159+
}
160+
el.focus({ preventScroll: true });
161+
}
162+
163+
function setInitialFocus (el, config) {
164+
if (config.initialFocus === false) return;
165+
166+
// Delay to next microtask to avoid breaking transitions and unwanted scrolling
167+
Promise.resolve().then(() => {
168+
const state = el[FOCUSTRAP_STATE_KEY];
169+
if (!state?.active) return;
170+
focusOrFallback(el, resolveInitialFocusTarget(el, config.initialFocus));
171+
});
172+
}
173+
174+
// ── Tab trapping ────────────────────────────────────────────
175+
176+
function handleKeydown (event, el) {
177+
if (event.key !== 'Tab') return;
178+
179+
const elements = getTabbableElements(el);
180+
181+
if (!elements.length) {
182+
event.preventDefault();
183+
return;
184+
}
185+
186+
// Tab boundaries use DOM order (elements[0] / elements[last]),
187+
// NOT getFirstFocusCandidate() — the radio-preference logic is for
188+
// initial focus only, not for Tab wrapping.
189+
const first = elements[0];
190+
const last = elements[elements.length - 1];
191+
192+
if (event.shiftKey) {
193+
if (document.activeElement === first) {
194+
last.focus({ preventScroll: true });
195+
event.preventDefault();
196+
}
197+
} else {
198+
if (document.activeElement === last) {
199+
first.focus({ preventScroll: true });
200+
event.preventDefault();
201+
}
202+
}
203+
}
204+
205+
export default DtFocustrapDirective;

0 commit comments

Comments
 (0)