UNPKG

@kobalte/core

Version:

Unstyled components and primitives for building accessible web apps and design systems with SolidJS.

234 lines (231 loc) 6.92 kB
import { DATA_TOP_LAYER_ATTR } from "./3NI6FTA2.jsx"; // src/primitives/create-focus-scope/create-focus-scope.tsx import { access, contains, focusWithoutScrolling, getActiveElement, getAllTabbableIn, getDocument, isFocusable, removeItemFromArray, visuallyHiddenStyles } from "@kobalte/utils"; import { createEffect, createSignal, onCleanup } from "solid-js"; import { isServer } from "solid-js/web"; var AUTOFOCUS_ON_MOUNT_EVENT = "focusScope.autoFocusOnMount"; var AUTOFOCUS_ON_UNMOUNT_EVENT = "focusScope.autoFocusOnUnmount"; var EVENT_OPTIONS = { bubbles: false, cancelable: true }; var focusScopeStack = { /** A stack of focus scopes, with the active one at the top */ stack: [], active() { return this.stack[0]; }, add(scope) { if (scope !== this.active()) { this.active()?.pause(); } this.stack = removeItemFromArray(this.stack, scope); this.stack.unshift(scope); }, remove(scope) { this.stack = removeItemFromArray(this.stack, scope); this.active()?.resume(); } }; function createFocusScope(props, ref) { const [isPaused, setIsPaused] = createSignal(false); const focusScope = { pause() { setIsPaused(true); }, resume() { setIsPaused(false); } }; let lastFocusedElement = null; const onMountAutoFocus = (e) => props.onMountAutoFocus?.(e); const onUnmountAutoFocus = (e) => props.onUnmountAutoFocus?.(e); const ownerDocument = () => getDocument(ref()); const createSentinel = () => { const element = ownerDocument().createElement("span"); element.setAttribute("data-focus-trap", ""); element.tabIndex = 0; Object.assign(element.style, visuallyHiddenStyles); return element; }; const tabbables = () => { const container = ref(); if (!container) { return []; } return getAllTabbableIn(container, true).filter( (el) => !el.hasAttribute("data-focus-trap") ); }; const firstTabbable = () => { const items = tabbables(); return items.length > 0 ? items[0] : null; }; const lastTabbable = () => { const items = tabbables(); return items.length > 0 ? items[items.length - 1] : null; }; const shouldPreventUnmountAutoFocus = () => { const container = ref(); if (!container) { return false; } const activeElement = getActiveElement(container); if (!activeElement) { return false; } if (contains(container, activeElement)) { return false; } return isFocusable(activeElement); }; createEffect(() => { if (isServer) { return; } const container = ref(); if (!container) { return; } focusScopeStack.add(focusScope); const previouslyFocusedElement = getActiveElement( container ); const hasFocusedCandidate = contains(container, previouslyFocusedElement); if (!hasFocusedCandidate) { const mountEvent = new CustomEvent( AUTOFOCUS_ON_MOUNT_EVENT, EVENT_OPTIONS ); container.addEventListener(AUTOFOCUS_ON_MOUNT_EVENT, onMountAutoFocus); container.dispatchEvent(mountEvent); if (!mountEvent.defaultPrevented) { setTimeout(() => { focusWithoutScrolling(firstTabbable()); if (getActiveElement(container) === previouslyFocusedElement) { focusWithoutScrolling(container); } }, 0); } } onCleanup(() => { container.removeEventListener(AUTOFOCUS_ON_MOUNT_EVENT, onMountAutoFocus); setTimeout(() => { const unmountEvent = new CustomEvent( AUTOFOCUS_ON_UNMOUNT_EVENT, EVENT_OPTIONS ); if (shouldPreventUnmountAutoFocus()) { unmountEvent.preventDefault(); } container.addEventListener( AUTOFOCUS_ON_UNMOUNT_EVENT, onUnmountAutoFocus ); container.dispatchEvent(unmountEvent); if (!unmountEvent.defaultPrevented) { focusWithoutScrolling( previouslyFocusedElement ?? ownerDocument().body ); } container.removeEventListener( AUTOFOCUS_ON_UNMOUNT_EVENT, onUnmountAutoFocus ); focusScopeStack.remove(focusScope); }, 0); }); }); createEffect(() => { if (isServer) { return; } const container = ref(); if (!container || !access(props.trapFocus) || isPaused()) { return; } const onFocusIn = (event) => { const target = event.target; if (target?.closest(`[${DATA_TOP_LAYER_ATTR}]`)) { return; } if (contains(container, target)) { lastFocusedElement = target; } else { focusWithoutScrolling(lastFocusedElement); } }; const onFocusOut = (event) => { const relatedTarget = event.relatedTarget; const target = relatedTarget ?? getActiveElement(container); if (target?.closest(`[${DATA_TOP_LAYER_ATTR}]`)) { return; } if (!contains(container, target)) { focusWithoutScrolling(lastFocusedElement); } }; ownerDocument().addEventListener("focusin", onFocusIn); ownerDocument().addEventListener("focusout", onFocusOut); onCleanup(() => { ownerDocument().removeEventListener("focusin", onFocusIn); ownerDocument().removeEventListener("focusout", onFocusOut); }); }); createEffect(() => { if (isServer) { return; } const container = ref(); if (!container || !access(props.trapFocus) || isPaused()) { return; } const startSentinel = createSentinel(); container.insertAdjacentElement("afterbegin", startSentinel); const endSentinel = createSentinel(); container.insertAdjacentElement("beforeend", endSentinel); function onFocus(event) { const first = firstTabbable(); const last = lastTabbable(); if (event.relatedTarget === first) { focusWithoutScrolling(last); } else { focusWithoutScrolling(first); } } startSentinel.addEventListener("focusin", onFocus); endSentinel.addEventListener("focusin", onFocus); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.previousSibling === endSentinel) { endSentinel.remove(); container.insertAdjacentElement("beforeend", endSentinel); } if (mutation.nextSibling === startSentinel) { startSentinel.remove(); container.insertAdjacentElement("afterbegin", startSentinel); } } }); observer.observe(container, { childList: true, subtree: false }); onCleanup(() => { startSentinel.removeEventListener("focusin", onFocus); endSentinel.removeEventListener("focusin", onFocus); startSentinel.remove(); endSentinel.remove(); observer.disconnect(); }); }); } export { createFocusScope };