Skip to content
Closed
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
63 changes: 63 additions & 0 deletions .changeset/floating.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<substrate>`) that owns the substrate's infrastructure — the dev
harness, framework deps, dev/build scripts. The publishable packages live one
Expand Down
10 changes: 6 additions & 4 deletions packages/core/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions packages/dom/utils/interact-outside/README.md
Original file line number Diff line number Diff line change
@@ -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()
```
36 changes: 36 additions & 0 deletions packages/dom/utils/interact-outside/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions packages/dom/utils/interact-outside/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { trackInteractOutside, type TrackInteractOutsideOptions } from './track-interact-outside'
88 changes: 88 additions & 0 deletions packages/dom/utils/interact-outside/src/track-interact-outside.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 =
'<div id="container"><button type="button" id="inside">inside</button></div>' +
'<button type="button" id="outside">outside</button>'
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()
})
})
Loading
Loading