From e6919de29ec6f2062d9d45b377cd23c570574827 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 7 Jun 2026 16:29:28 +0700 Subject: [PATCH] chore(platform): eslint/tsconfig migration + browser utils Migrate to eslint flat config and composite tsconfig; browser/multi updates (hideOthers, focusScope). --- core/platform/eslint.config.ts | 9 + core/platform/oxlint.config.ts | 15 -- core/platform/package.json | 10 +- .../browsers/animationLifecycle/index.test.ts | 6 +- .../src/browsers/focusGuard/index.test.ts | 4 +- .../src/browsers/focusScope/index.test.ts | 16 +- .../platform/src/browsers/focusScope/index.ts | 4 + .../src/browsers/hideOthers/index.test.ts | 197 ++++++++++++++++++ .../platform/src/browsers/hideOthers/index.ts | 190 +++++++++++++++++ core/platform/src/browsers/index.ts | 1 + core/platform/src/multi/debounce/index.ts | 49 ----- core/platform/src/multi/index.ts | 1 - core/platform/tsconfig.json | 11 +- core/platform/tsconfig.node.json | 8 + core/platform/tsconfig.src.json | 9 + core/platform/tsdown.config.ts | 1 + 16 files changed, 443 insertions(+), 88 deletions(-) create mode 100644 core/platform/eslint.config.ts delete mode 100644 core/platform/oxlint.config.ts create mode 100644 core/platform/src/browsers/hideOthers/index.test.ts create mode 100644 core/platform/src/browsers/hideOthers/index.ts delete mode 100644 core/platform/src/multi/debounce/index.ts create mode 100644 core/platform/tsconfig.node.json create mode 100644 core/platform/tsconfig.src.json 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 `