UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

180 lines (177 loc) 6.72 kB
import { useEffect, useRef } from 'preact/hooks'; import { ListenerCollection } from '../util/listener-collection'; import { useStableCallback } from './use-stable-callback'; function isElementDisabled(element) { return typeof element.disabled === 'boolean' && element.disabled; } function isElementVisible(element) { return element.offsetParent !== null; } function defaultSetFocus(element) { element.focus(); } /** * Enable arrow key navigation between interactive descendants of a * container element. * * In addition to moving focus between elements when arrow keys are pressed, * this also implements the "roving tabindex" pattern [1] which sets the * `tabindex` attribute of elements to control which element gets focus when the * user tabs into the container. * * See [2] for a reference of how keyboard navigation should work in web * applications and how it applies to various common widgets. * * @example * function MyToolbar() { * const container = useRef(); * * // Enable arrow key navigation between interactive elements in the * // toolbar container. * useArrowKeyNavigation(container); * * return ( * <div ref={container} role="toolbar"> * <button>Bold</bold> * <button>Italic</bold> * <a href="https://example.com/help">Help</a> * </div> * ) * } * * [1] https://www.w3.org/TR/wai-aria-practices/#kbd_roving_tabindex * [2] https://www.w3.org/TR/wai-aria-practices/#keyboard * */ export function useArrowKeyNavigation(containerRef, { autofocus = false, loop = true, horizontal = true, vertical = true, selector = 'a,button', containerVisible = true, focusElement: focusElement_ = defaultSetFocus } = {}) { // Keep track of the element that was last focused by this hook such that // navigation can be restored if focus moves outside the container and then // back to/into it. const lastFocusedItem = useRef(null); const focusElement = useStableCallback(focusElement_); useEffect(() => { if (!containerVisible) { 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 widget container to be focused when // opened but not be part of the subsequent keyboard-navigation 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, keyEvent) => { 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; focusElement(element, keyEvent); } } }; const onKeyDown = event => { const elements = getNavigableElements(); let currentIndex = elements.findIndex(item => item.tabIndex === 0); let handled = false; if (horizontal && event.key === 'ArrowLeft' || vertical && event.key === 'ArrowUp') { if (currentIndex === 0) { currentIndex = loop ? elements.length - 1 : currentIndex; } else { --currentIndex; } handled = true; } else if (horizontal && event.key === 'ArrowRight' || vertical && event.key === 'ArrowDown') { if (currentIndex === elements.length - 1) { currentIndex = loop ? 0 : currentIndex; } else { ++currentIndex; } handled = true; } else if (event.key === 'Home') { currentIndex = 0; handled = true; } else if (event.key === 'End') { currentIndex = elements.length - 1; handled = true; } if (!handled) { return; } updateTabIndexes(elements, currentIndex, true /* setFocus */, event); event.preventDefault(); event.stopPropagation(); }; const navigableElements = getNavigableElements(); // Start focus sequence with previously focused element, if any const initialIndex = lastFocusedItem.current ? navigableElements.indexOf(lastFocusedItem.current) : 0; updateTabIndexes(navigableElements, initialIndex, autofocus); 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 => { if (event.target === container && lastFocusedItem.current) { // Focus is moving back to the container after having left. Restore the // last tabindex. This allows users to exit and re-enter the widget // without resetting the navigation sequence. focusElement(lastFocusedItem.current); return; } const elements = getNavigableElements(); const targetIndex = elements.indexOf(event.target); if (targetIndex >= 0) { updateTabIndexes(elements, targetIndex, autofocus); } }); 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(); }; }, [autofocus, containerRef, focusElement, horizontal, loop, selector, vertical, containerVisible]); } //# sourceMappingURL=use-arrow-key-navigation.js.map