diff --git a/.changeset/floating.md b/.changeset/floating.md new file mode 100644 index 0000000..42172ca --- /dev/null +++ b/.changeset/floating.md @@ -0,0 +1,63 @@ +--- +'@dunky.dev/dom-layer-stack': minor +'@dunky.dev/react-use-layer-stack': minor +'@dunky.dev/dom-interact-outside': minor +'@dunky.dev/react-use-interact-outside': minor +'@dunky.dev/react-dialog': patch +--- + +Add the shared overlay utilities the whole overlay family (dialog, drawer, +alert-dialog, popover, menu, combobox) builds on, extracted from the dialog's +internals so stacked overlays from different primitives coordinate instead of +fighting. + +- `@dunky.dev/dom-layer-stack` — one shared registry of open overlay layers. + `registerLayer({ id, path, element, modal })` returns an unregister + disposer — `path` is the layer's nesting chain of layer ids, outermost + first, ending with its own, straight from the substrate's shared context; + `isTopmostLayer(id)` answers which layer owns Escape, the focus trap, and + outside presses (deepest layer — longest path — wins, open order breaks + ties); `layerContainsTarget(id, target)` tells outside-press detection that + a node in a descendant layer is not outside. Ancestry decides containment, + not depth — an independent layer, even a deeper one from an unrelated + stack, is still outside, so pressing a sibling stack's submenu dismisses + this overlay. Registering also keeps assistive-tech containment in sync: + everything outside the topmost modal layer — and the layers stacked above + it — gets `aria-hidden` + `inert`, with an exact undo. The single instance + is the point: with one registry, one Escape press closes exactly one layer + even when a menu stacks over a dialog. +- `@dunky.dev/react-use-layer-stack` — the React half: `useLayerPath()` reads + the shared nesting chain (empty outside any layer) and `LayerPathContext` + provides it — every overlay root reads the path, appends its own layer id, + and provides. React context crosses portals, so the path reflects logical + nesting where portaled DOM order inverts it. +- `@dunky.dev/dom-interact-outside` — document-level outside-interaction + detection for non-modal overlays with no backdrop to catch presses. + `trackInteractOutside(container, { onInteractOutside, ignore })` fires on + capture-phase `pointerdown` / `focusin` outside the container; a press that + also moves focus outside reports once, not twice; a touch press reports + only once its `click` confirms a tap, so a scroll or pan that starts + outside never dismisses; dismissal stays the caller's decision. +- `@dunky.dev/react-use-interact-outside` — its React lifecycle: + `useInteractOutside(ref, options)` binds while mounted and reads the handler + and `ignore` predicate through refs, so inline closures never re-bind the + document listeners. + +```ts +import { isTopmostLayer, layerContainsTarget, registerLayer } from '@dunky.dev/dom-layer-stack' +import { useInteractOutside } from '@dunky.dev/react-use-interact-outside' +import { useLayerPath } from '@dunky.dev/react-use-layer-stack' + +const path = useLayerPath() +const unregister = registerLayer({ id, path, element: panel, modal: false }) +useInteractOutside(panelRef, { + onInteractOutside: event => dismiss(event), + ignore: target => layerContainsTarget(id, target) || anchor.contains(target), +}) +``` + +`@dunky.dev/react-dialog` now consumes the shared layer stack instead of its +private one — same behavior, one refinement: a non-modal layer opening above a +modal dialog no longer drops the page's assistive-tech containment; the +containment anchors on the topmost modal layer and keeps the layers above it +reachable, which is what lets a popover or menu live inside a dialog. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b4e8a1e..bb8f2f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,6 +54,13 @@ scroll locking — lives once as a framework-free util under `dom/utils/`; each substrate wraps what needs a lifecycle in a thin hook under its own `hooks/` folder. A new substrate reuses all of it and only writes the wrappers. +One recorded exception to hooks-wrap-a-util: +`@dunky.dev/react-use-layer-stack` wraps no util. Overlay nesting is knowledge +only the substrate's component tree has, so the React half of +`@dunky.dev/dom-layer-stack` is a shared context — the layer path that feeds +`registerLayer` — rather than a lifecycle wrapper. It still imports nothing +from this repo, so the dependency direction holds. + Each substrate directory is itself a private workspace package (`@dunky-dev/`) that owns the substrate's infrastructure — the dev harness, framework deps, dev/build scripts. The publishable packages live one diff --git a/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md index 307c207..0a70f94 100644 --- a/packages/core/dialog/SPEC.md +++ b/packages/core/dialog/SPEC.md @@ -123,10 +123,12 @@ stack of dialogs only the topmost one exists until it closes. - **Stacking**: a dialog opened from within an open dialog stacks on top of it, at any depth. Each dialog in the stack stays fully independent — its own open/close state, role, dismissal settings, and open/close reporting. -- **Topmost only**: only the topmost dialog is interactive and exposed to - assistive technology. Everything beneath it — the page and every dialog it - was opened from — is hidden and unreachable, by pointer, keyboard, or screen - reader. +- **Topmost only**: in a modal stack, only the topmost dialog is interactive + and exposed to assistive technology. Everything beneath the topmost modal + dialog — the page and every dialog it was opened from — is hidden and + unreachable, by pointer, keyboard, or screen reader. Containment anchors on + the topmost modal layer, so a non-modal layer stacked above it stays exposed + alongside it (the shared contract is `@dunky.dev/dom-layer-stack`). - **Escape**: dismisses only the topmost dialog, subject to that dialog's own dismissal settings — a nested stack unwinds one layer per press. - **Outside press**: pressing around the topmost dialog is an outside diff --git a/packages/dom/utils/interact-outside/README.md b/packages/dom/utils/interact-outside/README.md new file mode 100644 index 0000000..73cccfb --- /dev/null +++ b/packages/dom/utils/interact-outside/README.md @@ -0,0 +1,37 @@ +# @dunky.dev/dom-interact-outside + +Framework-free document-level outside-interaction detection for non-modal +overlays (popover, menu, combobox) — the ones with no backdrop to catch +presses. `trackInteractOutside` attaches capture-phase `pointerdown` and +`focusin` listeners on the document and fires `onInteractOutside` when the +event target falls outside the container's subtree and isn't excused by the +`ignore` predicate — how the caller excludes nested layers and its +trigger/anchor. One gesture reports once: the `focusin` a press dispatches by +moving focus is folded into that press, never a second call. A touch press +reports only once its `click` confirms a tap, so a scroll or pan that starts +outside never dismisses. Pure detection: dismissal decisions stay with the +caller. + +Substrate hooks wrap this — e.g. `@dunky.dev/react-use-interact-outside` — so +every framework inherits identical detection behavior. + +## Install + +```sh +npm install @dunky.dev/dom-interact-outside +``` + +## Usage + +```ts +import { trackInteractOutside } from '@dunky.dev/dom-interact-outside' + +const release = trackInteractOutside(panel, { + onInteractOutside: event => dismiss(event), + // A press in a nested layer or on the anchor never counts as outside. + ignore: target => layerContainsTarget(id, target) || anchor.contains(target), +}) + +// later +release() +``` diff --git a/packages/dom/utils/interact-outside/package.json b/packages/dom/utils/interact-outside/package.json new file mode 100644 index 0000000..809b5f9 --- /dev/null +++ b/packages/dom/utils/interact-outside/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dunky.dev/dom-interact-outside", + "version": "0.0.0", + "description": "Framework-free document-level outside-interaction detection for a DOM subtree.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/utils/interact-outside" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + } +} diff --git a/packages/dom/utils/interact-outside/src/index.ts b/packages/dom/utils/interact-outside/src/index.ts new file mode 100644 index 0000000..00d1b3e --- /dev/null +++ b/packages/dom/utils/interact-outside/src/index.ts @@ -0,0 +1 @@ +export { trackInteractOutside, type TrackInteractOutsideOptions } from './track-interact-outside' diff --git a/packages/dom/utils/interact-outside/src/track-interact-outside.ts b/packages/dom/utils/interact-outside/src/track-interact-outside.ts new file mode 100644 index 0000000..1a5d587 --- /dev/null +++ b/packages/dom/utils/interact-outside/src/track-interact-outside.ts @@ -0,0 +1,88 @@ +export interface TrackInteractOutsideOptions { + /** Fired when a press or focus lands outside the container's subtree. */ + onInteractOutside: (event: PointerEvent | FocusEvent) => void + /** + * Excuses a target that should never count as outside — e.g. a nested + * layer's subtree, or the overlay's trigger/anchor. + */ + ignore?: (target: Node) => boolean +} + +/** + * Watches document-level `pointerdown` and `focusin` — capture phase, so a + * `stopPropagation` out on the page can't mask them — and fires + * `onInteractOutside` when the event target falls outside `container` and + * isn't excused by `ignore`. One gesture, one call: the focus a press moves + * is part of that press, so a press on a focusable outside element doesn't + * report twice. A touch press reports only once its `click` confirms a tap, + * so a scroll or pan that starts outside never reports. Pure detection: what + * to do about it stays the caller's decision. Returns a release that removes + * the listeners. + */ +export function trackInteractOutside( + container: HTMLElement, + options: TrackInteractOutsideOptions, +): () => void { + const isOutside = (target: EventTarget | null): boolean => + target instanceof Node && !container.contains(target) && options.ignore?.(target) !== true + + // A press on a focusable element also dispatches `focusin` as its default + // action — same gesture, so it must not report a second time. The flag lifts + // a task after the pointer lifts, not at `pointerup` itself, because touch + // focuses late: its compatibility `mousedown`/`focusin` fire after + // `pointerup`, in the same turn. + let pressing = false + + // A touch landing outside may be starting a scroll, not tapping to dismiss. + // The browser raises `click` only when the gesture settles as a tap, so the + // report waits for it: a pan or a cancelled press never clicks, and the + // next press re-arms or clears the wait. + let reportTap: (() => void) | undefined + const cancelTapReport = (): void => { + if (reportTap === undefined) return + document.removeEventListener('click', reportTap, true) + reportTap = undefined + } + + const onPointerDown = (event: PointerEvent): void => { + pressing = true + cancelTapReport() + if (!isOutside(event.target)) return + if (event.pointerType === 'touch') { + reportTap = () => { + reportTap = undefined + options.onInteractOutside(event) + } + document.addEventListener('click', reportTap, { capture: true, once: true }) + } else { + options.onInteractOutside(event) + } + } + + const onFocusIn = (event: FocusEvent): void => { + if (!pressing && isOutside(event.target)) options.onInteractOutside(event) + } + + const onPointerUp = (): void => { + setTimeout(() => { + pressing = false + }, 0) + } + + const onPointerCancel = (): void => { + cancelTapReport() + onPointerUp() + } + + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('focusin', onFocusIn, true) + document.addEventListener('pointerup', onPointerUp, true) + document.addEventListener('pointercancel', onPointerCancel, true) + return () => { + cancelTapReport() + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('focusin', onFocusIn, true) + document.removeEventListener('pointerup', onPointerUp, true) + document.removeEventListener('pointercancel', onPointerCancel, true) + } +} diff --git a/packages/dom/utils/interact-outside/tests/track-interact-outside.test.ts b/packages/dom/utils/interact-outside/tests/track-interact-outside.test.ts new file mode 100644 index 0000000..90757df --- /dev/null +++ b/packages/dom/utils/interact-outside/tests/track-interact-outside.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { trackInteractOutside } from '@dunky.dev/dom-interact-outside' +import type { TrackInteractOutsideOptions } from '@dunky.dev/dom-interact-outside' + +let release: (() => void) | undefined + +const mount = (options: TrackInteractOutsideOptions): void => { + document.body.innerHTML = + '
' + + '' + const container = document.getElementById('container') as HTMLElement + release = trackInteractOutside(container, options) +} + +const pressPointer = (id: string): void => { + document.getElementById(id)?.dispatchEvent(new Event('pointerdown', { bubbles: true })) +} + +// jsdom has no PointerEvent constructor, so the touch pointerType is grafted +// onto a plain Event. +const pressTouch = (id: string): void => { + const event = new Event('pointerdown', { bubbles: true }) + Object.defineProperty(event, 'pointerType', { value: 'touch' }) + document.getElementById(id)?.dispatchEvent(event) +} + +const click = (id: string): void => { + document.getElementById(id)?.dispatchEvent(new Event('click', { bubbles: true })) +} + +afterEach(() => { + release?.() + release = undefined + document.body.innerHTML = '' +}) + +describe('trackInteractOutside', () => { + it('fires on a pointer press outside the container, not on one inside', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + pressPointer('inside') + expect(onInteractOutside).not.toHaveBeenCalled() + + pressPointer('outside') + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(onInteractOutside.mock.calls[0]?.[0]?.target?.id).toBe('outside') + }) + + it('fires when focus lands outside the container', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + document.getElementById('outside')?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + }) + + it('fires once for a press that also moves focus outside', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + pressPointer('outside') + document.getElementById('outside')?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + }) + + it('hears focus again once the press settles', async () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + pressPointer('outside') + document.getElementById('outside')?.dispatchEvent(new Event('pointerup', { bubbles: true })) + await new Promise(resolve => setTimeout(resolve, 0)) + + document.getElementById('outside')?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(onInteractOutside).toHaveBeenCalledTimes(2) + }) + + it('does not fire for a target the ignore predicate excuses', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside, ignore: target => (target as Element).id === 'outside' }) + + pressPointer('outside') + expect(onInteractOutside).not.toHaveBeenCalled() + }) + + it('reports a touch press only once its click confirms a tap', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + pressTouch('outside') + expect(onInteractOutside).not.toHaveBeenCalled() + + click('outside') + expect(onInteractOutside).toHaveBeenCalledTimes(1) + // The press is what gets reported — the click only confirms it. + expect(onInteractOutside.mock.calls[0]?.[0]?.type).toBe('pointerdown') + }) + + it('never reports a touch gesture the browser turns into a scroll', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + pressTouch('outside') + // The browser takes the gesture over for scrolling: `pointercancel`, no + // gesture click — and the wait is disarmed, so even a later click (e.g. + // keyboard-activated, with no press of its own) reports nothing. + document.dispatchEvent(new Event('pointercancel')) + click('outside') + expect(onInteractOutside).not.toHaveBeenCalled() + }) + + it('detects an outside press even when its propagation is stopped', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + const outside = document.getElementById('outside') as HTMLElement + outside.addEventListener('pointerdown', event => event.stopPropagation()) + pressPointer('outside') + expect(onInteractOutside).toHaveBeenCalledTimes(1) + }) + + it('stops firing once released', () => { + const onInteractOutside = vi.fn() + mount({ onInteractOutside }) + + release?.() + pressPointer('outside') + expect(onInteractOutside).not.toHaveBeenCalled() + }) +}) diff --git a/packages/dom/utils/layer-stack/README.md b/packages/dom/utils/layer-stack/README.md new file mode 100644 index 0000000..5873648 --- /dev/null +++ b/packages/dom/utils/layer-stack/README.md @@ -0,0 +1,50 @@ +# @dunky.dev/dom-layer-stack + +Framework-free shared registry of open overlay layers (dialogs, popovers, +menus). Every open layer registers `{ id, path, element, modal }` — `path` is +its nesting chain of layer ids, outermost first, ending with its own — and +gets an unregister disposer back; `isTopmostLayer(id)` answers which layer +currently owns Escape, the focus trap, and outside presses — the deepest +layer (longest path) wins, open order breaks ties; `layerContainsTarget(id, +target)` reports whether a node falls inside the layer's subtree or a +descendant layer's, so outside-press detection can ignore nested layers. +Ancestry decides containment, not depth: an independent layer — a sibling, or +even a deeper layer of an unrelated stack — is still outside. + +Registering also keeps assistive-tech containment in sync: everything outside +the topmost modal layer — and the layers stacked above it — gets `aria-hidden` +and `inert`, with an exact undo when the stack changes. With no modal layer +registered, nothing is hidden, so non-modal layers participate in routing and +nesting without hiding anything. + +The registry is one shared instance on purpose: every primitive must agree on +which layer is topmost, so one Escape press closes exactly one layer even when +different primitives stack. + +`@dunky.dev/react-use-layer-stack` is the React half — the shared layer-path +hook and context that feed `path`. + +## Install + +```sh +npm install @dunky.dev/dom-layer-stack +``` + +## Usage + +```ts +import { isTopmostLayer, layerContainsTarget, registerLayer } from '@dunky.dev/dom-layer-stack' + +// While the overlay is open (`path` comes from the substrate's shared +// layer-path context): +const unregister = registerLayer({ id, path, element: panel, modal: true }) + +// Escape / focus trap / outside press ownership: +isTopmostLayer(id) + +// Outside-press detection: a press inside a descendant layer is never outside. +layerContainsTarget(id, event.target) + +// On close: +unregister() +``` diff --git a/packages/dom/utils/layer-stack/package.json b/packages/dom/utils/layer-stack/package.json new file mode 100644 index 0000000..8a5da5f --- /dev/null +++ b/packages/dom/utils/layer-stack/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dunky.dev/dom-layer-stack", + "version": "0.0.0", + "description": "Framework-free shared registry of open overlay layers.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/utils/layer-stack" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + } +} diff --git a/packages/dom/utils/layer-stack/src/hide-outside.ts b/packages/dom/utils/layer-stack/src/hide-outside.ts new file mode 100644 index 0000000..d23af6d --- /dev/null +++ b/packages/dom/utils/layer-stack/src/hide-outside.ts @@ -0,0 +1,67 @@ +// Never hide these: they carry no rendered content, or must stay announced. +const HIDE_SKIP = /^(SCRIPT|STYLE|LINK|TEMPLATE)$/ + +// The containment trick, generalized to several kept subtrees: walk from each +// target up to the document root and mark every sibling off the kept paths +// `aria-hidden` + `inert`, so assistive tech sees only the targets' subtrees +// and nothing outside them can be reached — by pointer, find-in-page, or +// programmatic focus. Returns a function that removes exactly what it added. +// Internal to the layer stack: standalone use would fight the registry's sync. +export function hideOutside(targets: HTMLElement[]): () => void { + // A target inside another target's subtree is already kept whole — walking + // up from it would wrongly hide its siblings within that subtree. + const roots: HTMLElement[] = [] + for (const target of targets) { + let contained = false + for (const other of targets) { + if (other !== target && other.contains(target)) { + contained = true + break + } + } + if (!contained) roots.push(target) + } + + // Every root and its ancestors form the kept paths — a sibling that carries + // another root's subtree must stay visible. + const keep = new Set() + for (const root of roots) { + let node: Element | null = root + while (node !== null) { + keep.add(node) + node = node.parentElement + } + } + + const hidden: Element[] = [] + for (const root of roots) { + let node: HTMLElement | null = root + while (node !== null && node !== document.body && node.parentElement !== null) { + for (const sibling of Array.from(node.parentElement.children)) { + // Skip the kept paths, content-less tags, and anything the author + // already controls — an existing `aria-hidden` (any value) or `inert` + // is theirs. Elements this walk already hid carry both attributes, so + // overlapping walks never double-hide. + if ( + keep.has(sibling) || + HIDE_SKIP.test(sibling.tagName) || + sibling.hasAttribute('aria-hidden') || + sibling.hasAttribute('inert') + ) { + continue + } + sibling.setAttribute('aria-hidden', 'true') + sibling.setAttribute('inert', '') + hidden.push(sibling) + } + node = node.parentElement + } + } + + return () => { + for (const element of hidden) { + element.removeAttribute('aria-hidden') + element.removeAttribute('inert') + } + } +} diff --git a/packages/dom/utils/layer-stack/src/index.ts b/packages/dom/utils/layer-stack/src/index.ts new file mode 100644 index 0000000..5560e26 --- /dev/null +++ b/packages/dom/utils/layer-stack/src/index.ts @@ -0,0 +1 @@ +export { registerLayer, isTopmostLayer, layerContainsTarget, type LayerInit } from './layer-stack' diff --git a/packages/dom/utils/layer-stack/src/layer-stack.ts b/packages/dom/utils/layer-stack/src/layer-stack.ts new file mode 100644 index 0000000..6c8e966 --- /dev/null +++ b/packages/dom/utils/layer-stack/src/layer-stack.ts @@ -0,0 +1,112 @@ +import { hideOutside } from './hide-outside' + +export interface LayerInit { + /** The id `isTopmostLayer` / `layerContainsTarget` answer for. */ + id: string + /** + * The layer's nesting chain — every enclosing layer's id, outermost first, + * ending with this layer's own `id`. Comes from the substrate's shared + * layer-path context; its length is the nesting depth, its contents the + * ancestry `layerContainsTarget` checks. + */ + path: readonly string[] + /** The layer's DOM subtree (its content panel). */ + element: HTMLElement + /** Whether the layer hides everything outside itself from assistive tech. */ + modal: boolean +} + +interface Layer extends LayerInit { + order: number +} + +// The shared registry of open overlay layers. One module-level instance on +// purpose: every primitive must agree on which layer is topmost, so one +// Escape press closes exactly one layer even across primitives. The topmost +// is the deepest-nested layer — the longest path — with open order breaking +// ties between siblings. +// +// Nesting (not DOM order) decides it: React inserts a nested layer's portal +// into the body *before* its parent's, so document order is the inverse of +// nesting. +const layers: Layer[] = [] +let nextOrder = 0 +let undoHide: (() => void) | undefined + +function isAbove(layer: Layer, other: Layer): boolean { + return ( + layer.path.length > other.path.length || + (layer.path.length === other.path.length && layer.order > other.order) + ) +} + +function topmost(): Layer | undefined { + let top: Layer | undefined + for (const layer of layers) { + if (top === undefined || isAbove(layer, top)) top = layer + } + return top +} + +// Keep the assistive-tech view in sync: the topmost MODAL layer anchors the +// containment, and every layer stacked above it (e.g. a non-modal popover +// over a modal dialog) stays reachable too; everything else is hidden. With +// no modal layer, nothing is hidden. Re-runs whenever the stack changes so a +// new layer hides the ones beneath it, and closing it restores them. +function syncContainment(): void { + undoHide?.() + undoHide = undefined + + let anchor: Layer | undefined + for (const layer of layers) { + if (layer.modal && (anchor === undefined || isAbove(layer, anchor))) anchor = layer + } + if (anchor === undefined) return + + const visible: HTMLElement[] = [] + for (const layer of layers) { + // `isConnected` guards teardown, when a content panel is already detached. + if ((layer === anchor || isAbove(layer, anchor)) && layer.element.isConnected) { + visible.push(layer.element) + } + } + if (visible.length > 0) undoHide = hideOutside(visible) +} + +export function registerLayer(layer: LayerInit): () => void { + const entry: Layer = { ...layer, order: nextOrder++ } + layers.push(entry) + syncContainment() + return () => { + const index = layers.indexOf(entry) + if (index !== -1) layers.splice(index, 1) + syncContainment() + } +} + +export function isTopmostLayer(id: string): boolean { + return topmost()?.id === id +} + +/** + * Whether `target` falls inside the layer's own subtree or a descendant + * layer's — outside-press detection uses this so a press in a nested layer + * never counts as outside the one beneath. Ancestry decides, not depth or + * stack position: a layer counts only when the queried id is on its `path`, + * so an independent layer — a sibling, or even a deeper layer of an unrelated + * stack — is still outside, and pressing it dismisses this one. + */ +export function layerContainsTarget(id: string, target: Node): boolean { + let base: Layer | undefined + for (const layer of layers) { + if (layer.id === id) { + base = layer + break + } + } + if (base === undefined) return false + for (const layer of layers) { + if ((layer === base || layer.path.includes(id)) && layer.element.contains(target)) return true + } + return false +} diff --git a/packages/dom/utils/layer-stack/tests/layer-stack.test.ts b/packages/dom/utils/layer-stack/tests/layer-stack.test.ts new file mode 100644 index 0000000..3750873 --- /dev/null +++ b/packages/dom/utils/layer-stack/tests/layer-stack.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { isTopmostLayer, layerContainsTarget, registerLayer } from '@dunky.dev/dom-layer-stack' +import type { LayerInit } from '@dunky.dev/dom-layer-stack' + +let disposers: (() => void)[] = [] + +const register = (layer: LayerInit): (() => void) => { + const unregister = registerLayer(layer) + disposers.push(unregister) + return unregister +} + +// Each panel gets its own body-level host, mimicking a portal: the host is on +// the panel's kept path, everything else at body level is outside. +const mountPanel = (id: string): HTMLElement => { + const host = document.createElement('div') + host.id = `${id}-host` + const panel = document.createElement('div') + panel.id = id + host.append(panel) + document.body.append(host) + return panel +} + +const hiddenAndInert = (element: Element): boolean => + element.getAttribute('aria-hidden') === 'true' && element.hasAttribute('inert') + +let page: HTMLElement + +beforeEach(() => { + page = document.createElement('main') + document.body.append(page) +}) + +afterEach(() => { + for (const dispose of disposers) dispose() + disposers = [] + document.body.innerHTML = '' +}) + +describe('topmost ownership', () => { + it('the deepest layer is topmost, regardless of registration order', () => { + register({ id: 'nested', path: ['root', 'nested'], element: mountPanel('nested'), modal: true }) + register({ id: 'root', path: ['root'], element: mountPanel('root'), modal: true }) + + expect(isTopmostLayer('nested')).toBe(true) + expect(isTopmostLayer('root')).toBe(false) + }) + + it('open order breaks ties between layers at the same depth', () => { + register({ id: 'first', path: ['first'], element: mountPanel('first'), modal: true }) + register({ id: 'second', path: ['second'], element: mountPanel('second'), modal: true }) + + expect(isTopmostLayer('second')).toBe(true) + }) + + it('unregistering the top layer promotes the one beneath', () => { + register({ id: 'root', path: ['root'], element: mountPanel('root'), modal: true }) + const unregister = register({ + id: 'nested', + path: ['root', 'nested'], + element: mountPanel('nested'), + modal: true, + }) + + unregister() + expect(isTopmostLayer('root')).toBe(true) + expect(isTopmostLayer('nested')).toBe(false) + }) + + it('no layer is topmost while nothing is registered', () => { + expect(isTopmostLayer('anything')).toBe(false) + }) +}) + +describe('assistive-tech containment', () => { + it('a modal layer hides everything outside its subtree, with an exact undo', () => { + const panel = mountPanel('modal') + const unregister = register({ id: 'modal', path: ['modal'], element: panel, modal: true }) + + expect(hiddenAndInert(page)).toBe(true) + expect(panel.hasAttribute('aria-hidden')).toBe(false) + expect(panel.parentElement?.hasAttribute('aria-hidden')).toBe(false) + + unregister() + expect(page.hasAttribute('aria-hidden')).toBe(false) + expect(page.hasAttribute('inert')).toBe(false) + }) + + it('a non-modal layer hides nothing', () => { + register({ id: 'popover', path: ['popover'], element: mountPanel('popover'), modal: false }) + expect(page.hasAttribute('aria-hidden')).toBe(false) + }) + + it('skips content-less tags and author-controlled attributes', () => { + const script = document.createElement('script') + const authored = document.createElement('div') + authored.setAttribute('aria-hidden', 'false') + document.body.append(script, authored) + + const unregister = register({ + id: 'modal', + path: ['modal'], + element: mountPanel('modal'), + modal: true, + }) + + expect(script.hasAttribute('aria-hidden')).toBe(false) + expect(authored.getAttribute('aria-hidden')).toBe('false') + expect(authored.hasAttribute('inert')).toBe(false) + + unregister() + expect(authored.getAttribute('aria-hidden')).toBe('false') + }) + + it('a modal layer above another hides the lower layer, and closing restores it', () => { + const rootPanel = mountPanel('root') + register({ id: 'root', path: ['root'], element: rootPanel, modal: true }) + const unregister = register({ + id: 'nested', + path: ['root', 'nested'], + element: mountPanel('nested'), + modal: true, + }) + + expect(hiddenAndInert(rootPanel.parentElement as Element)).toBe(true) + + unregister() + expect(rootPanel.parentElement?.hasAttribute('aria-hidden')).toBe(false) + expect(hiddenAndInert(page)).toBe(true) + }) + + it('keeps a non-modal layer above a modal one reachable while the page stays hidden', () => { + const dialogPanel = mountPanel('dialog') + const popoverPanel = mountPanel('popover') + register({ id: 'dialog', path: ['dialog'], element: dialogPanel, modal: true }) + register({ id: 'popover', path: ['dialog', 'popover'], element: popoverPanel, modal: false }) + + expect(hiddenAndInert(page)).toBe(true) + expect(dialogPanel.parentElement?.hasAttribute('aria-hidden')).toBe(false) + expect(popoverPanel.parentElement?.hasAttribute('aria-hidden')).toBe(false) + }) + + it('hides nothing new for a layer nested inside the anchoring subtree', () => { + const dialogPanel = mountPanel('dialog') + const inlinePopover = document.createElement('div') + const siblingButton = document.createElement('button') + dialogPanel.append(siblingButton, inlinePopover) + register({ id: 'dialog', path: ['dialog'], element: dialogPanel, modal: true }) + register({ id: 'popover', path: ['dialog', 'popover'], element: inlinePopover, modal: false }) + + expect(siblingButton.hasAttribute('aria-hidden')).toBe(false) + }) +}) + +describe('layerContainsTarget', () => { + it('reports a target inside its own subtree', () => { + const panel = mountPanel('dialog') + const inside = document.createElement('button') + panel.append(inside) + register({ id: 'dialog', path: ['dialog'], element: panel, modal: true }) + + expect(layerContainsTarget('dialog', inside)).toBe(true) + expect(layerContainsTarget('dialog', page)).toBe(false) + }) + + it('reports a target inside a descendant layer, but not one inside an ancestor', () => { + const rootPanel = mountPanel('root') + const nestedPanel = mountPanel('nested') + register({ id: 'root', path: ['root'], element: rootPanel, modal: true }) + register({ id: 'nested', path: ['root', 'nested'], element: nestedPanel, modal: false }) + + expect(layerContainsTarget('root', nestedPanel)).toBe(true) + expect(layerContainsTarget('nested', rootPanel)).toBe(false) + }) + + it('reports an independent sibling at the same depth as outside', () => { + const firstPanel = mountPanel('first') + const secondPanel = mountPanel('second') + register({ id: 'first', path: ['first'], element: firstPanel, modal: false }) + register({ id: 'second', path: ['second'], element: secondPanel, modal: false }) + + expect(layerContainsTarget('first', secondPanel)).toBe(false) + }) + + it("reports an unrelated stack's deeper layer as outside", () => { + const firstPanel = mountPanel('first') + const secondPanel = mountPanel('second') + const submenuPanel = mountPanel('submenu') + register({ id: 'first', path: ['first'], element: firstPanel, modal: false }) + register({ id: 'second', path: ['second'], element: secondPanel, modal: false }) + register({ + id: 'submenu', + path: ['second', 'submenu'], + element: submenuPanel, + modal: false, + }) + + // Deeper than `first`, but nested in `second`: only ancestry keeps a + // layer inside, so pressing the submenu still dismisses `first`. + expect(layerContainsTarget('first', submenuPanel)).toBe(false) + }) + + it('reports nothing for an unregistered id', () => { + const panel = mountPanel('dialog') + register({ id: 'dialog', path: ['dialog'], element: panel, modal: true }) + + expect(layerContainsTarget('unknown', panel)).toBe(false) + }) +}) diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 12a6a93..0080e31 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -35,8 +35,10 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:^", + "@dunky.dev/dom-layer-stack": "workspace:^", "@dunky.dev/react-state-machine": "^0.1.0", "@dunky.dev/react-use-focus-trap": "workspace:^", + "@dunky.dev/react-use-layer-stack": "workspace:^", "@dunky.dev/react-use-scroll-lock": "workspace:^" }, "devDependencies": { diff --git a/packages/react/dialog/src/context.ts b/packages/react/dialog/src/context.ts index 9cd4da2..97c28a4 100644 --- a/packages/react/dialog/src/context.ts +++ b/packages/react/dialog/src/context.ts @@ -18,11 +18,6 @@ export const useDialogContext = (): DialogContextValue => { return context } -// Nesting level (0 = outside any dialog, 1 = top-level). Kept internal and out -// of the public context — parts only reason about open/close. It decides the -// topmost dialog for Escape, focus, and assistive-tech containment when nested. -export const DialogDepthContext: Context = createContext(0) - // The element the Portal teleported into, or null for the page body. Content // reads it to scope the scroll lock to that container instead of the page. export const DialogPortalContext: Context = createContext( diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 5533816..b7abc37 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useImperativeHandle, + useMemo, useRef, type ComponentPropsWithoutRef, type ForwardRefExoticComponent, @@ -12,14 +13,15 @@ import { type RefObject, } from 'react' import { createPortal } from 'react-dom' +import { isTopmostLayer, registerLayer } from '@dunky.dev/dom-layer-stack' import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' +import { LayerPathContext, useLayerPath } from '@dunky.dev/react-use-layer-stack' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' import { mergeProps, normalize } from '@dunky.dev/react-state-machine' -import { DialogContext, DialogDepthContext, DialogPortalContext, useDialogContext } from './context' +import { DialogContext, DialogPortalContext, useDialogContext } from './context' import { getInitialFocus } from './utils/get-initial-focus' -import { isTopmostDialog, registerDialog } from './utils/stack' import { useDialog } from './use-dialog' // Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef @@ -35,12 +37,15 @@ export interface DialogProps extends DialogOptions { } export const Dialog: ((props: DialogProps) => ReactNode) & Parts = ({ children, ...options }) => { - const depth = useContext(DialogDepthContext) + 1 + const parentPath = useLayerPath() const value = useDialog(options) + const id = value.machine.context.id + // Memoized: a fresh array every render would re-render every path consumer. + const path = useMemo(() => [...parentPath, id], [parentPath, id]) return ( - + {children} - + ) } @@ -99,9 +104,9 @@ export const Backdrop: PartComponent = forw const merged = mergeProps(props as Record, { ...bindings, - // Only the topmost dialog of a stack answers an outside press. + // Only the topmost layer of a stack answers an outside press. onClick: (event: MouseEvent) => { - if (isTopmostDialog(machine.context.id)) onClick?.(event) + if (isTopmostLayer(machine.context.id)) onClick?.(event) }, }) @@ -129,11 +134,11 @@ export const Viewport: PartComponent = forw const merged = mergeProps(props as Record, { ...bindings, // Content presses bubble up here — only a press that started on the - // viewport itself is an outside interaction, and only the topmost dialog + // viewport itself is an outside interaction, and only the topmost layer // of a stack answers it. onClick: (event: MouseEvent) => { if (event.target !== event.currentTarget) return - if (!isTopmostDialog(machine.context.id)) return + if (!isTopmostLayer(machine.context.id)) return onClick?.(event) }, }) @@ -156,7 +161,7 @@ export const Content: PartComponent = for DialogContentProps >(({ initialFocus, ...props }, forwardedRef) => { const { api, machine } = useDialogContext() - const depth = useContext(DialogDepthContext) + const path = useLayerPath() const portalContainer = useContext(DialogPortalContext) const contentRef = useRef(null) useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDialogElement) @@ -172,9 +177,9 @@ export const Content: PartComponent = for if (content === null) return const previous = document.activeElement - const unregister = registerDialog({ + const unregister = registerLayer({ id: machine.context.id, - depth, + path, element: content, modal: machine.context.modal, }) @@ -191,16 +196,16 @@ export const Content: PartComponent = for unregister() if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) } - }, [machine, depth]) + }, [machine, path]) // Content only mounts while open, so the lock spans exactly the open state. // A scoped dialog locks its portal container; a page dialog locks the body. useScrollLock(machine.context.modal, portalContainer) useFocusTrap(contentRef, { - // Only a modal dialog traps, and only while topmost — a nested dialog + // Only a modal dialog traps, and only while topmost — a nested layer // owns focus while open. - enabled: () => machine.context.modal && isTopmostDialog(machine.context.id), + enabled: () => machine.context.modal && isTopmostLayer(machine.context.id), }) const merged = mergeProps(props as Record, { diff --git a/packages/react/dialog/src/effects.ts b/packages/react/dialog/src/effects.ts index 7796774..c75d931 100644 --- a/packages/react/dialog/src/effects.ts +++ b/packages/react/dialog/src/effects.ts @@ -1,8 +1,7 @@ +import { isTopmostLayer } from '@dunky.dev/dom-layer-stack' import type { ComponentEffect } from '@dunky.dev/react-state-machine' import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog' -import { isTopmostDialog } from './utils/stack' - // Substrate effects: prop-driven or document-level work the machine can't own. // useMachine runs one useEffect per entry, keyed on the listed prop deps. type DialogEffect = ComponentEffect @@ -24,9 +23,9 @@ const trackEscape: DialogEffect = [ (machine, props) => { const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !machine.matches('open')) return - // Only the topmost dialog answers Escape — a nested stack closes one + // Only the topmost layer answers Escape — a nested stack closes one // layer at a time. - if (!isTopmostDialog(machine.context.id)) return + if (!isTopmostLayer(machine.context.id)) return props.onEscapeKeyDown?.(event) if (!event.defaultPrevented) machine.send({ type: 'escape' }) } diff --git a/packages/react/dialog/src/utils/hide-outside.ts b/packages/react/dialog/src/utils/hide-outside.ts deleted file mode 100644 index 8283e6a..0000000 --- a/packages/react/dialog/src/utils/hide-outside.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Never hide these: they carry no rendered content, or must stay announced. -const HIDE_SKIP = /^(SCRIPT|STYLE|LINK|TEMPLATE)$/ - -// The containment trick: walk from `target` up to the document root and mark -// every sibling along the way `aria-hidden` + `inert`, so assistive tech sees -// only the target's subtree and nothing outside it can be reached — by pointer, -// find-in-page, or programmatic focus. Returns a function that removes exactly -// what it added. Callers hide one target at a time. -export function hideOutside(target: HTMLElement): () => void { - const hidden: Element[] = [] - - let node: HTMLElement | null = target - while (node !== null && node !== document.body && node.parentElement !== null) { - for (const sibling of Array.from(node.parentElement.children)) { - // Skip the path itself, content-less tags, and anything the author already - // controls — an existing `aria-hidden` (any value) or `inert` is theirs. - if ( - sibling === node || - HIDE_SKIP.test(sibling.tagName) || - sibling.hasAttribute('aria-hidden') || - sibling.hasAttribute('inert') - ) { - continue - } - sibling.setAttribute('aria-hidden', 'true') - sibling.setAttribute('inert', '') - hidden.push(sibling) - } - node = node.parentElement - } - - return () => { - for (const element of hidden) { - element.removeAttribute('aria-hidden') - element.removeAttribute('inert') - } - } -} diff --git a/packages/react/dialog/src/utils/stack.ts b/packages/react/dialog/src/utils/stack.ts deleted file mode 100644 index 5fc3982..0000000 --- a/packages/react/dialog/src/utils/stack.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { hideOutside } from './hide-outside' - -// The shared registry of open dialogs. The topmost — the one Escape and the -// focus trap act on, and the one whose content stays visible to assistive tech — -// is the deepest-nested dialog, with open order breaking ties between siblings. -// -// Depth (not DOM order) decides it: React inserts a nested dialog's portal into -// the body *before* its parent's, so document order is the inverse of nesting. -interface Layer { - id: string - depth: number - order: number - element: HTMLElement - modal: boolean -} - -const layers: Layer[] = [] -let nextOrder = 0 -let undoHide: (() => void) | undefined - -function topmost(): Layer | undefined { - let top: Layer | undefined - for (const layer of layers) { - if ( - top === undefined || - layer.depth > top.depth || - (layer.depth === top.depth && layer.order > top.order) - ) { - top = layer - } - } - return top -} - -// Keep the assistive-tech view in sync: only the topmost modal dialog stays -// reachable; everything else is hidden. Re-runs whenever the stack changes so a -// nested dialog hides the one beneath it, and closing it restores the layer. -function syncAriaHidden(): void { - undoHide?.() - undoHide = undefined - const top = topmost() - if (top?.modal !== true) return - // `isConnected` guards teardown, when the content is already detached. - if (top.element.isConnected) undoHide = hideOutside(top.element) -} - -export function registerDialog(layer: Omit): () => void { - const entry: Layer = { ...layer, order: nextOrder++ } - layers.push(entry) - syncAriaHidden() - return () => { - const index = layers.indexOf(entry) - if (index !== -1) layers.splice(index, 1) - syncAriaHidden() - } -} - -export function isTopmostDialog(id: string): boolean { - return topmost()?.id === id -} diff --git a/packages/react/hooks/use-interact-outside/README.md b/packages/react/hooks/use-interact-outside/README.md new file mode 100644 index 0000000..2bfede2 --- /dev/null +++ b/packages/react/hooks/use-interact-outside/README.md @@ -0,0 +1,32 @@ +# @dunky.dev/react-use-interact-outside + +React binding for +[`@dunky.dev/dom-interact-outside`](../../../dom/utils/interact-outside): +`useInteractOutside(ref, options)` fires `onInteractOutside` for presses and +focus landing outside the referenced container while the component is mounted. +The detection itself is framework-free — this hook only owns the React +lifecycle, reading the handler and `ignore` predicate through refs so inline +closures never re-bind the document listeners. + +## Install + +```sh +npm install @dunky.dev/react-use-interact-outside +``` + +## Usage + +```tsx +import { useRef } from 'react' +import { useInteractOutside } from '@dunky.dev/react-use-interact-outside' + +function PopoverPanel() { + const panelRef = useRef(null) + useInteractOutside(panelRef, { + onInteractOutside: event => dismiss(event), + // A press in a nested layer or on the anchor never counts as outside. + ignore: target => layerContainsTarget(id, target) || anchor.contains(target), + }) + return
...
+} +``` diff --git a/packages/react/hooks/use-interact-outside/package.json b/packages/react/hooks/use-interact-outside/package.json new file mode 100644 index 0000000..c29ed5a --- /dev/null +++ b/packages/react/hooks/use-interact-outside/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dunky.dev/react-use-interact-outside", + "version": "0.0.0", + "description": "React binding for @dunky.dev/dom-interact-outside.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/react/hooks/use-interact-outside" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-interact-outside": "workspace:^" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/react/hooks/use-interact-outside/src/index.ts b/packages/react/hooks/use-interact-outside/src/index.ts new file mode 100644 index 0000000..d883303 --- /dev/null +++ b/packages/react/hooks/use-interact-outside/src/index.ts @@ -0,0 +1 @@ +export { useInteractOutside, type UseInteractOutsideOptions } from './use-interact-outside' diff --git a/packages/react/hooks/use-interact-outside/src/use-interact-outside.ts b/packages/react/hooks/use-interact-outside/src/use-interact-outside.ts new file mode 100644 index 0000000..20fa643 --- /dev/null +++ b/packages/react/hooks/use-interact-outside/src/use-interact-outside.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import { trackInteractOutside } from '@dunky.dev/dom-interact-outside' +import type { TrackInteractOutsideOptions } from '@dunky.dev/dom-interact-outside' + +export interface UseInteractOutsideOptions extends TrackInteractOutsideOptions {} + +/** + * Fires `onInteractOutside` for presses and focus landing outside `target` + * while mounted — the React lifecycle around `trackInteractOutside`. Call it + * from the component that renders the container, so both mount together: the + * detection binds once, when the target first exists. + */ +export function useInteractOutside( + target: RefObject, + options: UseInteractOutsideOptions, +): void { + // Read through a ref so inline `onInteractOutside` / `ignore` closures don't + // re-bind the document listeners on every render. + const optionsRef = useRef(options) + optionsRef.current = options + + useEffect(() => { + const container = target.current + if (container === null) return + return trackInteractOutside(container, { + onInteractOutside: event => optionsRef.current.onInteractOutside(event), + ignore: node => optionsRef.current.ignore?.(node) === true, + }) + }, [target]) +} diff --git a/packages/react/hooks/use-interact-outside/tests/use-interact-outside.test.tsx b/packages/react/hooks/use-interact-outside/tests/use-interact-outside.test.tsx new file mode 100644 index 0000000..6d81be9 --- /dev/null +++ b/packages/react/hooks/use-interact-outside/tests/use-interact-outside.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +// The React lifecycle around @dunky.dev/dom-interact-outside — the +// detection/ignore behavior itself is covered in the util's own tests. +import { useRef } from 'react' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useInteractOutside } from '@dunky.dev/react-use-interact-outside' +import type { UseInteractOutsideOptions } from '@dunky.dev/react-use-interact-outside' + +function Tracker(options: UseInteractOutsideOptions) { + const target = useRef(null) + useInteractOutside(target, options) + return ( +
+ +
+ ) +} + +// RTL auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('useInteractOutside', () => { + it('detects while mounted and stops after unmount', () => { + const onInteractOutside = vi.fn() + const { unmount } = render() + + fireEvent.pointerDown(document.body) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + + unmount() + fireEvent.pointerDown(document.body) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + }) + + it('forwards ignore to the tracker without re-binding', () => { + const onInteractOutside = vi.fn() + render( true} />) + + fireEvent.pointerDown(document.body) + expect(onInteractOutside).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react/hooks/use-layer-stack/README.md b/packages/react/hooks/use-layer-stack/README.md new file mode 100644 index 0000000..8a8dc32 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/README.md @@ -0,0 +1,40 @@ +# @dunky.dev/react-use-layer-stack + +The React half of [`@dunky.dev/dom-layer-stack`](../../../dom/utils/layer-stack): +`useLayerPath()` reads the shared nesting chain for overlay layers — the +enclosing layers' ids, outermost first, empty outside any layer — and +`LayerPathContext` provides it. Every overlay root reads the path, appends its +own layer id, and provides it; the part that mounts while open passes the +value to `registerLayer`, which takes nesting depth from the path's length and +ancestry from its contents. + +React context crosses portals, so the path reflects logical nesting where +portaled DOM order inverts it — and one chain shared by every primitive keeps +cross-primitive stacks (a popover inside a dialog) correctly ordered even when +nested layers mount in the same commit. + +## Install + +```sh +npm install @dunky.dev/react-use-layer-stack +``` + +## Usage + +```tsx +import { useEffect, useMemo } from 'react' +import { registerLayer } from '@dunky.dev/dom-layer-stack' +import { LayerPathContext, useLayerPath } from '@dunky.dev/react-use-layer-stack' + +function OverlayRoot({ id, children }) { + const parentPath = useLayerPath() + const path = useMemo(() => [...parentPath, id], [parentPath, id]) + return {children} +} + +function OverlayContent() { + const path = useLayerPath() + useEffect(() => registerLayer({ id, path, element, modal }), [path]) + // ... +} +``` diff --git a/packages/react/hooks/use-layer-stack/package.json b/packages/react/hooks/use-layer-stack/package.json new file mode 100644 index 0000000..73a46c5 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/package.json @@ -0,0 +1,46 @@ +{ + "name": "@dunky.dev/react-use-layer-stack", + "version": "0.0.0", + "description": "React half of @dunky.dev/dom-layer-stack: the shared overlay layer-path hook and context.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/react/hooks/use-layer-stack" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/react/hooks/use-layer-stack/src/index.ts b/packages/react/hooks/use-layer-stack/src/index.ts new file mode 100644 index 0000000..09a8fa0 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/src/index.ts @@ -0,0 +1,2 @@ +export { LayerPathContext } from './layer-path-context' +export { useLayerPath } from './use-layer-path' diff --git a/packages/react/hooks/use-layer-stack/src/layer-path-context.ts b/packages/react/hooks/use-layer-stack/src/layer-path-context.ts new file mode 100644 index 0000000..1dc62a2 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/src/layer-path-context.ts @@ -0,0 +1,17 @@ +import { createContext } from 'react' +import type { Context } from 'react' + +/** + * The shared nesting chain for overlay layers — the enclosing layers' ids, + * outermost first (empty outside any layer). Every overlay root reads it, + * appends its own layer id, and provides the result; the part that mounts + * while open feeds the value to `registerLayer`, which takes nesting depth + * from the path's length and ancestry from its contents. + * + * React context crosses portals, so the path reflects logical nesting where + * portaled DOM order inverts it — and one chain shared by every primitive + * keeps cross-primitive stacks (a popover inside a dialog) ordered even when + * nested layers mount in the same commit, where registration order alone + * would pick the wrong topmost. + */ +export const LayerPathContext: Context = createContext([]) diff --git a/packages/react/hooks/use-layer-stack/src/use-layer-path.ts b/packages/react/hooks/use-layer-stack/src/use-layer-path.ts new file mode 100644 index 0000000..ae37e64 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/src/use-layer-path.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react' +import { LayerPathContext } from './layer-path-context' + +/** The overlay layer path at the call site — `LayerPathContext` carries the contract. */ +export function useLayerPath(): readonly string[] { + return useContext(LayerPathContext) +} diff --git a/packages/react/hooks/use-layer-stack/tests/use-layer-path.test.tsx b/packages/react/hooks/use-layer-stack/tests/use-layer-path.test.tsx new file mode 100644 index 0000000..b6f8274 --- /dev/null +++ b/packages/react/hooks/use-layer-stack/tests/use-layer-path.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +// The shared nesting chain for @dunky.dev/dom-layer-stack — the registry's +// topmost/containment behavior itself is covered in the util's own tests. +import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { LayerPathContext, useLayerPath } from '@dunky.dev/react-use-layer-stack' + +// The consumption pattern every overlay root follows: read, append own id, +// provide. +function Layer({ id, children }: { id: string; children?: ReactNode }) { + const path = [...useLayerPath(), id] + return ( + + {path.join(',')} + {children} + + ) +} + +// RTL auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('useLayerPath', () => { + it('starts empty outside any layer', () => { + render() + expect(screen.getByTestId('root').textContent).toBe('root') + }) + + it('reflects logical nesting across portals, where DOM order inverts it', () => { + render({createPortal(, document.body)}) + expect(screen.getByTestId('inner').textContent).toBe('outer,inner') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c9ff48..b487891 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,10 @@ importers: packages/dom/utils/focus-trap: {} + packages/dom/utils/interact-outside: {} + + packages/dom/utils/layer-stack: {} + packages/dom/utils/scroll-lock: {} packages/react: @@ -92,12 +96,18 @@ importers: '@dunky.dev/dialog': specifier: workspace:^ version: link:../../core/dialog + '@dunky.dev/dom-layer-stack': + specifier: workspace:^ + version: link:../../dom/utils/layer-stack '@dunky.dev/react-state-machine': specifier: ^0.1.0 version: 0.1.0(react@19.2.7) '@dunky.dev/react-use-focus-trap': specifier: workspace:^ version: link:../hooks/use-focus-trap + '@dunky.dev/react-use-layer-stack': + specifier: workspace:^ + version: link:../hooks/use-layer-stack '@dunky.dev/react-use-scroll-lock': specifier: workspace:^ version: link:../hooks/use-scroll-lock @@ -140,6 +150,46 @@ importers: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + packages/react/hooks/use-interact-outside: + dependencies: + '@dunky.dev/dom-interact-outside': + specifier: workspace:^ + version: link:../../../dom/utils/interact-outside + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.2.15 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + + packages/react/hooks/use-layer-stack: + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.2.15 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + packages/react/hooks/use-scroll-lock: dependencies: '@dunky.dev/dom-scroll-lock': diff --git a/tsconfig.json b/tsconfig.json index cbffd69..c3956bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,12 @@ "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], + "@dunky.dev/dom-interact-outside": ["./packages/dom/utils/interact-outside/src"], + "@dunky.dev/dom-layer-stack": ["./packages/dom/utils/layer-stack/src"], "@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], "@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"], + "@dunky.dev/react-use-interact-outside": ["./packages/react/hooks/use-interact-outside/src"], + "@dunky.dev/react-use-layer-stack": ["./packages/react/hooks/use-layer-stack/src"], "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"], "@dunky-dev/core": ["./packages/core/src"] }, diff --git a/tsdown.config.ts b/tsdown.config.ts index d2af0e4..1e327f2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -14,9 +14,13 @@ export default defineConfig({ 'packages/core', 'packages/core/dialog', 'packages/dom/utils/focus-trap', + 'packages/dom/utils/interact-outside', + 'packages/dom/utils/layer-stack', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', 'packages/react/hooks/use-focus-trap', + 'packages/react/hooks/use-interact-outside', + 'packages/react/hooks/use-layer-stack', 'packages/react/hooks/use-scroll-lock', ], entry: ['src/index.ts'],