From da982c0520c64d092be9aa87b4fc5b70a2852842 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Fri, 4 Sep 2026 17:08:21 +0200 Subject: [PATCH] feat(dialog): a popup inside the dialog owns Tab and Escape while it holds focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A select menu, popover, menu, or combobox list inside a modal dialog never joins the layer stack, so the dialog kept treating itself as topmost: its capture-phase Escape closed the dialog under the popup, and its trap cancelled Tab inside the popup and pulled focus back into the window. - dom-overlay: `foreignPopupHoldsFocus(id)` and `expandedPopupControlHoldsFocus(id)` read the real keyboard owner from ARIA — the popup roles (aria-haspopup's values) and an expanded control with aria-haspopup — telling a registered layer from a popup that never registered. No cooperation from the popup is needed. - dom-dialog: the Escape listener stands down for either; the trap stands down for a foreign popup only (Tab is how an expanded control's popup is left). - SPECs: core behavior contract, dom-dialog, dom-overlay. Stories: a hand-rolled listbox inside the dialog, mirrored in React and Solid. Co-Authored-By: Claude Fable 5.1 --- .changeset/dialog-inner-popups.md | 15 ++ .changeset/overlay-popup-focus.md | 19 +++ packages/core/dialog/SPEC.md | 9 +- packages/dom/components/dialog/SPEC.md | 13 +- packages/dom/components/dialog/src/effects.ts | 14 +- .../dom/components/dialog/src/focus-trap.ts | 10 +- .../components/dialog/tests/dialog.test.ts | 45 ++++++ packages/dom/utils/overlay/SPEC.md | 26 ++++ packages/dom/utils/overlay/src/index.ts | 1 + packages/dom/utils/overlay/src/popup-focus.ts | 46 ++++++ packages/dom/utils/overlay/src/stack.ts | 16 +++ .../utils/overlay/tests/popup-focus.test.ts | 133 ++++++++++++++++++ .../react/dialog/stories/dialog.stories.tsx | 111 ++++++++++++++- .../solid/dialog/stories/dialog.stories.tsx | 113 ++++++++++++++- 14 files changed, 562 insertions(+), 9 deletions(-) create mode 100644 .changeset/dialog-inner-popups.md create mode 100644 .changeset/overlay-popup-focus.md create mode 100644 packages/dom/utils/overlay/src/popup-focus.ts create mode 100644 packages/dom/utils/overlay/tests/popup-focus.test.ts 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..777f9f4 --- /dev/null +++ b/.changeset/overlay-popup-focus.md @@ -0,0 +1,19 @@ +--- +'@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. + +```ts +enabled: () => isTopmostLayer(id) && !foreignPopupHoldsFocus(id) +``` 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..b864994 100644 --- a/packages/dom/components/dialog/tests/dialog.test.ts +++ b/packages/dom/components/dialog/tests/dialog.test.ts @@ -124,6 +124,36 @@ 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, + '', + ) + ;(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) + }) }) describe('openDialogLayer', () => { @@ -440,6 +470,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, + '', + ) + 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..ab364cb 100644 --- a/packages/dom/utils/overlay/SPEC.md +++ b/packages/dom/utils/overlay/SPEC.md @@ -54,6 +54,30 @@ 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 anything outside the window that is in no popup role at all — + the page is inert while a modal layer is open, so whatever holds focus out + there is a layer. Focus on the body doesn't count: 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 +121,8 @@ 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. | +| `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..867b9f1 100644 --- a/packages/dom/utils/overlay/src/index.ts +++ b/packages/dom/utils/overlay/src/index.ts @@ -1,4 +1,5 @@ export { registerLayer, isTopmostLayer, layersBelow, type Layer } from './stack' +export { 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..42f8b2e --- /dev/null +++ b/packages/dom/utils/overlay/src/popup-focus.ts @@ -0,0 +1,46 @@ +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. +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. Outside every registered window and in no popup role counts too: + * the page is inert while a modal layer is open, so whatever holds focus out + * there is a layer. The body (focus in browser chrome, or nowhere) doesn't: 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 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..3c349f4 --- /dev/null +++ b/packages/dom/utils/overlay/tests/popup-focus.test.ts @@ -0,0 +1,133 @@ +// @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 = ''): HTMLElement => { + const content = document.createElement('div') + content.tabIndex = -1 + content.innerHTML = html + document.body.append(content) + registered.push(registerLayer({ id, depth, element: content, modal: true })) + 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, + '', + ) + 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) + }) + + 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/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 ( +
+ + {open && ( +
    { + if (event.key === 'Escape') close() + if (event.key === 'Tab') setOpen(false) + }} + > + {editors.map(editor => ( +
  • pick(editor)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') pick(editor) + }} + > + {editor} +
  • + ))} +
+ )} +
+ ) +} + +export const innerPopup: StoryType = { + render: () => ( + + Open dialog + + + + + + Board settings + + Open the listbox, then press Tab and Escape: the popup answers first, the dialog only + once it is closed. + + +
+ +
+
+
+
+
+ ), +} + // 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/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 ( +
+ + +
    { + if (event.key === 'Escape') close() + if (event.key === 'Tab') setOpen(false) + }} + > + + {editor => ( +
  • pick(editor)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') pick(editor) + }} + > + {editor} +
  • + )} +
    +
+
+
+ ) +} + +export const innerPopup: StoryType = { + render: () => ( + + Open dialog + + + + + + Board settings + + Open the listbox, then press Tab and Escape: the popup answers first, the dialog only + once it is closed. + + +
+ +
+
+
+
+
+ ), +} + // 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