Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/dialog-accessible-name-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@dunky.dev/dom-dialog': patch
---

The dialog warns when it resolves no accessible name. The core contract —
a rendered Title, or an `aria-label` / `aria-labelledby` on Content, never
neither — was documented but unenforced, so a nameless dialog shipped
silently. `openDialogLayer` now checks the window's own attributes on open
and warns with the fix, the same loud treatment the stranded-focus miss
already gets. The check reads the DOM rather than the machine's
Title-presence flag (a label may arrive through a prop spread), defers a
macrotask so a rendered Title's registration commits first, and is cancelled
by the close.
21 changes: 21 additions & 0 deletions .changeset/dialog-drag-never-dismisses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@dunky.dev/dom-press-origin': minor
'@dunky.dev/dom-dialog': minor
'@dunky.dev/react-dialog': patch
'@dunky.dev/solid-dialog': patch
---

A text-selection drag no longer dismisses the dialog. A mousedown inside the
window with a mouseup outside it makes the browser fire `click` on their
common ancestor — the viewport or the backdrop — which was indistinguishable
from a genuine outside press by the click's target alone, so selecting text
across the window's edge closed the dialog and lost the form state with it.

Where a press *began* now decides, not where it ended. New package:
`@dunky.dev/dom-press-origin` — `trackPressOrigin(element)` captures each
press's origin at `pointerdown`, before the browser collapses the click
target; it is its own util because every light-dismissable layer needs the
same guard. `@dunky.dev/dom-dialog`'s `acceptsBackdropPress` /
`acceptsViewportPress` take the answer as a new `startedInside` argument and
refuse the press when the gesture started inside the window. A press that
starts and ends outside still dismisses as before.
26 changes: 26 additions & 0 deletions .changeset/dialog-nonmodal-pointer-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@dunky.dev/dom-overlay': minor
'@dunky.dev/dom-dialog': minor
'@dunky.dev/react-dialog': patch
'@dunky.dev/solid-dialog': patch
---

