From 41db3f7072fc87244ee633dba0c20f124201d7f8 Mon Sep 17 00:00:00 2001 From: jalil Date: Tue, 18 Aug 2026 17:13:21 +0200 Subject: [PATCH] Move dialogs onto Reka UI primitives --- .changeset/calm-dialog-foundations.md | 14 + docs/components/base.md | 11 +- .../src/base/components/ConfirmDialog.vue | 129 ++++- .../components/src/base/components/Dialog.vue | 448 +++++------------- .../src/base/internal/DialogSurface.vue | 226 +++++++++ .../src/base/internal/useDialogLayer.ts | 110 +++++ .../test/components/ConfirmDialog.test.ts | 128 +++++ .../components/test/components/Dialog.test.ts | 335 +++++++++++++ packages/components/test/setup.ts | 11 + 9 files changed, 1060 insertions(+), 352 deletions(-) create mode 100644 .changeset/calm-dialog-foundations.md create mode 100644 packages/components/src/base/internal/DialogSurface.vue create mode 100644 packages/components/src/base/internal/useDialogLayer.ts create mode 100644 packages/components/test/components/ConfirmDialog.test.ts create mode 100644 packages/components/test/components/Dialog.test.ts diff --git a/.changeset/calm-dialog-foundations.md b/.changeset/calm-dialog-foundations.md new file mode 100644 index 00000000..3e338f64 --- /dev/null +++ b/.changeset/calm-dialog-foundations.md @@ -0,0 +1,14 @@ +--- +'@1001-digital/components': minor +'@1001-digital/layers.base': patch +--- + +Move dialogs and confirmations onto Reka UI primitives for reliable modal, +focus, dismissal, and nested-layer accessibility while preserving the existing +wrapper APIs and transition-complete `closed` event. + +The non-compat surface is no longer a native `` with a `::backdrop`. +Style integrations should target `.dialog[data-state='open']` and +`.dialog-overlay` instead of `dialog[open]` and `dialog::backdrop`. In compat +mode, the overlay now precedes the `
` inside `.dialog-layer` rather +than following it as an adjacent sibling. diff --git a/docs/components/base.md b/docs/components/base.md index a8d3fba3..370f0924 100644 --- a/docs/components/base.md +++ b/docs/components/base.md @@ -62,7 +62,7 @@ Base components come from `@1001-digital/components`. Nuxt apps usually receive | Component | Purpose | | --------------- | -------------------------------------------------------------------------------------- | -| `Dialog` | Accessible dialog wrapper around native dialog behavior. | +| `Dialog` | Accessible modal dialog built on Reka UI primitives. | | `ConfirmDialog` | Global confirm dialog controlled by `useConfirm()`. | | `Popover` | Floating panel for contextual content. | | `Tooltip` | Accessible tooltip. | @@ -83,6 +83,15 @@ Base components come from `@1001-digital/components`. Nuxt apps usually receive ``` +`title` provides the visible accessible name. For titleless dialogs, use +`label` or the standard `aria-label`; `description` associates visible help +text with the dialog. Dialog surfaces expose `data-state="open|closed"`. They +use a `.dialog-overlay` element instead of native `::backdrop`, so +custom selectors should use `.dialog[data-state='open']` and +`.dialog-overlay` rather than `dialog[open]` and `dialog::backdrop`. Compat +mode retains its `
` surface, but the overlay is +now placed before it inside `.dialog-layer`. + Dropdown-related components: `Dropdown`, `DropdownItem`, `DropdownGroup`, `DropdownLabel`, `DropdownSeparator`, `DropdownCheckboxItem`, `DropdownRadioGroup`, `DropdownRadioItem`, `DropdownSub`. diff --git a/packages/components/src/base/components/ConfirmDialog.vue b/packages/components/src/base/components/ConfirmDialog.vue index 2a267ead..2d97807d 100644 --- a/packages/components/src/base/components/ConfirmDialog.vue +++ b/packages/components/src/base/components/ConfirmDialog.vue @@ -1,36 +1,115 @@ diff --git a/packages/components/src/base/components/Dialog.vue b/packages/components/src/base/components/Dialog.vue index 4c7bfff7..1a7fed17 100644 --- a/packages/components/src/base/components/Dialog.vue +++ b/packages/components/src/base/components/Dialog.vue @@ -1,97 +1,111 @@ - - diff --git a/packages/components/src/base/internal/DialogSurface.vue b/packages/components/src/base/internal/DialogSurface.vue new file mode 100644 index 00000000..abe74703 --- /dev/null +++ b/packages/components/src/base/internal/DialogSurface.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/packages/components/src/base/internal/useDialogLayer.ts b/packages/components/src/base/internal/useDialogLayer.ts new file mode 100644 index 00000000..3a192e3f --- /dev/null +++ b/packages/components/src/base/internal/useDialogLayer.ts @@ -0,0 +1,110 @@ +import { + onBeforeUnmount, + onMounted, + ref, + shallowRef, + watch, + type Ref, +} from 'vue' + +interface DialogLayer { + id: symbol + order: Ref +} + +interface DialogLayerRegistry { + host: HTMLElement + layers: DialogLayer[] + users: number +} + +const registries = new WeakMap() + +const createRegistry = (target: HTMLElement): DialogLayerRegistry => { + const host = document.createElement('div') + host.className = 'dialog-layer-root' + host.style.zIndex = 'var(--z-index-dialog)' + target.append(host) + + return { host, layers: [], users: 0 } +} + +const rankLayers = (layers: DialogLayer[]) => { + layers.forEach((layer, order) => { + layer.order.value = order + }) +} + +/** + * Orders active dialogs inside one shared stacking context. The host stays at + * the public dialog z-index, so nested dialogs never overtake global toasts. + * Host creation is deferred until mount to keep server output deterministic. + */ +export const useDialogLayer = ( + open: Readonly>, + teleportTarget: HTMLElement | null, +) => { + const id = Symbol('dialog-layer') + const layerOrder = ref(0) + const layerTarget = shallowRef(teleportTarget || 'body') + let registry: DialogLayerRegistry | undefined + let active = false + + const activateLayer = () => { + if (!registry) return + + if (active) { + const index = registry.layers.findIndex((layer) => layer.id === id) + if (index !== -1 && index !== registry.layers.length - 1) { + const layer = registry.layers[index] + if (!layer) return + + registry.layers.splice(index, 1) + registry.layers.push(layer) + rankLayers(registry.layers) + } + return + } + + registry.layers.push({ id, order: layerOrder }) + active = true + rankLayers(registry.layers) + } + + const releaseLayer = () => { + if (!registry || !active) return + + const index = registry.layers.findIndex((layer) => layer.id === id) + if (index !== -1) registry.layers.splice(index, 1) + active = false + layerOrder.value = 0 + rankLayers(registry.layers) + } + + watch(open, (value) => { + if (value) activateLayer() + }) + + onMounted(() => { + const target = teleportTarget || document.body + registry = registries.get(target) || createRegistry(target) + registries.set(target, registry) + registry.users += 1 + layerTarget.value = registry.host + + if (open.value) activateLayer() + }) + + onBeforeUnmount(() => { + if (!registry) return + + releaseLayer() + registry.users -= 1 + if (registry.users === 0) { + registry.host.remove() + registries.delete(teleportTarget || document.body) + } + }) + + return { layerOrder, layerTarget, releaseLayer } +} diff --git a/packages/components/test/components/ConfirmDialog.test.ts b/packages/components/test/components/ConfirmDialog.test.ts new file mode 100644 index 00000000..f2ff2f23 --- /dev/null +++ b/packages/components/test/components/ConfirmDialog.test.ts @@ -0,0 +1,128 @@ +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { defineComponent, ref } from 'vue' +import BaseDialog from '../../src/base/components/Dialog.vue' +import ConfirmDialog from '../../src/base/components/ConfirmDialog.vue' +import { useConfirm } from '../../src/base/composables/confirm' + +describe('ConfirmDialog', () => { + it('focuses safe cancellation, ignores outside interaction, and never submits', async () => { + const view = render( + defineComponent({ + components: { ConfirmDialog }, + setup() { + const result = ref('idle') + const submissions = ref(0) + const { confirm } = useConfirm() + const ask = async () => { + result.value = 'pending' + result.value = String( + await confirm({ + title: 'Delete artwork?', + description: 'This cannot be undone.', + okText: 'Delete', + }), + ) + } + return { ask, result, submissions } + }, + template: ` +
+ + + + {{ result }} + {{ submissions }} + `, + }), + ) + const user = userEvent.setup() + const opener = view.getByRole('button', { name: 'Ask to delete' }) + + await user.click(opener) + const dialog = await view.findByRole('alertdialog', { + name: 'Delete artwork?', + }) + const cancel = view.getByRole('button', { name: 'Cancel' }) + const action = view.getByRole('button', { name: 'Delete' }) + + await waitFor(() => expect(document.activeElement).toBe(cancel)) + expect(cancel.getAttribute('type')).toBe('button') + expect(action.getAttribute('type')).toBe('button') + + await fireEvent.pointerDown(document.body) + expect(view.getByRole('alertdialog')).toBe(dialog) + + expect( + ( + await axe(document.body, { + rules: { 'color-contrast': { enabled: false } }, + }) + ).violations, + ).toEqual([]) + + await user.click(action) + await waitFor(() => + expect(view.getByTestId('result').textContent).toBe('true'), + ) + expect(view.getByTestId('submissions').textContent).toBe('0') + + await user.click(opener) + await view.findByRole('alertdialog', { name: 'Delete artwork?' }) + await user.click(view.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => + expect(view.getByTestId('result').textContent).toBe('false'), + ) + expect(view.getByTestId('submissions').textContent).toBe('0') + + await user.click(opener) + await view.findByRole('alertdialog', { name: 'Delete artwork?' }) + await user.keyboard('{Escape}') + + await waitFor(() => + expect(view.getByTestId('result').textContent).toBe('false'), + ) + expect(view.getByTestId('submissions').textContent).toBe('0') + }) + + it('stacks a confirmation above an already-open dialog', async () => { + const view = render( + defineComponent({ + components: { BaseDialog, ConfirmDialog }, + setup() { + const { confirm } = useConfirm() + const ask = () => { + void confirm({ title: 'Confirm nested action?' }) + } + + return { ask, open: ref(true) } + }, + template: ` + + + + + `, + }), + ) + const user = userEvent.setup() + const outer = await view.findByRole('dialog', { name: 'Outer dialog' }) + const outerLayer = Number( + outer.closest('.dialog-layer')?.dataset.layerOrder, + ) + + await user.click(view.getByRole('button', { name: 'Ask for confirmation' })) + const confirmation = await view.findByRole('alertdialog', { + name: 'Confirm nested action?', + }) + const confirmationLayer = Number( + confirmation.closest('.dialog-layer')?.dataset.layerOrder, + ) + + expect(confirmationLayer).toBeGreaterThan(outerLayer) + await user.click(view.getByRole('button', { name: 'Cancel' })) + }) +}) diff --git a/packages/components/test/components/Dialog.test.ts b/packages/components/test/components/Dialog.test.ts new file mode 100644 index 00000000..18611252 --- /dev/null +++ b/packages/components/test/components/Dialog.test.ts @@ -0,0 +1,335 @@ +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { defineComponent, ref } from 'vue' +import BaseDialog from '../../src/base/components/Dialog.vue' + +describe('Dialog', () => { + it('is named, described, keyboard-contained, and has a safe close control', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { open: ref(false), surfaceClicks: ref(0) } + }, + template: ` + + + + + + {{ surfaceClicks }} + `, + }), + ) + const user = userEvent.setup() + const opener = view.getByRole('button', { name: 'Open preferences' }) + + await user.click(opener) + const dialog = await view.findByRole('dialog', { name: 'Preferences' }) + const close = view.getByRole('button', { name: 'Close Preferences' }) + + expect(dialog.id).toBe('preferences-dialog') + expect(dialog.getAttribute('data-purpose')).toBe('settings') + expect(dialog.getAttribute('aria-describedby')).toBeTruthy() + expect(close.getAttribute('type')).toBe('button') + await user.click(view.getByRole('button', { name: 'First setting' })) + expect(view.getByTestId('surface-clicks').textContent).toBe('1') + await waitFor(() => + expect(dialog.contains(document.activeElement)).toBe(true), + ) + + for (let index = 0; index < 5; index++) { + await user.tab() + expect(dialog.contains(document.activeElement)).toBe(true) + } + + expect( + ( + await axe(document.body, { + rules: { 'color-contrast': { enabled: false } }, + }) + ).violations, + ).toEqual([]) + }) + + it('honors Escape and outside dismissal policies and restores the opener', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { open: ref(false), locked: ref(true) } + }, + template: ` + + + + + {{ open }} + `, + }), + ) + const user = userEvent.setup() + const opener = view.getByRole('button', { name: 'Open account' }) + + await user.click(opener) + const dialog = await view.findByRole('dialog', { name: 'Account' }) + + await fireEvent.pointerDown(document.body) + await user.keyboard('{Escape}') + expect(view.getByTestId('open-state').textContent).toBe('true') + + await user.click(view.getByRole('button', { name: 'Allow closing' })) + await user.keyboard('{Escape}') + expect(view.getByTestId('open-state').textContent).toBe('false') + + if (dialog.isConnected) { + await fireEvent.animationEnd(dialog, { + animationName: 'dialog-content-exit', + }) + } + await waitFor(() => expect(document.activeElement).toBe(opener)) + }) + + it('dismisses on outside pointer interaction when allowed', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { open: ref(false) } + }, + template: ` + + + Content + + {{ open }} + `, + }), + ) + const user = userEvent.setup() + + await user.click( + view.getByRole('button', { name: 'Open dismissible dialog' }), + ) + await view.findByRole('dialog', { name: 'Dismissible dialog' }) + await fireEvent.pointerDown(document.body) + + await waitFor(() => + expect(view.getByTestId('open-state').textContent).toBe('false'), + ) + }) + + it('updates forwarded attributes and uses aria-label as its fallback name', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + const dialogId = ref('original-dialog') + const dialogLabel = ref('Original label') + const descriptionId = ref() + const purpose = ref('original') + const width = ref('20rem') + const update = () => { + dialogId.value = 'updated-dialog' + dialogLabel.value = 'Updated label' + descriptionId.value = 'updated-description' + purpose.value = 'updated' + width.value = '36rem' + } + + return { + descriptionId, + dialogId, + dialogLabel, + open: ref(true), + purpose, + update, + width, + } + }, + template: ` +

