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 is focusable. */
28export const isFocusTarget = (node: Element): boolean =>
29 !!node.matches(focusableSelectors) && isVisible(node);
30
31/** Returns whether this node may contain focusable elements. */
32export const hasFocusTargets = (node: Element): boolean =>
33 !node.matches(excludeSelector) &&
34 isVisible(node) &&
35 !!node.querySelector(focusableSelectors);
36
37/** Returns a sorted list of focus targets inside the given element. */
38export const getFocusTargets = (node: Element): HTMLElement[] => {
39 const elements = node.querySelectorAll(focusableSelectors);
40 const targets: HTMLElement[] = [];
41 const tabIndexTargets: [
42 index: number,
43 tabIndex: number,
44 element: HTMLElement
45 ][] = [];
46 for (let i = 0, l = elements.length; i < l; i++) {
47 const element = elements[i] as HTMLElement;
48 if (isVisible(element)) {
49 const tabIndex = getTabIndex(element);
50 if (tabIndex === 0) {
51 targets.push(element);
52 } else if (tabIndex > 0) {
53 tabIndexTargets.push([i, tabIndex, element]);
54 }
55 }
56 }
57
58 return tabIndexTargets.length
59 ? tabIndexTargets
60 .sort(sortByTabindex)
61 .map(x => x[2])
62 .concat(targets)
63 : targets;
64};
65
66/** Returns the first focus target that should be focused automatically. */
67export const getFirstFocusTarget = (node: HTMLElement): HTMLElement | null =>
68 getFocusTargets(node)[0] || null;
69
70/** Returns the first focus target that should be focused automatically in a modal/dialog. */
71export const getAutofocusTarget = (node: HTMLElement): HTMLElement => {
72 const elements = node.querySelectorAll(focusableSelectors);
73 for (let i = 0, l = elements.length; i < l; i++) {
74 const element = elements[i] as HTMLElement;
75 if (isVisible(element) && element.matches('[autofocus]')) return element;
76 }
77
78 node.setAttribute('tabindex', '-1');
79 return node;
80};
81
82/** Returns the next (optionally in reverse) focus target given a target node. */
83export const getNextFocusTarget = (
84 node: HTMLElement,
85 reverse?: boolean
86): HTMLElement | null => {
87 let current: Element | null = node;
88 while (current) {
89 let next: Element | null = current;
90 while (
91 (next = reverse ? next.previousElementSibling : next.nextElementSibling)
92 ) {
93 if (isVisible(next) && !!next.matches(focusableSelectors)) {
94 return next as HTMLElement;
95 } else if (hasFocusTargets(next)) {
96 const targets = getFocusTargets(next);
97 if (targets.length) return targets[reverse ? targets.length - 1 : 0];
98 }
99 }
100
101 current = current.parentElement;
102 }
103
104 return null;
105};
106
107/** Focuses the given node or blurs if null is passed. */
108export const focus = (node: Element | null) => {
109 if (node) {
110 (node as HTMLElement).focus();
111 } else if (document.activeElement) {
112 (document.activeElement as HTMLElement).blur();
113 }
114};