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