diff --git a/.changeset/dialog-inner-popups.md b/.changeset/dialog-inner-popups.md
new file mode 100644
index 0000000..a642f33
--- /dev/null
+++ b/.changeset/dialog-inner-popups.md
@@ -0,0 +1,15 @@
+---
+'@dunky.dev/dom-dialog': patch
+---
+
+A popup opened inside the dialog owns Tab and Escape while it holds focus.
+
+A select menu, a popover, a menu, or a combobox list inside a modal dialog —
+from any library, as long as it carries ARIA popup semantics — is not a layer
+the stack knows, so the dialog kept treating itself as topmost: its
+capture-phase Escape closed the dialog together with the popup, and its focus
+trap cancelled Tab inside the popup and pulled focus back into the window. Both
+now stand down while such a popup holds focus and resume once focus is back in
+the window, so one Escape closes the popup and the next reaches the dialog. A
+control whose popup is expanded (a combobox input) hands over Escape only; Tab
+is how its popup is left, so the trap keeps it.
diff --git a/.changeset/overlay-popup-focus.md b/.changeset/overlay-popup-focus.md
new file mode 100644
index 0000000..d827cdd
--- /dev/null
+++ b/.changeset/overlay-popup-focus.md
@@ -0,0 +1,22 @@
+---
+'@dunky.dev/dom-overlay': minor
+---
+
+Two queries tell a layer when a popup it cannot see owns the keyboard:
+`foreignPopupHoldsFocus(id)` and `expandedPopupControlHoldsFocus(id)`.
+
+A popup can hold focus inside a layer without ever registering in the stack —
+a third-party listbox or menu, a combobox's list. The stack still names the
+layer topmost, so the layer would keep answering Escape and trapping Tab under
+the popup. The queries read the real owner from ARIA instead: focus in an
+element with a popup role (`aria-haspopup`'s values) that is neither the
+layer's window nor a registered layer, or on a control inside the window whose
+popup is expanded (`aria-expanded` with `aria-haspopup`). No cooperation from
+the popup is required.
+
+`POPUP_SELECTOR` — the selector naming those popup roles — is exported too, so
+a consumer can match the same set the queries do.
+
+```ts
+enabled: () => isTopmostLayer(id) && !foreignPopupHoldsFocus(id)
+```
diff --git a/README.md b/README.md
index 13f1a4c..51c3a18 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ up there with the same behavior and the same a11y.
The engine that runs the core machines lives in its own repo:
[dunky-dev/state-machine](https://github.com/dunky-dev/state-machine). Here it
-is just a dependency — this repo defines *what* each primitive does and how it
+is just a dependency — this repo defines _what_ each primitive does and how it
renders, not how machines execute.
## Layout
diff --git a/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md
index 2ca4129..57190d7 100644
--- a/packages/core/dialog/SPEC.md
+++ b/packages/core/dialog/SPEC.md
@@ -150,6 +150,12 @@ Per the [APG modal-dialog keyboard interaction](https://www.w3.org/WAI/ARIA/apg/
always the cycle's last stop, wherever it renders — the dismissal
affordance follows the content instead of interrupting it. Focus never tabs
out of the dialog.
+- **Inner popups**: a popup opened inside the dialog that is not itself a
+ dialog — a select menu, a popover, a menu, a combobox list, from this
+ library or any other — owns the keyboard while it holds focus: Tab stays in
+ it and Escape closes it alone. The dialog's trap and Escape resume once
+ focus is back in the window. The dialog recognizes the popup by its ARIA
+ popup semantics, so no cooperation from the popup is needed.
- **No focusables**: Tab is a no-op; focus stays on the dialog window.
- **On close**: focus returns to the element focused before opening (normally
the Trigger).
@@ -170,7 +176,8 @@ stack of dialogs only the topmost one exists until it closes.
- **Escape**: lands only on the topmost dialog, subject to that dialog's own
dismissal settings and veto. Its reach is that dialog's escape scope: one
layer (the default — the stack unwinds one layer per press) or the whole
- stack.
+ stack. An inner popup (select menu, popover, menu, combobox list) counts as
+ a layer of its own: one press closes it, the next reaches the dialog.
- **Outside press**: pressing around the topmost dialog is an outside
interaction for that dialog alone, following its own dismissal settings; the
dialogs beneath are unaffected.
diff --git a/packages/dom/components/dialog/SPEC.md b/packages/dom/components/dialog/SPEC.md
index 89c39f4..95cacc2 100644
--- a/packages/dom/components/dialog/SPEC.md
+++ b/packages/dom/components/dialog/SPEC.md
@@ -44,6 +44,14 @@ answer wherever focus is. Only the topmost layer answers, and it offers the
consumer's `onEscapeKeyDown` a veto through `preventDefault` before it moves
the machine.
+A popup inside the dialog that never joined the stack — a listbox or menu from
+another library, a control whose popup is expanded — answers before the dialog
+while it holds focus: the listener stands down until focus is back in the
+window, so one press closes the popup and the next reaches the dialog. The
+stack cannot name such a layer, so the answer is read from ARIA through the
+overlay util's focus queries (`foreignPopupHoldsFocus`,
+`expandedPopupControlHoldsFocus`).
+
How far an allowed Escape reaches is that dialog's `escapeScope`: itself, so a
nested stack unwinds one dialog per press, or the whole stack at once. Either
way the dialog that received the press is the only one that gates or vetoes
@@ -129,7 +137,10 @@ dialog's to answer:
`dialogTrapOptions` is the trap configuration the substrate hands to its
`trapFocus` wrapper: a modal dialog traps while it is topmost, and the Close
-part is the cycle's last stop wherever it renders.
+part is the cycle's last stop wherever it renders. The trap stands down while
+an unregistered popup inside the dialog holds focus — Tab is the popup's for
+as long as it does. A control whose popup is expanded keeps the trap: focus is
+still in the window, and Tab is how such a popup is left.
## API
diff --git a/packages/dom/components/dialog/src/effects.ts b/packages/dom/components/dialog/src/effects.ts
index 285b6d1..f225449 100644
--- a/packages/dom/components/dialog/src/effects.ts
+++ b/packages/dom/components/dialog/src/effects.ts
@@ -1,5 +1,10 @@
import { dialogEffects, type DialogEffect } from '@dunky.dev/dialog'
-import { isTopmostLayer, layersBelow } from '@dunky.dev/dom-overlay'
+import {
+ expandedPopupControlHoldsFocus,
+ foreignPopupHoldsFocus,
+ isTopmostLayer,
+ layersBelow,
+} from '@dunky.dev/dom-overlay'
// Escape is a document-level concern, not a part's — it must work wherever
// focus is.
@@ -9,7 +14,12 @@ 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, unless this dialog's scope is the whole stack.
- if (!isTopmostLayer(machine.context.id)) return
+ const { id } = machine.context
+ if (!isTopmostLayer(id)) return
+ // A popup inside the dialog that never joined the stack answers first
+ // while it holds focus — this listener runs in the capture phase, so
+ // it would otherwise close the dialog under the popup.
+ if (foreignPopupHoldsFocus(id) || expandedPopupControlHoldsFocus(id)) return
props.onEscapeKeyDown?.(event)
if (event.defaultPrevented) return
// Read the stack before the send: closing this layer releases it, and
diff --git a/packages/dom/components/dialog/src/focus-trap.ts b/packages/dom/components/dialog/src/focus-trap.ts
index e5ab97f..3caea49 100644
--- a/packages/dom/components/dialog/src/focus-trap.ts
+++ b/packages/dom/components/dialog/src/focus-trap.ts
@@ -1,6 +1,6 @@
import type { DialogMachine } from '@dunky.dev/dialog'
import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap'
-import { isTopmostLayer } from '@dunky.dev/dom-overlay'
+import { foreignPopupHoldsFocus, isTopmostLayer } from '@dunky.dev/dom-overlay'
/**
* The trap configuration for a dialog window, for whichever hook the substrate
@@ -11,8 +11,12 @@ import { isTopmostLayer } from '@dunky.dev/dom-overlay'
export function dialogTrapOptions(machine: DialogMachine, closeId: () => string): TrapFocusOptions {
return {
// Only a modal dialog traps, and only while topmost — a nested dialog
- // owns focus while open.
- enabled: () => machine.context.modal && isTopmostLayer(machine.context.id),
+ // owns focus while open, and so does a popup inside the dialog that holds
+ // it without having joined the stack.
+ enabled: () =>
+ machine.context.modal &&
+ isTopmostLayer(machine.context.id) &&
+ !foreignPopupHoldsFocus(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(closeId()),
diff --git a/packages/dom/components/dialog/tests/dialog.test.ts b/packages/dom/components/dialog/tests/dialog.test.ts
index 984516d..81755f8 100644
--- a/packages/dom/components/dialog/tests/dialog.test.ts
+++ b/packages/dom/components/dialog/tests/dialog.test.ts
@@ -29,12 +29,17 @@ const build = (options: DialogOptions = {}): DialogService => {
}
// The Escape listener is the last effect in the list; the ones before it are
-// the core's, covered by the core's own tests.
+// the core's, covered by the core's own tests. Every armed listener is
+// disposed after the test: the listener is document-level, so one left over
+// — a vetoing one above all — would answer the next test's Escape too.
+const armed: (() => void)[] = []
const armEscape = (service: DialogService, props: DialogOptions = {}): (() => void) => {
const [effect] = domDialogEffects[
domDialogEffects.length - 1
] as (typeof domDialogEffects)[number]
- return effect(service, props) ?? ((): void => {})
+ const dispose = effect(service, props) ?? ((): void => {})
+ armed.push(dispose)
+ return dispose
}
const pressEscape = (): boolean =>
@@ -57,6 +62,7 @@ const mountLayer = (id: string, depth: number, html = '', dismiss?: () => void):
}
afterEach(() => {
+ while (armed.length > 0) (armed.pop() as () => void)()
while (registered.length > 0) (registered.pop() as () => void)()
document.body.innerHTML = ''
vi.restoreAllMocks()
@@ -124,6 +130,55 @@ describe('domDialogEffects — Escape', () => {
pressEscape()
expect(service.matches('open')).toBe(true)
})
+
+ // A popup inside the dialog owns Escape while it holds focus — read from
+ // ARIA, so a popup that never registers in the stack counts too.
+ it('stands down while a popup inside the dialog holds focus', () => {
+ const service = build({ defaultOpen: true })
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '
a
',
+ )
+ ;(content.querySelector('#option') as HTMLElement).focus()
+ armEscape(service)
+
+ pressEscape()
+ expect(service.matches('open')).toBe(true)
+ })
+
+ it('stands down while a control with an expanded popup holds focus', () => {
+ const service = build({ defaultOpen: true })
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '',
+ )
+ ;(content.querySelector('#combo') as HTMLElement).focus()
+ armEscape(service)
+
+ pressEscape()
+ expect(service.matches('open')).toBe(true)
+ })
+
+ // Escape answers wherever focus is. A non-modal dialog leaves the page live,
+ // so focus out there is the page's — not a popup the dialog must yield to.
+ it('closes a non-modal dialog while focus sits on the page', () => {
+ const service = build({ defaultOpen: true, modal: false })
+ const content = document.createElement('div')
+ content.tabIndex = -1
+ document.body.append(content)
+ registered.push(
+ registerLayer({ id: 'dlg', depth: 1, element: content, modal: false, backdrop: () => null }),
+ )
+ const page = document.createElement('button')
+ document.body.append(page)
+ page.focus()
+ armEscape(service)
+
+ pressEscape()
+ expect(service.matches('open')).toBe(false)
+ })
})
describe('openDialogLayer', () => {
@@ -440,6 +495,21 @@ describe('dialogTrapOptions', () => {
expect(enabled?.()).toBe(false)
})
+ it('stands down while a popup inside the dialog holds focus', () => {
+ const service = build({ defaultOpen: true })
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '
a
',
+ )
+ const { enabled } = dialogTrapOptions(service, () => 'dlg-close')
+
+ ;(content.querySelector('#option') as HTMLElement).focus()
+ expect(enabled?.()).toBe(false)
+ content.focus()
+ expect(enabled?.()).toBe(true)
+ })
+
it('resolves Close as the cycle’s last stop, wherever it renders', () => {
const service = build({ defaultOpen: true })
mountLayer('dlg', 1, '')
diff --git a/packages/dom/utils/overlay/SPEC.md b/packages/dom/utils/overlay/SPEC.md
index 139b251..f1dae10 100644
--- a/packages/dom/utils/overlay/SPEC.md
+++ b/packages/dom/utils/overlay/SPEC.md
@@ -54,6 +54,32 @@ against each other.
- The backdrop is resolved through a getter, not a snapshot: a re-hide (a
layer above closing) sees the element current at that moment.
+### Popups the stack never sees
+
+A popup can hold focus inside a layer without registering — a listbox or menu
+from another library, a combobox's list. The stack still names the layer
+topmost, so the layer would keep answering Escape and trapping Tab under the
+popup. Two queries read the real owner from ARIA instead, and a layer consults
+them to stand down:
+
+- `foreignPopupHoldsFocus(id)` — focus sits in a popup that is neither the
+ layer's own window nor a registered layer: an element with a popup role
+ (`aria-haspopup`'s values — listbox, menu, tree, grid, dialog), wherever it
+ renders, or — for a modal layer — anything outside the window that is in no
+ popup role at all: its page is inert, so whatever holds focus out there is a
+ layer. A non-modal layer leaves the page live, so focus out there is the
+ page's and the layer keeps answering. Focus on the body never counts: the
+ layer re-enters from there. Registered layers never count as foreign — a
+ layer beneath is inert, and focus reported there re-enters the topmost
+ layer's trap.
+- `expandedPopupControlHoldsFocus(id)` — focus sits on a control inside the
+ window whose popup is expanded (`aria-expanded="true"` with `aria-haspopup`),
+ the way a combobox keeps focus on its input while its list is open.
+ `aria-expanded` alone is a disclosure, which has no popup to close.
+
+No cooperation from the popup is required — any well-formed ARIA popup works.
+A popup that does register is simply topmost, and the stack answers as usual.
+
### Initial focus
The strict rule is only that focus moves into the overlay: an overlay that
@@ -97,6 +123,9 @@ again — but keeps painting until its exit visual finishes:
| `Layer` | `OverlayLayer` + `element`, `modal`, an optional `backdrop` getter, and an optional `dismiss`. |
| `isTopmostLayer(id)` | Whether the layer owns Escape and the focus trap right now. |
| `layersBelow(id)` | The layers stacked beneath, topmost first — the unwinding order for a stack-scoped dismissal. |
+| `POPUP_SELECTOR` | The CSS selector naming the popup roles (`aria-haspopup`'s values) the queries read ownership from. |
+| `foreignPopupHoldsFocus(id)` | Whether focus sits in a popup that is neither the layer's window nor a registered layer. |
+| `expandedPopupControlHoldsFocus(id)` | Whether focus sits on a control inside the layer's window whose popup is expanded. |
| `getInitialFocus(content, designated?)` | The element to focus on open: `designated`, else first form field, else the overlay window — each step filtered for renderedness. |
| `hideExitingLayer(content, boundary, backdrop?)` | Inerts the still-painting layer for the exit window; returns the undo. |
| `watchExitAnimation(element, onComplete)` | Reports the exit visual's end once; returns the cancel. |
diff --git a/packages/dom/utils/overlay/src/index.ts b/packages/dom/utils/overlay/src/index.ts
index 1753d05..004d8d5 100644
--- a/packages/dom/utils/overlay/src/index.ts
+++ b/packages/dom/utils/overlay/src/index.ts
@@ -1,4 +1,9 @@
export { registerLayer, isTopmostLayer, layersBelow, type Layer } from './stack'
+export {
+ POPUP_SELECTOR,
+ foreignPopupHoldsFocus,
+ expandedPopupControlHoldsFocus,
+} from './popup-focus'
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/popup-focus.ts b/packages/dom/utils/overlay/src/popup-focus.ts
new file mode 100644
index 0000000..fe72d64
--- /dev/null
+++ b/packages/dom/utils/overlay/src/popup-focus.ts
@@ -0,0 +1,47 @@
+import { getLayer, layerContaining } from './stack'
+
+// A popup can hold focus inside a layer without ever joining the stack — a
+// listbox or menu from another library, a combobox's list — so the stack still
+// names the layer topmost while the popup owns the keyboard. Ownership is read
+// from ARIA instead: the popup roles (`aria-haspopup`'s values) mark the layer
+// focus sits in, and the stack tells a registered layer from a popup that
+// never registered. No cooperation is required — any well-formed ARIA popup
+// works.
+export const POPUP_SELECTOR =
+ '[role="listbox"], [role="menu"], [role="tree"], [role="grid"], [role="dialog"], [role="alertdialog"]'
+
+/**
+ * Whether focus sits in a popup that is neither the layer's own window nor a
+ * registered layer — wherever it renders, inside the window or portalled
+ * beside it. For a modal layer, outside every registered window and in no
+ * popup role counts too: its page is inert, so whatever holds focus out there
+ * is a layer. A non-modal layer leaves the page live, so focus out there is
+ * the page's and the layer keeps answering. The body (focus in browser chrome,
+ * or nowhere) never counts: the layer re-enters from there.
+ */
+export function foreignPopupHoldsFocus(id: string): boolean {
+ const layer = getLayer(id)
+ if (layer === undefined) return false
+ const active = document.activeElement
+ if (active === null || active === document.body) return false
+ const popup = active.closest(POPUP_SELECTOR)
+ if (popup === null) return layer.modal && layerContaining(active) === undefined
+ return popup !== layer.element && layerContaining(popup)?.element !== popup
+}
+
+/**
+ * Whether focus sits on a control inside the layer's window whose popup is
+ * expanded — a combobox keeps focus on its input while its list is open — so
+ * the popup owns Escape. `aria-expanded` alone isn't enough: a disclosure or
+ * accordion trigger is expanded too and has no popup to close, so
+ * `aria-haspopup` must name one.
+ */
+export function expandedPopupControlHoldsFocus(id: string): boolean {
+ const layer = getLayer(id)
+ if (layer === undefined) return false
+ const active = document.activeElement
+ if (active === null || !layer.element.contains(active)) return false
+ if (active.getAttribute('aria-expanded') !== 'true') return false
+ const popup = active.getAttribute('aria-haspopup')
+ return popup !== null && popup !== 'false'
+}
diff --git a/packages/dom/utils/overlay/src/stack.ts b/packages/dom/utils/overlay/src/stack.ts
index c0c8a90..b4dcac8 100644
--- a/packages/dom/utils/overlay/src/stack.ts
+++ b/packages/dom/utils/overlay/src/stack.ts
@@ -102,3 +102,19 @@ export function isTopmostLayer(id: string): boolean {
export function layersBelow(id: string): Layer[] {
return getStore().stack.below(id)
}
+
+export function getLayer(id: string): Layer | undefined {
+ for (const layer of getStore().stack.ordered()) {
+ if (layer.id === id) return layer
+ }
+ return undefined
+}
+
+// The registered layer whose window holds `node` — what tells a sibling in
+// the stack from a popup that never registered.
+export function layerContaining(node: Node): Layer | undefined {
+ for (const layer of getStore().stack.ordered()) {
+ if (layer.element.contains(node)) return layer
+ }
+ return undefined
+}
diff --git a/packages/dom/utils/overlay/tests/popup-focus.test.ts b/packages/dom/utils/overlay/tests/popup-focus.test.ts
new file mode 100644
index 0000000..5b7904a
--- /dev/null
+++ b/packages/dom/utils/overlay/tests/popup-focus.test.ts
@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ expandedPopupControlHoldsFocus,
+ foreignPopupHoldsFocus,
+ registerLayer,
+} from '@dunky.dev/dom-overlay'
+
+const registered: Array<() => void> = []
+
+// A registered layer window with the given markup inside it.
+const mountLayer = (id: string, depth: number, html = '', modal = true): HTMLElement => {
+ const content = document.createElement('div')
+ content.tabIndex = -1
+ content.innerHTML = html
+ document.body.append(content)
+ registered.push(registerLayer({ id, depth, element: content, modal }))
+ return content
+}
+
+// Markup appended beside the layers, the way a portalled popup lands.
+const mountBeside = (html: string): HTMLElement => {
+ const host = document.createElement('div')
+ host.innerHTML = html
+ document.body.append(host)
+ return host
+}
+
+const focus = (root: ParentNode, selector: string): void => {
+ ;(root.querySelector(selector) as HTMLElement).focus()
+}
+
+afterEach(() => {
+ for (const unregister of registered) unregister()
+ registered.length = 0
+ document.body.innerHTML = ''
+})
+
+describe('foreignPopupHoldsFocus', () => {
+ it('is false while focus sits in the layer window itself', () => {
+ const content = mountLayer('dlg', 1, '')
+ focus(content, '#action')
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(false)
+ })
+
+ it('is true while focus sits in a popup rendered inside the window', () => {
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '
a
',
+ )
+ focus(content, '#option')
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(true)
+ })
+
+ it('is true while focus sits in a popup portalled beside the window', () => {
+ mountLayer('dlg', 1)
+ const beside = mountBeside('')
+ focus(beside, '#inside')
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(true)
+ })
+
+ // Outside the window and not in a popup role: the page is inert while a
+ // modal layer is open, so whatever holds focus out there is a layer.
+ it('is true while focus sits outside the window in an element with no popup role', () => {
+ mountLayer('dlg', 1)
+ const beside = mountBeside('')
+ focus(beside, '#floating')
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(true)
+ })
+
+ // A non-modal layer leaves the page live: focus out there is the page's,
+ // not a popup's.
+ it('is false while focus sits outside a non-modal layer in an element with no popup role', () => {
+ mountLayer('dlg', 1, '', false)
+ const beside = mountBeside('')
+ focus(beside, '#page')
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(false)
+ })
+
+ it('is false while focus sits in another registered layer', () => {
+ const lower = mountLayer('lower', 1, '')
+ mountLayer('upper', 2)
+ focus(lower, '#beneath')
+
+ expect(foreignPopupHoldsFocus('upper')).toBe(false)
+ })
+
+ it('is false while focus sits on the body', () => {
+ mountLayer('dlg', 1)
+ ;(document.activeElement as HTMLElement | null)?.blur()
+
+ expect(foreignPopupHoldsFocus('dlg')).toBe(false)
+ })
+
+ it('is false for an id the stack does not know', () => {
+ const beside = mountBeside('')
+ focus(beside, '#item')
+
+ expect(foreignPopupHoldsFocus('nobody')).toBe(false)
+ })
+})
+
+describe('expandedPopupControlHoldsFocus', () => {
+ it('is true while focus sits on a control inside the window whose popup is expanded', () => {
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '',
+ )
+ focus(content, '#combo')
+
+ expect(expandedPopupControlHoldsFocus('dlg')).toBe(true)
+ })
+
+ it('is false once the popup collapses', () => {
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '',
+ )
+ focus(content, '#combo')
+
+ expect(expandedPopupControlHoldsFocus('dlg')).toBe(false)
+ })
+
+ // Expanded, but with no popup to close: a disclosure or accordion trigger.
+ it('is false for an expanded control without a popup', () => {
+ const content = mountLayer(
+ 'dlg',
+ 1,
+ '',
+ )
+ focus(content, '#disclosure')
+
+ expect(expandedPopupControlHoldsFocus('dlg')).toBe(false)
+ })
+})
diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md
index f874feb..4e7b816 100644
--- a/packages/react/dialog/SPEC.md
+++ b/packages/react/dialog/SPEC.md
@@ -149,11 +149,11 @@ The positioning + scroll layer around the dialog window.
The dialog window; renders a `
` with the `dialog` role.
-| Prop | Type | Default | Description |
-| -------------- | -------------------------------- | ----------------- | ------------------------------------------- |
-| `initialFocus` | `RefObject` | the dialog window | The element to focus when the dialog opens. |
+| Prop | Type | Default | Description |
+| -------------- | -------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| `initialFocus` | `RefObject` | the dialog window | The element to focus when the dialog opens. |
| `restoreFocus` | `RefObject` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed). Typically the trigger. |
-| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
### `Dialog.Title`
diff --git a/packages/react/dialog/stories/dialog.stories.tsx b/packages/react/dialog/stories/dialog.stories.tsx
index aa022ed..750edd1 100644
--- a/packages/react/dialog/stories/dialog.stories.tsx
+++ b/packages/react/dialog/stories/dialog.stories.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState, type CSSProperties } from 'react'
+import { useEffect, useRef, useState, type CSSProperties } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Dialog } from '@dunky.dev/react-dialog'
@@ -69,6 +69,24 @@ const input: CSSProperties = {
borderRadius: 6,
font: 'inherit',
}
+const listbox: CSSProperties = {
+ position: 'absolute',
+ top: '100%',
+ left: 0,
+ minWidth: 220,
+ margin: '4px 0 0',
+ padding: 4,
+ listStyle: 'none',
+ background: 'white',
+ border: '1px solid #ccc',
+ borderRadius: 6,
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.16)',
+}
+const option: CSSProperties = {
+ padding: '6px 10px',
+ borderRadius: 4,
+ cursor: 'pointer',
+}
// A scoped dialog opens inside a container instead of over the whole page: it
// portals into that element, and its overlay layers switch from `fixed`
// (viewport-pinned) to `absolute` (container-pinned).
@@ -424,6 +442,97 @@ export const nested: StoryType = {
render: () => ,
}
+// A popup inside the dialog that never joins the layer stack — a third-party
+// listbox stands in. While it holds focus, Tab and Escape are its: the
+// dialog's trap and Escape stand down until focus is back in the window, so
+// one Escape closes the listbox and the next closes the dialog.
+const editors = ['Team members', 'Anyone with the link', 'Only me']
+const Listbox = () => {
+ const [open, setOpen] = useState(false)
+ const [value, setValue] = useState(editors[0])
+ const buttonRef = useRef(null)
+ const listRef = useRef(null)
+ const close = () => {
+ setOpen(false)
+ buttonRef.current?.focus()
+ }
+ const pick = (editor: string) => {
+ setValue(editor)
+ close()
+ }
+ useEffect(() => {
+ if (open) listRef.current?.querySelector('[role="option"]')?.focus()
+ }, [open])
+ return (
+
+ )
+}
+
+export const innerPopup: StoryType = {
+ render: () => (
+
+ ),
+}
+
// closeOnBack turns the host's Back into a dismissal: while the dialog is open,
// a guard entry sits in the session history, so the browser's Back closes the
// dialog instead of leaving the page — what mobile users expect from a
diff --git a/packages/solid/dialog/SPEC.md b/packages/solid/dialog/SPEC.md
index f0f98ea..4166469 100644
--- a/packages/solid/dialog/SPEC.md
+++ b/packages/solid/dialog/SPEC.md
@@ -155,11 +155,11 @@ The positioning + scroll layer around the dialog window.
The dialog window; renders a `
` with the `dialog` role.
-| Prop | Type | Default | Description |
-| -------------- | --------------------------------------------------------- | ----------------- | ------------------------------------------------------------------- |
-| `initialFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | the dialog window | The element to focus when the dialog opens — resolved at open time. |
+| Prop | Type | Default | Description |
+| -------------- | --------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `initialFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | the dialog window | The element to focus when the dialog opens — resolved at open time. |
| `restoreFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed) — resolved at close time. Typically the trigger. |
-| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
### `Dialog.Title`
diff --git a/packages/solid/dialog/stories/dialog.stories.tsx b/packages/solid/dialog/stories/dialog.stories.tsx
index a1c71ba..b2cf3b9 100644
--- a/packages/solid/dialog/stories/dialog.stories.tsx
+++ b/packages/solid/dialog/stories/dialog.stories.tsx
@@ -1,4 +1,4 @@
-import { createSignal, Repeat } from 'solid-js'
+import { createEffect, createSignal, For, Repeat, Show } from 'solid-js'
import type { JSX } from '@solidjs/web'
import type { Meta, StoryObj } from 'storybook-solidjs-vite'
import { Dialog } from '@dunky.dev/solid-dialog'
@@ -101,6 +101,25 @@ const scopedViewport: JSX.CSSProperties = { ...viewport, position: 'absolute' }
// through state — see the alertDialog story.
const closableContent: JSX.CSSProperties = { ...content, position: 'relative' }
+const listbox: JSX.CSSProperties = {
+ position: 'absolute',
+ top: '100%',
+ left: '0',
+ 'min-width': '220px',
+ margin: '4px 0 0',
+ padding: '4px',
+ 'list-style': 'none',
+ background: 'white',
+ border: '1px solid #ccc',
+ 'border-radius': '6px',
+ 'box-shadow': '0 4px 16px rgba(0, 0, 0, 0.16)',
+}
+const option: JSX.CSSProperties = {
+ padding: '6px 10px',
+ 'border-radius': '4px',
+ cursor: 'pointer',
+}
+
const CloseButton = () => (
×
@@ -427,6 +446,98 @@ export const nested: StoryType = {
render: () => ,
}
+// A popup inside the dialog that never joins the layer stack — a third-party
+// listbox stands in. While it holds focus, Tab and Escape are its: the
+// dialog's trap and Escape stand down until focus is back in the window, so
+// one Escape closes the listbox and the next closes the dialog.
+const editors = ['Team members', 'Anyone with the link', 'Only me']
+const Listbox = () => {
+ const [open, setOpen] = createSignal(false)
+ const [value, setValue] = createSignal(editors[0])
+ const [button, setButton] = createSignal()
+ const [list, setList] = createSignal()
+ const close = () => {
+ setOpen(false)
+ button()?.focus()
+ }
+ const pick = (editor: string) => {
+ setValue(editor)
+ close()
+ }
+ createEffect(() => {
+ if (open()) list()?.querySelector('[role="option"]')?.focus()
+ })
+ return (
+
+ )
+}
+
+export const innerPopup: StoryType = {
+ render: () => (
+
+ ),
+}
+
// closeOnBack turns the host's Back into a dismissal: while the dialog is open,
// a guard entry sits in the session history, so the browser's Back closes the
// dialog instead of leaving the page — what mobile users expect from a