Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/calm-dialog-foundations.md
Original file line number Diff line number Diff line change
@@ -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 `<dialog>` 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 `<article>` inside `.dialog-layer` rather
than following it as an adjacent sibling.
11 changes: 10 additions & 1 deletion docs/components/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -83,6 +83,15 @@ Base components come from `@1001-digital/components`. Nuxt apps usually receive
</template>
```

`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 `<dialog>::backdrop`, so
custom selectors should use `.dialog[data-state='open']` and
`.dialog-overlay` rather than `dialog[open]` and `dialog::backdrop`. Compat
mode retains its `<article class="dialog compat">` surface, but the overlay is
now placed before it inside `.dialog-layer`.

Dropdown-related components:

`Dropdown`, `DropdownItem`, `DropdownGroup`, `DropdownLabel`, `DropdownSeparator`, `DropdownCheckboxItem`, `DropdownRadioGroup`, `DropdownRadioItem`, `DropdownSub`.
Expand Down
129 changes: 104 additions & 25 deletions packages/components/src/base/components/ConfirmDialog.vue
Original file line number Diff line number Diff line change
@@ -1,36 +1,115 @@
<template>
<Dialog
v-model:open="open"
:title="state?.title"
:closable="false"
:click-outside="false"
<AlertDialogRoot
:open="open"
@update:open="onOpenChange"
>
<p v-if="state?.description">{{ state.description }}</p>

<template #footer>
<Button
class="tertiary"
@click="resolve(false)"
<AlertDialogPortal :to="layerTarget">
<div
v-if="displayedState"
class="dialog-layer"
:data-layer-order="layerOrder"
:style="{ '--dialog-layer-order': layerOrder }"
>
{{ state?.cancelText || 'Cancel' }}
</Button>
<Button @click="resolve(true)">
{{ state?.okText || 'OK' }}
</Button>
</template>
</Dialog>
<AlertDialogOverlay class="dialog-overlay" />
<AlertDialogContent
as-child
@after-leave="onAfterLeave"
>
<DialogSurface class="confirm-dialog">
<AlertDialogTitle as="h1">
{{ displayedState?.title }}
</AlertDialogTitle>

<section>
<AlertDialogDescription
as="p"
:class="{
'dialog-visually-hidden': !displayedState?.description,
}"
>
{{
displayedState?.description ||
'Confirm or cancel this action.'
}}
</AlertDialogDescription>
</section>

<footer>
<AlertDialogCancel as-child>
<Button
type="button"
class="tertiary"
@click.capture="pendingResult = false"
>
{{ displayedState?.cancelText || 'Cancel' }}
</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button
type="button"
@click.capture="pendingResult = true"
>
{{ displayedState?.okText || 'OK' }}
</Button>
</AlertDialogAction>
</footer>
</DialogSurface>
</AlertDialogContent>
</div>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Dialog from './Dialog.vue'
import { computed, inject, shallowRef, watch } from 'vue'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
import DialogSurface from '../internal/DialogSurface.vue'
import { useDialogLayer } from '../internal/useDialogLayer'
import Button from './Button.vue'
import { useConfirm } from '../composables/confirm'
import { useConfirm, type ConfirmState } from '../composables/confirm'

const teleportTarget = inject<HTMLElement | null>('teleport-target', null)
const { state, resolve } = useConfirm()
const displayedState = shallowRef<ConfirmState | null>(null)
const open = computed(() => !!state.value)
const { layerOrder, layerTarget, releaseLayer } = useDialogLayer(
open,
teleportTarget,
)
let pendingResult: boolean | undefined

watch(
state,
(value) => {
if (value) {
displayedState.value = value
pendingResult = undefined
}
},
{ immediate: true },
)

const onOpenChange = (value: boolean) => {
if (value || !state.value) return

const result = pendingResult ?? false
pendingResult = undefined
resolve(result)
}

const onAfterLeave = () => {
if (open.value) return

const open = computed({
get: () => !!state.value,
set: () => resolve(false),
})
releaseLayer()
displayedState.value = null
}
</script>
Loading
Loading