useConfirm
Ask a yes-or-no question from anywhere and await the answer. No dialog state in your component.
vue
<script setup lang="ts">
import { useConfirm } from '@shared/composables/useConfirm'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const PROJECT = 'Northwind migration'
const { confirm } = useConfirm()
const answer = ref('Nothing asked yet.')
async function plain(): Promise<void> {
const ok = await confirm({
title: 'Sign out?',
description: 'You will be returned to the login screen.',
confirmLabel: 'Sign out',
})
answer.value = ok ? 'Signed out.' : 'Stayed signed in.'
}
async function destructive(): Promise<void> {
const ok = await confirm({
title: `Delete ${PROJECT}?`,
description: 'Every board, file and comment goes with it.',
variant: 'destructive',
requireMatch: PROJECT,
})
answer.value = ok ? `Deleted ${PROJECT}.` : 'Nothing was deleted.'
}
async function multiStep(): Promise<void> {
const ok = await confirm({
title: 'Transfer ownership?',
description: 'You will lose administrator rights on this workspace.',
variant: 'multi-step',
})
answer.value = ok ? 'Ownership transferred.' : 'Transfer canceled.'
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" @click="plain()">
Plain confirm
</Button>
<Button size="sm" variant="outline" @click="destructive()">
Type the name
</Button>
<Button size="sm" variant="outline" @click="multiStep()">
Multi-step
</Button>
</div>
<p class="rounded-lg border border-border p-3 sa-text-sm">
{{ answer }}
</p>
</div>
</template>Import
ts
import { useConfirm } from '@shared/composables/useConfirm'Signature
ts
function useConfirm(): {
confirm: (opts: ConfirmOptions) => Promise<boolean>
}
interface ConfirmOptions {
title: string
description?: string
variant?: 'default' | 'destructive' | 'multi-step'
requireMatch?: string
confirmLabel?: string
cancelLabel?: string
}ConfirmOptions
| Key | Type | Description |
|---|---|---|
title | string | Required. A translation key or a literal. |
description | string | The line under it. |
variant | 'default' | 'destructive' | 'multi-step' | Which dialog the host renders. |
requireMatch | string | With destructive, the text the reader has to type before Confirm enables. |
confirmLabel | string | Defaults to "Confirm". |
cancelLabel | string | Defaults to "Cancel". |
Which dialog you get
variant | Plus | Renders |
|---|---|---|
'default' or omitted | — | Confirm dialog |
'destructive' | — | Confirm dialog in the destructive color |
'destructive' | requireMatch | Destructive confirm |
'multi-step' | — | Multi-step confirm |
Example
vue
<script setup lang="ts">
import { useConfirm } from '@shared/composables/useConfirm'
import { ref } from 'vue'
interface Project {
id: number
name: string
}
const project = ref<Project>({ id: 4, name: 'Northwind migration' })
const removed = ref(false)
const { confirm } = useConfirm()
async function signOut() {
const ok = await confirm({
title: 'Sign out?',
description: 'You will be returned to the login screen.',
confirmLabel: 'Sign out',
})
if (ok)
console.log('signing out')
}
async function remove() {
const ok = await confirm({
title: `Delete ${project.value.name}?`,
description: 'This cannot be undone.',
variant: 'destructive',
requireMatch: project.value.name,
})
removed.value = ok
}
</script>
<template>
<div class="flex flex-wrap gap-2">
<button type="button" @click="signOut()">
Sign out
</button>
<button type="button" @click="remove()">
Delete project
</button>
<span v-if="removed">Deleted.</span>
</div>
</template>Notes
- One host, not one app per call.
ConfirmHostis mounted once inApp.vue, so a dialog inherits the app's context — i18n, the theme, the router — and can play its exit animation. Spinning up acreateAppper confirmation gets none of that. - A second
confirm()while one is open cancels the first, resolving itfalse. It does not queue and it does not throw, so two overlapping flows cannot leave a promise hanging forever. - A
destructiverequest is retitled at the host: the heading becomes "Are you sure?" and your title drops into the body, where it names the target. That is done once, centrally, so every call site keeps passingtitle: 'Delete Northwind migration?'. Adefaultconfirm is left alone, because it is already a question. - The dialog resolves
falseon Escape and on an overlay press, so there is no path where the promise never settles. - Labels default to translation keys, so they follow the language switcher. A string you pass wins.
Related
- Confirm dialog — the component, and its controlled form.
- Destructive confirm — type-the-name.
- Multi-step confirm — acknowledge, then confirm.
useFormDirty— the route guard that awaits this.