A non-modal dialog no longer blocks the page around it. `Dialog.Viewport` is
a full-coverage layer, so even with no backdrop rendered it swallowed every
press aimed at the page beneath — the page element never received it, and
the dialog dismissed instead. Now, while `modal={false}`, the Viewport
renders with `pointer-events: none` and the window with
`pointer-events: auto` (the consumer's own `style` wins over both), so
presses on the empty area reach the page and the window stays interactive.

Outside-press detection moves with it: a transparent Viewport never receives
the press it used to detect, so `@dunky.dev/dom-overlay` gains
`watchOutsidePress` — a document-level watch (the only vantage point that
sees both the portaled layer and the page) with the same refusals as the
element path: topmost layer only, the layer's element excepted, and the
trigger excepted so its own press stays a plain toggle instead of a
close-and-reopen. It lives in the overlay util, not the dialog package,
because any layer whose surface lets presses fall through will need it.
`@dunky.dev/dom-dialog`'s `viewportPointerEvents(modal)` and
`contentPointerEvents` carry the two style values so every DOM substrate
applies the same pair.
10 changes: 9 additions & 1 deletion packages/core/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ Using the dialog is a walkthrough of intent, not a prop list:
- Pressing the **backdrop** — or the **viewport** area around the dialog window —
counts as an "outside interaction"; whether that dismisses follows the
dialog's dismissal settings, whichever layer was pressed. Presses inside the
content never count as outside.
content never count as outside — including a press that starts inside and
releases outside (a text-selection drag): where a press ends doesn't decide,
where it began does. The trigger's own press is never an outside interaction
either; it stays a plain toggle.
- A **non-modal** dialog (`modal={false}`) coexists with the page: no backdrop,
no focus trap, no scroll lock, and the page around it stays fully
interactive — a press on the empty area around the window reaches whatever
the page has there. That press still counts as the dialog's outside
interaction (the trigger excepted), following the same dismissal settings.
- The **content** is the dialog window, labelled and described by the Title and
Description parts when they are rendered.
- **Title / Description** name and describe the dialog. The dialog's ARIA name
Expand Down
54 changes: 45 additions & 9 deletions packages/dom/components/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ typically because it lacks `tabindex="-1"` — focus is stranded outside the
layer, against the modal dialog pattern. That miss is loud: a `console.warn`
names the fix instead of failing silently.

The window's accessible name gets the same loud treatment: neither a
rendered Title (`aria-labelledby`) nor an explicit `aria-label` on the
window warns, rather than shipping a dialog assistive tech can't name.
Reading the window's own attributes — not the machine's Title-registered
flag — is deliberate: a consumer's label can arrive through a prop spread
rather than a typed prop, and the DOM is where it lands either way. The
check defers a macrotask so a rendered Title's registration commits first
(it arrives through the substrate's own re-render, not this call's turn),
and the close cancels it — a dialog gone before the check fires must not
warn about a window that no longer exists.

The disposer releases the stack **before** restoring focus. Both orders are
load-bearing: the stack must exist before focus moves in, and the layers
beneath must be un-inerted before focus can land on one of them.
Expand Down Expand Up @@ -119,6 +130,29 @@ dialog's to answer:
press must have started on the viewport itself, and then the same topmost
rule applies.

Both also refuse a press whose gesture began inside the window. A
`click`'s own target can't answer that once the browser has collapsed it: a
mousedown inside the window and a mouseup outside it fires `click` on their
common ancestor — the backdrop or the viewport, not where the press
actually started — so a text-selection drag that starts inside and releases
outside would otherwise read as a genuine outside press and lose whatever
the user was selecting. The origin comes from
[`@dunky.dev/dom-press-origin`](../../utils/press-origin/SPEC.md)'s
`trackPressOrigin`, which captures it at `pointerdown`, before that
collapse, the only point it's still recoverable — a substrate tracks the
window and feeds `startedInside` into both predicates.

A non-modal dialog has no Backdrop, and its Viewport lets a press on the
empty area fall through to the page rather than swallow it —
`viewportPointerEvents` is `none` there, `contentPointerEvents` on the
window stays `auto` regardless. That fall-through means Viewport never
receives those presses to detect them, so the substrate arms
[`@dunky.dev/dom-overlay`](../../utils/overlay/SPEC.md)'s
`watchOutsidePress` instead — the document-level outside-press watch for
layers whose own surfaces can't see one. It carries the same refusals as
the click-based path (topmost-only, the window excepted, the trigger
excepted, the started-inside guard); the dialog only supplies its pieces.

### Focus trap

`dialogTrapOptions` is the trap configuration the substrate hands to its
Expand All @@ -127,15 +161,17 @@ part is the cycle's last stop wherever it renders.

## API

| Export | Description |
| ------------------------------------- | --------------------------------------------------------------------------- |
| `domDialogEffects` | Core effects + the document Escape listener, as `DialogEffect` tuples. |
| `openDialogLayer(content, options)` | The open sequence; returns the close sequence. |
| `startExitWindow(content, options)` | Hides and watches the still-painting layer; returns the undo. |
| `guardBackNavigation(options)` | The history guard: report the open state as it changes, release at the end. |
| `acceptsBackdropPress(id)` | Whether a backdrop press is this dialog's outside interaction. |
| `acceptsViewportPress(id, event)` | Same for the viewport, ignoring presses that bubbled from the content. |
| `dialogTrapOptions(machine, closeId)` | `TrapFocusOptions` for the dialog window. |
| Export | Description |
| ------------------------------------------------ | ----------------------------------------------------------------------------------- |
| `domDialogEffects` | Core effects + the document Escape listener, as `DialogEffect` tuples. |
| `openDialogLayer(content, options)` | The open sequence; returns the close sequence. |
| `startExitWindow(content, options)` | Hides and watches the still-painting layer; returns the undo. |
| `guardBackNavigation(options)` | The history guard: report the open state as it changes, release at the end. |
| `acceptsBackdropPress(id, startedInside)` | Whether a backdrop press is this dialog's outside interaction. |
| `acceptsViewportPress(id, event, startedInside)` | Same for the viewport, ignoring presses that bubbled from the content. |
| `viewportPointerEvents(modal)` | The Viewport's `pointer-events` value: `undefined` while modal, `'none'` otherwise. |
| `contentPointerEvents` | The window's `pointer-events` value — always `'auto'`. |
| `dialogTrapOptions(machine, closeId)` | `TrapFocusOptions` for the dialog window. |

## Constraints

Expand Down
1 change: 1 addition & 0 deletions packages/dom/components/dialog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export {
type BackNavigationGuardOptions,
} from './back-navigation'
export { acceptsBackdropPress, acceptsViewportPress } from './press'
export { viewportPointerEvents, contentPointerEvents } from './viewport-style'
export { dialogTrapOptions } from './focus-trap'
19 changes: 19 additions & 0 deletions packages/dom/components/dialog/src/open-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,26 @@ export function openDialogLayer(content: HTMLElement, options: OpenDialogLayerOp
}
}

