UNPKG

focus-svelte

Version:

Focus lock for svelte with zero dependencies.

515 lines (514 loc) 16.3 kB
import { readable } from "svelte/store"; import { tick } from "svelte"; const OVERRIDE = "focusOverride"; const DATA_OVERRIDE = "data-focus-override"; class NodeState { constructor(node) { this.shownBy = new Set(); this.hiddenBy = new Set(); this.focusedBy = new Set(); this.unfocusedBy = new Set(); this.updateTabIndexOrigin(node); this.updateOverride(node); this.updateAriaHiddenOrigin(node); this.tabIndexAssigned = null; this.ariaHiddenAssignedValue = null; } tabbable() { if (this.tabIndexAssigned !== null && this.tabIndexAssigned === -1) { return false; } if (this.tabIndexAssigned !== null && this.tabIndexAssigned > -1) { return true; } return this.tabIndexOriginValue > -1; } updateAriaHiddenOrigin(node) { const value = this.parseAriaHidden(node); if (this.ariaHiddenOrigin === undefined) { this.ariaHiddenOrigin = value; return true; } if (this.ariaHiddenOrigin === value || this.ariaHiddenAssignedValue === value) { return false; } this.ariaHiddenOrigin = value; return true; } updateTabIndexOrigin(node, value) { if (value !== undefined) { if (this.tabIndexAssigned !== value && this.tabIndexOriginAssigned !== value) { if (value != null) { this.tabIndexOriginValue = value; } this.tabIndexOriginAssigned = value; return true; } return false; } const tabIndex = node.tabIndex; if (this.tabIndexOriginValue !== tabIndex && this.tabIndexAssigned !== tabIndex) { this.tabIndexOriginValue = tabIndex; this.tabIndexOriginAssigned = this.parseTabIndex(node); return true; } return false; } parseOverride(value) { if (!value) { return false; } value = value.toLowerCase(); return value === "true" || value === "focus"; } updateOverride(node, value) { value = value !== undefined ? value : node.dataset[OVERRIDE]; const val = this.parseOverride(value); if (this.override !== val) { this.override = val; return true; } return false; } operationsFor(node, assignAriaHidden) { return [this.tabIndexOp(node), this.ariaHiddenOp(node, assignAriaHidden)]; } ariaHiddenOp(node, assignAriaHidden) { if (!assignAriaHidden || this.override) { return null; } if (this.shownBy.size) { this.ariaHiddenAssignedValue = false; } else if (this.hiddenBy.size) { this.ariaHiddenAssignedValue = true; } else { if (this.ariaHiddenAssignedValue !== null) { if (this.ariaHiddenOrigin === null) { return () => { node.removeAttribute("aria-hidden"); this.ariaHiddenAssignedValue = null; }; } const ariaHiddenOrigin = this.ariaHiddenOrigin.toString(); return () => { node.ariaHidden = ariaHiddenOrigin; }; } } if (this.ariaHiddenAssignedValue !== null) { const value = this.ariaHiddenAssignedValue.toString(); return () => { node.ariaHidden = value; }; } return null; } tabIndexOp(node) { if (this.override) { return null; } if (this.focusedBy.size) { if (this.tabIndexAssigned === -1 || node.tabIndex !== -1) { this.tabIndexAssigned = 0; } else if (this.tabIndexAssigned === null || node.tabIndex === this.tabIndexAssigned) { return null; } } else if (this.unfocusedBy.size) { const parsed = this.parseTabIndex(node); if ((parsed !== null && parsed >= 0) || (this.tabIndexAssigned === null && this.tabIndexOriginValue >= 0) || this.tabIndexAssigned === 0) { this.tabIndexAssigned = -1; } else { return null; } } else { if (this.tabIndexAssigned !== null) { if (this.tabIndexOriginAssigned === null) { this.tabIndexAssigned = null; return () => { node.removeAttribute("tabindex"); }; } const value = this.tabIndexOriginAssigned; this.tabIndexOriginAssigned = null; return () => { node.tabIndex = value; }; } } if (this.tabIndexAssigned !== null && node.tabIndex !== this.tabIndexAssigned) { const { tabIndexAssigned } = this; return () => { node.tabIndex = tabIndexAssigned; }; } return null; } addTrap(key, options, node) { const { trap, focusable, assignAriaHidden } = options; if (node === trap) { if (focusable) { this.tabIndexAssigned = 0; } this.focusedBy.add(key); this.unfocusedBy.delete(key); this.shownBy.add(key); this.hiddenBy.delete(key); return this.operationsFor(node, !!assignAriaHidden); } if (trap.contains(node) || node.contains(trap)) { this.focusedBy.add(key); this.unfocusedBy.delete(key); if (assignAriaHidden) { this.shownBy.add(key); this.hiddenBy.delete(key); } return this.operationsFor(node, !!assignAriaHidden); } this.unfocusedBy.add(key); this.focusedBy.delete(key); if (assignAriaHidden) { this.hiddenBy.add(key); this.shownBy.delete(key); } return this.operationsFor(node, !!assignAriaHidden); } removeLock(key) { this.focusedBy.delete(key); this.unfocusedBy.delete(key); this.hiddenBy.delete(key); this.shownBy.delete(key); } parseTabIndex(node, value) { if (value === undefined) { if (!node.hasAttribute("tabindex")) { return null; } return node.tabIndex; } if (value == null) { value = ""; } value = value.trim(); if (value === "") { return null; } const parsed = parseInt(value); if (isNaN(parsed)) { return null; } return parsed; } parseAriaHidden(node) { const val = node.getAttribute("aria-hidden"); if (val === "true") { return true; } if (val === "false") { return false; } return null; } } const context = readable(undefined, (set) => { set(new WeakMap()); return () => { set(new WeakMap()); }; }); let observer; const mutations = readable([], function (set) { if (typeof document === "undefined") { set([]); return; } if (!observer) { observer = new MutationObserver((mutations) => { set(mutations); }); } observer.observe(document.body, { attributes: true, attributeFilter: ["tabindex", "aria-hidden", DATA_OVERRIDE], attributeOldValue: false, childList: true, subtree: true, }); return () => { observer.disconnect(); }; }); const allBodyNodes = () => document.body.querySelectorAll("*"); // eslint-disable-next-line @typescript-eslint/no-empty-function function noop() { } const exec = (op) => op && op(); export function focus(trap, opts) { const key = Object.freeze({}); let state; let enabled = false; let assignAriaHidden = false; let focusable = false; let element = undefined; let options; let unsubscribeFromMutations = undefined; let unsubscribeFromState = undefined; let previousElement = undefined; if (typeof document === "undefined") { return { update: noop, destroy: noop }; } function nodeState(node) { let ns = state.get(node); if (!ns) { ns = new NodeState(node); state.set(node, ns); } return ns; } function addTrapToNodeState(node) { if (!(node instanceof HTMLElement)) { return []; } const ns = nodeState(node); return ns.addTrap(key, options, node); } function removeTrapFromNodeState(node) { if (!(node instanceof HTMLElement)) { return []; } if (!state) { return []; } const ns = state.get(node); if (!ns) { return []; } ns.removeLock(key); return ns.operationsFor(node, assignAriaHidden); } async function createTrap(nodes) { let ops = []; nodes.forEach((node) => { ops = ops.concat(addTrapToNodeState(node)); }); await options.delay(); ops.forEach((fn) => exec(fn)); } async function destroyTrap(nodes) { let ops = []; nodes.forEach((node) => { ops = ops.concat(removeTrapFromNodeState(node)); }); if (options) { await options.delay(); } ops.forEach((fn) => exec(fn)); } async function handleAttributeChange(mutation) { const { target: node } = mutation; if (!(node instanceof HTMLElement)) { return; } const { attributeName } = mutation; if (attributeName === null) { return; } const ns = state.get(node); if (!ns) { return; } let ops = undefined; switch (attributeName) { case "tabindex": if (ns.updateTabIndexOrigin(node, node.hasAttribute("tabindex") ? node.tabIndex : null)) { ops = [ns.tabIndexOp(node)]; } break; case DATA_OVERRIDE: if (ns.updateOverride(node, node.dataset[OVERRIDE])) { ops = ns.operationsFor(node, assignAriaHidden); } break; case "aria-hidden": if (ns.updateAriaHiddenOrigin(node)) { ops = [ns.ariaHiddenOp(node, assignAriaHidden)]; } break; } if (!ops) { return; } await options.delay(); ops.forEach((op) => exec(op)); } function handleNodesAdded(mutation) { const { addedNodes } = mutation; if (addedNodes === null) { return; } createTrap(addedNodes); mutation.addedNodes.forEach((node) => { createTrap(node.childNodes); }); } function handleMutation(mutation) { if (!state) { return; } if (mutation.type === "childList" && mutation.addedNodes) { handleNodesAdded(mutation); } if (mutation.type === "attributes") { handleAttributeChange(mutation); } } const handleMutations = (mutations) => mutations.forEach(handleMutation); async function setFocus() { await options.focusDelay(); const { preventScroll } = options; if (element) { let elem = null; if (typeof element === "string") { try { elem = trap.querySelector(element); } catch (err) { elem = null; } } if (element instanceof Element) { elem = element; } if (elem && elem instanceof HTMLElement && elem.tabIndex > -1) { elem.focus({ preventScroll }); previousElement = elem; return; } } if (trap.tabIndex > -1) { trap.focus({ preventScroll }); } if (typeof document !== "undefined" && document.activeElement === trap) { previousElement = trap; return; } const nodes = trap.querySelectorAll("*"); for (let i = 0; i < nodes.length; i++) { const node = nodes.item(i); const ns = state.get(node); if (!ns) { continue; } if (ns.tabbable() && node instanceof HTMLElement) { node.focus({ preventScroll }); previousElement = node; return; } } } function blurFocus() { const current = document.activeElement; if (current instanceof HTMLElement) { const ns = state.get(current); if (ns && !ns.tabbable()) { current.blur(); } } } const subscribeToState = () => context.subscribe(($state) => { state = $state; }); function update(opts) { const previouslyEnabled = enabled; if (typeof opts === "boolean") { enabled = opts; assignAriaHidden = false; opts = {}; } else if (typeof opts == "object") { enabled = !!(opts === null || opts === void 0 ? void 0 : opts.enabled); } else { enabled = false; opts = {}; } assignAriaHidden = !!(opts === null || opts === void 0 ? void 0 : opts.assignAriaHidden); focusable = !!opts.focusable; element = opts.element; let { focusDelay, delay } = opts; const { preventScroll } = opts; if (typeof focusDelay === "number") { const ms = focusDelay; focusDelay = () => new Promise((res) => setTimeout(res, ms)); } if (typeof delay === "number") { const ms = delay; delay = () => new Promise((res) => setTimeout(res, ms)); } if (!focusDelay) { focusDelay = tick; } if (!delay) { delay = tick; } options = { assignAriaHidden, enabled, focusable, trap, element, focusDelay, delay, preventScroll, }; if (!enabled) { return destroy(); } if (!state && unsubscribeFromState) { unsubscribeFromState(); unsubscribeFromState = subscribeToState(); } if (!unsubscribeFromState) { unsubscribeFromState = subscribeToState(); } createTrap(allBodyNodes()); if (!unsubscribeFromMutations) { unsubscribeFromMutations = mutations.subscribe(handleMutations); } if (!previouslyEnabled || !previousElement || (element !== undefined && element !== previousElement)) { blurFocus(); setFocus(); } } function destroy() { if (unsubscribeFromMutations) { unsubscribeFromMutations(); unsubscribeFromMutations = undefined; } destroyTrap(allBodyNodes()); if (unsubscribeFromState) { unsubscribeFromState(); unsubscribeFromState = undefined; } if (typeof document !== "undefined") { const { activeElement } = document; if (trap === activeElement || trap.contains(activeElement)) { if (activeElement instanceof HTMLElement) { activeElement.blur(); } } } } if (opts === true || (typeof opts === "object" && (opts === null || opts === void 0 ? void 0 : opts.enabled))) { update(opts); } return { update, destroy }; }