Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/dialog-exit-animation.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 0 additions & 28 deletions .changeset/dom-dialog-package.md

This file was deleted.

29 changes: 29 additions & 0 deletions .changeset/overlay-packages.md
Original file line number Diff line number Diff line change
@@ -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).
51 changes: 51 additions & 0 deletions packages/core/utils/overlay/README.md
Original file line number Diff line number Diff line change
@@ -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<DomLayer>()

// 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()
```
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/core/utils/overlay/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createLayerStack, type LayerStack, type OverlayLayer } from './layer-stack'
63 changes: 63 additions & 0 deletions packages/core/utils/overlay/src/layer-stack.ts
Original file line number Diff line number Diff line change
@@ -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<T extends OverlayLayer> {
// 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<T extends OverlayLayer>(): LayerStack<T> {
const layers: Array<T & { order: number }> = []
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
},
}
}
44 changes: 44 additions & 0 deletions packages/core/utils/overlay/tests/layer-stack.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestLayer>()
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<TestLayer>()
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<TestLayer>()
expect(stack.topmost()).toBeUndefined()
expect(stack.isTopmost('anything')).toBe(false)
})

it('stacks are independent — registering in one never affects another', () => {
const a = createLayerStack<TestLayer>()
const b = createLayerStack<TestLayer>()
a.register({ id: 'x', depth: 1 })

expect(a.isTopmost('x')).toBe(true)
expect(b.topmost()).toBeUndefined()
})
})
58 changes: 0 additions & 58 deletions packages/dom/utils/dialog/README.md

This file was deleted.

68 changes: 0 additions & 68 deletions packages/dom/utils/dialog/src/stack.ts

This file was deleted.

Loading
Loading