focus-svelte
Version:
Focus lock for svelte with zero dependencies.
515 lines (514 loc) • 16.3 kB
JavaScript
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 };
}