-
Notifications
You must be signed in to change notification settings - Fork 125
/
focus.js
142 lines (135 loc) · 5.08 KB
/
focus.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/* eslint-disable no-console */
import {
isInShadowDOM,
isWebComponent,
querySelectorWithShadowRoot,
} from './webComponents';
/** defaultFocusSelector
* Selector string for both pre-v3 and v3 va-segmented-progress-bar's H2
* Both H2s include "Step {index} of {total}: {page title}"
* NOTE: For v3 bar, pass its shadowRoot as root param to the focus methods. [See FormNav.jsx.]
*/
export const defaultFocusSelector =
// #nav-form-header is pre-v3-bar's H2
// .usa-step-indicator__heading is v3-bar's H2
// TODO: Remove pre-v3 selector, after DST defaults all components to v3 [~2024-02-17].
'#nav-form-header, .usa-step-indicator__heading';
/**
* Focus on element
* @param {String|Element} selectorOrElement - CSS selector or attached DOM
* element
* @param {FocusOptions} options - "preventScroll" or "focusVisible". See
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#parameters
* @param {Element} root - root element for querySelector; would allow focusing
* on elements inside of shadow dom
*/
export async function focusElement(selectorOrElement, options, root) {
function applyFocus(el) {
if (el) {
// Use getAttribute to grab the "tabindex" attribute (returns string), not
// the "tabIndex" property (returns number). Focusable elements will
// automatically have a tabIndex of zero, otherwise it's -1.
const tabindex = el.getAttribute('tabindex');
// No need to add, or remove a tabindex="0"
if (el.tabIndex !== 0) {
el.setAttribute('tabindex', '-1');
if (typeof tabindex === 'undefined' || tabindex === null) {
// Remove tabindex on blur. If a web-component is focused using a -1
// tabindex and is not removed on blur, the shadow elements inside will
// not be focusable
el.addEventListener(
'blur',
() => {
el.removeAttribute('tabindex');
},
{ once: true },
);
}
}
el.focus(options);
if (isInShadowDOM(el)) {
// Safari doesn't dispatch focus events on shadow-DOM elements,
// so we manually dispath it to ensure screen readers are aware.
el.dispatchEvent(new FocusEvent('focus'));
}
}
}
if (isWebComponent(root) || isWebComponent(selectorOrElement, root)) {
const elWithShadowRoot = await querySelectorWithShadowRoot(
selectorOrElement,
root,
);
applyFocus(elWithShadowRoot); // synchronous code
} else {
const el =
typeof selectorOrElement === 'string'
? (root || document).querySelector(selectorOrElement)
: selectorOrElement;
applyFocus(el); // synchronous code
}
}
/**
* Web components may not have their shadow DOM rendered right away, so we need
* to wait & check before setting focus on the selector; if not found after max
* iterations, then fall back to the default selector (step _ of _ h2)
* Discussion: https://dsva.slack.com/archives/CBU0KDSB1/p1676479946812439
* @param {String} selector - focus target selector
* @param {Element} root - starting element of the querySelector
* @param {Number} timeInterval - time in milliseconds to delay
* @param {String} internalSelector - selector pointing to an element inside the
* component we're waiting for (could be an element in shadow DOM)
* @example waitForRenderThenFocus('h3', document.querySelector('va-radio').shadowRoot);
*/
export function waitForRenderThenFocus(
selector,
root = document,
timeInterval = 250,
// added because we first need to wait for a component to be rendered, then we
// need to target an element inside the component (in regular or in a web
// component's shadow DOM)
internalSelector,
) {
const maxIterations = 6; // 1.5 seconds
let count = 0;
const interval = setInterval(() => {
const el = (root || document).querySelector(selector);
if (el) {
clearInterval(interval);
if (internalSelector) {
focusElement(internalSelector, {}, el);
} else {
focusElement(el);
}
} else if (count >= maxIterations) {
clearInterval(interval);
focusElement(defaultFocusSelector); // fallback to breadcrumbs
}
count += 1;
}, timeInterval);
}
/**
* Focus on first found element within the list; we're ignoreing DOM order, i.e.
* using focusElement('h3, h2') will always focus on the h2 (higher on the page)
* @param {String|Array} selectors - selectors in the desired order; if the
* first selector has no target, it'll move to the second, etc.
* @param {Element} root - starting element of the querySelector; may be a
* shadowRoot
* @example focusByOrder('#main h3, .nav-header > h2');
* @example focusByOrder(['#main h3', '.nav-header > h2']);
*/
export function focusByOrder(selectors, root) {
let list = selectors || '';
if (typeof selectors === 'string') {
list = selectors.split(',');
}
if (Array.isArray(list)) {
list.some(selector => {
const el = (root || document).querySelector((selector || '').trim());
if (el) {
focusElement(el, {}, root);
return true;
}
return false;
});
}
}