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