UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

165 lines (161 loc) 6.23 kB
import { useEffect, useRef } from 'preact/hooks'; import { ListenerCollection } from '../util/listener-collection'; function isElementDisabled(element) { return typeof element.disabled === 'boolean' && element.disabled; } function isElementVisible(element) { return element.offsetParent !== null; } /** * Trap focus within a modal dialog and support roving tabindex with 'Tab' and * 'Shift-Tab' keys to navigate through interactive descendants. See [1] for * reference for how keyboard navigation should work within modal dialogs. * * Note that this hook does not set initial focus: routing initial focus * appropriately is the responsibility of the consuming component. * * NB: This hook should be removed/disused once we migrate to using native * <dialog> elements. The hook duplicates some logic in `useArrowKeyNavigation`. * * @example * function MyModalDialog() { * const container = useRef(); * * // Enable tab key navigation between interactive elements in the * // modal-dialog container. * useTabKeyNavigation(container); * * return ( * <div ref={container} role="dialog" aria-modal> * <button>Bold</bold> * <button>Italic</bold> * <a href="https://example.com/help">Help</a> * </div> * ) * } * * [1] https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction * */ // By default, include standard browser focus-able, tab-sequence elements (links, buttons, // inputs). Also include the containers for ARIA interactive widgets `grid` and // `tablist`. Internal keyboard navigation for those widgets should be handled // separately: exclude `tab`-role buttons from this hook's navigation sequence. const defaultSelector = 'a,button:not([role="tab"]),input,select,textarea,[role="grid"],[role="tablist"]'; export function useTabKeyNavigation(containerRef, { enabled = true, selector = defaultSelector } = {}) { const lastFocusedItem = useRef(null); useEffect(() => { if (!enabled) { return () => {}; } if (!containerRef.current) { throw new Error('Container ref not set'); } const container = containerRef.current; const getNavigableElements = () => { const elements = Array.from(container.querySelectorAll(selector)); const filtered = elements.filter(el => isElementVisible(el) && !isElementDisabled(el)); // Include the container itself in the set of navigable elements if it // is currently focused. It will not be part of the tab sequence once it // loses focus. This allows, e.g., a modal container to be focused when // opened but not be part of the subsequent trapped tab sequence. if (document.activeElement === container) { filtered.unshift(container); } return filtered; }; /** * Update the `tabindex` attribute of navigable elements. * * Exactly one element will have `tabindex=0` and all others will have * `tabindex=1`. * @param currentIndex - Index of element in `elements` to make current. * Defaults to the current element if there is one, or the first element * otherwise. * @param setFocus - Whether to focus the current element */ const updateTabIndexes = (elements = getNavigableElements(), currentIndex = -1, setFocus = false) => { if (currentIndex < 0) { currentIndex = elements.findIndex(el => el.tabIndex === 0); if (currentIndex < 0) { currentIndex = 0; } } for (const [index, element] of elements.entries()) { element.tabIndex = index === currentIndex ? 0 : -1; if (index === currentIndex && setFocus) { lastFocusedItem.current = element; element.focus(); } } }; const onKeyDown = event => { const elements = getNavigableElements(); let currentIndex = elements.findIndex(item => item.tabIndex === 0); if ((currentIndex === -1 || elements[currentIndex] === container) && lastFocusedItem.current) { // Focus is moving back to/into the container after having left (or // active tabindex is a non-navigable element). Restore previous active // tabindex. This allows the user to exit and re-enter the widget // without losing tab-sequence position. currentIndex = elements.indexOf(lastFocusedItem.current); } let handled = false; if (event.key === 'Tab' && event.shiftKey) { if (currentIndex === 0) { currentIndex = elements.length - 1; } else { --currentIndex; } handled = true; } else if (event.key === 'Tab') { if (currentIndex === elements.length - 1) { currentIndex = 0; } else { ++currentIndex; } handled = true; } if (!handled) { return; } updateTabIndexes(elements, currentIndex, true); event.preventDefault(); event.stopPropagation(); }; const elements = getNavigableElements(); // One of the navigable elements may already have focus const focusedIndex = elements.indexOf(document.activeElement); updateTabIndexes(elements, focusedIndex); const listeners = new ListenerCollection(); // Set an element as current when it gains focus. In Safari this event // may not be received if the element immediately loses focus after it // is triggered. listeners.add(container, 'focusin', event => { const elements = getNavigableElements(); const targetIndex = elements.indexOf(event.target); if (targetIndex >= 0) { updateTabIndexes(elements, targetIndex); } }); listeners.add(container, 'keydown', onKeyDown); // Update the tab indexes of elements as they are added, removed, enabled // or disabled. const mo = new MutationObserver(() => { updateTabIndexes(); }); mo.observe(container, { subtree: true, attributes: true, attributeFilter: ['disabled'], childList: true }); return () => { listeners.removeAll(); mo.disconnect(); }; }, [containerRef, enabled, selector]); } //# sourceMappingURL=use-tab-key-navigation.js.map