@silk-hq/components
Version:
UI Components by Silk
187 lines (162 loc) • 6.87 kB
text/typescript
/* eslint-disable */
// @ts-nocheck
/*
* 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.
*/
// 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.
let refCountMap = new WeakMap<Element, number>();
let observerStack = [];
/**
* 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) {
let visibleNodes = new Set<Element>(targets);
let hiddenNodes = new Set<Element>();
let walk = (root: Element) => {
// Keep live announcer and top layer elements (e.g. toasts) visible.
for (let element of root.querySelectorAll(
"[data-live-announcer], [data-react-aria-top-layer]"
)) {
visibleNodes.add(element);
}
let 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) ||
// @ts-ignore
(hiddenNodes.has(node.parentElement) &&
// @ts-ignore
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 (let target of visibleNodes) {
if (node.contains(target)) {
return NodeFilter.FILTER_SKIP;
}
}
return NodeFilter.FILTER_ACCEPT;
};
let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode,
});
// TreeWalker does not include the root.
let 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;
}
}
};
let hide = (node: Element) => {
let 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) {
// @ts-ignore
observerStack[observerStack.length - 1].disconnect();
}
walk(root);
let observer = new MutationObserver((changes) => {
for (let 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 (let node of change.removedNodes) {
if (node instanceof Element) {
visibleNodes.delete(node);
hiddenNodes.delete(node);
}
}
for (let 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 });
let observerWrapper = {
observe() {
observer.observe(root, { childList: true, subtree: true });
},
disconnect() {
observer.disconnect();
},
};
// @ts-ignore
observerStack.push(observerWrapper);
return () => {
observer.disconnect();
for (let node of hiddenNodes) {
let count = refCountMap.get(node);
if (count === 1) {
node.removeAttribute("aria-hidden");
refCountMap.delete(node);
} else {
// @ts-ignore
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) {
// @ts-ignore
observerStack[observerStack.length - 1].observe();
}
} else {
// @ts-ignore
observerStack.splice(observerStack.indexOf(observerWrapper), 1);
}
};
}