UNPKG

@gfellerph/focusgroup-polyfill

Version:

Partial polyfill for the focusgroup attribute (https://open-ui.org/components/focusgroup.explainer/).

178 lines (157 loc) 5.53 kB
import { getChildren, getOptions, getDirectionMap, isFocusgroupCandidate, candidateReasons, rovingFocusgroups, disableRovingTabindex, initializeRovingTabindex, setRovingTabindex, resetRovingTabindex, findNextCandidate, DIRECTION, getParentFocusgroup, } from "./src/shadow-tree-walker.js"; // A map for keeping track of observed root nodes const observedRoots = new WeakMap(); /** * Add a focusin listener to a root element to enable focusgroup behaviour on that element and * its decendants * @param {Element} root */ export default function registerFocusinListener(root) { if (!observedRoots.has(root)) { observedRoots.set(root, true); root.addEventListener("focusin", focusInHandler); } } /** * Find the active element, even in shadow roots * @param {Event} event * @returns {Element} */ export const getActiveElement = (event) => { let root = event.target.shadowRoot; let keepGoing = root != null; if (keepGoing) { // Oh boy, it's a shadow root, dig as deep as necessary to find the actual // target while (keepGoing) { if (root.activeElement.shadowRoot != null) { root = root.activeElement.shadowRoot; } else { keepGoing = false; } } // Continuous focusin events are not fired from the same shadow root, a dedicated listener has to be set for each root registerFocusinListener(root); return root.activeElement; } else { // It's the light dom, the target is the actually focused element return event.target; } }; /** * Focus in event handler * @param {FocusEvent} focusEvent */ function focusInHandler(focusEvent) { // Find the real focused element, even if it's nested in a shadow-root const activeElement = getActiveElement(focusEvent); // Check if target is a candidate const { isCandidate, reason, focusgroup } = isFocusgroupCandidate(activeElement); // If it is, start to handle keydown events if (isCandidate) { focusEvent.stopPropagation(); const options = getOptions(focusgroup); if (!options.nomemory) { initializeRovingTabindex(focusgroup); } // Check if there are parent focusgroups and disable roving tabindex on them let currentParentFocusgroup = getParentFocusgroup(focusgroup); while (currentParentFocusgroup) { disableRovingTabindex(currentParentFocusgroup); currentParentFocusgroup = getParentFocusgroup(currentParentFocusgroup); } const keydownHandler = (event) => { handleKeydown(event, activeElement, focusgroup); }; activeElement.addEventListener("keydown", keydownHandler); activeElement.addEventListener( "blur", () => activeElement.removeEventListener("keydown", keydownHandler), { once: true } ); } else if ( reason === candidateReasons.KEY_CONFLICT && rovingFocusgroups.has(focusgroup) ) { // Focus is on a key conflict field, disable roving behavior disableRovingTabindex(focusgroup); } } /** * Keydown event handler * @param {KeyboardEvent} event * @param {Element} focusTarget * @param {Element} focusGroup * @returns */ function handleKeydown(event, focusTarget, focusGroup) { // If default is prevented, disable focusgroup behavior if (event.defaultPrevented) return; const key = `${event.getModifierState("Meta") ? "Meta" : ""}${event.key}`; const options = getOptions(focusGroup); const keyMap = getDirectionMap(focusTarget, options); if (key in keyMap) { focusNode(focusTarget, focusGroup, options, keyMap[key], event); } } /** * Figure out which node to focus next * @param {Element} activeElement The currently focused element * @param {Element} activeFocusGroup The parent focusgroup of the selected element * @param {import("./src/shadow-tree-walker.js").FocusgroupOptions} options * @param {DIRECTION} direction Whether the direction is forward or not * @param {KeyboardEvent} event The event that fired */ function focusNode(activeElement, activeFocusGroup, options, direction, event) { // Switch start node if meta key is pressed to enable jumping to first/last const startNode = direction === DIRECTION.NEXT || direction === DIRECTION.PREVIOUS ? activeElement : activeFocusGroup; const forward = direction === DIRECTION.FIRST || direction === DIRECTION.NEXT; let nodeToFocus = findNextCandidate(startNode, forward); // Handle wrapping behaviour if (nodeToFocus == null && options.wrap) { const children = getChildren(activeFocusGroup); const startingNode = forward ? children[0] : children[children.length - 1]; nodeToFocus = findNextCandidate(startingNode, forward, true, false, 1); } // TODO: Check if nodeToFocus is in viewport if (nodeToFocus) { // Key event is handled by the focusgroup, prevent other default events event.preventDefault(); if (!options.nomemory) { setRovingTabindex(activeElement); resetRovingTabindex(nodeToFocus); } nodeToFocus.focus(); } } /** * Feature detection for focusgroup * @returns {boolean} */ function focusgroupSupported() { const div = document.createElement("div"); return "focusgroup" in div; } // Start polyfill // TODO: let users define the scope where they want focusgroup to be active if (window && !focusgroupSupported()) { registerFocusinListener(window); }