@kobalte/core
Version:
Unstyled components and primitives for building accessible web apps and design systems with SolidJS.
218 lines (183 loc) • 6.5 kB
text/typescript
/*
* This file is based on code from react-spectrum.
* Apache License Version 2.0, Copyright 2020 Adobe.
*
* Credits to the React Spectrum team:
* https://github.com/adobe/react-spectrum/blob/810579b671791f1593108f62cdc1893de3a220e3/packages/@react-aria/overlays/src/ariaHideOutside.ts
*/
import { type MaybeAccessor, access } from "@kobalte/utils";
import { createEffect, onCleanup } from "solid-js";
import { DATA_TOP_LAYER_ATTR } from "../../dismissable-layer/layer-stack";
import { DATA_LIVE_ANNOUNCER_ATTR } from "../../live-announcer";
export interface CreateHideOutsideProps {
/** The elements that should remain visible. */
targets: MaybeAccessor<Array<Element>>;
/** Nothing will be hidden above this element. */
root?: MaybeAccessor<HTMLElement | undefined>;
/** Whether the hide outside behavior is disabled or not. */
isDisabled?: MaybeAccessor<boolean | undefined>;
}
/**
* Hides all elements in the DOM outside the given targets from screen readers
* using aria-hidden, and returns a function to revert these changes.
* In addition, changes to the DOM are watched and new elements
* outside the targets are automatically hidden.
*/
export function createHideOutside(props: CreateHideOutsideProps) {
createEffect(() => {
if (access(props.isDisabled)) {
return;
}
onCleanup(ariaHideOutside(access(props.targets), access(props.root)));
});
}
interface ObserverWrapper {
observe(): void;
disconnect(): void;
}
// Keeps a ref count of all hidden elements.
// Added to when hiding an element, and subtracted from when showing it again.
// When it reaches zero, aria-hidden is removed.
const refCountMap = new WeakMap<Element, number>();
const observerStack: Array<ObserverWrapper> = [];
/**
* Hides all elements in the DOM outside the given targets from screen readers using aria-hidden,
* and returns a function to revert these changes. In addition, changes to the DOM are watched
* and new elements outside the targets are automatically hidden.
* @param targets - The elements that should remain visible.
* @param root - Nothing will be hidden above this element.
* @returns - A function to restore all hidden elements.
*/
export function ariaHideOutside(targets: Element[], root = document.body) {
const visibleNodes = new Set<Element>(targets);
const hiddenNodes = new Set<Element>();
const walk = (root: Element) => {
// Keep live announcer and top layer elements (e.g. toasts) visible.
for (const element of root.querySelectorAll(
`[${DATA_LIVE_ANNOUNCER_ATTR}], [${DATA_TOP_LAYER_ATTR}]`,
)) {
visibleNodes.add(element);
}
const acceptNode = (node: Element) => {
// Skip this node and its children if it is one of the target nodes, or a live announcer.
// Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is
// made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row".
// For that case we want to hide the cells inside as well (https://bugs.webkit.org/show_bug.cgi?id=222623).
if (
visibleNodes.has(node) ||
(node.parentElement &&
hiddenNodes.has(node.parentElement) &&
node.parentElement.getAttribute("role") !== "row")
) {
return NodeFilter.FILTER_REJECT;
}
// Skip this node but continue to children if one of the targets is inside the node.
for (const target of visibleNodes) {
if (node.contains(target)) {
return NodeFilter.FILTER_SKIP;
}
}
return NodeFilter.FILTER_ACCEPT;
};
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode,
});
// TreeWalker does not include the root.
const acceptRoot = acceptNode(root);
if (acceptRoot === NodeFilter.FILTER_ACCEPT) {
hide(root);
}
if (acceptRoot !== NodeFilter.FILTER_REJECT) {
let node = walker.nextNode() as Element;
while (node != null) {
hide(node);
node = walker.nextNode() as Element;
}
}
};
const hide = (node: Element) => {
const refCount = refCountMap.get(node) ?? 0;
// If already aria-hidden, and the ref count is zero, then this element
// was already hidden and there's nothing for us to do.
if (node.getAttribute("aria-hidden") === "true" && refCount === 0) {
return;
}
if (refCount === 0) {
node.setAttribute("aria-hidden", "true");
}
hiddenNodes.add(node);
refCountMap.set(node, refCount + 1);
};
// If there is already a MutationObserver listening from a previous call,
// disconnect it so the new on takes over.
if (observerStack.length) {
observerStack[observerStack.length - 1].disconnect();
}
walk(root);
const observer = new MutationObserver((changes) => {
for (const change of changes) {
if (change.type !== "childList" || change.addedNodes.length === 0) {
continue;
}
// If the parent element of the added nodes is not within one of the targets,
// and not already inside a hidden node, hide all of the new children.
if (
![...visibleNodes, ...hiddenNodes].some((node) =>
node.contains(change.target),
)
) {
for (const node of change.removedNodes) {
if (node instanceof Element) {
visibleNodes.delete(node);
hiddenNodes.delete(node);
}
}
for (const node of change.addedNodes) {
if (
(node instanceof HTMLElement || node instanceof SVGElement) &&
(node.dataset.liveAnnouncer === "true" ||
node.dataset.reactAriaTopLayer === "true")
) {
visibleNodes.add(node);
} else if (node instanceof Element) {
walk(node);
}
}
}
}
});
observer.observe(root, { childList: true, subtree: true });
const observerWrapper = {
observe() {
observer.observe(root, { childList: true, subtree: true });
},
disconnect() {
observer.disconnect();
},
};
observerStack.push(observerWrapper);
return () => {
observer.disconnect();
for (const node of hiddenNodes) {
const count = refCountMap.get(node);
if (count == null) {
return;
}
if (count === 1) {
node.removeAttribute("aria-hidden");
refCountMap.delete(node);
} else {
refCountMap.set(node, count - 1);
}
}
// Remove this observer from the stack, and start the previous one.
if (observerWrapper === observerStack[observerStack.length - 1]) {
observerStack.pop();
if (observerStack.length) {
observerStack[observerStack.length - 1].observe();
}
} else {
observerStack.splice(observerStack.indexOf(observerWrapper), 1);
}
};
}