diff --git a/core/platform/eslint.config.ts b/core/platform/eslint.config.ts
new file mode 100644
index 0000000..f458d0d
--- /dev/null
+++ b/core/platform/eslint.config.ts
@@ -0,0 +1,9 @@
+import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
+
+export default compose(base, typescript, imports, stylistic, {
+ name: 'platform/overrides',
+ files: ['src/multi/global/index.ts'],
+ rules: {
+ 'unicorn/prefer-global-this': 'off',
+ },
+});
diff --git a/core/platform/oxlint.config.ts b/core/platform/oxlint.config.ts
deleted file mode 100644
index b295e66..0000000
--- a/core/platform/oxlint.config.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { defineConfig } from 'oxlint';
-import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
-
-export default defineConfig(
- compose(base, typescript, imports, stylistic, {
- overrides: [
- {
- files: ['src/multi/global/index.ts'],
- rules: {
- 'unicorn/prefer-global-this': 'off',
- },
- },
- ],
- }),
-);
diff --git a/core/platform/package.json b/core/platform/package.json
index e78a9cf..bc65656 100644
--- a/core/platform/package.json
+++ b/core/platform/package.json
@@ -49,18 +49,18 @@
}
},
"scripts": {
- "lint:check": "oxlint -c oxlint.config.ts",
- "lint:fix": "oxlint -c oxlint.config.ts --fix",
+ "lint:check": "eslint .",
+ "lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
- "@robonen/oxlint": "workspace:*",
+ "@robonen/eslint": "workspace:*",
+ "@robonen/stdlib": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
- "@stylistic/eslint-plugin": "catalog:",
- "oxlint": "catalog:",
+ "eslint": "catalog:",
"tsdown": "catalog:"
}
}
diff --git a/core/platform/src/browsers/animationLifecycle/index.test.ts b/core/platform/src/browsers/animationLifecycle/index.test.ts
index ae6df45..f697335 100644
--- a/core/platform/src/browsers/animationLifecycle/index.test.ts
+++ b/core/platform/src/browsers/animationLifecycle/index.test.ts
@@ -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', () => {
diff --git a/core/platform/src/browsers/focusGuard/index.test.ts b/core/platform/src/browsers/focusGuard/index.test.ts
index 6fc80bf..ffe3995 100644
--- a/core/platform/src/browsers/focusGuard/index.test.ts
+++ b/core/platform/src/browsers/focusGuard/index.test.ts
@@ -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(() => {
diff --git a/core/platform/src/browsers/focusScope/index.test.ts b/core/platform/src/browsers/focusScope/index.test.ts
index 4c95e22..d9acad6 100644
--- a/core/platform/src/browsers/focusScope/index.test.ts
+++ b/core/platform/src/browsers/focusScope/index.test.ts
@@ -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 {
diff --git a/core/platform/src/browsers/focusScope/index.ts b/core/platform/src/browsers/focusScope/index.ts
index b1f00b9..4662ee4 100644
--- a/core/platform/src/browsers/focusScope/index.ts
+++ b/core/platform/src/browsers/focusScope/index.ts
@@ -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;
}
/**
diff --git a/core/platform/src/browsers/hideOthers/index.test.ts b/core/platform/src/browsers/hideOthers/index.test.ts
new file mode 100644
index 0000000..db33355
--- /dev/null
+++ b/core/platform/src/browsers/hideOthers/index.test.ts
@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { hideOthers } from '.';
+
+function setupTree(): { keep: HTMLElement; siblings: HTMLElement[] } {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ const keep = document.querySelector('#keep')!;
+ const siblings = [
+ document.querySelector('#a')!,
+ document.querySelector('#b')!,
+ document.querySelector('#c')!,
+ document.querySelector('#header')!,
+ document.querySelector('#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('#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
+ `;
+ const keep = document.querySelector('#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 = `
+
+
+
+ `;
+ const a = document.querySelector('#a')!;
+ const b = document.querySelector('#b')!;
+ const c = document.querySelector('#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 = `
+
+
+ `;
+ const root = document.querySelector('#root')!;
+ const keep = document.querySelector('#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('#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 = `
+
+
+ `;
+ const host = document.querySelector('#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('#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();
+ });
+});
diff --git a/core/platform/src/browsers/hideOthers/index.ts b/core/platform/src/browsers/hideOthers/index.ts
new file mode 100644
index 0000000..304b6a8
--- /dev/null
+++ b/core/platform/src/browsers/hideOthers/index.ts
@@ -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();
+let uncontrolledNodes = new WeakMap();
+let markerRegistry = new Map>();
+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): 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 {
+ let marker = markerRegistry.get(name);
+ if (!marker) {
+ marker = new WeakMap();
+ markerRegistry.set(name, marker);
+ }
+ return marker;
+}
+
+function hideNode(node: Element, marker: WeakMap, 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, 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();
+ const toStop = new Set();
+ 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 `