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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add alert-dialog
pnpm dlx ermineui add alert-dialog
yarn dlx ermineui add alert-dialog
bunx ermineui add alert-dialog

That 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

FileWhere it goes
AlertDialog.vuesrc/components/ui/alert-dialog/AlertDialog.vue
AlertDialogAction.vuesrc/components/ui/alert-dialog/AlertDialogAction.vue
AlertDialogCancel.vuesrc/components/ui/alert-dialog/AlertDialogCancel.vue
AlertDialogContent.vuesrc/components/ui/alert-dialog/AlertDialogContent.vue
AlertDialogDescription.vuesrc/components/ui/alert-dialog/AlertDialogDescription.vue
AlertDialogFooter.vuesrc/components/ui/alert-dialog/AlertDialogFooter.vue
AlertDialogTitle.vuesrc/components/ui/alert-dialog/AlertDialogTitle.vue
AlertDialogTrigger.vuesrc/components/ui/alert-dialog/AlertDialogTrigger.vue
index.tssrc/components/ui/alert-dialog/index.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts
scroll-lock.ts helpersrc/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'