@solid-aria/focus
Version:
Primitives for dealing with focus rings and focus management.
856 lines (714 loc) • 26.7 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var interactions = require('@solid-aria/interactions');
var props = require('@solid-primitives/props');
var utils = require('@solid-primitives/utils');
var solidJs = require('solid-js');
var utils$1 = require('@solid-aria/utils');
var web = require('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$1, ref) {
const [autofocus, setAutofocus] = solidJs.createSignal(!!utils.access(props$1.autofocus));
const {
focusProps
} = interactions.createFocus(props$1);
const {
keyboardProps
} = interactions.createKeyboard(props$1);
const focusableProps = { ...props.combineProps(focusProps, keyboardProps),
get tabIndex() {
return utils.access(props$1.excludeFromTabOrder) && !utils.access(props$1.isDisabled) ? -1 : undefined;
}
};
solidJs.onMount(() => {
var _access;
autofocus() && ((_access = utils.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] = solidJs.createSignal(false);
const [isFocusVisibleState, setFocusVisibleState] = solidJs.createSignal(utils.access(props.autofocus) || interactions.isKeyboardFocusVisible());
const isFocusVisible = () => isFocused() && isFocusVisibleState();
interactions.createFocusVisibleListener(setFocusVisibleState, () => null, // hack for passing a dep that never changes
{
isTextInput: !!utils.access(props.isTextInput)
});
const {
focusProps
} = interactions.createFocus({
isDisabled: () => utils.access(props.within),
onFocusChange: setFocused
});
const {
focusWithinProps
} = interactions.createFocusWithin({
isDisabled: () => !utils.access(props.within),
onFocusWithinChange: setFocused
});
const focusRingProps = solidJs.createMemo(() => utils.access(props.within) ? focusWithinProps : focusProps);
return {
isFocused,
isFocusVisible,
focusProps: solidJs.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 (interactions.getInteractionModality() === "virtual") {
const lastFocusedElement = document.activeElement;
utils$1.runAfterTransition(() => {
// If focus did not move and the element is still in the document, focus it.
if (document.activeElement === lastFocusedElement && document.contains(element)) {
utils$1.focusWithoutScrolling(element);
}
});
} else {
utils$1.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__*/web.template(`<span data-focus-scope-start hidden></span>`, 2),
_tmpl$2 = /*#__PURE__*/web.template(`<span data-focus-scope-end hidden></span>`, 2);
const FocusContext = solidJs.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 = solidJs.useContext(FocusContext);
const resolvedChildren = solidJs.children(() => props.children);
solidJs.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);
});
solidJs.createEffect(() => {
const scopeRef = ctx.scopeRef();
const parentScope = ctx.parentScope();
scopes.set(scopeRef, parentScope);
solidJs.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 = solidJs.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$;
})(), web.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] = solidJs.createSignal([]);
const parentContext = solidJs.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 web.createComponent(FocusContext.Provider, {
value: context,
get children() {
return web.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 = solidJs.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();
}
});
};
solidJs.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));
solidJs.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));
});
});
solidJs.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 = solidJs.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);
}
}
}
};
solidJs.createEffect(() => {
const nodeToRestore = nodeToRestoreMemo();
if (!restoreFocus()) {
return;
}
if (!contain()) {
document.addEventListener("keydown", onKeyDown, true);
}
solidJs.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;
}
exports.FocusScope = FocusScope;
exports.createFocusManager = createFocusManager;
exports.createFocusRing = createFocusRing;
exports.createFocusable = createFocusable;
exports.focusSafely = focusSafely;
exports.getFocusableTreeWalker = getFocusableTreeWalker;
exports.isElementVisible = isElementVisible;
exports.useFocusManager = useFocusManager;
//# sourceMappingURL=index.js.map