The updated description.

+ + + + `, + }), + ) + const user = userEvent.setup() + const dialog = await view.findByRole('dialog', { name: 'Original label' }) + + expect(dialog.id).toBe('original-dialog') + expect(dialog.getAttribute('aria-describedby')).toBeNull() + expect(dialog.getAttribute('data-purpose')).toBe('original') + expect(dialog.style.getPropertyValue('--dialog-width')).toBe('20rem') + + await user.click(view.getByRole('button', { name: 'Update attributes' })) + await waitFor(() => + expect(view.getByRole('dialog', { name: 'Updated label' })).toBe(dialog), + ) + expect(dialog.id).toBe('updated-dialog') + expect(dialog.getAttribute('aria-label')).toBe('Updated label') + expect(dialog.getAttribute('aria-describedby')).toBe('updated-description') + expect(dialog.getAttribute('data-purpose')).toBe('updated') + expect(dialog.style.getPropertyValue('--dialog-width')).toBe('36rem') + }) + + it('stacks dialogs by open order instead of permanent mount order', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { firstOpen: ref(false), secondOpen: ref(false) } + }, + template: ` + + + First + + + + + `, + }), + ) + const user = userEvent.setup() + + await user.click(view.getByRole('button', { name: 'Open second dialog' })) + const second = await view.findByRole('dialog', { name: 'Second dialog' }) + const secondLayer = Number( + second.closest('.dialog-layer')?.dataset.layerOrder, + ) + + await user.click(view.getByRole('button', { name: 'Open first dialog' })) + let first = await view.findByRole('dialog', { name: 'First dialog' }) + let firstLayerElement = first.closest('.dialog-layer') + const firstLayer = Number(firstLayerElement?.dataset.layerOrder) + + expect(firstLayer).toBeGreaterThan(secondLayer) + const layerRoot = + firstLayerElement?.closest('.dialog-layer-root') + expect(layerRoot).toBeTruthy() + expect(layerRoot!.style.zIndex).toBe('var(--z-index-dialog)') + expect( + firstLayerElement!.style.getPropertyValue('--dialog-layer-order'), + ).toBe('1') + + for (let cycle = 0; cycle < 2; cycle++) { + await user.keyboard('{Escape}') + await fireEvent.animationEnd(first, { + animationName: 'dialog-content-exit', + }) + await waitFor(() => + expect( + second.closest('.dialog-layer')?.dataset.layerOrder, + ).toBe('0'), + ) + + await user.click(view.getByRole('button', { name: 'Open first dialog' })) + first = await view.findByRole('dialog', { name: 'First dialog' }) + firstLayerElement = first.closest('.dialog-layer') + expect(firstLayerElement?.dataset.layerOrder).toBe('1') + } + }) + + it('preserves the compat article shape with full dialog semantics', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { open: ref(false) } + }, + template: ` + + +

