UNPKG

@solid-aria/focus

Version:
517 lines (516 loc) 20.2 kB
/* * Copyright 2022 Solid Aria Working Group. * MIT License * * Portions of this file are based on code from react-spectrum. * Copyright 2020 Adobe. All rights reserved. * * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import { children, createContext, createEffect, createMemo, createReaction, createSignal, onCleanup, useContext } from "solid-js"; import { focusSafely } from "./focusSafely"; import { isElementVisible } from "./isElementVisible"; const FocusContext = createContext(); let activeScope = null; const scopes = new Map(); function FocusScopeContainer(props) { let startRef; let endRef; // The context always exists because `FocusScopeContainer` is only used in `FocusScope`. // eslint-disable-next-line const ctx = useContext(FocusContext); const resolvedChildren = children(() => props.children); createEffect(() => { // hacks to trigger the effect when this dependencies changes. resolvedChildren(); ctx.parentScope(); // Find all rendered nodes between the sentinels and add them to the scope. let node = startRef?.nextSibling; const nodes = []; while (node && node !== endRef) { nodes.push(node); node = node.nextSibling; } ctx.setScopeRef(nodes); }); createEffect(() => { const scopeRef = ctx.scopeRef(); const parentScope = ctx.parentScope(); scopes.set(scopeRef, parentScope); onCleanup(() => { // Restore the active scope on unmount if this scope or a descendant scope is active. // Parent effect cleanups run before children, so we need to check if the // parent scope actually still exists before restoring the active scope to it. if ((scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) && (!parentScope || scopes.has(parentScope))) { activeScope = parentScope; } scopes.delete(scopeRef); }); }); createFocusContainment(ctx.scopeRef, () => !!props.contain); createRestoreFocus(ctx.scopeRef, () => !!props.restoreFocus, () => !!props.contain); const autofocusReaction = createReaction(() => { if (!props.autofocus) { return; } activeScope = ctx.scopeRef(); // Use `requestAnimationFrame` to ensure DOM elements has been rendered // and things like browser `autofocus` has run first. requestAnimationFrame(() => { if (activeScope && !isElementInScope(document.activeElement, activeScope)) { focusFirstInScope(ctx.scopeRef()); } }); }); // Auto focus logic is done via a reaction and run only once when scopeRef changes. // This ensure scopeRef is not empty when trying to focus an element in the `FocusScope`. autofocusReaction(ctx.scopeRef); return (<> <span data-focus-scope-start hidden ref={startRef}/> {resolvedChildren()} <span data-focus-scope-end hidden ref={endRef}/> </>); } /** * A FocusScope manages focus for its descendants. It supports containing focus inside * the scope, restoring focus to the previously focused element on unmount, and auto * focusing children on mount. It also acts as a container for a programmatic focus * management interface that can be used to move focus forward and back in response * to user events. */ export function FocusScope(props) { const [scopeRef, setScopeRef] = createSignal([]); const parentContext = useContext(FocusContext); const parentScope = () => parentContext?.scopeRef() ?? null; const focusManager = createFocusManagerForScope(scopeRef); const context = { scopeRef, setScopeRef, parentScope, focusManager }; return (<FocusContext.Provider value={context}> <FocusScopeContainer {...props}/> </FocusContext.Provider>); } /** * Returns a FocusManager interface for the parent FocusScope. * A FocusManager can be used to programmatically move focus within a FocusScope, * e.g. in response to user events like keyboard navigation. */ export function useFocusManager() { const context = useContext(FocusContext); if (!context) { throw new Error("[solid-aria]: useFocusManager should be used in a <FocusScope>"); } return context.focusManager; } function createFocusManagerForScope(scopeRef) { return { focusNext(opts = {}) { const scope = scopeRef(); const { from, tabbable, wrap } = opts; const node = from || document.activeElement; const sentinel = scope[0].previousElementSibling; const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope); walker.currentNode = (isElementInScope(node, scope) ? node : sentinel); let nextNode = walker.nextNode(); if (!nextNode && wrap) { walker.currentNode = sentinel; nextNode = walker.nextNode(); } if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusPrevious(opts = {}) { const scope = scopeRef(); const { from, tabbable, wrap } = opts; const node = from || document.activeElement; const sentinel = scope[scope.length - 1].nextElementSibling; const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope); walker.currentNode = (isElementInScope(node, scope) ? node : sentinel); let previousNode = walker.previousNode(); if (!previousNode && wrap) { walker.currentNode = sentinel; previousNode = walker.previousNode(); } if (previousNode) { focusElement(previousNode, true); } return previousNode; }, focusFirst(opts = {}) { const scope = scopeRef(); const { tabbable } = opts; const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope); walker.currentNode = scope[0].previousElementSibling; const nextNode = walker.nextNode(); if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusLast(opts = {}) { const scope = scopeRef(); const { tabbable } = opts; const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope); walker.currentNode = scope[scope.length - 1].nextElementSibling; const previousNode = walker.previousNode(); if (previousNode) { focusElement(previousNode, true); } return previousNode; } }; } const focusableElements = [ "input:not([disabled]):not([type=hidden])", "select:not([disabled])", "textarea:not([disabled])", "button:not([disabled])", "a[href]", "area[href]", "summary", "iframe", "object", "embed", "audio[controls]", "video[controls]", "[contenteditable]" ]; const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(":not([hidden]),") + ",[tabindex]:not([disabled]):not([hidden])"; const tabbableElements = [...focusableElements, '[tabindex]:not([tabindex="-1"]):not([disabled])']; const TABBABLE_ELEMENT_SELECTOR = tabbableElements.join(':not([hidden]):not([tabindex="-1"]),'); function getScopeRoot(scope) { return scope[0].parentElement; } function createFocusContainment(scopeRef, contain) { let focusedNode; let raf; // Handle the Tab key to contain focus within the scope const onKeyDown = (e) => { const scope = scopeRef(); if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey || scope !== activeScope) { return; } const focusedElement = document.activeElement; if (!isElementInScope(focusedElement, scope)) { return; } const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable: true }, scope); walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()); if (!nextElement) { if (e.shiftKey) { walker.currentNode = scope[scope.length - 1].nextElementSibling; } else { walker.currentNode = scope[0].previousElementSibling; } nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()); } e.preventDefault(); if (nextElement) { focusElement(nextElement, true); } }; const onFocusIn = (e) => { const scope = scopeRef(); // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if (!activeScope || isAncestorScope(activeScope, scope)) { activeScope = scope; focusedNode = e.target; } else if (scope === activeScope && !isElementInChildScope(e.target, scope)) { // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar), // restore focus to the previously focused node or the first tabbable element in the active scope. if (focusedNode) { focusedNode.focus(); } else if (activeScope) { focusFirstInScope(activeScope); } } else if (scope === activeScope) { focusedNode = e.target; } }; const onFocusOut = (e) => { const scope = scopeRef(); // Firefox doesn't shift focus back to the Dialog properly without this raf = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (scope === activeScope && !isElementInChildScope(document.activeElement, scope)) { activeScope = scope; focusedNode = e.target; focusedNode.focus(); } }); }; createEffect(() => { const scope = scopeRef(); if (!contain()) { return; } document.addEventListener("keydown", onKeyDown, false); document.addEventListener("focusin", onFocusIn, false); scope.forEach(element => element.addEventListener("focusin", onFocusIn, false)); scope.forEach(element => element.addEventListener("focusout", onFocusOut, false)); onCleanup(() => { document.removeEventListener("keydown", onKeyDown, false); document.removeEventListener("focusin", onFocusIn, false); scope.forEach(element => element.removeEventListener("focusin", onFocusIn, false)); scope.forEach(element => element.removeEventListener("focusout", onFocusOut, false)); }); }); onCleanup(() => cancelAnimationFrame(raf)); } function isElementInAnyScope(element) { for (const scope of scopes.keys()) { if (isElementInScope(element, scope)) { return true; } } return false; } function isElementInScope(element, scope) { return scope.some(node => node.contains(element)); } function isElementInChildScope(element, scope) { // node.contains in isElementInScope covers child scopes that are also DOM children, // but does not cover child scopes in portals. for (const s of scopes.keys()) { if ((s === scope || isAncestorScope(scope, s)) && isElementInScope(element, s)) { return true; } } return false; } function isAncestorScope(ancestor, scope) { if (!scope) { return false; } const parent = scopes.get(scope); if (!parent) { return false; } if (parent === ancestor) { return true; } return isAncestorScope(ancestor, parent); } function focusElement(element, scroll = false) { if (element != null && !scroll) { try { focusSafely(element); } catch (err) { // ignore } } else if (element != null) { try { element.focus(); } catch (err) { // ignore } } } function focusFirstInScope(scope) { const sentinel = scope[0].previousElementSibling; const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable: true }, scope); walker.currentNode = sentinel; focusElement(walker.nextNode()); } function createRestoreFocus(scopeRef, restoreFocus, contain) { // create a memo to save the active element before a child with autofocus=true mounts. const nodeToRestoreMemo = createMemo(() => { return typeof document !== "undefined" ? document.activeElement : null; }); // Handle the Tab key so that tabbing out of the scope goes to the next element // after the node that had focus when the scope mounted. This is important when // using portals for overlays, so that focus goes to the expected element when // tabbing out of the overlay. const onKeyDown = (e) => { if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) { return; } const focusedElement = document.activeElement; if (!isElementInScope(focusedElement, scopeRef())) { return; } let nodeToRestore = nodeToRestoreMemo(); // Create a DOM tree walker that matches all tabbable elements const walker = getFocusableTreeWalker(document.body, { tabbable: true }); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()); if (!document.body.contains(nodeToRestore) || nodeToRestore === document.body) { nodeToRestore = null; } // If there is no next element, or it is outside the current scope, move focus to the // next element after the node to restore to instead. if ((!nextElement || !isElementInScope(nextElement, scopeRef())) && nodeToRestore) { walker.currentNode = nodeToRestore; // Skip over elements within the scope, in case the scope immediately follows the node to restore. do { nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()); } while (isElementInScope(nextElement, scopeRef())); e.preventDefault(); e.stopPropagation(); if (nextElement) { focusElement(nextElement, true); } else { // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope) // then move focus to the body. // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger) if (!isElementInAnyScope(nodeToRestore)) { focusedElement.blur(); } else { focusElement(nodeToRestore, true); } } } }; createEffect(() => { const nodeToRestore = nodeToRestoreMemo(); if (!restoreFocus()) { return; } if (!contain()) { document.addEventListener("keydown", onKeyDown, true); } onCleanup(() => { if (!contain()) { document.removeEventListener("keydown", onKeyDown, true); } if (restoreFocus() && nodeToRestore && isElementInScope(document.activeElement, scopeRef())) { requestAnimationFrame(() => { if (document.body.contains(nodeToRestore)) { focusElement(nodeToRestore); } }); } }); }); } /** * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ export function getFocusableTreeWalker(root, opts, scope) { const selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { // Skip nodes inside the starting node. if (opts?.from?.contains(node)) { return NodeFilter.FILTER_REJECT; } if (node.matches(selector) && isElementVisible(node) && (!scope || isElementInScope(node, scope))) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_SKIP; } }); if (opts?.from) { walker.currentNode = opts.from; } return walker; } /** * Creates a FocusManager object that can be used to move focus within an element. */ export function createFocusManager(ref) { return { focusNext(opts = {}) { const root = ref; const { from, tabbable, wrap } = opts; const node = from || document.activeElement; const walker = getFocusableTreeWalker(root, { tabbable }); if (node && root.contains(node)) { walker.currentNode = node; } let nextNode = walker.nextNode(); if (!nextNode && wrap) { walker.currentNode = root; nextNode = walker.nextNode(); } if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusPrevious(opts = {}) { const root = ref; const { from, tabbable, wrap } = opts; const node = from || document.activeElement; const walker = getFocusableTreeWalker(root, { tabbable }); if (node && root.contains(node)) { walker.currentNode = node; } else { const next = last(walker); if (next) { focusElement(next, true); } return next; } let previousNode = walker.previousNode(); if (!previousNode && wrap) { walker.currentNode = root; previousNode = last(walker); } if (previousNode) { focusElement(previousNode, true); } return previousNode; }, focusFirst(opts = {}) { const root = ref; const { tabbable } = opts; const walker = getFocusableTreeWalker(root, { tabbable }); const nextNode = walker.nextNode(); if (nextNode) { focusElement(nextNode, true); } return nextNode; }, focusLast(opts = {}) { const root = ref; const { tabbable } = opts; const walker = getFocusableTreeWalker(root, { tabbable }); const next = last(walker); if (next) { focusElement(next, true); } return next; } }; } function last(walker) { let next; let last; do { last = walker.lastChild(); if (last) { next = last; } } while (last); return next; }