UNPKG

@solid-aria/focus

Version:
845 lines (705 loc) 26.3 kB
import { createFocus, createKeyboard, isKeyboardFocusVisible, createFocusVisibleListener, createFocusWithin, getInteractionModality } from '@solid-aria/interactions'; import { combineProps } from '@solid-primitives/props'; import { access } from '@solid-primitives/utils'; import { createSignal, onMount, createMemo, mergeProps, createContext, useContext, children, createEffect, onCleanup, createReaction } from 'solid-js'; import { runAfterTransition, focusWithoutScrolling } from '@solid-aria/utils'; import { createComponent, memo, template } from 'solid-js/web'; /* * 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. */ // TODO: add all the focus provider stuff when needed /** * Make an element focusable, capable of auto focus and excludable from tab order. */ function createFocusable(props, ref) { const [autofocus, setAutofocus] = createSignal(!!access(props.autofocus)); const { focusProps } = createFocus(props); const { keyboardProps } = createKeyboard(props); const focusableProps = { ...combineProps(focusProps, keyboardProps), get tabIndex() { return access(props.excludeFromTabOrder) && !access(props.isDisabled) ? -1 : undefined; } }; onMount(() => { var _access; autofocus() && ((_access = access(ref)) === null || _access === void 0 ? void 0 : _access.focus()); setAutofocus(false); }); return { focusableProps }; } /* * 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. */ /** * Determines whether a focus ring should be shown to indicate keyboard focus. * Focus rings are visible only when the user is interacting with a keyboard, * not with a mouse, touch, or other input methods. */ function createFocusRing(props = {}) { const [isFocused, setFocused] = createSignal(false); const [isFocusVisibleState, setFocusVisibleState] = createSignal(access(props.autofocus) || isKeyboardFocusVisible()); const isFocusVisible = () => isFocused() && isFocusVisibleState(); createFocusVisibleListener(setFocusVisibleState, () => null, // hack for passing a dep that never changes { isTextInput: !!access(props.isTextInput) }); const { focusProps } = createFocus({ isDisabled: () => access(props.within), onFocusChange: setFocused }); const { focusWithinProps } = createFocusWithin({ isDisabled: () => !access(props.within), onFocusWithinChange: setFocused }); const focusRingProps = createMemo(() => access(props.within) ? focusWithinProps : focusProps); return { isFocused, isFocusVisible, focusProps: mergeProps(focusRingProps) }; } /* * 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. */ /** * A utility function that focuses an element while avoiding undesired side effects such * as page scrolling and screen reader issues with CSS transitions. */ function focusSafely(element) { // If the user is interacting with a virtual cursor, e.g. screen reader, then // wait until after any animated transitions that are currently occurring on // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. if (getInteractionModality() === "virtual") { const lastFocusedElement = document.activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. if (document.activeElement === lastFocusedElement && document.contains(element)) { focusWithoutScrolling(element); } }); } else { focusWithoutScrolling(element); } } /* * 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. */ function isStyleVisible(element) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { return false; } const { display, visibility } = element.style; let isVisible = display !== "none" && visibility !== "hidden" && visibility !== "collapse"; if (isVisible) { if (!element.ownerDocument.defaultView) { return isVisible; } const { getComputedStyle } = element.ownerDocument.defaultView; const { display: computedDisplay, visibility: computedVisibility } = getComputedStyle(element); isVisible = computedDisplay !== "none" && computedVisibility !== "hidden" && computedVisibility !== "collapse"; } return isVisible; } function isAttributeVisible(element, childElement) { return !element.hasAttribute("hidden") && (element.nodeName === "DETAILS" && childElement && childElement.nodeName !== "SUMMARY" ? element.hasAttribute("open") : true); } /** * Adapted from https://github.com/testing-library/jest-dom and * https://github.com/vuejs/vue-test-utils-next/. * Licensed under the MIT License. * @param element - Element to evaluate for display or visibility. */ function isElementVisible(element, childElement) { return element.nodeName !== "#comment" && isStyleVisible(element) && isAttributeVisible(element, childElement) && (!element.parentElement || isElementVisible(element.parentElement, element)); } const _tmpl$ = /*#__PURE__*/template(`<span data-focus-scope-start hidden></span>`, 2), _tmpl$2 = /*#__PURE__*/template(`<span data-focus-scope-end hidden></span>`, 2); 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 === null || startRef === void 0 ? void 0 : 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 [(() => { const _el$ = _tmpl$.cloneNode(true); const _ref$ = startRef; typeof _ref$ === "function" ? _ref$(_el$) : startRef = _el$; return _el$; })(), memo(resolvedChildren), (() => { const _el$2 = _tmpl$2.cloneNode(true); const _ref$2 = endRef; typeof _ref$2 === "function" ? _ref$2(_el$2) : endRef = _el$2; return _el$2; })()]; } /** * 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. */ function FocusScope(props) { const [scopeRef, setScopeRef] = createSignal([]); const parentContext = useContext(FocusContext); const parentScope = () => { var _parentContext$scopeR; return (_parentContext$scopeR = parentContext === null || parentContext === void 0 ? void 0 : parentContext.scopeRef()) !== null && _parentContext$scopeR !== void 0 ? _parentContext$scopeR : null; }; const focusManager = createFocusManagerForScope(scopeRef); const context = { scopeRef, setScopeRef, parentScope, focusManager }; return createComponent(FocusContext.Provider, { value: context, get children() { return createComponent(FocusScopeContainer, props); } }); } /** * 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. */ 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. */ function getFocusableTreeWalker(root, opts, scope) { const selector = opts !== null && opts !== void 0 && opts.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { var _opts$from; // Skip nodes inside the starting node. if (opts !== null && opts !== void 0 && (_opts$from = opts.from) !== null && _opts$from !== void 0 && _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 !== null && opts !== void 0 && opts.from) { walker.currentNode = opts.from; } return walker; } /** * Creates a FocusManager object that can be used to move focus within an element. */ 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; } export { FocusScope, createFocusManager, createFocusRing, createFocusable, focusSafely, getFocusableTreeWalker, isElementVisible, useFocusManager }; //# sourceMappingURL=index.js.map