Dialog
A flexible overlay for showing messages, forms, or actions. Keeps focus on content while allowing clear, user-friendly interactions.
Share
A typical real-world dialog — rich #default body, an action-row #actions slot that mixes a left-side status with a right-side CTA, and Dropdowns nested inside the body for inline role changes.
<script setup lang="ts">
// Share dialog — invite teammates by email and adjust per-row access.
// Rich `#default` body + a simple `#actions` slot CTA.
import { ref } from 'vue'
import { Avatar, Button, Dialog, Select, TextInput } from 'frappe-ui'
const open = ref(false)
type Role = 'view' | 'comment' | 'edit'
type Member = {
name: string
email: string
initials: string
image: string
role: Role | 'owner'
you?: boolean
}
const inviteEmail = ref('')
const avatarFor = (seed: string) => `https://i.pravatar.cc/80?u=${seed}`
const members = ref<Member[]>([
{
name: 'Faris Ansari',
email: 'faris@example.com',
initials: 'FA',
image: avatarFor('faris@example.com'),
role: 'owner',
you: true,
},
{
name: 'Ankush Menat',
email: 'ankush@example.com',
initials: 'AM',
image: avatarFor('ankush@example.com'),
role: 'edit',
},
{
name: 'Shariq Ansari',
email: 'shariq@example.com',
initials: 'SA',
image: avatarFor('shariq@example.com'),
role: 'comment',
},
])
const roleOptions = [
{ label: 'Can view', value: 'view' },
{ label: 'Can comment', value: 'comment' },
{ label: 'Can edit', value: 'edit' },
]
function invite() {
const email = inviteEmail.value.trim()
if (!email) return
const local = email.split('@')[0]
members.value.push({
name: local.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
email,
initials: local.slice(0, 2).toUpperCase(),
image: avatarFor(email),
role: 'edit',
})
inviteEmail.value = ''
}
</script>
<template>
<Button icon-left="lucide-share-2" @click="open = true">Share…</Button>
<Dialog v-model:open="open" size="lg" title="Share "Q4 Roadmap"">
<div class="flex flex-col gap-5">
<div class="flex gap-2">
<TextInput
v-model="inviteEmail"
type="email"
placeholder="Add people by email…"
class="flex-1"
@keydown.enter="invite"
/>
<Button variant="solid" :disabled="!inviteEmail" @click="invite">
Invite
</Button>
</div>
<div class="flex flex-col gap-1">
<p class="text-p-sm text-ink-gray-5">People with access</p>
<div class="flex flex-col">
<div
v-for="member in members"
:key="member.email"
class="flex items-center gap-3 py-1.5"
>
<Avatar :image="member.image" :label="member.initials" size="md" />
<div class="min-w-0 flex-1 leading-tight">
<div class="text-base text-ink-gray-9">
{{ member.name }}
<span
v-if="member.you"
class="ml-0.5 text-p-xs text-ink-gray-5"
>
(you)
</span>
</div>
<div class="truncate text-p-sm text-ink-gray-5">
{{ member.email }}
</div>
</div>
<span
v-if="member.role === 'owner'"
class="text-p-sm text-ink-gray-5"
>
Owner
</span>
<Select
v-else
v-model="member.role"
:options="roleOptions"
class="w-36 shrink-0"
/>
</div>
</div>
</div>
</div>
<template #actions="{ close }">
<div class="flex justify-end">
<Button variant="solid" @click="close">Done</Button>
</div>
</template>
</Dialog>
</template>Multi-step wizard
One <Dialog> instance, four steps. The body content swaps with internal state while the dialog only animates in once; the title, dismissible flag, and primary CTA all react to the current step.
<script setup lang="ts">
// Multi-step wizard inside one `<Dialog>` instance. The body content,
// `title`, primary CTA, and `dismissible` flag all swap with the
// current step — the dialog itself only animates in once.
import { computed, reactive, ref } from 'vue'
import { Button, Dialog, FormControl, TextInput } from 'frappe-ui'
const open = ref(false)
const step = ref(0)
const steps = ['Project', 'Team', 'Done'] as const
const form = reactive({
template: 'roadmap',
name: '',
emails: '',
})
const templateOptions = [
{ label: 'Roadmap', value: 'roadmap' },
{ label: 'Sprint board', value: 'sprint' },
{ label: 'Wiki', value: 'wiki' },
]
const title = computed(() => {
if (step.value === 0) return 'Set up your workspace'
if (step.value === 1) return 'Invite your team'
return 'You\'re all set'
})
const inviteCount = computed(
() => form.emails.split(/[\s,;]+/).filter(Boolean).length,
)
function reset() {
step.value = 0
form.template = 'roadmap'
form.name = ''
form.emails = ''
}
</script>
<template>
<Button variant="solid" @click="(reset(), (open = true))">Get started</Button>
<Dialog
v-model:open="open"
size="lg"
:title="title"
:dismissible="step === steps.length - 1"
>
<div class="mb-5 flex items-center gap-1.5">
<span
v-for="(_, i) in steps"
:key="i"
class="h-1 flex-1 rounded-full transition-colors"
:class="i <= step ? 'bg-surface-gray-7' : 'bg-surface-gray-3'"
/>
</div>
<div v-if="step === 0" class="flex flex-col gap-4">
<FormControl
label="Project name"
v-model="form.name"
placeholder="e.g. Q4 Roadmap"
autofocus
/>
<FormControl
type="select"
label="Template"
v-model="form.template"
:options="templateOptions"
/>
</div>
<div v-else-if="step === 1" class="flex flex-col gap-3">
<p class="text-base text-ink-gray-7">
Add teammates so they can jump in too. Paste a comma- or
space-separated list of emails.
</p>
<TextInput
v-model="form.emails"
placeholder="jane@example.com, marco@example.com"
/>
<p class="text-p-sm text-ink-gray-5">
{{ inviteCount }}
{{ inviteCount === 1 ? 'invite' : 'invites' }} ready.
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-base text-ink-gray-8">
{{ form.name || 'Your workspace' }} is ready to go.
</p>
<p class="text-p-sm text-ink-gray-5">
We'll set up the
{{ templateOptions.find((t) => t.value === form.template)?.label }}
template{{
inviteCount > 0
? ` and send ${inviteCount} ${
inviteCount === 1 ? 'invite' : 'invites'
}.`
: '.'
}}
</p>
</div>
<template #actions="{ close }">
<div class="flex w-full justify-between gap-2">
<Button v-if="step > 0 && step < steps.length - 1" @click="step -= 1">
Back
</Button>
<span v-else />
<div class="flex gap-2">
<Button v-if="step < steps.length - 1" @click="close">Skip</Button>
<Button
v-if="step < steps.length - 1"
variant="solid"
:disabled="step === 0 && !form.name.trim()"
@click="step += 1"
>
{{ step === steps.length - 2 ? 'Finish' : 'Continue' }}
</Button>
<Button v-else variant="solid" @click="close">Done</Button>
</div>
</div>
</template>
</Dialog>
</template>Full canvas (bare)
bare: true strips all auto-chrome — no padded card, no auto-header, no auto-actions. Pair it with Dialog.Title for an accessible heading when you don't want visual chrome. A command palette is the canonical reason to reach for it.
<script setup lang="ts">
// Command palette — the canonical reason to reach for `bare: true`.
// The visual is a floating search panel with input + list + hint footer,
// not a card with padding and a header, so we strip the auto-chrome and
// lay out the body directly. `Dialog.Title` keeps the heading accessible
// without rendering one.
import { computed, ref, watch } from 'vue'
import { Button, Dialog, KeyboardShortcut, TextInput } from 'frappe-ui'
const open = ref(false)
const query = ref('')
const activeIndex = ref(0)
type Command = {
label: string
hint: string
icon: string
combo?: string
}
const commands: Command[] = [
{
label: 'New project',
hint: 'Create',
icon: 'lucide-folder-plus',
combo: 'Mod+N',
},
{ label: 'New task', hint: 'Create', icon: 'lucide-plus', combo: 'Mod+T' },
{ label: 'Invite teammate', hint: 'People', icon: 'lucide-user-plus' },
{
label: 'Search documents',
hint: 'Find',
icon: 'lucide-file-search',
combo: 'Mod+/',
},
{
label: 'Open settings',
hint: 'Workspace',
icon: 'lucide-settings',
combo: 'Mod+,',
},
{ label: 'Switch theme', hint: 'Preferences', icon: 'lucide-moon-star' },
{ label: 'Log out', hint: 'Account', icon: 'lucide-log-out' },
]
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return commands
return commands.filter((c) => c.label.toLowerCase().includes(q))
})
watch([query, open], () => {
activeIndex.value = 0
})
function run(cmd: Command) {
// eslint-disable-next-line no-console
console.log('run:', cmd.label)
open.value = false
}
function move(delta: number) {
if (!filtered.value.length) return
activeIndex.value =
(activeIndex.value + delta + filtered.value.length) % filtered.value.length
}
function onEnter() {
const cmd = filtered.value[activeIndex.value]
if (cmd) run(cmd)
}
</script>
<template>
<Button @click="open = true">
<template #prefix>
<span class="lucide-search size-4" />
</template>
Open command palette
</Button>
<Dialog v-model:open="open" size="lg" bare>
<Dialog.Title as-child>
<h2 class="sr-only">Command palette</h2>
</Dialog.Title>
<div class="flex flex-col">
<ul class="max-h-80 overflow-y-auto p-2">
<TextInput
class="mb-2"
v-model="query"
size="md"
placeholder="Type a command…"
autofocus
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="onEnter"
@keydown.esc="open = false"
>
<template #prefix>
<span class="lucide-search size-4 text-ink-gray-5" />
</template>
</TextInput>
<li
v-if="!filtered.length"
class="px-3 py-8 text-center text-p-sm text-ink-gray-5"
>
No commands match "{{ query }}"
</li>
<li
v-for="(cmd, i) in filtered"
:key="cmd.label"
class="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-base"
:class="
i === activeIndex
? 'bg-surface-gray-2 text-ink-gray-9'
: 'text-ink-gray-7 hover:bg-surface-gray-2'
"
@mouseenter="activeIndex = i"
@click="run(cmd)"
>
<span
:class="[cmd.icon, 'size-4 text-ink-gray-6']"
aria-hidden="true"
/>
<span class="flex-1 truncate">{{ cmd.label }}</span>
<span class="text-p-xs text-ink-gray-5">{{ cmd.hint }}</span>
<KeyboardShortcut v-if="cmd.combo" :combo="cmd.combo" bg />
</li>
</ul>
<div
class="flex items-center gap-4 border-t border-outline-gray-modals px-4 py-2 text-p-xs text-ink-gray-5"
>
<span class="flex items-center gap-1.5">
<KeyboardShortcut combo="ArrowUp" bg />
<KeyboardShortcut combo="ArrowDown" bg />
Navigate
</span>
<span class="flex items-center gap-1.5">
<KeyboardShortcut combo="Enter" bg />
Select
</span>
<span class="ml-auto flex items-center gap-1.5">
<KeyboardShortcut combo="Escape" bg />
Close
</span>
</div>
</div>
</Dialog>
</template>Imperative API
The dialog.* helpers cover the confirm-family surface — confirm and the danger preset — without mounting a <Dialog> yourself. In real apps <Dialogs /> is mounted by FrappeUIProvider, the same component that hosts the toast viewport, so no extra setup is needed.
Pass an actions array to dialog.confirm (or dialog.danger) when a flow needs more than the default confirm + cancel pair. Each action accepts full Button props and its own awaited onClick; the clicked button shows a loading spinner while its handler is pending and every other button is disabled until it settles. Throwing from onClick surfaces inline via the shared error region.
dialog.danger is a one-line preset for irreversible actions. It forces theme: 'red', defaults the icon to a warning triangle, and defaults confirmLabel to 'Delete'. Everything confirm accepts (including actions[]) is forwarded through.
<script setup lang="ts">
// Imperative `dialog.*` API — the full confirm-family surface in one place.
//
// Covers:
// - `dialog.confirm` — basic + destructive (`theme: 'red'`) + async `onConfirm`.
// - `dialog.confirm({ actions })` — flows with more than the default
// confirm + cancel pair (paste/replace/cancel, save/discard/cancel,
// inline error from a throwing handler).
// - `dialog.danger` — preset for irreversible actions; forces
// `theme: 'red'` + warning icon, defaults `confirmLabel` to 'Delete',
// and composes with `actions[]` for "delete with options" flows.
//
// In real apps `<Dialogs />` is mounted by `FrappeUIProvider`, the same
// component that hosts the toast viewport. No extra setup needed.
import { Button, dialog, Dialogs } from 'frappe-ui'
function destructiveConfirm() {
dialog.confirm({
title: 'Delete space',
message:
'This will permanently delete the space along with 12 discussions and 4 tasks.',
confirmLabel: 'Delete',
theme: 'red',
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 900))
},
})
}
function minimalConfirm() {
dialog.confirm({
title: 'Import workbook',
message: 'Are you sure you want to import this workbook?',
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 500))
},
})
}
// Each action's button props (theme, variant, icon, …) flow through to the
// underlying `Button`. Each action's `onClick` is awaited independently —
// the clicked button shows a loading spinner while its handler is pending,
// and every other button is disabled until it settles.
function pastePage() {
dialog.confirm({
title: 'Paste page',
message:
'A page with this name already exists. Create a new copy or replace the current page?',
actions: [
{ label: 'Cancel', variant: 'outline' },
{
label: 'Create copy',
onClick: async () => {
await new Promise((r) => setTimeout(r, 600))
},
},
{
label: 'Replace',
theme: 'red',
variant: 'solid',
onClick: async () => {
await new Promise((r) => setTimeout(r, 900))
},
},
],
})
}
function navigateAway() {
dialog.confirm({
title: 'Unsaved changes',
message: 'You have unsaved changes. Save before leaving?',
actions: [
{ label: 'Cancel', variant: 'outline' },
{
label: 'Discard',
theme: 'red',
variant: 'subtle',
onClick: () => {
// eslint-disable-next-line no-console
console.log('discarded')
},
},
{
label: 'Save and leave',
variant: 'solid',
onClick: async () => {
await new Promise((r) => setTimeout(r, 700))
},
},
],
})
}
// Throwing inside `onClick` is caught and rendered as an inline error,
// and every button re-enables so the user can retry or cancel.
function actionThatFails() {
dialog.confirm({
title: 'Send invitation',
message: 'Send an invite to jane@example.com?',
actions: [
{ label: 'Cancel', variant: 'outline' },
{
label: 'Send',
variant: 'solid',
onClick: async () => {
await new Promise((r) => setTimeout(r, 500))
throw new Error('Server rejected the invitation: rate limit hit.')
},
},
],
})
}
function dangerDelete() {
dialog.danger({
title: 'Delete comment',
message: 'Are you sure you want to delete this comment?',
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 500))
},
})
}
function dangerCustomLabel() {
// `confirmLabel` overrides the default 'Delete' when the action isn't a
// delete in the literal sense.
dialog.danger({
title: 'Revoke invitation',
message: 'This will revoke jane@example.com\'s pending invitation.',
confirmLabel: 'Revoke',
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 500))
},
})
}
function dangerWithActions() {
// `actions[]` composes with `danger` — useful for "delete with options"
// (e.g., delete only this occurrence vs the whole series).
dialog.danger({
title: 'Delete recurring task',
message: 'How would you like to delete this task?',
actions: [
{ label: 'Cancel', variant: 'outline' },
{
label: 'This occurrence',
variant: 'subtle',
theme: 'red',
onClick: async () => {
await new Promise((r) => setTimeout(r, 400))
},
},
{
label: 'Entire series',
variant: 'solid',
theme: 'red',
onClick: async () => {
await new Promise((r) => setTimeout(r, 400))
},
},
],
})
}
// Close-behavior matrix for `onConfirm`:
// • resolves → auto-closes (close() runs after the await)
// • throws / rejects → stays open, thrown message rendered inline
// • calls close() → closes immediately (the trailing auto-close is a no-op)
//
// Note: calling `ctx.setError(msg)` from inside `onConfirm` without throwing
// does NOT keep the dialog open — the auto-close still fires after the
// handler resolves. Use `throw` to stay open with an inline error.
function optimisticClose() {
// Closes the dialog right away, then finishes the work in the background —
// useful when the user has already committed and the UI shouldn't block.
dialog.confirm({
title: 'Send report',
message: 'Send the weekly report to your team? The dialog will close immediately.',
confirmLabel: 'Send',
onConfirm: async ({ close }) => {
close()
await new Promise((r) => setTimeout(r, 1500))
// eslint-disable-next-line no-console
console.log('report sent in background')
},
})
}
// Stay open after an async call by throwing. The thrown message flows through
// `extractErrorMessage` and is rendered inline; the confirm button re-enables
// so the user can retry, edit, or cancel. This is the canonical pattern for
// server-side validation failures (e.g., username taken, quota exceeded).
function keepOpenAfterAsync() {
dialog.confirm({
title: 'Claim username',
message: 'Claim the username "frappe-fan"? This pretends to call the server.',
confirmLabel: 'Claim',
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 700))
// Server says no — throw to keep the dialog open with the reason.
throw new Error('That username is already taken. Try a different one.')
},
})
}
</script>
<template>
<div class="w-full flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<Button theme="red" variant="subtle" @click="destructiveConfirm">
dialog.confirm (destructive)
</Button>
<Button @click="minimalConfirm">dialog.confirm (minimal)</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button @click="pastePage">3 actions (paste page)</Button>
<Button @click="navigateAway">3 actions (save/discard/cancel)</Button>
<Button @click="actionThatFails">Action that throws</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button theme="red" variant="subtle" @click="dangerDelete">
dialog.danger (delete)
</Button>
<Button theme="red" variant="subtle" @click="dangerCustomLabel">
dialog.danger (custom label)
</Button>
<Button theme="red" variant="subtle" @click="dangerWithActions">
dialog.danger + actions[]
</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button @click="optimisticClose">close() before await</Button>
<Button @click="keepOpenAfterAsync">stay open after async (throw)</Button>
</div>
</div>
<!-- In real apps <FrappeUIProvider> auto-mounts <Dialogs />. -->
<Dialogs />
</template>Prompt
dialog.prompt collects structured input through a fields[] array. Each field renders through FormControl, so any FormControl type works — including text, select, checkbox, and combobox. For combobox fields, allowCreate passes the typed query through as the value when nothing in the list matches — handy for category-style fields where users can add new entries inline.
Each field can also declare a validate function. It runs after the built-in required check, in parallel across all fields, and returns a non-empty string to mark the field invalid (shown inline below it) or null for valid. The second argument is a snapshot of every field's current value, so validators can reference siblings. The submit button keeps its loading state while async validators settle.
<script setup lang="ts">
// `dialog.prompt(...)` — the full prompt surface in one place.
//
// Covers:
// - Basic prompt with `text` + `select` fields (and a `defaultValue`).
// - `type: 'combobox'` for searchable pickers, plus grouped options and
// `allowCreate: true` (mirrors Combobox's `allowCustomValue` so the typed
// query becomes the value when no option matches — useful for
// category-style fields where users can add new entries inline).
// - Per-field `validate` — sync, async, and cross-field. Validators run
// after the built-in `required` check, in parallel across all fields.
// Returning a non-empty string marks the field invalid and renders that
// string inline below it; returning `null`/`undefined`/`''` means valid.
// The submit button keeps its loading state while async validators
// settle, and errors clear the instant the user edits the field.
import { Button, dialog, Dialogs } from 'frappe-ui'
function newFolder() {
dialog.prompt({
title: 'New folder',
fields: [
{
name: 'name',
label: 'Folder name',
type: 'text',
required: true,
placeholder: 'e.g. Q4 Plans',
},
{
name: 'visibility',
label: 'Visibility',
type: 'select',
defaultValue: 'private',
options: [
{ label: 'Private', value: 'private' },
{ label: 'Team', value: 'team' },
{ label: 'Public', value: 'public' },
],
},
],
confirmLabel: 'Create',
onConfirm: async ({ values }) => {
await new Promise((r) => setTimeout(r, 500))
// eslint-disable-next-line no-console
console.log('created folder', values)
},
})
}
const spaces = [
{ label: 'Engineering', value: 'engineering' },
{ label: 'Design', value: 'design' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Product', value: 'product' },
{ label: 'Operations', value: 'operations' },
{ label: 'Sales', value: 'sales' },
]
function moveDiscussion() {
dialog.prompt({
title: 'Move discussion',
message: 'Pick a space to move this discussion to.',
fields: [
{
name: 'space',
label: 'Space',
type: 'combobox',
options: spaces,
placeholder: 'Search spaces…',
required: true,
},
],
confirmLabel: 'Move',
onConfirm: async ({ values }) => {
await new Promise((r) => setTimeout(r, 500))
// eslint-disable-next-line no-console
console.log('moved to', values.space)
},
})
}
function newSpaceWithCategory() {
// Grouped + `allowCreate` — typing a brand new category name accepts
// the query as the value instead of forcing the user to use the list.
dialog.prompt({
title: 'New space',
fields: [
{
name: 'name',
label: 'Space name',
type: 'text',
required: true,
placeholder: 'e.g. Q4 Roadmap',
},
{
name: 'category',
label: 'Category',
type: 'combobox',
allowCreate: true,
placeholder: 'Pick or type a new category',
options: [
{
group: 'Existing',
options: [
{ label: 'Engineering', value: 'engineering' },
{ label: 'Design', value: 'design' },
{ label: 'Product', value: 'product' },
],
},
],
},
],
confirmLabel: 'Create',
onConfirm: async ({ values }) => {
await new Promise((r) => setTimeout(r, 500))
// eslint-disable-next-line no-console
console.log('created', values)
},
})
}
const reservedUsernames = new Set(['admin', 'root', 'support', 'me'])
function createAccount() {
dialog.prompt({
title: 'Create account',
fields: [
{
name: 'username',
label: 'Username',
required: true,
placeholder: 'jane.doe',
// Async validator — pretend we're hitting the server to check
// uniqueness. The submit button keeps spinning until this resolves.
validate: async (value: string) => {
await new Promise((r) => setTimeout(r, 600))
if (reservedUsernames.has(value.toLowerCase())) {
return `"${value}" is reserved. Pick another.`
}
if (value.length < 3) return 'Use at least 3 characters.'
return null
},
},
{
name: 'email',
label: 'Email',
required: true,
placeholder: 'jane@example.com',
// Sync validator — runs in the same parallel pass as `username`.
validate: (value: string) => {
if (!value.includes('@')) return 'That doesn\'t look like an email.'
return null
},
},
{
name: 'password',
label: 'Password',
type: 'text',
required: true,
// Cross-field validation via the second argument.
validate: (value: string, all) => {
if (value.length < 8) return 'Use at least 8 characters.'
if (typeof all.username === 'string' && value.includes(all.username)) {
return 'Password must not contain your username.'
}
return null
},
},
],
confirmLabel: 'Create account',
onConfirm: async ({ values }) => {
await new Promise((r) => setTimeout(r, 500))
// eslint-disable-next-line no-console
console.log('created', values)
},
})
}
</script>
<template>
<div class="w-full flex flex-wrap gap-2">
<Button @click="newFolder">Basic (text + select)</Button>
<Button @click="moveDiscussion">Combobox (single field)</Button>
<Button @click="newSpaceWithCategory">
Combobox + grouped + allowCreate
</Button>
<Button @click="createAccount">
Per-field validate (sync + async + cross-field)
</Button>
</div>
<Dialogs />
</template>API Reference
Show types
import type { ButtonProps } from '../Button'
export type DialogSize =
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'xl'
| '2xl'
| '3xl'
| '4xl'
| '5xl'
| '6xl'
| '7xl'
export type DialogTheme = 'yellow' | 'blue' | 'red' | 'green'
export type DialogPosition = 'center' | 'top'
/** Legacy icon appearance — replaced by `theme`. */
export type DialogIconAppearance = 'warning' | 'info' | 'danger' | 'success'
export type DialogIcon = {
name: string
/** Color tone. Replaces deprecated `appearance`. */
theme?: DialogTheme
/** @deprecated Use `theme` instead. */
appearance?: DialogIconAppearance
}
export type DialogActionContext = {
close: () => void
}
export type DialogAction = ButtonProps & {
onClick?: (context: DialogActionContext) => void | Promise<void>
}
/** A `DialogAction` augmented with a reactive `loading` flag, as surfaced to the `#actions` slot. */
export type DialogReactiveAction = DialogAction & { loading: boolean }
/** Legacy blob — accepted for back-compat, warns once. */
export type DialogOptions = {
title?: string
message?: string
size?: DialogSize
icon?: string | DialogIcon
actions?: DialogAction[]
position?: DialogPosition
paddingTop?: string | number
}
export interface DialogProps {
// Visibility — both supported, `open` is canonical.
/** Controls whether the dialog is open (v-model:open). Canonical. */
open?: boolean
/** Controls whether the dialog is open (v-model). Also supported. */
modelValue?: boolean
// Content.
/** Dialog title. Renders the auto-header. */
title?: string
/** Description text rendered below the title. */
message?: string
/** Icon shown next to the title in the auto-header. */
icon?: string | DialogIcon
// Layout.
/** Max-width size of the dialog. Default `'lg'`. */
size?: DialogSize
/** Vertical placement. Default `'center'`. */
position?: DialogPosition
/** Overrides the position-based top padding (escape hatch). */
paddingTop?: string | number
// Actions.
/** Footer action buttons. */
actions?: DialogAction[]
// Behavior.
/** Allow outside-click and Escape to close. Default `true`. */
dismissible?: boolean
/** Show the top-right close button. Default `true`. */
showCloseButton?: boolean
/** Drop the chrome: no padded card, no auto-header, no auto-actions. Default `false`. */
bare?: boolean
// Deprecated.
/** @deprecated Use `dismissible` (inverted) instead. */
disableOutsideClickToClose?: boolean
/** @deprecated Use flat top-level props instead. */
options?: DialogOptions
}
export interface DialogEmits {
/** Fired when the dialog open state changes via `v-model:open`. */
'update:open': [value: boolean]
/** Fired when the dialog open state changes via `v-model`. */
'update:modelValue': [value: boolean]
/** Fired when the dialog transitions to closed. */
close: []
/** Fired after the close animation finishes. */
'after-leave': []
}
/** Scoped payload exposed to every Dialog slot. */
export interface DialogSlotProps {
/** Closes the dialog. */
close: () => void
}
/** Scoped payload for the `#actions` slot. */
export interface DialogActionsSlotProps extends DialogSlotProps {
/** Reactive list of resolved actions (with `loading` state) for re-laying-out auto-rendered buttons. */
actions: DialogReactiveAction[]
}
export interface DialogExposed {
/** Closes the dialog. */
close: () => void
}
export interface DialogSlots {
/** Main content rendered inside the padded card. Exposes `{ close }`. */
default?: (props: DialogSlotProps) => any
/** Title area; accepts arbitrary content (extra buttons next to title, etc.). Exposes `{ close }`. */
title?: (props: DialogSlotProps) => any
/** Footer override; exposes `{ close, actions }`. */
actions?: (props: DialogActionsSlotProps) => any
/** @deprecated Use `#default` + `bare` prop. */
body?: () => any
/** @deprecated Use `#default`. */
'body-main'?: () => any
/** @deprecated Use `#title` for extras (no direct replacement). */
'body-header'?: () => any
/** @deprecated Use `#title`. */
'body-title'?: () => any
/** @deprecated Use `#default`. */
'body-content'?: () => any
}Controls whether the dialog is open (v-model:open). Canonical.
Controls whether the dialog is open (v-model). Also supported.
Dialog title. Renders the auto-header.
Description text rendered below the title.
Icon shown next to the title in the auto-header.
Max-width size of the dialog. Default `'lg'`.
Vertical placement. Default `'center'`.
Overrides the position-based top padding (escape hatch).
Footer action buttons.
Allow outside-click and Escape to close. Default `true`.
Show the top-right close button. Default `true`.
Drop the chrome: no padded card, no auto-header, no auto-actions. Default `false`.
Deprecated — Use `dismissible` (inverted) instead.
Deprecated — Use flat top-level props instead.
| Slot | Payload |
|---|---|
default | DialogSlotProps Main content rendered inside the padded card. Exposes `{ close }`. |
title | DialogSlotProps Title area; accepts arbitrary content (extra buttons next to title, etc.). Exposes `{ close }`. |
actions | DialogActionsSlotProps Footer override; exposes `{ close, actions }`. |
body | Deprecated — Use `#default` + `bare` prop. |
body-main | Deprecated — Use `#default`. |
body-header | Deprecated — Use `#title` for extras (no direct replacement). |
body-title | Deprecated — Use `#title`. |
body-content | Deprecated — Use `#default`. |
Main content rendered inside the padded card. Exposes `{ close }`.
Title area; accepts arbitrary content (extra buttons next to title, etc.). Exposes `{ close }`.
Footer override; exposes `{ close, actions }`.
Deprecated — Use `#default` + `bare` prop.
Deprecated — Use `#default`.
Deprecated — Use `#title` for extras (no direct replacement).
Deprecated — Use `#title`.
Deprecated — Use `#default`.
| Event | Payload |
|---|---|
update:modelValue | [value: boolean] Fired when the model value changes. |
update:open | [value: boolean] Fired when the open state changes. |
close | [] Fired when the component closes. |
after-leave | [] |
Fired when the model value changes.
Fired when the open state changes.
Fired when the component closes.