Alert dialog
A dialog that asks for a decision before something that cannot be undone. It only closes by choosing.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add alert-dialogpnpm dlx ermineui add alert-dialogyarn dlx ermineui add alert-dialogbunx ermineui add alert-dialogThat copies 9 files into src/components/ui/alert-dialog/, 3 helpers into src/lib/ermine/, and the 2 components it is built from. Files you already have are kept.
Use
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'<AlertDialog>
<AlertDialogTrigger as-child><Button variant="destructive">Delete project</Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel />
<AlertDialogAction variant="destructive" @click="remove">Delete project</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
AlertDialog.vue | src/components/ui/alert-dialog/AlertDialog.vue |
AlertDialogAction.vue | src/components/ui/alert-dialog/AlertDialogAction.vue |
AlertDialogCancel.vue | src/components/ui/alert-dialog/AlertDialogCancel.vue |
AlertDialogContent.vue | src/components/ui/alert-dialog/AlertDialogContent.vue |
AlertDialogDescription.vue | src/components/ui/alert-dialog/AlertDialogDescription.vue |
AlertDialogFooter.vue | src/components/ui/alert-dialog/AlertDialogFooter.vue |
AlertDialogTitle.vue | src/components/ui/alert-dialog/AlertDialogTitle.vue |
AlertDialogTrigger.vue | src/components/ui/alert-dialog/AlertDialogTrigger.vue |
index.ts | src/components/ui/alert-dialog/index.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
scroll-lock.ts helper | src/lib/ermine/scroll-lock.ts |
It is built from: Button, Dialog. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
AlertDialog.vue 13 lines
<script setup lang="ts">
import { Dialog } from '../dialog'
const open = defineModel<boolean>('open', { default: false })
</script>
<template>
<!-- Shares the dialog's state and focus handling; what differs is how it may be closed. -->
<Dialog v-slot="slotProps" v-model:open="open">
<slot v-bind="slotProps" />
</Dialog>
</template>AlertDialogAction.vue 20 lines
<script setup lang="ts">
import { Button } from '../button'
import { useDialog } from '../dialog/context'
withDefaults(
defineProps<{
/** Use "destructive" when confirming deletes something. */
variant?: 'default' | 'destructive'
}>(),
{ variant: 'default' },
)
const dialog = useDialog()
</script>
<template>
<!-- Your own @click runs as well; the dialog closes either way. -->
<Button :variant="variant" @click="dialog.setOpen(false)"><slot>Continue</slot></Button>
</template>AlertDialogCancel.vue 11 lines
<script setup lang="ts">
import { Button } from '../button'
import { useDialog } from '../dialog/context'
const dialog = useDialog()
</script>
<template>
<Button variant="outline" data-alert-cancel @click="dialog.setOpen(false)"><slot>Cancel</slot></Button>
</template>AlertDialogContent.vue 123 lines
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { lockScroll } from '../../lib/scroll-lock'
import { useDialog } from '../dialog/context'
defineOptions({ inheritAttrs: false })
const dialog = useDialog()
const content = ref<HTMLElement | null>(null)
useLayer(content, dialog.open, {
modal: true,
loopFocus: true,
// A press outside does nothing: the question has to be answered. Escape counts as "cancel".
onDismiss: (reason) => {
if (reason === 'escape') dialog.setOpen(false)
},
// Focus starts on Cancel, so a stray Enter never confirms something destructive.
initialFocus: () => content.value?.querySelector<HTMLElement>('[data-alert-cancel]'),
})
watch(
dialog.open,
(open, _previous, onCleanup) => {
if (open) onCleanup(lockScroll())
},
{ immediate: true },
)
</script>
<template>
<Portal>
<Transition name="er-alert-dialog">
<div v-if="dialog.open.value" class="er-alert-dialog-overlay">
<div
ref="content"
v-bind="$attrs"
role="alertdialog"
aria-modal="true"
tabindex="-1"
class="er-alert-dialog"
:aria-labelledby="dialog.titleId"
:aria-describedby="dialog.hasDescription.value ? dialog.descriptionId : undefined"
>
<slot />
</div>
</div>
</Transition>
</Portal>
</template>
<style>
@layer components {
.er-alert-dialog-overlay {
position: fixed;
inset: 0;
z-index: var(--er-z-overlay);
display: grid;
place-items: center;
padding: 1rem;
background: var(--er-overlay);
overflow-y: auto;
}
.er-alert-dialog {
display: grid;
gap: 0.5rem;
width: 100%;
max-width: 28rem;
padding: 1.5rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-background);
color: var(--er-foreground);
box-shadow: var(--er-shadow-lg);
outline: none;
}
.er-alert-dialog-title {
margin: 0;
font-size: 1.125rem;
font-weight: var(--er-weight-semibold);
line-height: 1.3;
}
.er-alert-dialog-description {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.875rem;
line-height: 1.5;
}
.er-alert-dialog-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.er-alert-dialog-enter-active,
.er-alert-dialog-leave-active,
.er-alert-dialog-enter-active .er-alert-dialog,
.er-alert-dialog-leave-active .er-alert-dialog {
transition:
opacity var(--er-duration) var(--er-ease),
transform var(--er-duration) var(--er-ease);
}
.er-alert-dialog-enter-from,
.er-alert-dialog-leave-to {
opacity: 0;
}
.er-alert-dialog-enter-from .er-alert-dialog,
.er-alert-dialog-leave-to .er-alert-dialog {
transform: scale(0.96);
}
}
</style>AlertDialogDescription.vue 18 lines
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useDialog } from '../dialog/context'
const dialog = useDialog()
onMounted(() => {
dialog.hasDescription.value = true
})
onBeforeUnmount(() => {
dialog.hasDescription.value = false
})
</script>
<template>
<p :id="dialog.descriptionId" class="er-alert-dialog-description"><slot /></p>
</template>AlertDialogFooter.vue 4 lines
<template>
<div class="er-alert-dialog-footer"><slot /></div>
</template>AlertDialogTitle.vue 10 lines
<script setup lang="ts">
import { useDialog } from '../dialog/context'
const dialog = useDialog()
</script>
<template>
<h2 :id="dialog.titleId" class="er-alert-dialog-title"><slot /></h2>
</template>AlertDialogTrigger.vue 18 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { DialogTrigger } from '../dialog'
withDefaults(
defineProps<{
as?: string | Component
/** Use the single child (e.g. a Button) as the trigger instead of rendering one. */
asChild?: boolean
}>(),
{ as: 'button' },
)
</script>
<template>
<DialogTrigger :as="as" :as-child="asChild"><slot /></DialogTrigger>
</template>index.ts 10 lines
/** Alert dialog — a dialog that asks for a decision before something that cannot be undone. It only closes by choosing. */
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'