From 89ed3f7f9c1e5c6909ff2cfaa4c5ed952846518e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 18:53:33 +0200 Subject: [PATCH 1/2] refactor(overlay): extract generic overlay coordination; drop dom-dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layer stack, assistive-tech containment, exit window, and initial focus were never dialog-specific — only their names were. Extract them so the whole overlay family (drawer, alert-dialog, popover, menu, combobox) builds on one implementation: - @dunky.dev/overlay (core/utils) — the agnostic half: a stack of open layers and the topmost rule (deepest nesting, open order breaking ties). No DOM, no framework, so a future native substrate reuses it. - @dunky.dev/dom-overlay — the DOM realization on top: the stack wired to aria-hidden/inert containment, hideExitingLayer/watchExitAnimation, and getInitialFocus. @dunky.dev/dom-dialog is removed (never released); @dunky.dev/react-dialog now consumes @dunky.dev/dom-overlay (registerDialog/isTopmostDialog become registerLayer/isTopmostLayer). Public API and behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/dialog-exit-animation.md | 2 +- .changeset/dom-dialog-package.md | 28 -------- .changeset/overlay-packages.md | 29 ++++++++ packages/core/utils/overlay/README.md | 51 ++++++++++++++ .../utils/overlay}/package.json | 6 +- packages/core/utils/overlay/src/index.ts | 1 + .../core/utils/overlay/src/layer-stack.ts | 63 +++++++++++++++++ .../utils/overlay/tests/layer-stack.test.ts | 44 ++++++++++++ packages/dom/utils/dialog/README.md | 58 ---------------- packages/dom/utils/dialog/src/stack.ts | 68 ------------------- packages/dom/utils/overlay/README.md | 59 ++++++++++++++++ packages/dom/utils/overlay/package.json | 39 +++++++++++ .../src/get-initial-focus.ts | 4 +- .../src/hide-exiting-layer.ts | 2 +- .../{dialog => overlay}/src/hide-outside.ts | 2 +- .../utils/{dialog => overlay}/src/index.ts | 2 +- packages/dom/utils/overlay/src/stack.ts | 45 ++++++++++++ .../src/watch-exit-animation.ts | 2 +- .../tests/containment.test.ts} | 42 +++--------- .../{dialog => overlay}/tests/exit.test.ts | 2 +- packages/react/dialog/package.json | 2 +- packages/react/dialog/src/dialog.tsx | 14 ++-- packages/react/dialog/src/effects.ts | 4 +- pnpm-lock.yaml | 14 ++-- tsconfig.json | 3 +- tsdown.config.ts | 3 +- 26 files changed, 374 insertions(+), 215 deletions(-) delete mode 100644 .changeset/dom-dialog-package.md create mode 100644 .changeset/overlay-packages.md create mode 100644 packages/core/utils/overlay/README.md rename packages/{dom/utils/dialog => core/utils/overlay}/package.json (73%) create mode 100644 packages/core/utils/overlay/src/index.ts create mode 100644 packages/core/utils/overlay/src/layer-stack.ts create mode 100644 packages/core/utils/overlay/tests/layer-stack.test.ts delete mode 100644 packages/dom/utils/dialog/README.md delete mode 100644 packages/dom/utils/dialog/src/stack.ts create mode 100644 packages/dom/utils/overlay/README.md create mode 100644 packages/dom/utils/overlay/package.json rename packages/dom/utils/{dialog => overlay}/src/get-initial-focus.ts (74%) rename packages/dom/utils/{dialog => overlay}/src/hide-exiting-layer.ts (94%) rename packages/dom/utils/{dialog => overlay}/src/hide-outside.ts (95%) rename packages/dom/utils/{dialog => overlay}/src/index.ts (69%) create mode 100644 packages/dom/utils/overlay/src/stack.ts rename packages/dom/utils/{dialog => overlay}/src/watch-exit-animation.ts (95%) rename packages/dom/utils/{dialog/tests/stack.test.ts => overlay/tests/containment.test.ts} (77%) rename packages/dom/utils/{dialog => overlay}/tests/exit.test.ts (99%) diff --git a/.changeset/dialog-exit-animation.md b/.changeset/dialog-exit-animation.md index f93770f..e89e242 100644 --- a/.changeset/dialog-exit-animation.md +++ b/.changeset/dialog-exit-animation.md @@ -1,7 +1,7 @@ --- '@dunky.dev/dialog': minor '@dunky.dev/react-dialog': minor -'@dunky.dev/dom-dialog': minor +'@dunky.dev/dom-overlay': minor --- Add exit-animation support via a new `animated` option. An animated dialog diff --git a/.changeset/dom-dialog-package.md b/.changeset/dom-dialog-package.md deleted file mode 100644 index 6657df0..0000000 --- a/.changeset/dom-dialog-package.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@dunky.dev/dom-dialog': minor -'@dunky.dev/react-dialog': patch ---- - -Add `@dunky.dev/dom-dialog` — the dialog's framework-free DOM behavior, -extracted from the React binding: the shared layer stack (`registerDialog` / -`isTopmostDialog`) with its assistive-tech containment, and `getInitialFocus`. -These were React-free already but lived inside `@dunky.dev/react-dialog`, so -any other substrate would have had to copy them — forking behavior that must -stay identical everywhere. One module is also one stack: dialogs from -different substrates on the same page now stack, hide, and unwind correctly -against each other. `@dunky.dev/react-dialog` consumes it; its public API and -behavior are unchanged. - -```ts -import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' - -// On open: join the stack, then move focus in. -const unregister = registerDialog({ - id, - depth, // nesting level, 1 = top-level - element: content, // the dialog window - modal: true, - backdrop: () => backdropElement, // the layer's own backdrop stays pressable -}) -getInitialFocus(content).focus({ preventScroll: true }) -``` diff --git a/.changeset/overlay-packages.md b/.changeset/overlay-packages.md new file mode 100644 index 0000000..f5f4d1d --- /dev/null +++ b/.changeset/overlay-packages.md @@ -0,0 +1,29 @@ +--- +'@dunky.dev/overlay': minor +'@dunky.dev/dom-overlay': minor +'@dunky.dev/react-dialog': patch +--- + +Add `@dunky.dev/overlay` and `@dunky.dev/dom-overlay` — the shared overlay +coordination the whole overlay family (dialog, drawer, alert-dialog, popover, +menu, combobox) builds on, so the behavior is implemented once instead of +forked per primitive. + +- `@dunky.dev/overlay` is the agnostic half: a stack of open layers and the + rule for which is topmost (deepest nesting, open order breaking ties). No + DOM, no framework — a future native substrate reuses it. +- `@dunky.dev/dom-overlay` is the DOM realization on top of it: the layer + stack wired to assistive-tech containment (`aria-hidden` + `inert`), the + exit window (`hideExitingLayer` / `watchExitAnimation`), and initial focus + (`getInitialFocus`). + +```ts +import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' +import { registerLayer, isTopmostLayer } from '@dunky.dev/dom-overlay' +``` + +This replaces `@dunky.dev/dom-dialog`, which is removed — its behavior was +never dialog-specific, only its name was. `@dunky.dev/react-dialog` now +consumes `@dunky.dev/dom-overlay`; its public API and behavior are unchanged +(`registerDialog` / `isTopmostDialog` become `registerLayer` / +`isTopmostLayer` internally). diff --git a/packages/core/utils/overlay/README.md b/packages/core/utils/overlay/README.md new file mode 100644 index 0000000..ca31eb7 --- /dev/null +++ b/packages/core/utils/overlay/README.md @@ -0,0 +1,51 @@ +# @dunky.dev/overlay + +The agnostic half of overlay coordination: a stack of open overlay layers and +the rule for which one is **topmost**. The topmost layer is the one that owns +Escape, the focus trap, and — when modal — assistive-tech containment, so every +overlay primitive (dialog, drawer, popover, menu, combobox) must agree on it. + +This package is host-free — no DOM, no framework. It knows nothing about how a +layer is drawn or how containment is applied; it only tracks the layers and +resolves the topmost. A host binding extends it with a payload (a DOM element, a +native view) and its own containment: `@dunky.dev/dom-overlay` is the DOM one. + +- **Topmost** is the deepest-nested layer — highest `depth` — with open order + breaking ties between layers at the same depth. Depth, not registration or + document order, decides it: a host may insert a nested layer before its + parent (React portals do), inverting document order relative to nesting. +- **One stack per host.** A running app is browser or native, never both, so + each host binding creates a single stack every primitive registers into. That + shared instance is what makes one Escape close exactly one layer, even across + different primitives. + +## Install + +```sh +npm install @dunky.dev/overlay +``` + +## Usage + +```ts +import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' + +// A host binding extends OverlayLayer with whatever it needs to draw/contain. +interface DomLayer extends OverlayLayer { + element: HTMLElement + modal: boolean +} + +const stack = createLayerStack() + +// On open: join the stack. +const unregister = stack.register({ id, depth, element, modal: true }) + +// Escape, outside-press, focus trapping: only the topmost layer answers. +if (stack.isTopmost(id)) { + // ... +} + +// On close: leave the stack. +unregister() +``` diff --git a/packages/dom/utils/dialog/package.json b/packages/core/utils/overlay/package.json similarity index 73% rename from packages/dom/utils/dialog/package.json rename to packages/core/utils/overlay/package.json index a63f969..3c47e11 100644 --- a/packages/dom/utils/dialog/package.json +++ b/packages/core/utils/overlay/package.json @@ -1,12 +1,12 @@ { - "name": "@dunky.dev/dom-dialog", + "name": "@dunky.dev/overlay", "version": "0.0.0", - "description": "Framework-free DOM behavior for dialog substrates: the shared layer stack, assistive-tech containment, and initial focus.", + "description": "Agnostic overlay-layer stack: registration and topmost resolution, shared by every overlay primitive across substrates.", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/dunky-dev/ui.git", - "directory": "packages/dom/utils/dialog" + "directory": "packages/core/utils/overlay" }, "files": [ "dist" diff --git a/packages/core/utils/overlay/src/index.ts b/packages/core/utils/overlay/src/index.ts new file mode 100644 index 0000000..42e094c --- /dev/null +++ b/packages/core/utils/overlay/src/index.ts @@ -0,0 +1 @@ +export { createLayerStack, type LayerStack, type OverlayLayer } from './layer-stack' diff --git a/packages/core/utils/overlay/src/layer-stack.ts b/packages/core/utils/overlay/src/layer-stack.ts new file mode 100644 index 0000000..3033fe9 --- /dev/null +++ b/packages/core/utils/overlay/src/layer-stack.ts @@ -0,0 +1,63 @@ +// The overlay family — dialog, drawer, popover, menu, combobox — shares one +// coordination problem: when overlays stack, which layer is topmost? The +// topmost owns Escape, the focus trap, and (when modal) assistive-tech +// containment. This is the agnostic half of the answer: the registry and the +// topmost decision, with no host assumptions. A host realization (DOM, native) +// gives each layer a payload — the element or view — and applies its own +// containment when the stack shifts. + +export interface OverlayLayer { + id: string + // Nesting depth (1 = top-level). The deepest layer is topmost; open order + // breaks ties between layers at the same depth. Depth — not registration or + // document order — decides it, because a host may insert a nested layer + // before its parent (React portals do), inverting document order relative to + // nesting. + depth: number +} + +export interface LayerStack { + // Joins the layer to the stack; the returned disposer removes it. + register: (layer: T) => () => void + // The topmost layer, or undefined when the stack is empty. + topmost: () => T | undefined + isTopmost: (id: string) => boolean +} + +// One stack per running host: a browser page or a native app is one or the +// other, never both, so each host binding creates a single instance every +// primitive registers into — that shared instance is what makes one Escape +// close exactly one layer, even across different primitives. +export function createLayerStack(): LayerStack { + const layers: Array = [] + let nextOrder = 0 + + const topmost = (): T | undefined => { + let top: (T & { order: number }) | 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 + } + + return { + register(layer) { + const entry = { ...layer, order: nextOrder++ } + layers.push(entry) + return () => { + const index = layers.indexOf(entry) + if (index !== -1) layers.splice(index, 1) + } + }, + topmost, + isTopmost(id) { + return topmost()?.id === id + }, + } +} diff --git a/packages/core/utils/overlay/tests/layer-stack.test.ts b/packages/core/utils/overlay/tests/layer-stack.test.ts new file mode 100644 index 0000000..1ee7e5e --- /dev/null +++ b/packages/core/utils/overlay/tests/layer-stack.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { createLayerStack } from '@dunky.dev/overlay' + +interface TestLayer { + id: string + depth: number +} + +describe('createLayerStack', () => { + it('deeper nesting wins regardless of registration order', () => { + const stack = createLayerStack() + stack.register({ id: 'deep', depth: 2 }) + stack.register({ id: 'shallow', depth: 1 }) + + expect(stack.isTopmost('deep')).toBe(true) + expect(stack.isTopmost('shallow')).toBe(false) + expect(stack.topmost()?.id).toBe('deep') + }) + + it('open order breaks ties between layers at the same depth', () => { + const stack = createLayerStack() + stack.register({ id: 'first', depth: 1 }) + const unregisterSecond = stack.register({ id: 'second', depth: 1 }) + expect(stack.isTopmost('second')).toBe(true) + + unregisterSecond() + expect(stack.isTopmost('first')).toBe(true) + }) + + it('has no topmost when empty', () => { + const stack = createLayerStack() + expect(stack.topmost()).toBeUndefined() + expect(stack.isTopmost('anything')).toBe(false) + }) + + it('stacks are independent — registering in one never affects another', () => { + const a = createLayerStack() + const b = createLayerStack() + a.register({ id: 'x', depth: 1 }) + + expect(a.isTopmost('x')).toBe(true) + expect(b.topmost()).toBeUndefined() + }) +}) diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md deleted file mode 100644 index 0d40efd..0000000 --- a/packages/dom/utils/dialog/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# @dunky.dev/dom-dialog - -Framework-free DOM behavior for dialog substrates. One shared module owns -what every substrate's dialog must agree on: - -- **The layer stack** — `registerDialog` / `isTopmostDialog`. Every open - dialog registers a layer; the topmost (deepest nesting, open order breaking - ties) is the one Escape and the focus trap act on. While the topmost layer - is modal, everything outside its content is marked `aria-hidden` + `inert`, - except the layer's own backdrop — rendered outside the content's subtree - yet part of the layer, it must stay pressable for outside-press dismissal. - Unregistering restores exactly what was hidden and re-syncs for the layer - beneath. -- **Initial focus** — `getInitialFocus` resolves where focus moves on open: a - dialog that collects input starts at its first form field; any other - content keeps focus on the dialog window itself. -- **The exit window** — an animated dialog leaves the stack the moment it - starts closing, but keeps painting until its exit visual finishes. - `hideExitingLayer` takes the still-painting layer out of the page's - interaction (`inert`: pointer, tab order, assistive tech) for that window, - and `watchExitAnimation` reports when the visual finished — on the - element's own `transitionend`/`animationend`, immediately under - `prefers-reduced-motion`, or at a fallback ceiling so a missing exit style - can't hang the close. Both return a cancel/undo for the reopen interrupt. - -Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog` — so every -framework shares one stack: dialogs from different substrates on the same -page stack, hide, and unwind correctly against each other. - -## Install - -```sh -npm install @dunky.dev/dom-dialog -``` - -## Usage - -```ts -import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' - -// On open: join the stack, then move focus in. -const unregister = registerDialog({ - id, - depth, // nesting level, 1 = top-level - element: content, // the dialog window - modal: true, - backdrop: () => backdropElement, // stays pressable while topmost -}) -getInitialFocus(content).focus({ preventScroll: true }) - -// Escape, outside-press, focus trapping: only the topmost layer answers. -if (isTopmostDialog(id)) { - // ... -} - -// On close: leave the stack; the layer beneath is restored. -unregister() -``` diff --git a/packages/dom/utils/dialog/src/stack.ts b/packages/dom/utils/dialog/src/stack.ts deleted file mode 100644 index e4b89f6..0000000 --- a/packages/dom/utils/dialog/src/stack.ts +++ /dev/null @@ -1,68 +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: a substrate may insert a nested dialog's -// portal into the body *before* its parent's (React does), so document order -// can be the inverse of nesting. -export interface DialogLayer { - id: string - depth: number - order: number - element: HTMLElement - modal: boolean - /** - * Resolves the layer's own backdrop — rendered outside the content's subtree - * yet part of the layer, so it must stay pressable while its dialog is - * topmost. A getter (not a snapshot) so a re-hide — when a layer above - * closes — sees the element current at that moment. - */ - backdrop?: () => Element | null -} - -const layers: DialogLayer[] = [] -let nextOrder = 0 -let undoHide: (() => void) | undefined - -function topmost(): DialogLayer | undefined { - let top: DialogLayer | 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, top.backdrop?.() ?? null) -} - -export function registerDialog(layer: Omit): () => void { - const entry: DialogLayer = { ...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/dom/utils/overlay/README.md b/packages/dom/utils/overlay/README.md new file mode 100644 index 0000000..c37a3ea --- /dev/null +++ b/packages/dom/utils/overlay/README.md @@ -0,0 +1,59 @@ +# @dunky.dev/dom-overlay + +Framework-free DOM behavior for overlay substrates — the DOM realization of +`@dunky.dev/overlay`. One shared module owns what every substrate's overlay +(dialog, drawer, alert-dialog, popover, menu, combobox) must agree on: + +- **The layer stack** — `registerLayer` / `isTopmostLayer`, built on the + agnostic stack in `@dunky.dev/overlay`. Every open overlay registers a layer; + the topmost (deepest nesting, open order breaking ties) is the one Escape and + the focus trap act on. While the topmost layer is modal, everything outside + its content is marked `aria-hidden` + `inert`, except the layer's own backdrop + — rendered outside the content's subtree yet part of the layer, it must stay + pressable for outside-press dismissal. Unregistering restores exactly what was + hidden and re-syncs for the layer beneath. +- **Initial focus** — `getInitialFocus` resolves where focus moves on open: an + overlay that collects input starts at its first form field; any other content + keeps focus on the overlay window itself. +- **The exit window** — an animated overlay leaves the stack the moment it + starts closing, but keeps painting until its exit visual finishes. + `hideExitingLayer` takes the still-painting layer out of the page's + interaction (`inert`: pointer, tab order, assistive tech) for that window, + and `watchExitAnimation` reports when the visual finished — on the + element's own `transitionend`/`animationend`, immediately under + `prefers-reduced-motion`, or at a fallback ceiling so a missing exit style + can't hang the close. Both return a cancel/undo for the reopen interrupt. + +Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog` — so every +framework shares one stack: overlays from different substrates on the same +page stack, hide, and unwind correctly against each other. + +## Install + +```sh +npm install @dunky.dev/dom-overlay +``` + +## Usage + +```ts +import { getInitialFocus, isTopmostLayer, registerLayer } from '@dunky.dev/dom-overlay' + +// On open: join the stack, then move focus in. +const unregister = registerLayer({ + id, + depth, // nesting level, 1 = top-level + element: content, // the overlay window + modal: true, + backdrop: () => backdropElement, // stays pressable while topmost +}) +getInitialFocus(content).focus({ preventScroll: true }) + +// Escape, outside-press, focus trapping: only the topmost layer answers. +if (isTopmostLayer(id)) { + // ... +} + +// On close: leave the stack; the layer beneath is restored. +unregister() +``` diff --git a/packages/dom/utils/overlay/package.json b/packages/dom/utils/overlay/package.json new file mode 100644 index 0000000..01d08b5 --- /dev/null +++ b/packages/dom/utils/overlay/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dunky.dev/dom-overlay", + "version": "0.0.0", + "description": "Framework-free DOM behavior for overlay substrates: the shared layer stack with assistive-tech containment, the exit window, and initial focus.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/utils/overlay" + }, + "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/overlay": "workspace:*" + } +} diff --git a/packages/dom/utils/dialog/src/get-initial-focus.ts b/packages/dom/utils/overlay/src/get-initial-focus.ts similarity index 74% rename from packages/dom/utils/dialog/src/get-initial-focus.ts rename to packages/dom/utils/overlay/src/get-initial-focus.ts index db8badf..6d5b491 100644 --- a/packages/dom/utils/dialog/src/get-initial-focus.ts +++ b/packages/dom/utils/overlay/src/get-initial-focus.ts @@ -1,6 +1,6 @@ -// The strict rule is only that focus moves into the dialog: a dialog that +// The strict rule is only that focus moves into the overlay: an overlay that // collects input starts at its first form field; any other content keeps -// focus on the dialog window itself. +// focus on the overlay window itself. const FORM_FIELD_SELECTOR = 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled])' diff --git a/packages/dom/utils/dialog/src/hide-exiting-layer.ts b/packages/dom/utils/overlay/src/hide-exiting-layer.ts similarity index 94% rename from packages/dom/utils/dialog/src/hide-exiting-layer.ts rename to packages/dom/utils/overlay/src/hide-exiting-layer.ts index 5b3a0c9..09f1004 100644 --- a/packages/dom/utils/dialog/src/hide-exiting-layer.ts +++ b/packages/dom/utils/overlay/src/hide-exiting-layer.ts @@ -1,5 +1,5 @@ /** - * A closing dialog has already left the stack — the page beneath is live + * A closing overlay has already left the stack — the page beneath is live * again — but its layer keeps painting until the exit visual finishes. Take * the layer out of the page's interaction for that window (`inert` covers * pointer, tab order, and assistive tech): the content's outermost portalled diff --git a/packages/dom/utils/dialog/src/hide-outside.ts b/packages/dom/utils/overlay/src/hide-outside.ts similarity index 95% rename from packages/dom/utils/dialog/src/hide-outside.ts rename to packages/dom/utils/overlay/src/hide-outside.ts index 703afd9..f763ef9 100644 --- a/packages/dom/utils/dialog/src/hide-outside.ts +++ b/packages/dom/utils/overlay/src/hide-outside.ts @@ -6,7 +6,7 @@ const HIDE_SKIP = /^(SCRIPT|STYLE|LINK|TEMPLATE)$/ * 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. `exclude` names the one same-layer - * element rendered outside the target's subtree (the dialog's own backdrop, + * element rendered outside the target's subtree (the layer's own backdrop, * portalled alongside its viewport) that must stay pressable. Returns a * function that removes exactly what it added. Callers hide one target at a * time. diff --git a/packages/dom/utils/dialog/src/index.ts b/packages/dom/utils/overlay/src/index.ts similarity index 69% rename from packages/dom/utils/dialog/src/index.ts rename to packages/dom/utils/overlay/src/index.ts index e07bf03..eee4d03 100644 --- a/packages/dom/utils/dialog/src/index.ts +++ b/packages/dom/utils/overlay/src/index.ts @@ -1,4 +1,4 @@ -export { registerDialog, isTopmostDialog, type DialogLayer } from './stack' +export { registerLayer, isTopmostLayer, type Layer } from './stack' export { getInitialFocus } from './get-initial-focus' export { watchExitAnimation } from './watch-exit-animation' export { hideExitingLayer } from './hide-exiting-layer' diff --git a/packages/dom/utils/overlay/src/stack.ts b/packages/dom/utils/overlay/src/stack.ts new file mode 100644 index 0000000..fe1e497 --- /dev/null +++ b/packages/dom/utils/overlay/src/stack.ts @@ -0,0 +1,45 @@ +import { createLayerStack, type OverlayLayer } from '@dunky.dev/overlay' +import { hideOutside } from './hide-outside' + +// The DOM realization of the shared layer stack: each layer carries its +// element and modality, and the module-level instance keeps assistive-tech +// containment in sync as the stack shifts. +export interface Layer extends OverlayLayer { + element: HTMLElement + modal: boolean + /** + * Resolves the layer's own backdrop — rendered outside the content's subtree + * yet part of the layer, so it must stay pressable while its layer is + * topmost. A getter (not a snapshot) so a re-hide — when a layer above + * closes — sees the element current at that moment. + */ + backdrop?: () => Element | null +} + +const stack = createLayerStack() +let undoHide: (() => void) | undefined + +// Keep the assistive-tech view in sync: only the topmost modal layer stays +// reachable; everything else is hidden. Re-runs whenever the stack changes so a +// nested layer hides the one beneath it, and closing it restores the layer. +function syncContainment(): void { + undoHide?.() + undoHide = undefined + const top = stack.topmost() + if (top?.modal !== true) return + // `isConnected` guards teardown, when the content is already detached. + if (top.element.isConnected) undoHide = hideOutside(top.element, top.backdrop?.() ?? null) +} + +export function registerLayer(layer: Layer): () => void { + const unregister = stack.register(layer) + syncContainment() + return () => { + unregister() + syncContainment() + } +} + +export function isTopmostLayer(id: string): boolean { + return stack.isTopmost(id) +} diff --git a/packages/dom/utils/dialog/src/watch-exit-animation.ts b/packages/dom/utils/overlay/src/watch-exit-animation.ts similarity index 95% rename from packages/dom/utils/dialog/src/watch-exit-animation.ts rename to packages/dom/utils/overlay/src/watch-exit-animation.ts index 301403c..72c369a 100644 --- a/packages/dom/utils/dialog/src/watch-exit-animation.ts +++ b/packages/dom/utils/overlay/src/watch-exit-animation.ts @@ -1,6 +1,6 @@ // The ceiling on waiting for the exit visual: if the author styled no // transition/animation for `data-state="closing"` (or it never ends on this -// element), the dialog must still reach `closed` rather than hang mid-exit. +// element), the overlay must still reach `closed` rather than hang mid-exit. const EXIT_FALLBACK_MS = 500 /** diff --git a/packages/dom/utils/dialog/tests/stack.test.ts b/packages/dom/utils/overlay/tests/containment.test.ts similarity index 77% rename from packages/dom/utils/dialog/tests/stack.test.ts rename to packages/dom/utils/overlay/tests/containment.test.ts index dbe793a..a82dde8 100644 --- a/packages/dom/utils/dialog/tests/stack.test.ts +++ b/packages/dom/utils/overlay/tests/containment.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' -import type { DialogLayer } from '@dunky.dev/dom-dialog' +import { getInitialFocus, isTopmostLayer, registerLayer } from '@dunky.dev/dom-overlay' +import type { Layer } from '@dunky.dev/dom-overlay' interface MountedLayer { backdrop: HTMLElement @@ -10,7 +10,7 @@ interface MountedLayer { } // The anatomy every substrate portals to the body: backdrop and viewport as -// flat siblings, the dialog window inside the viewport. +// flat siblings, the overlay window inside the viewport. const mountLayer = (): MountedLayer => { const backdrop = document.createElement('div') const viewport = document.createElement('div') @@ -22,8 +22,8 @@ const mountLayer = (): MountedLayer => { const registered: Array<() => void> = [] -const register = (layer: Omit): (() => void) => { - const unregister = registerDialog(layer) +const register = (layer: Layer): (() => void) => { + const unregister = registerLayer(layer) registered.push(unregister) return unregister } @@ -37,7 +37,7 @@ afterEach(() => { document.body.innerHTML = '' }) -describe('registerDialog containment', () => { +describe('registerLayer containment', () => { it('hides everything outside the topmost modal layer and restores it on unregister', () => { const outside = document.createElement('main') document.body.append(outside) @@ -113,38 +113,12 @@ describe('registerDialog containment', () => { expect(hiddenFrom(outer.backdrop)).toBe(true) expect(hiddenFrom(outer.viewport)).toBe(true) expect(inner.backdrop.hasAttribute('inert')).toBe(false) + expect(isTopmostLayer('inner')).toBe(true) unregisterInner() expect(outer.backdrop.hasAttribute('inert')).toBe(false) expect(hiddenFrom(inner.viewport)).toBe(true) - }) -}) - -describe('isTopmostDialog', () => { - it('deeper nesting wins regardless of registration order', () => { - const shallow = mountLayer() - const deep = mountLayer() - register({ id: 'deep', depth: 2, element: deep.content, modal: true }) - register({ id: 'shallow', depth: 1, element: shallow.content, modal: true }) - - expect(isTopmostDialog('deep')).toBe(true) - expect(isTopmostDialog('shallow')).toBe(false) - }) - - it('open order breaks ties between layers at the same depth', () => { - const first = mountLayer() - const second = mountLayer() - register({ id: 'first', depth: 1, element: first.content, modal: true }) - const unregisterSecond = register({ - id: 'second', - depth: 1, - element: second.content, - modal: true, - }) - expect(isTopmostDialog('second')).toBe(true) - - unregisterSecond() - expect(isTopmostDialog('first')).toBe(true) + expect(isTopmostLayer('outer')).toBe(true) }) }) diff --git a/packages/dom/utils/dialog/tests/exit.test.ts b/packages/dom/utils/overlay/tests/exit.test.ts similarity index 99% rename from packages/dom/utils/dialog/tests/exit.test.ts rename to packages/dom/utils/overlay/tests/exit.test.ts index 841e975..6e38ec3 100644 --- a/packages/dom/utils/dialog/tests/exit.test.ts +++ b/packages/dom/utils/overlay/tests/exit.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { hideExitingLayer, watchExitAnimation } from '@dunky.dev/dom-dialog' +import { hideExitingLayer, watchExitAnimation } from '@dunky.dev/dom-overlay' afterEach(() => { document.body.innerHTML = '' diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 4338bcd..1b2ef19 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", - "@dunky.dev/dom-dialog": "workspace:*", "@dunky.dev/dom-navigation": "workspace:*", + "@dunky.dev/dom-overlay": "workspace:*", "@dunky.dev/react-state-machine": "^0.1.0", "@dunky.dev/react-use-focus-trap": "workspace:*", "@dunky.dev/react-use-scroll-lock": "workspace:*" diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 98bf9f7..7f1d39e 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -20,10 +20,10 @@ import { interceptBackNavigation } from '@dunky.dev/dom-navigation' import { getInitialFocus, hideExitingLayer, - isTopmostDialog, - registerDialog, + isTopmostLayer, + registerLayer, watchExitAnimation, -} from '@dunky.dev/dom-dialog' +} from '@dunky.dev/dom-overlay' import { mergeProps, normalize } from '@dunky.dev/react-state-machine' import { DialogContext, useDialogContext } from './context' import { useDialog } from './use-dialog' @@ -132,7 +132,7 @@ export const Backdrop: PartComponent = forw ...bindings, // Only the topmost dialog of a stack answers an outside press. onClick: (event: MouseEvent) => { - if (isTopmostDialog(machine.context.id)) onClick?.(event) + if (isTopmostLayer(machine.context.id)) onClick?.(event) }, }) @@ -164,7 +164,7 @@ export const Viewport: PartComponent = forw // 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) }, }) @@ -203,7 +203,7 @@ export const Content: PartComponent = for if (!api.open || content === null) return const previous = document.activeElement - const unregister = registerDialog({ + const unregister = registerLayer({ id: machine.context.id, depth, element: content, @@ -251,7 +251,7 @@ export const Content: PartComponent = for useFocusTrap(contentRef, { // Only a modal dialog traps, and only while topmost — a nested dialog // owns focus while open. - enabled: () => machine.context.modal && isTopmostDialog(machine.context.id), + enabled: () => machine.context.modal && isTopmostLayer(machine.context.id), // The Close part is the cycle's last stop wherever it renders (core // SPEC); found by its derived id. last: () => document.getElementById(api.ids.close), diff --git a/packages/react/dialog/src/effects.ts b/packages/react/dialog/src/effects.ts index 1f49690..6ec4cfc 100644 --- a/packages/react/dialog/src/effects.ts +++ b/packages/react/dialog/src/effects.ts @@ -1,6 +1,6 @@ import type { ComponentEffect } from '@dunky.dev/react-state-machine' import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog' -import { isTopmostDialog } from '@dunky.dev/dom-dialog' +import { isTopmostLayer } from '@dunky.dev/dom-overlay' // 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. @@ -25,7 +25,7 @@ const trackEscape: DialogEffect = [ if (event.key !== 'Escape' || !machine.matches('open')) return // Only the topmost dialog 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9f59242..1247ae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,12 +66,18 @@ importers: specifier: ^0.1.0 version: 0.1.0 - packages/dom/utils/dialog: {} + packages/core/utils/overlay: {} packages/dom/utils/focus-trap: {} packages/dom/utils/navigation: {} + packages/dom/utils/overlay: + dependencies: + '@dunky.dev/overlay': + specifier: workspace:* + version: link:../../../core/utils/overlay + packages/dom/utils/scroll-lock: {} packages/react: @@ -103,12 +109,12 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog - '@dunky.dev/dom-dialog': - specifier: workspace:* - version: link:../../dom/utils/dialog '@dunky.dev/dom-navigation': specifier: workspace:* version: link:../../dom/utils/navigation + '@dunky.dev/dom-overlay': + specifier: workspace:* + version: link:../../dom/utils/overlay '@dunky.dev/react-state-machine': specifier: ^0.1.0 version: 0.1.0(react@19.2.7) diff --git a/tsconfig.json b/tsconfig.json index 3ebacee..568c747 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,9 +15,10 @@ "strict": true, "paths": { "@dunky.dev/controllable": ["./packages/core/utils/controllable/src"], + "@dunky.dev/overlay": ["./packages/core/utils/overlay/src"], "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], - "@dunky.dev/dom-dialog": ["./packages/dom/utils/dialog/src"], + "@dunky.dev/dom-overlay": ["./packages/dom/utils/overlay/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], "@dunky.dev/dom-navigation": ["./packages/dom/utils/navigation/src"], "@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index d64d16c..a76ac1d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,8 +12,9 @@ export default defineConfig({ workspace: [ 'packages/core/dialog', 'packages/core/utils/controllable', - 'packages/dom/utils/dialog', + 'packages/core/utils/overlay', 'packages/dom/utils/focus-trap', + 'packages/dom/utils/overlay', 'packages/dom/utils/navigation', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', From a5068e3beb3b5472172fdb93010edd7b19171245 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 18:55:35 +0200 Subject: [PATCH 2/2] docs(dom-overlay): describe capabilities, not the export names The prose bullets leaked function names and DOM mechanism literals that drift from the code. Keep the names in the Usage example; describe behavior above it. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dom/utils/overlay/README.md | 33 +++++++++++++--------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/dom/utils/overlay/README.md b/packages/dom/utils/overlay/README.md index c37a3ea..848797a 100644 --- a/packages/dom/utils/overlay/README.md +++ b/packages/dom/utils/overlay/README.md @@ -4,25 +4,22 @@ Framework-free DOM behavior for overlay substrates — the DOM realization of `@dunky.dev/overlay`. One shared module owns what every substrate's overlay (dialog, drawer, alert-dialog, popover, menu, combobox) must agree on: -- **The layer stack** — `registerLayer` / `isTopmostLayer`, built on the - agnostic stack in `@dunky.dev/overlay`. Every open overlay registers a layer; - the topmost (deepest nesting, open order breaking ties) is the one Escape and - the focus trap act on. While the topmost layer is modal, everything outside - its content is marked `aria-hidden` + `inert`, except the layer's own backdrop - — rendered outside the content's subtree yet part of the layer, it must stay - pressable for outside-press dismissal. Unregistering restores exactly what was - hidden and re-syncs for the layer beneath. -- **Initial focus** — `getInitialFocus` resolves where focus moves on open: an - overlay that collects input starts at its first form field; any other content - keeps focus on the overlay window itself. +- **The layer stack** — the DOM side of the shared stack in `@dunky.dev/overlay`. + Every open overlay joins the stack; the topmost (deepest nesting, open order + breaking ties) is the one Escape and the focus trap act on. While a modal + layer is topmost, everything outside it is hidden from assistive tech and + taken out of pointer and keyboard reach — except the layer's own backdrop, + which stays pressable so an outside press can still dismiss. Closing a layer + restores exactly what it hid and hands the page to the layer beneath. +- **Initial focus** — where focus moves when an overlay opens: an overlay that + collects input starts at its first form field; any other content keeps focus + on the overlay window itself. - **The exit window** — an animated overlay leaves the stack the moment it - starts closing, but keeps painting until its exit visual finishes. - `hideExitingLayer` takes the still-painting layer out of the page's - interaction (`inert`: pointer, tab order, assistive tech) for that window, - and `watchExitAnimation` reports when the visual finished — on the - element's own `transitionend`/`animationend`, immediately under - `prefers-reduced-motion`, or at a fallback ceiling so a missing exit style - can't hang the close. Both return a cancel/undo for the reopen interrupt. + starts closing, but keeps painting until its exit visual finishes. For that + window the still-painting layer is taken out of interaction, and the end of + the visual is reported so the overlay can unmount — with a fallback ceiling + so a missing exit style can't hang the close, and skipped entirely under + reduced motion. Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog` — so every framework shares one stack: overlays from different substrates on the same