Alert Dialog

A modal dialog that interrupts the user with important content and expects a response.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add alert-dialog
 
Expand

This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/dialog @zag-js/vue

Built on the same Zag.js dialog machine as Dialog (there's no separate alert-dialog machine - Zag ships one dialog machine with a role option), just with role: "alertdialog" and outside-click/Escape dismissal turned off by default, since an alert dialog is meant to force an explicit choice. AlertDialogAction runs your handler first, then closes the dialog through the same close-trigger the machine gives AlertDialogCancel - call event.preventDefault() in your handler to keep it open instead. The plain border shown here is a placeholder; swap it for the Frame primitive later. Vue SFCs can't export multiple components from one file, so Alert Dialog is a folder instead of a single file — copy all of it into your project.

components/ui/alert-dialog/context.ts
Expand

import type { InjectionKey, ComputedRef } from "vue";
import type * as dialogMachine from "@zag-js/dialog";

type AlertDialogApi = ComputedRef<ReturnType<typeof dialogMachine.connect>>;

const AlertDialogApiKey: InjectionKey<AlertDialogApi> = Symbol("alert-dialog-api");

export { AlertDialogApiKey };
export type { AlertDialogApi };
 
Expand
components/ui/alert-dialog/AlertDialogRoot.vue
Expand

<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as dialogMachine from "@zag-js/dialog";
import { AlertDialogApiKey } from "./context";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<dialogMachine.Props>;
const id = useId();

const service = useMachine(
  dialogMachine.machine,
  computed(() => ({
    id,
    role: "alertdialog",
    // An alert dialog demands an explicit choice - dismissing it by clicking
    // outside or pressing Escape would let the user skip that choice.
    closeOnInteractOutside: false,
    closeOnEscape: false,
    ...rawAttrs,
  }))
);
const api = computed(() => dialogMachine.connect(service, normalizeProps));

provide(AlertDialogApiKey, api);
</script>

<template>
  <slot />
</template>
 
Expand
components/ui/alert-dialog/AlertDialogTrigger.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import Button from "@/components/ui/button.vue";
import { AlertDialogApiKey } from "./context";

defineProps<{ class?: string }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;
</script>

<template>
  <Button v-bind="api.getTriggerProps()" :class="$props.class">
    <slot />
  </Button>
</template>
 
Expand
components/ui/alert-dialog/AlertDialogContent.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Portal from "@/components/ui/portal.vue";
import { usePresence } from "@/components/ui/presence";
import { AlertDialogApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;

const backdrop = usePresence(() => api.value.open);
const content = usePresence(() => api.value.open);
</script>

<template>
  <Portal>
    <div
      v-bind="api.getBackdropProps()"
      :ref="backdrop.setNode"
      :hidden="!backdrop.present.value"
      class="fixed inset-0 bg-background/80 z-50 [&[data-state='open']]:animate-in [&[data-state='open']]:fade-in-0 [&[data-state='closed']]:animate-out [&[data-state='closed']]:fade-out-0"
    />
    <div v-bind="api.getPositionerProps()">
      <div
        v-bind="api.getContentProps()"
        :ref="content.setNode"
        :hidden="!content.present.value"
        :class="
          twMerge([
            `outline-none fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] sm:max-w-lg p-6 border border-primary/30 bg-background`,
            `[&[data-state='open']]:animate-in [&[data-state='open']]:fade-in-0 [&[data-state='open']]:zoom-in-80 [&[data-state='open']]:duration-250`,
            `[&[data-state='closed']]:animate-out [&[data-state='closed']]:fade-out-0 [&[data-state='closed']]:zoom-out-80 [&[data-state='closed']]:duration-400`,
            className,
          ])
        "
      >
        <slot />
      </div>
    </div>
  </Portal>
</template>
 
Expand
components/ui/alert-dialog/AlertDialogTitle.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { AlertDialogApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;
</script>

<template>
  <div v-bind="api.getTitleProps()" :class="twMerge(['font-bold text-lg', className])">
    <slot />
  </div>
</template>
 
Expand
components/ui/alert-dialog/AlertDialogDescription.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { AlertDialogApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;
</script>

<template>
  <div v-bind="api.getDescriptionProps()" :class="twMerge(['opacity-80 py-2', className])">
    <slot />
  </div>
</template>
 
Expand
components/ui/alert-dialog/AlertDialogCancel.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import Button from "@/components/ui/button.vue";
import { AlertDialogApiKey } from "./context";

defineProps<{ class?: string }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;
</script>

<template>
  <Button shape="flat" v-bind="api.getCloseTriggerProps()" :class="$props.class">
    <slot />
  </Button>
</template>
 
Expand
components/ui/alert-dialog/AlertDialogAction.vue
Expand

<script setup lang="ts">
import { computed, inject } from "vue";
import Button from "@/components/ui/button.vue";
import { AlertDialogApiKey } from "./context";

defineProps<{ class?: string }>();
// Declaring this as an emit (rather than leaving @click to fall through as a
// plain DOM attribute) means it composes cleanly with the machine's own
// close handler below instead of both landing on the button and firing
// twice.
const emit = defineEmits<{ click: [event: MouseEvent] }>();

const injectedApi = inject(AlertDialogApiKey);
if (!injectedApi)
  throw new Error("AlertDialog parts must be used within <AlertDialogRoot>");
const api = injectedApi;

// The consumer's handler runs first (it does the actual delete/confirm
// action); the machine's own onClick then closes the dialog, unless that
// handler called event.preventDefault() to keep it open.
const triggerProps = computed(() => {
  const { onClick, ...rest } = api.value.getCloseTriggerProps();
  return {
    ...rest,
    onClick: (event: MouseEvent) => {
      emit("click", event);
      onClick?.(event);
    },
  };
});
</script>

<template>
  <Button variant="destructive" v-bind="triggerProps" :class="$props.class">
    <slot />
  </Button>
</template>
 
Expand
components/ui/alert-dialog/index.ts
Expand

export { default as AlertDialogRoot } from "./AlertDialogRoot.vue";
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  AlertDialogRoot,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogCancel,
  AlertDialogAction,
} from "@/components/ui/alert-dialog";
 
Expand
Expand

<AlertDialogRoot>
  <AlertDialogTrigger>Delete account</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
    <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
    <div class="flex justify-end gap-2 mt-4">
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction @click="deleteAccount">Continue</AlertDialogAction>
    </div>
  </AlertDialogContent>
</AlertDialogRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.