// The window's own attributes are the reliable read: a consumer's label may
// arrive through a prop spread rather than a typed prop, and the DOM is
// where it lands either way. Deferred a macrotask: a rendered Title
// registers presence through its own effect, and the canonical nesting
// (Title inside Content) settles before this call's own effect runs — but
// nothing requires that arrangement, so a Title rendered as a later
// sibling still gets a chance to register first. Cleared on close: a
// dialog that closes before the check fires must not warn about a window
// that no longer exists.
const nameCheck = setTimeout(() => {
if (!content.hasAttribute('aria-label') && !content.hasAttribute('aria-labelledby')) {
console.warn(
'[openDialogLayer] the dialog has no accessible name: render a <Dialog.Title>, ' +
'or pass aria-label / aria-labelledby to Content.',
)
}
})

return () => {
clearTimeout(nameCheck)
unregister()
if (previous instanceof HTMLElement) previous.focus({ preventScroll: true })
}
Expand Down
19 changes: 16 additions & 3 deletions packages/dom/components/dialog/src/press.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,31 @@ interface PressTarget {
/**
* Whether a backdrop press is this dialog's outside interaction. Only the
* topmost dialog of a stack answers one — a nested stack dismisses one layer
* at a time, the same rule Escape follows.
* at a time, the same rule Escape follows. `startedInside` (see
* `trackPressOrigin` in `@dunky.dev/dom-press-origin`) refuses a press whose gesture began inside the
* window: a text-selection drag that starts inside and releases on the
* backdrop must not read as an outside press.
*/
export function acceptsBackdropPress(id: string): boolean {
export function acceptsBackdropPress(id: string, startedInside: boolean): boolean {
if (startedInside) return false
return isTopmostLayer(id)
}

/**
* Whether a viewport press is this dialog's outside interaction. Content
* presses bubble up to the viewport, so only a press that started on the
* viewport itself counts — and then only for the topmost dialog.
* `startedInside` catches what the bubble check can't: a text-selection drag
* starting inside the window and releasing on the viewport's own background
* collapses the click's target to the viewport, passing the bubble check —
* see `trackPressOrigin`.
*/
export function acceptsViewportPress(id: string, event: PressTarget): boolean {
export function acceptsViewportPress(
id: string,
event: PressTarget,
startedInside: boolean,
): boolean {
if (startedInside) return false
if (event.target !== event.currentTarget) return false
return isTopmostLayer(id)
}
15 changes: 15 additions & 0 deletions packages/dom/components/dialog/src/viewport-style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* The Viewport's pointer-events reach. Modal: unset — it is the outside-
* press surface for whatever gutter sits around the window. Non-modal: the
* page coexists with the dialog, so the Viewport's own full-coverage box
* must not swallow presses meant for whatever sits beneath it — `none` lets
* them fall through; the window stays reachable regardless (see
* `contentPointerEvents`).
*/
export function viewportPointerEvents(modal: boolean): 'none' | undefined {
return modal ? undefined : 'none'
}

/** The dialog window always stays interactive, even where its Viewport
* disables pointer events to let non-modal presses fall through around it. */
export const contentPointerEvents = 'auto' as const
53 changes: 49 additions & 4 deletions packages/dom/components/dialog/tests/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const registered: (() => void)[] = []
const mountLayer = (id: string, depth: number, html = '', dismiss?: () => void): HTMLElement => {
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-label', 'Layer')
content.innerHTML = html
document.body.append(content)
registered.push(
Expand Down Expand Up @@ -137,6 +138,7 @@ describe('openDialogLayer', () => {
): { content: HTMLElement; close: () => void } => {
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-label', 'Dialog')
content.innerHTML = html
document.body.append(content)

Expand All @@ -154,6 +156,7 @@ describe('openDialogLayer', () => {
it('moves focus to the first form field, without scrolling the locked surface', () => {
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-label', 'Dialog')
content.innerHTML = '<input id="field" />'
document.body.append(content)
const field = document.getElementById('field') as HTMLInputElement
Expand All @@ -168,6 +171,7 @@ describe('openDialogLayer', () => {
it('honors an explicit initialFocus over the overlay default', () => {
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-label', 'Dialog')
content.innerHTML = '<input id="field" /><button id="pick">pick</button>'
document.body.append(content)
const pick = content.querySelector('#pick') as HTMLButtonElement
Expand All @@ -180,6 +184,7 @@ describe('openDialogLayer', () => {
it('falls back to the dialog window when the target refuses focus', () => {
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-label', 'Dialog')
content.innerHTML = '<input id="field" disabled />'
document.body.append(content)
const field = content.querySelector('#field') as HTMLInputElement
Expand All @@ -201,6 +206,32 @@ describe('openDialogLayer', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('tabindex="-1"'))
})

it('warns when the dialog has neither a Title nor an accessible label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const content = document.createElement('div')
content.tabIndex = -1
document.body.append(content)

registered.push(openDialogLayer(content, options))
await new Promise(resolve => setTimeout(resolve, 0))

expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name'))
})

it('does not warn when the window carries aria-labelledby', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const content = document.createElement('div')
content.tabIndex = -1
content.setAttribute('aria-labelledby', 'dlg-title')
content.innerHTML = '<h2 id="dlg-title">Title</h2>'
document.body.append(content)

registered.push(openDialogLayer(content, options))
await new Promise(resolve => setTimeout(resolve, 0))

expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('no accessible name'))
})

it('restores focus to whatever held it before the dialog opened', () => {
const trigger = document.createElement('button')
document.body.append(trigger)
Expand Down Expand Up @@ -357,19 +388,33 @@ describe('guardBackNavigation', () => {
describe('outside-press gating', () => {
it('lets only the topmost dialog answer a backdrop press', () => {
mountLayer('dlg', 1)
expect(acceptsBackdropPress('dlg')).toBe(true)
expect(acceptsBackdropPress('dlg', false)).toBe(true)

mountLayer('above', 2)
expect(acceptsBackdropPress('dlg')).toBe(false)
expect(acceptsBackdropPress('dlg', false)).toBe(false)
})

it('ignores a viewport press that bubbled up from the content', () => {
mountLayer('dlg', 1)
const viewport = document.createElement('div')
const content = document.createElement('div')

expect(acceptsViewportPress('dlg', { target: viewport, currentTarget: viewport })).toBe(true)
expect(acceptsViewportPress('dlg', { target: content, currentTarget: viewport })).toBe(false)
expect(acceptsViewportPress('dlg', { target: viewport, currentTarget: viewport }, false)).toBe(
true,
)
expect(acceptsViewportPress('dlg', { target: content, currentTarget: viewport }, false)).toBe(
false,
)
})

it('refuses a backdrop or viewport press whose gesture started inside the window', () => {
mountLayer('dlg', 1)
const viewport = document.createElement('div')

expect(acceptsBackdropPress('dlg', true)).toBe(false)
expect(acceptsViewportPress('dlg', { target: viewport, currentTarget: viewport }, true)).toBe(
false,
)
})
})

Expand Down
Loading
Loading