Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { getTabIndex, isVisible } from './element';
2
3const excludeSelector =
4 ':not([tabindex^="-"]):not([aria-modal]):not([role="dialog"])';
5
6const focusableSelectors = [
7 'input:not([type="hidden"]):not([disabled])' + excludeSelector,
8 'select:not([disabled])' + excludeSelector,
9 'textarea:not([disabled])' + excludeSelector,
10 'button:not([disabled])' + excludeSelector,
11 'iframe' + excludeSelector,
12 'a[href]' + excludeSelector,
13 'audio[controls]' + excludeSelector,
14 'video[controls]' + excludeSelector,
15 '[contenteditable]' + excludeSelector,
16 '[tabindex]' + excludeSelector,
17].join(',');
18
19/** Generic sorting function for tupel containing elements with indices and tab indices. */
20const sortByTabindex = <T extends HTMLElement>(
21 a: [number, number, T],
22 b: [number, number, T]
23) => {
24 return a[1] === a[1] ? a[0] - b[0] : a[1] - a[1];
25};
26
27/** Returns whether this node may contain focusable elements. */
28export const hasFocusTargets = (node: Element): boolean =>
29 !node.matches(excludeSelector) &&
30 isVisible(node) &&
31 !!node.querySelector(focusableSelectors);
32
33/** Returns a sorted list of focus targets inside the given element. */
34export const getFocusTargets = (node: Element): HTMLElement[] => {
35 const elements = node.querySelectorAll(focusableSelectors);
36 const targets: HTMLElement[] = [];
37 const tabIndexTargets: [
38 index: number,
39 tabIndex: number,
40 element: HTMLElement
41 ][] = [];
42 for (let i = 0, l = elements.length; i < l; i++) {
43 const element = elements[i] as HTMLElement;
44 if (isVisible(element)) {
45 const tabIndex = getTabIndex(element);
46 if (tabIndex === 0) {
47 targets.push(element);
48 } else if (tabIndex > 0) {
49 tabIndexTargets.push([i, tabIndex, element]);
50 }
51 }
52 }
53
54 return tabIndexTargets.length
55 ? tabIndexTargets
56 .sort(sortByTabindex)
57 .map(x => x[2])
58 .concat(targets)
59 : targets;
60};
61
62/** Returns the first focus target that should be focused automatically. */
63export const getFirstFocusTarget = (node: HTMLElement): HTMLElement | null =>
64 getFocusTargets(node)[0] || null;
65
66/** Returns the first focus target that should be focused automatically in a modal/dialog. */
67export const getAutofocusTarget = (node: HTMLElement): HTMLElement => {
68 const elements = node.querySelectorAll(focusableSelectors);
69 for (let i = 0, l = elements.length; i < l; i++) {
70 const element = elements[i] as HTMLElement;
71 if (isVisible(element) && element.matches('[autofocus]')) return element;
72 }
73
74 node.setAttribute('tabindex', '-1');
75 return node;
76};
77
78/** Returns the next (optionally in reverse) focus target given a target node. */
79export const getNextFocusTarget = (
80 node: HTMLElement,
81 reverse?: boolean
82): HTMLElement | null => {
83 let current: Element | null = node;
84 while (current) {
85 let next: Element | null = current;
86 while (
87 (next = reverse ? next.previousElementSibling : next.nextElementSibling)
88 ) {
89 if (isVisible(next) && !!next.matches(focusableSelectors)) {
90 return next as HTMLElement;
91 } else if (hasFocusTargets(next)) {
92 const targets = getFocusTargets(next);
93 if (targets.length) return targets[reverse ? targets.length - 1 : 0];
94 }
95 }
96
97 current = current.parentElement;
98 }
99
100 return null;
101};