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:
2026-06-07 16:29:28 +07:00
parent da8d137be4
commit e6919de29e
16 changed files with 443 additions and 88 deletions
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './animationLifecycle';
export * from './focusGuard';
export * from './focusScope';
export * from './hideOthers';
-49
View File
@@ -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
View File
@@ -1,2 +1 @@
export * from './global';
// export * from './debounce';