Review the legacy transaction.

+ +
+ `, + }), + ) + const user = userEvent.setup() + + await user.click(view.getByRole('button', { name: 'Open legacy flow' })) + const dialog = await view.findByRole('dialog', { + name: 'Legacy transaction flow', + }) + + expect(dialog.tagName).toBe('ARTICLE') + expect(dialog.classList.contains('compat')).toBe(true) + expect(dialog.classList.contains('open')).toBe(true) + expect(dialog.classList.contains('transaction-flow')).toBe(true) + expect(dialog.getAttribute('aria-describedby')).toBe('legacy-description') + await waitFor(() => + expect(dialog.contains(document.activeElement)).toBe(true), + ) + }) + + it('emits closed only after the content leave animation', async () => { + const view = render( + defineComponent({ + components: { BaseDialog }, + setup() { + return { open: ref(true), closed: ref(0) } + }, + template: ` + + + Content + + {{ closed }} + `, + }), + ) + const dialog = await view.findByRole('dialog', { name: 'Timed dialog' }) + + await fireEvent.animationStart(dialog, { + animationName: 'dialog-content-enter', + }) + await fireEvent.click( + view.getByRole('button', { name: 'Close from owner', hidden: true }), + ) + + expect(view.getByTestId('closed-count').textContent).toBe('0') + expect(dialog.getAttribute('data-state')).toBe('closed') + + await fireEvent.animationEnd(dialog, { + animationName: 'dialog-content-exit', + }) + await waitFor(() => + expect(view.getByTestId('closed-count').textContent).toBe('1'), + ) + }) +}) diff --git a/packages/components/test/setup.ts b/packages/components/test/setup.ts index 1d6976fb..7e56a60a 100644 --- a/packages/components/test/setup.ts +++ b/packages/components/test/setup.ts @@ -24,6 +24,17 @@ Object.defineProperty(globalThis, 'PointerEvent', { value: MouseEvent, }) +if (!globalThis.CSS) { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: {}, + }) +} + +if (!globalThis.CSS.escape) { + globalThis.CSS.escape = (value) => value +} + Object.defineProperties(HTMLElement.prototype, { hasPointerCapture: { configurable: true,