-
Notifications
You must be signed in to change notification settings - Fork 83
/
focus-utils.js
276 lines (251 loc) · 8.22 KB
/
focus-utils.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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
/**
* @license
* Copyright (c) 2021 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
// We consider the keyboard to be active if the window has received a keydown
// event since the last mousedown event.
let keyboardActive = false;
// Listen for top-level keydown and mousedown events.
// Use capture phase so we detect events even if they're handled.
window.addEventListener(
'keydown',
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
'mousedown',
() => {
keyboardActive = false;
},
{ capture: true },
);
/**
* Returns the actually focused element by traversing shadow
* trees recursively to ensure it's the leaf element.
*
* @return {Element}
*/
export function getDeepActiveElement() {
let host = document.activeElement || document.body;
while (host.shadowRoot && host.shadowRoot.activeElement) {
host = host.shadowRoot.activeElement;
}
return host;
}
/**
* Returns true if the window has received a keydown
* event since the last mousedown event.
*
* @return {boolean}
*/
export function isKeyboardActive() {
return keyboardActive;
}
/**
* Returns true if the element is hidden directly with `display: none` or `visibility: hidden`,
* false otherwise.
*
* The method doesn't traverse the element's ancestors, it only checks for the CSS properties
* set directly to or inherited by the element.
*
* @param {HTMLElement} element
* @return {boolean}
*/
function isElementHiddenDirectly(element) {
// Check inline style first to save a re-flow.
const style = element.style;
if (style.visibility === 'hidden' || style.display === 'none') {
return true;
}
const computedStyle = window.getComputedStyle(element);
if (computedStyle.visibility === 'hidden' || computedStyle.display === 'none') {
return true;
}
return false;
}
/**
* Returns if element `a` has lower tab order compared to element `b`
* (both elements are assumed to be focusable and tabbable).
* Elements with tabindex = 0 have lower tab order compared to elements
* with tabindex > 0.
* If both have same tabindex, it returns false.
*
* @param {HTMLElement} a
* @param {HTMLElement} b
* @return {boolean}
*/
function hasLowerTabOrder(a, b) {
// Normalize tabIndexes
// e.g. in Firefox `<div contenteditable>` has `tabIndex = -1`
const ati = Math.max(a.tabIndex, 0);
const bti = Math.max(b.tabIndex, 0);
return ati === 0 || bti === 0 ? bti > ati : ati > bti;
}
/**
* Merge sort iterator, merges the two arrays into one, sorted by tabindex.
*
* @param {HTMLElement[]} left
* @param {HTMLElement[]} right
* @return {HTMLElement[]}
*/
function mergeSortByTabIndex(left, right) {
const result = [];
while (left.length > 0 && right.length > 0) {
if (hasLowerTabOrder(left[0], right[0])) {
result.push(right.shift());
} else {
result.push(left.shift());
}
}
return result.concat(left, right);
}
/**
* Sorts an array of elements by tabindex. Returns a new array.
*
* @param {HTMLElement[]} elements
* @return {HTMLElement[]}
*/
function sortElementsByTabIndex(elements) {
// Implement a merge sort as Array.prototype.sort does a non-stable sort
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
const len = elements.length;
if (len < 2) {
return elements;
}
const pivot = Math.ceil(len / 2);
const left = sortElementsByTabIndex(elements.slice(0, pivot));
const right = sortElementsByTabIndex(elements.slice(pivot));
return mergeSortByTabIndex(left, right);
}
/**
* Returns true if the element is hidden, false otherwise.
*
* An element is treated as hidden when any of the following conditions are met:
* - the element itself or one of its ancestors has `display: none`.
* - the element has or inherits `visibility: hidden`.
*
* @param {HTMLElement} element
* @return {boolean}
*/
export function isElementHidden(element) {
// `offsetParent` is `null` when the element itself
// or one of its ancestors is hidden with `display: none`.
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
// However `offsetParent` is also null when the element is using fixed
// positioning, so additionally check if the element takes up layout space.
if (element.offsetParent === null && element.clientWidth === 0 && element.clientHeight === 0) {
return true;
}
return isElementHiddenDirectly(element);
}
/**
* Returns true if the element is focusable, otherwise false.
*
* The list of focusable elements is taken from http://stackoverflow.com/a/1600194/4228703.
* However, there isn't a definite list, it's up to the browser.
* The only standard we have is DOM Level 2 HTML https://www.w3.org/TR/DOM-Level-2-HTML/html.html,
* according to which the only elements that have a `focus()` method are:
* - HTMLInputElement
* - HTMLSelectElement
* - HTMLTextAreaElement
* - HTMLAnchorElement
*
* This notably omits HTMLButtonElement and HTMLAreaElement.
* Referring to these tests with tabbables in different browsers
* http://allyjs.io/data-tables/focusable.html
*
* @param {HTMLElement} element
* @return {boolean}
*/
export function isElementFocusable(element) {
// The element cannot be focused if its `tabindex` attribute is set to `-1`.
if (element.matches('[tabindex="-1"]')) {
return false;
}
// Elements that cannot be focused if they have a `disabled` attribute.
if (element.matches('input, select, textarea, button, object')) {
return element.matches(':not([disabled])');
}
// Elements that can be focused even if they have a `disabled` attribute.
return element.matches('a[href], area[href], iframe, [tabindex], [contentEditable]');
}
/**
* Returns true if the element is focused, false otherwise.
*
* @param {HTMLElement} element
* @return {boolean}
*/
export function isElementFocused(element) {
return element.getRootNode().activeElement === element;
}
/**
* Returns the normalized element tabindex. If not focusable, returns -1.
* It checks for the attribute "tabindex" instead of the element property
* `tabIndex` since browsers assign different values to it.
* e.g. in Firefox `<div contenteditable>` has `tabIndex = -1`
*
* @param {HTMLElement} element
* @return {number}
*/
function normalizeTabIndex(element) {
if (!isElementFocusable(element)) {
return -1;
}
const tabIndex = element.getAttribute('tabindex') || 0;
return Number(tabIndex);
}
/**
* Searches for nodes that are tabbable and adds them to the `result` array.
* Returns if the `result` array needs to be sorted by tabindex.
*
* @param {Node} node The starting point for the search; added to `result` if tabbable.
* @param {HTMLElement[]} result
* @return {boolean}
* @private
*/
function collectFocusableNodes(node, result) {
if (node.nodeType !== Node.ELEMENT_NODE || isElementHiddenDirectly(node)) {
// Don't traverse children if the node is not an HTML element or not visible.
return false;
}
const element = /** @type {HTMLElement} */ (node);
const tabIndex = normalizeTabIndex(element);
let needsSort = tabIndex > 0;
if (tabIndex >= 0) {
result.push(element);
}
let children = [];
if (element.localName === 'slot') {
children = element.assignedNodes({ flatten: true });
} else {
// Use shadow root if possible, will check for distributed nodes.
children = (element.shadowRoot || element).children;
}
[...children].forEach((child) => {
// Ensure method is always invoked to collect focusable children.
needsSort = collectFocusableNodes(child, result) || needsSort;
});
return needsSort;
}
/**
* Returns a tab-ordered array of focusable elements for a root element.
* The resulting array will include the root element if it is focusable.
*
* The method traverses nodes in shadow DOM trees too if any.
*
* @param {HTMLElement} element
* @return {HTMLElement[]}
*/
export function getFocusableElements(element) {
const focusableElements = [];
const needsSortByTabIndex = collectFocusableNodes(element, focusableElements);
// If there is at least one element with tabindex > 0,
// we need to sort the final array by tabindex.
if (needsSortByTabIndex) {
return sortElementsByTabIndex(focusableElements);
}
return focusableElements;
}