Skip to content

Commit 7df21b9

Browse files
committed
focustrap directive
1 parent edd98b2 commit 7df21b9

File tree

10 files changed

+1294
-0
lines changed

10 files changed

+1294
-0
lines changed

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

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)