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};