chore(platform): eslint/tsconfig migration + browser utils
Migrate to eslint flat config and composite tsconfig; browser/multi updates (hideOthers, focusScope).
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
isAnimatable,
|
||||
shouldSuspendUnmount,
|
||||
dispatchAnimationEvent,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} from '.';
|
||||
|
||||
describe('getAnimationName', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { focusGuard, createGuardAttrs } from '.';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createGuardAttrs, focusGuard } from '.';
|
||||
|
||||
describe('focusGuard', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, describe, it, expect } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getActiveElement,
|
||||
getTabbableCandidates,
|
||||
getTabbableEdges,
|
||||
focusFirst,
|
||||
focus,
|
||||
isHidden,
|
||||
isSelectableInput,
|
||||
AUTOFOCUS_ON_MOUNT,
|
||||
AUTOFOCUS_ON_UNMOUNT,
|
||||
EVENT_OPTIONS,
|
||||
focus,
|
||||
focusFirst,
|
||||
getActiveElement,
|
||||
getTabbableCandidates,
|
||||
getTabbableEdges,
|
||||
isHidden,
|
||||
isSelectableInput,
|
||||
} from '.';
|
||||
|
||||
function createContainer(html: string): HTMLElement {
|
||||
|
||||
@@ -136,6 +136,8 @@ export function findFirstVisible(elements: HTMLElement[], container: HTMLElement
|
||||
if (!isHidden(element, container))
|
||||
return element;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +152,8 @@ export function findLastVisible(elements: HTMLElement[], container: HTMLElement)
|
||||
if (!isHidden(elements[i]!, container))
|
||||
return elements[i];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hideOthers } from '.';
|
||||
|
||||
function setupTree(): { keep: HTMLElement; siblings: HTMLElement[] } {
|
||||
document.body.innerHTML = `
|
||||
<div id="a"></div>
|
||||
<div id="b"></div>
|
||||
<main id="container">
|
||||
<header id="header"></header>
|
||||
<section id="keep"><span id="inside"></span></section>
|
||||
<footer id="footer"></footer>
|
||||
</main>
|
||||
<div id="c"></div>
|
||||
`;
|
||||
const keep = document.querySelector<HTMLElement>('#keep')!;
|
||||
const siblings = [
|
||||
document.querySelector<HTMLElement>('#a')!,
|
||||
document.querySelector<HTMLElement>('#b')!,
|
||||
document.querySelector<HTMLElement>('#c')!,
|
||||
document.querySelector<HTMLElement>('#header')!,
|
||||
document.querySelector<HTMLElement>('#footer')!,
|
||||
];
|
||||
return { keep, siblings };
|
||||
}
|
||||
|
||||
describe('hideOthers', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('hides siblings and ancestor-siblings of the target', () => {
|
||||
const { keep, siblings } = setupTree();
|
||||
|
||||
hideOthers(keep);
|
||||
|
||||
for (const el of siblings) expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(keep.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(document.querySelector('#inside')!.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(document.querySelector('#container')!.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks hidden nodes with the default marker', () => {
|
||||
const { keep } = setupTree();
|
||||
|
||||
hideOthers(keep);
|
||||
|
||||
expect(document.querySelector('#a')!.getAttribute('data-aria-hidden')).toBe('true');
|
||||
expect(keep.getAttribute('data-aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('undo restores the DOM to its original state', () => {
|
||||
const { keep, siblings } = setupTree();
|
||||
|
||||
const undo = hideOthers(keep);
|
||||
undo();
|
||||
|
||||
for (const el of siblings) {
|
||||
expect(el.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(el.getAttribute('data-aria-hidden')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ref-counts stacked calls so partial undo keeps outer layer hidden', () => {
|
||||
const { keep, siblings } = setupTree();
|
||||
|
||||
const undoOuter = hideOthers(keep);
|
||||
const undoInner = hideOthers(keep);
|
||||
|
||||
undoInner();
|
||||
|
||||
for (const el of siblings) expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
undoOuter();
|
||||
|
||||
for (const el of siblings) expect(el.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves pre-existing aria-hidden attributes after undo', () => {
|
||||
const { keep } = setupTree();
|
||||
const a = document.querySelector<HTMLElement>('#a')!;
|
||||
a.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const undo = hideOthers(keep);
|
||||
expect(a.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
undo();
|
||||
expect(a.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(a.getAttribute('data-aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not hide [aria-live] regions or <script> elements', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="keep"></div>
|
||||
<div id="sibling"></div>
|
||||
<div id="live" aria-live="polite"></div>
|
||||
<script id="script"></script>
|
||||
`;
|
||||
const keep = document.querySelector<HTMLElement>('#keep')!;
|
||||
|
||||
hideOthers(keep);
|
||||
|
||||
expect(document.querySelector('#sibling')!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(document.querySelector('#live')!.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(document.querySelector('#script')!.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an array of targets', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="a"></div>
|
||||
<div id="b"></div>
|
||||
<div id="c"></div>
|
||||
`;
|
||||
const a = document.querySelector<HTMLElement>('#a')!;
|
||||
const b = document.querySelector<HTMLElement>('#b')!;
|
||||
const c = document.querySelector<HTMLElement>('#c')!;
|
||||
|
||||
hideOthers([a, b]);
|
||||
|
||||
expect(a.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(b.getAttribute('aria-hidden')).toBeNull();
|
||||
expect(c.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('respects a custom parentNode scope', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="outside"></div>
|
||||
<section id="root">
|
||||
<div id="keep"></div>
|
||||
<div id="sibling"></div>
|
||||
</section>
|
||||
`;
|
||||
const root = document.querySelector<HTMLElement>('#root')!;
|
||||
const keep = document.querySelector<HTMLElement>('#keep')!;
|
||||
|
||||
hideOthers(keep, root);
|
||||
|
||||
expect(document.querySelector('#sibling')!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(document.querySelector('#outside')!.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('respects a custom marker name', () => {
|
||||
const { keep } = setupTree();
|
||||
|
||||
const undo = hideOthers(keep, undefined, 'data-custom-marker');
|
||||
|
||||
const a = document.querySelector<HTMLElement>('#a')!;
|
||||
expect(a.getAttribute('data-custom-marker')).toBe('true');
|
||||
expect(a.getAttribute('data-aria-hidden')).toBeNull();
|
||||
|
||||
undo();
|
||||
expect(a.getAttribute('data-custom-marker')).toBeNull();
|
||||
});
|
||||
|
||||
it('walks up ShadowDOM hosts to find targets inside closed subtrees', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="host"></div>
|
||||
<div id="sibling"></div>
|
||||
`;
|
||||
const host = document.querySelector<HTMLElement>('#host')!;
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const inner = document.createElement('div');
|
||||
shadow.appendChild(inner);
|
||||
|
||||
hideOthers(inner);
|
||||
|
||||
expect(document.querySelector('#sibling')!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(host.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a no-op when parent is unavailable', () => {
|
||||
const orphan = document.createElement('div');
|
||||
const bodyChild = document.createElement('div');
|
||||
document.body.appendChild(bodyChild);
|
||||
|
||||
// Orphan has no ownerDocument.body? It does — jsdom. Simulate by pointing
|
||||
// to a parentNode the target isn't contained in.
|
||||
const undo = hideOthers(orphan, bodyChild);
|
||||
expect(() => undo()).not.toThrow();
|
||||
expect(bodyChild.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('logs and continues when setAttribute throws on a node', () => {
|
||||
const { keep } = setupTree();
|
||||
const a = document.querySelector<HTMLElement>('#a')!;
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const original = a.setAttribute;
|
||||
a.setAttribute = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
|
||||
expect(() => hideOthers(keep)).not.toThrow();
|
||||
expect(error).toHaveBeenCalled();
|
||||
|
||||
a.setAttribute = original;
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
type Undo = VoidFunction;
|
||||
|
||||
const CONTROL_ATTR = 'aria-hidden';
|
||||
const DEFAULT_MARKER = 'data-aria-hidden';
|
||||
/**
|
||||
* `aria-live` regions and scripts stay readable — see theKashey/aria-hidden#10.
|
||||
*/
|
||||
const PRESERVE_SELECTOR = '[aria-live], script';
|
||||
|
||||
/**
|
||||
* Ref-counted global state. Shapes are intentionally monomorphic and only
|
||||
* mutated via `.set/.get/.delete` so V8 keeps fast-property access on the
|
||||
* backing `WeakMap`s.
|
||||
*/
|
||||
let counterMap = new WeakMap<Element, number>();
|
||||
let uncontrolledNodes = new WeakMap<Element, boolean>();
|
||||
let markerRegistry = new Map<string, WeakMap<Element, number>>();
|
||||
let lockCount = 0;
|
||||
|
||||
function unwrapHost(node: Node | null): Element | null {
|
||||
let cursor: Node | null = node;
|
||||
while (cursor) {
|
||||
const host = (cursor as unknown as { host?: Element }).host;
|
||||
if (host) return host;
|
||||
cursor = cursor.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects each target into `parent`: if a target sits outside (e.g. inside a
|
||||
* shadow root), substitute its nearest ShadowDOM host.
|
||||
*/
|
||||
function normalizeTargets(parent: Element, targets: readonly Element[]): Element[] {
|
||||
const out: Element[] = [];
|
||||
for (let i = 0, len = targets.length; i < len; i++) {
|
||||
const target = targets[i]!;
|
||||
if (parent.contains(target)) {
|
||||
out.push(target);
|
||||
continue;
|
||||
}
|
||||
const host = unwrapHost(target);
|
||||
if (host && parent.contains(host)) out.push(host);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function markAncestors(targets: readonly Element[], keep: Set<Node>): void {
|
||||
for (let i = 0, len = targets.length; i < len; i++) {
|
||||
let cursor: Node | null = targets[i]!;
|
||||
while (cursor && !keep.has(cursor)) {
|
||||
keep.add(cursor);
|
||||
cursor = cursor.parentNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateMarker(name: string): WeakMap<Element, number> {
|
||||
let marker = markerRegistry.get(name);
|
||||
if (!marker) {
|
||||
marker = new WeakMap<Element, number>();
|
||||
markerRegistry.set(name, marker);
|
||||
}
|
||||
return marker;
|
||||
}
|
||||
|
||||
function hideNode(node: Element, marker: WeakMap<Element, number>, markerName: string): void {
|
||||
try {
|
||||
const attr = node.getAttribute(CONTROL_ATTR);
|
||||
const alreadyHidden = attr !== null && attr !== 'false';
|
||||
const counterValue = (counterMap.get(node) || 0) + 1;
|
||||
const markerValue = (marker.get(node) || 0) + 1;
|
||||
|
||||
counterMap.set(node, counterValue);
|
||||
marker.set(node, markerValue);
|
||||
|
||||
if (counterValue === 1 && alreadyHidden) uncontrolledNodes.set(node, true);
|
||||
if (markerValue === 1) node.setAttribute(markerName, 'true');
|
||||
if (!alreadyHidden) node.setAttribute(CONTROL_ATTR, 'true');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('hideOthers: cannot operate on', node, error);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreNode(node: Element, marker: WeakMap<Element, number>, markerName: string): void {
|
||||
const counterValue = (counterMap.get(node) || 0) - 1;
|
||||
const markerValue = (marker.get(node) || 0) - 1;
|
||||
|
||||
counterMap.set(node, counterValue);
|
||||
marker.set(node, markerValue);
|
||||
|
||||
if (counterValue === 0) {
|
||||
if (!uncontrolledNodes.has(node)) node.removeAttribute(CONTROL_ATTR);
|
||||
uncontrolledNodes.delete(node);
|
||||
}
|
||||
if (markerValue === 0) node.removeAttribute(markerName);
|
||||
}
|
||||
|
||||
function applyAttributeToOthers(originalTargets: Element[], parentNode: Element, markerName: string): Undo {
|
||||
const targets = normalizeTargets(parentNode, originalTargets);
|
||||
const marker = getOrCreateMarker(markerName);
|
||||
|
||||
// PACKED_ELEMENTS from cradle to grave: only `Element` is ever pushed.
|
||||
const hiddenNodes: Element[] = [];
|
||||
const toKeep = new Set<Node>();
|
||||
const toStop = new Set<Element>();
|
||||
for (let i = 0, len = targets.length; i < len; i++) toStop.add(targets[i]!);
|
||||
|
||||
markAncestors(targets, toKeep);
|
||||
|
||||
// Iterative DFS over live `HTMLCollection`s — no closure per descent, no
|
||||
// `Array.from` clone.
|
||||
const stack: Element[] = [parentNode];
|
||||
while (stack.length > 0) {
|
||||
const parent = stack.pop()!;
|
||||
if (toStop.has(parent)) continue;
|
||||
|
||||
const children = parent.children;
|
||||
for (let i = 0, len = children.length; i < len; i++) {
|
||||
const node = children[i]!;
|
||||
if (toKeep.has(node)) {
|
||||
stack.push(node);
|
||||
}
|
||||
else {
|
||||
hideNode(node, marker, markerName);
|
||||
hiddenNodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
lockCount++;
|
||||
|
||||
return () => {
|
||||
for (let i = 0, len = hiddenNodes.length; i < len; i++) {
|
||||
restoreNode(hiddenNodes[i]!, marker, markerName);
|
||||
}
|
||||
lockCount--;
|
||||
if (lockCount === 0) {
|
||||
counterMap = new WeakMap();
|
||||
uncontrolledNodes = new WeakMap();
|
||||
markerRegistry = new Map();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @name hideOthers
|
||||
* @category Browsers
|
||||
* @description Marks every sibling of `target` (within `parentNode`, defaulting
|
||||
* to `document.body`) as `aria-hidden="true"` so assistive technologies skip
|
||||
* them. `aria-live` regions and `<script>` elements are preserved. Returns an
|
||||
* undo function that restores the previous state; calls stack (ref-counted)
|
||||
* across multiple layers.
|
||||
*
|
||||
* Port of the `aria-hidden` npm package, kept dependency-free.
|
||||
*
|
||||
* @param {Element | Element[]} target - Element(s) to keep visible to AT
|
||||
* @param {Element} [parentNode] - Root to scan; defaults to `document.body`
|
||||
* @param {string} [markerName] - Data attribute used to ref-count our mutations
|
||||
* @returns {Undo} Function that reverts the aria-hidden mutations
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function hideOthers(target: Element | Element[], parentNode?: Element, markerName = DEFAULT_MARKER): Undo {
|
||||
// `typeof` avoids a ReferenceError in SSR environments where `document` is
|
||||
// not defined as a global.
|
||||
if (typeof document === 'undefined') return noop;
|
||||
|
||||
// Copy the input into our own packed `Element[]` so user mutations don't
|
||||
// affect us and we never call into the iterator protocol later.
|
||||
const targets: Element[] = [];
|
||||
if (Array.isArray(target)) {
|
||||
for (let i = 0, len = target.length; i < len; i++)
|
||||
targets.push(target[i]!);
|
||||
}
|
||||
else {
|
||||
targets.push(target);
|
||||
}
|
||||
|
||||
const activeParent = parentNode ?? targets[0]?.ownerDocument.body;
|
||||
if (!activeParent) return noop;
|
||||
|
||||
const preserved = activeParent.querySelectorAll(PRESERVE_SELECTOR);
|
||||
for (let i = 0, len = preserved.length; i < len; i++) targets.push(preserved[i]!);
|
||||
|
||||
return applyAttributeToOthers(targets, activeParent, markerName);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
export * from './hideOthers';
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// eslint-disable
|
||||
|
||||
export interface DebounceOptions {
|
||||
/**
|
||||
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge
|
||||
*/
|
||||
readonly immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Call the function on the trailing edge with the last used arguments.
|
||||
* Result of call is from previous call
|
||||
*/
|
||||
readonly trailing?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DEBOUNCE_OPTIONS: DebounceOptions = {
|
||||
trailing: true,
|
||||
}
|
||||
|
||||
export function debounce<FnArguments extends unknown[], FnReturn>(
|
||||
fn: (...args: FnArguments) => PromiseLike<FnReturn> | FnReturn,
|
||||
timeout: number = 20,
|
||||
options: DebounceOptions = {},
|
||||
) {
|
||||
options = {
|
||||
...DEFAULT_DEBOUNCE_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (!Number.isFinite(timeout) || timeout <= 0)
|
||||
throw new TypeError('Debounce timeout must be a positive number');
|
||||
|
||||
// Last result for leading edge
|
||||
let leadingValue: PromiseLike<FnReturn> | FnReturn;
|
||||
|
||||
// Debounce timeout id
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// Promises to be resolved when debounce is finished
|
||||
let resolveList: Array<(value: unknown) => void> = [];
|
||||
|
||||
// State of currently resolving promise
|
||||
let currentResolve: Promise<FnReturn>;
|
||||
|
||||
// Trailing call information
|
||||
let trailingArgs: unknown[];
|
||||
|
||||
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from './global';
|
||||
// export * from './debounce';
|
||||
|
||||
Reference in New Issue
Block a user