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 const targets = getFocusTargets(node);
65 return targets.find(x => x.matches('[autofocus]')) || targets[0] || null;
66};
67
68/** Returns the next (optionally in reverse) focus target given a target node. */
69export const getNextFocusTarget = (
70 node: HTMLElement,
71 reverse?: boolean
72): HTMLElement | null => {
73 let current: Element | null = node;
74 while (current) {
75 let next: Element | null = current;
76 while (
77 (next = reverse ? next.previousElementSibling : next.nextElementSibling)
78 ) {
79 if (isVisible(next) && !!next.matches(focusableSelectors)) {
80 return next as HTMLElement;
81 } else if (hasFocusTargets(next)) {
82 const targets = getFocusTargets(next);
83 if (targets.length) return targets[reverse ? targets.length - 1 : 0];
84 }
85 }
86
87 current = current.parentElement;
88 }
89
90 return null;
91};