Modal Surfaces
The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.
Alert Dialog — Modal confirmation dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Does not close on overlay click. `wire-action` names a Livewire method the confirm button calls (busy until it settles) before closing. `native` renders it without JavaScript as a native <dialog> or an inline confirm step that submits a real form.
Preview
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account and remove your data from our servers.
<x-ui.alert-dialog>
<x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
{{ __('Delete account') }}
</x-ui.alert-dialog.trigger>
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
Installation
php artisan ui:add alert-dialog
Note
This component ships an Alpine behavior module at
resources/js/ui/alert-dialog.js. Import it once from your bundle so it registers on alpine:init:
import './alert-dialog.js';
Registry contract
php artisan ui:add alert-dialog
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/alert-dialog.blade.php -
resources/views/components/ui/alert-dialog/trigger.blade.php -
resources/views/components/ui/alert-dialog/content.blade.php -
resources/views/components/ui/alert-dialog/header.blade.php -
resources/views/components/ui/alert-dialog/title.blade.php -
resources/views/components/ui/alert-dialog/description.blade.php -
resources/views/components/ui/alert-dialog/footer.blade.php -
resources/views/components/ui/alert-dialog/cancel.blade.php -
resources/views/components/ui/alert-dialog/action.blade.php -
resources/js/ui/alert-dialog.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Alert Dialog (`alert-dialog`)
Modal confirmation dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Does not close on overlay click. `wire-action` names a Livewire method the confirm button calls (busy until it settles) before closing. `native` renders it without JavaScript as a native <dialog> or an inline confirm step that submits a real form.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add alert-dialog
```
## Usage
```blade
<x-ui.alert-dialog>
<x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
{{ __('Delete account') }}
</x-ui.alert-dialog.trigger>
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
```
## Props
- `wireAction` (string|null, default `null`) — Livewire component method the confirm action calls (via $wire.call) before the dialog closes; without it the confirm button just closes the dialog.
- `wireParams` (array, default `[]`) — Positional arguments passed to wireAction.
- `name` (string|null, default `null`) — Opens and closes the alert dialog on a window open-dialog / close-dialog event whose detail is { name }, like dialog. Null listens for nothing.
- `native` (false|'modal'|'inline', default `false`) — No-JavaScript mode. 'modal' (or a bare `native`) renders a native <dialog> opened by the trigger with an invoker command (command="show-modal"); 'inline' renders the confirm step in place for a confirm page. No Alpine, inline script or inline style, so it works under a strict CSP. Give each native alert dialog a unique `name`: its id is alert-dialog-<name>. wire-action, wire-params and the open-dialog events do not apply.
- `formAction` (string|null, default `null`) — Native modes: URL of the real form the action button submits. Without it the modal confirm is a <form method="dialog"> that only closes.
- `formMethod` (string, default `POST`) — Native modes: the form verb; PUT, PATCH and DELETE are method-spoofed by the form item, which also adds the CSRF field.
- `formHiddenFields` (array, default `[]`) — Native modes: hidden inputs as ['name' => 'value'], rendered by the form item's hidden-fields.
- `variant` (null|primary|danger, default `null`) — Documented catalog control used by the preview workbench.
- `size` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sticky` (bool, default `false`) — Declared by @props in the registry Blade source.
- `busyLabel` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Confirming a consequential or destructive action, such as deleting a record, before it runs.
- Requiring the user to make an explicit choice before the dialog can be dismissed, unlike a dismissible informational modal.
## Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Use only when the user must acknowledge or decide about a consequential condition before continuing.
- Showing a general-purpose modal for editing or browsing content; use dialog instead, since alert-dialog does not close on overlay click.
- The confirmation is low-stakes and reversible; a plain button or toast undo is less disruptive.
## Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
## Rules
- Use the `<brok:alert-dialog>` tag (or `<x-ui.alert-dialog>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/alert-dialog
- Registry JSON (files, props, contract): https://brokui.dev/r/open/alert-dialog.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- A named alert dialog opens on a window open-dialog event with its name,
for example from a shortcut or a Livewire dispatch. --}}
<div class="flex flex-wrap items-center gap-2">
<x-ui.button type="button" variant="destructive" x-data x-on:click="$dispatch('open-dialog', { name: 'discard-draft' })">
{{ __('Discard draft') }}
</x-ui.button>
<x-ui.alert-dialog name="discard-draft">
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Discard this draft?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('The draft and its notes are removed. This cannot be undone.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Keep draft') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Discard') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
</div>
{{-- native: a native <dialog> opened by an invoker command. No Alpine, no
inline script: it works under a strict CSP. The action submits a real
form (DELETE spoofed by the form item, with CSRF and hidden fields). --}}
<div class="flex flex-wrap items-center gap-2">
<x-ui.alert-dialog native name="delete-note" form-action="#" form-method="DELETE" :form-hidden-fields="['note' => 42]">
<x-ui.alert-dialog.trigger class="h-10 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90">
{{ __('Delete note') }}
</x-ui.alert-dialog.trigger>
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Delete this note?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('The note and its revisions are removed. This cannot be undone.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Delete') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
</div>
Native Inline
{{-- native="inline": the confirm step in place, for a confirm page or for
browsers without invoker commands. Cancel links back; the action posts. --}}
<x-ui.alert-dialog native="inline" name="revoke-client" form-action="#" :form-hidden-fields="['client' => 'claude-desktop']">
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Revoke Claude Desktop?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('It loses access at once and must be connected again to use your library.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel href="#">{{ __('Keep access') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Revoke') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| wireAction | string | null | null | Livewire component method the confirm action calls (via $wire.call) before the dialog closes; without it the confirm button just closes the dialog. |
| wireParams | array | [] | Positional arguments passed to wireAction. |
| name | string | null | null | Opens and closes the alert dialog on a window open-dialog / close-dialog event whose detail is { name }, like dialog. Null listens for nothing. |
| native | false | 'modal' | 'inline' | false | No-JavaScript mode. 'modal' (or a bare `native`) renders a native <dialog> opened by the trigger with an invoker command (command="show-modal"); 'inline' renders the confirm step in place for a confirm page. No Alpine, inline script or inline style, so it works under a strict CSP. Give each native alert dialog a unique `name`: its id is alert-dialog-<name>. wire-action, wire-params and the open-dialog events do not apply. |
| formAction | string | null | null | Native modes: URL of the real form the action button submits. Without it the modal confirm is a <form method="dialog"> that only closes. |
| formMethod | string | POST | Native modes: the form verb; PUT, PATCH and DELETE are method-spoofed by the form item, which also adds the CSRF field. |
| formHiddenFields | array | [] | Native modes: hidden inputs as ['name' => 'value'], rendered by the form item's hidden-fields. |
| variant | null | primary | danger | null | Documented catalog control used by the preview workbench. |
| size | mixed | null | null | Declared by @props in the registry Blade source. |
| sticky | bool | false | Declared by @props in the registry Blade source. |
| busyLabel | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— Compositional parts: trigger, content, header, title, description, footer, cancel, and action.x-ui.alert-dialog.trigger— Installed subcomponent from the registry item.x-ui.alert-dialog.content— Installed subcomponent from the registry item.x-ui.alert-dialog.header— Installed subcomponent from the registry item.x-ui.alert-dialog.title— Installed subcomponent from the registry item.x-ui.alert-dialog.description— Installed subcomponent from the registry item.x-ui.alert-dialog.footer— Installed subcomponent from the registry item.x-ui.alert-dialog.cancel— Installed subcomponent from the registry item.x-ui.alert-dialog.action— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Unlike a plain dialog, an alert dialog does not close on overlay click, so the user must choose cancel or confirm.
- The action button runs the root's wireAction, if any, staying disabled and busy until the call resolves, then closes the dialog; without a wireAction it closes immediately.
- Marks the rest of the page inert while open (reference-counted across stacked overlays) so a screen reader on a virtual cursor cannot browse behind the modal.
- A nested layer can call pushEscapeLayer(close)/popEscapeLayer(close) to claim the first Escape press instead of the dialog closing; the global Escape handler also defers to any keydown that already called preventDefault().
- Call announceSubjectChange(message) to tell assistive tech an already-open dialog's content changed in place; ordinary open is not announced twice.
- Named alert dialogs (name="discard-draft"): a window open-dialog event with detail { name } opens it and close-dialog closes it (not while its wired action is busy). Focus returns to the element that had it (IC-003).
- Native modal mode: the browser makes the page inert, closes on Escape and returns focus to the trigger; overlay clicks do not close it. Cancel is a formmethod="dialog" button that closes without submitting and takes focus on open; the action is a submit button of the real form (its name and value are sent).
- Native inline mode renders the title, description and buttons in flow inside a section named by the title; cancel is a link to its href, else the previous URL. Use it for a confirm page, or as the fallback for browsers without invoker commands (before Chrome 135, Firefox 144 and Safari 26.2).
- Native modes are for plain form posts: a Livewire re-render of the dialog closes it, so keep the default mode (with wire-action) inside Livewire components.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Handle a short focused interruption or blocking decision.
Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Confirming a consequential or destructive action, such as deleting a record, before it runs.
- Requiring the user to make an explicit choice before the dialog can be dismissed, unlike a dismissible informational modal.
Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Use only when the user must acknowledge or decide about a consequential condition before continuing.
- Showing a general-purpose modal for editing or browsing content; use dialog instead, since alert-dialog does not close on overlay click.
- The confirmation is low-stakes and reversible; a plain button or toast undo is less disruptive.
Use instead
- Inline content
- Drawer or dedicated page for longer tasks
Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Escape
- Focus
managed
- The panel uses role="alertdialog" so assistive tech treats it as requiring an explicit response, unlike a plain dialog.
- Background content is inert while the alert dialog is open; do not rely on aria-hidden or z-index alone to keep it from a virtual cursor.
- In native modal mode the <dialog> carries role="alertdialog" with aria-labelledby and aria-describedby pointing at the title and description, and the rest of the page is inert while it is open.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="alert-dialog-{{ $record->id }}">
<x-ui.alert-dialog>
<x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
{{ __('Delete account') }}
</x-ui.alert-dialog.trigger>
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:alert-dialog
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Livewire: the component method the confirm action calls (`$wire.call`)
// before the dialog closes, with `wireParams` as positional arguments.
// The action button is busy (disabled, aria-busy) until the call returns;
// without Livewire the dialog simply closes. Null leaves the action a
// plain close button that the consumer wires themselves.
'wireAction' => null,
'wireParams' => [],
// Opens and closes on a window `open-dialog` / `close-dialog` event whose
// detail names it ({ name }), like a named dialog. Null listens for
// nothing.
'name' => null,
// No-JavaScript mode. false (default) keeps the Alpine dialog above.
// 'modal' (or a bare `native`) renders a native <dialog> that the trigger
// opens with an invoker command (command="show-modal"); 'inline' renders
// the confirm step in place, for a confirm page or section. Neither mode
// prints Alpine, inline script or inline style, so both work under a
// strict Content-Security-Policy. `name` names the dialog (its id is
// alert-dialog-<name>); give every native alert dialog on a page its own.
'native' => false,
// Native modes: the real form the action button submits. Without
// form-action the modal confirm is a <form method="dialog"> that only
// closes. form-method PUT/PATCH/DELETE are spoofed by the form item, and
// form-hidden-fields renders its hidden inputs.
'formAction' => null,
'formMethod' => 'POST',
'formHiddenFields' => [],
])
@php
$config = [
'action' => filled($wireAction) ? (string) $wireAction : null,
'params' => array_values((array) $wireParams),
'name' => filled($name) ? (string) $name : null,
];
// livewire-confirm renders this component under its own name; the
// browser keeps only the first of two identical attributes, so read the
// override out of the bag and print exactly one.
$slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog');
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
@endphp
@if ($nativeMode !== null)
{{-- Native mode: no Alpine root. The parts read the mode through @aware. --}}
<div
data-slot="{{ $slotName }}"
data-native="{{ $nativeMode }}"
@if (filled($name)) data-dialog-name="{{ $name }}" @endif
{{ $attributes->except('data-slot')->merge(['class' => 'contents']) }}
>
{{ $slot }}
</div>
@else
{{--
Alert Dialog (spec §17 dialog focus trap / Escape / focus return + §19 behavior).
Root holds open state, focus trapping and focus restoration. Each instance
is self-contained, so multiple and nested alert dialogs work. Unlike a plain
dialog, an alert dialog does not close on overlay click.
--}}
<div
x-data="uiAlertDialog({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="open"
x-id="['alert-dialog-title', 'alert-dialog-description', 'alert-dialog-content']"
data-slot="{{ $slotName }}"
@if (filled($name)) data-dialog-name="{{ $name }}" @endif
{{ $attributes->except('data-slot')->merge(['class' => 'contents']) }}
>
{{ $slot }}
</div>
@endif
@aware([
'native' => false,
'name' => null,
])
@props([
// Button look for the trigger, from the button item's recipe: a variant
// (default, destructive, outline, secondary, ghost, link) and a size (sm,
// md, lg). Both null: the unstyled trigger, for a fully custom look.
'variant' => null,
'size' => null,
])
@php
// livewire-confirm targets its trigger under its own name; print one data-slot.
$slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-trigger');
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
$dialogId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm');
$triggerClasses = 'inline-flex items-center justify-center';
$looksLikeButton = $variant !== null || $size !== null;
if ($looksLikeButton) {
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$buttonStyles = $styles['button'];
$variant = $styles['normalizeVariant']($variant) ?: 'default';
$variant = ['primary' => 'default', 'danger' => 'destructive'][$variant] ?? $variant;
$variant = isset($buttonStyles['variants'][$variant]) ? $variant : 'default';
$size = isset($buttonStyles['sizes'][$size ?? 'md']) ? ($size ?? 'md') : 'md';
$sizeClass = $variant === 'link'
? ($buttonStyles['linkSizes'][$size] ?? $buttonStyles['linkSizes']['md'])
: $buttonStyles['sizes'][$size];
$triggerClasses = trim($buttonStyles['base'].' '.$buttonStyles['variants'][$variant].' '.$sizeClass);
}
$lookAttributes = $looksLikeButton ? ['data-variant' => $variant, 'data-size' => $size] : [];
@endphp
@if ($nativeMode === 'modal')
{{-- Opens the native <dialog> with an invoker command: no JavaScript. --}}
<button
type="button"
data-slot="{{ $slotName }}"
command="show-modal"
commandfor="{{ $dialogId }}"
aria-haspopup="dialog"
{{ $attributes->except('data-slot')->merge(['class' => $triggerClasses, ...$lookAttributes]) }}
>
{{ $slot }}
</button>
@elseif ($nativeMode === null)
<button
type="button"
data-slot="{{ $slotName }}"
x-ref="trigger"
x-bind:aria-expanded="open"
x-bind:aria-controls="$id('alert-dialog-content')"
x-bind:disabled="busy"
@click="show()"
{{ $attributes->except('data-slot')->merge(['class' => $triggerClasses, ...$lookAttributes]) }}
>
{{ $slot }}
</button>
@endif
@aware([
'native' => false,
'name' => null,
'formAction' => null,
'formMethod' => 'POST',
'formHiddenFields' => [],
])
@php
// livewire-confirm targets its panel under its own name; print one data-slot.
$slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-content');
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
@endphp
@if ($nativeMode !== null)
@php
$dialogId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm');
$hasForm = filled($formAction);
$panelClasses = 'relative w-full rounded-lg border border-border bg-card p-6 text-start text-card-foreground shadow-lg';
@endphp
@if ($nativeMode === 'modal')
{{-- A native modal <dialog>: the browser traps the page (inert), closes
it on Escape and returns focus to the trigger. The dialog is a
transparent full-viewport frame, so the panel keeps its gutter on a
narrow screen; it is display:none until open. --}}
<dialog
id="{{ $dialogId }}"
data-slot="alert-dialog-portal"
data-native="modal"
role="alertdialog"
aria-labelledby="{{ $dialogId }}-title"
aria-describedby="{{ $dialogId }}-description"
class="m-0 h-full max-h-none w-full max-w-none items-center justify-center overflow-y-auto bg-transparent p-4 text-foreground open:flex backdrop:bg-scrim/50"
>
@if ($hasForm)
<x-ui.form :action="$formAction" :method="$formMethod" :hidden-fields="$formHiddenFields" class="w-full max-w-lg" data-slot="alert-dialog-form">
<div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
{{ $slot }}
</div>
</x-ui.form>
@else
<form method="dialog" data-slot="alert-dialog-form" class="w-full max-w-lg">
<div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
{{ $slot }}
</div>
</form>
@endif
</dialog>
@else
{{-- Inline: the confirm step in place (a confirm page or section). --}}
<section
id="{{ $dialogId }}"
data-slot="alert-dialog-portal"
data-native="inline"
aria-labelledby="{{ $dialogId }}-title"
aria-describedby="{{ $dialogId }}-description"
class="w-full max-w-lg"
>
@if ($hasForm)
<x-ui.form :action="$formAction" :method="$formMethod" :hidden-fields="$formHiddenFields" data-slot="alert-dialog-form">
<div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
{{ $slot }}
</div>
</x-ui.form>
@else
<div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
{{ $slot }}
</div>
@endif
</section>
@endif
@else
<template x-teleport="body">
<div
data-slot="alert-dialog-portal"
x-ref="portal"
x-show="open"
x-cloak
class="fixed inset-0 z-modal flex items-center justify-center p-4"
{{-- Defers to any surface that already handled the key (a nested menu
calling preventDefault() on its own Escape) and to a nested layer
that claims the first Escape via pushEscapeLayer() before this
closes the dialog itself. --}}
@keydown.escape.window="open && !$event.defaultPrevented && escape()"
>
{{-- Announces an in-place subject change (see announceSubjectChange())
without re-announcing on ordinary open, which the dialog role
already covers. --}}
<p data-slot="alert-dialog-live-region" aria-live="polite" class="sr-only" x-text="subjectAnnouncement"></p>
{{-- Overlay (alert dialogs do not close on overlay click) --}}
<div
data-slot="alert-dialog-overlay"
x-show="open"
x-transition:enter="motion-safe:transition-opacity motion-safe:duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="motion-safe:transition-opacity motion-safe:duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-scrim/50"
></div>
{{-- Panel --}}
<div
data-slot="{{ $slotName }}"
x-ref="panel"
x-bind:id="$id('alert-dialog-content')"
role="alertdialog"
aria-modal="true"
tabindex="-1"
x-bind:aria-labelledby="$id('alert-dialog-title')"
x-bind:aria-describedby="$id('alert-dialog-description')"
x-show="open"
x-transition:enter="motion-safe:transition motion-safe:duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@keydown.tab.prevent="trap($event)"
{{ $attributes->except('data-slot')->merge(['class' => 'relative z-10 w-full max-w-lg rounded-lg border border-border bg-card p-6 text-card-foreground shadow-lg motion-reduce:transition-none']) }}
>
{{ $slot }}
</div>
</div>
</template>
@endif
@props([
// Pin the header to the top of a scrollable alert dialog body.
'sticky' => false,
])
{{-- Anatomy shared with the other overlays — see ui/overlay/header.blade.php. --}}
<x-ui.overlay.header data-slot="alert-dialog-header" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.header>
@aware([
'native' => false,
'name' => null,
])
@php
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
@endphp
@if ($nativeMode !== null)
{{-- Native modes: a static id the dialog's aria-labelledby points at, no Alpine. --}}
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$titleId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm').'-title';
@endphp
<h2 id="{{ $titleId }}" data-slot="alert-dialog-title" {{ $attributes->except('data-slot')->merge(['class' => $styles['overlay']['title']]) }}>{{ $slot }}</h2>
@else
{{-- Anatomy shared with the other overlays — see ui/overlay/title.blade.php. --}}
<x-ui.overlay.title data-slot="alert-dialog-title" scope="alert-dialog-title" :track="false" {{ $attributes }}>{{ $slot }}</x-ui.overlay.title>
@endif
@aware([
'native' => false,
'name' => null,
])
@php
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
@endphp
@if ($nativeMode !== null)
{{-- Native modes: a static id the dialog's aria-describedby points at, no Alpine. --}}
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$descriptionId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm').'-description';
@endphp
<p id="{{ $descriptionId }}" data-slot="alert-dialog-description" {{ $attributes->except('data-slot')->merge(['class' => $styles['overlay']['description']]) }}>{{ $slot }}</p>
@else
{{-- Anatomy shared with the other overlays — see ui/overlay/description.blade.php. --}}
<x-ui.overlay.description data-slot="alert-dialog-description" scope="alert-dialog-description" :track="false" {{ $attributes }}>{{ $slot }}</x-ui.overlay.description>
@endif
@props([
// Pin the footer to the bottom of a scrollable alert dialog body.
'sticky' => false,
])
{{-- Anatomy shared with the other overlays — see ui/overlay/footer.blade.php. --}}
<x-ui.overlay.footer data-slot="alert-dialog-footer" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.footer>
@aware([
'native' => false,
])
@php
$nativeMode = match (true) {
$native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
$native === 'inline' => 'inline',
default => null,
};
$cancelClasses = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none';
@endphp
@if ($nativeMode === 'modal')
{{-- Closes the native dialog without submitting the real form: a
formmethod="dialog" submit works in every browser with <dialog>, no
invoker command needed. It takes focus when the dialog opens, so Enter
never confirms by accident. --}}
<button
type="submit"
formmethod="dialog"
formnovalidate
autofocus
value="cancel"
data-slot="alert-dialog-cancel"
{{ $attributes->merge(['class' => $cancelClasses]) }}
>
{{ $slot }}
</button>
@elseif ($nativeMode === 'inline')
{{-- Inline confirm page: cancel is a link back (href, else the previous URL). --}}
<a
data-slot="alert-dialog-cancel"
{{ $attributes->except('href')->merge(['class' => $cancelClasses]) }}
href="{{ $attributes->get('href') ?: url()->previous() }}"
>
{{ $slot }}
</a>
@else
<button
type="button"
data-slot="alert-dialog-cancel"
x-bind:disabled="busy"
@click="hide()"
{{ $attributes->merge(['class' => 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none']) }}
>
{{ $slot }}
</button>
@endif
@aware([
'native' => false,
])
@props([
// default: the primary fill. destructive: the red fill for an action that
// deletes or cannot be undone — the confirmation reads as the risk it is.
'variant' => 'default',
// Label shown while a `wire-action` call is in flight.
'busyLabel' => null,
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$variant = in_array($variant, ['default', 'destructive'], true) ? $variant : 'default';
$fill = $variant === 'destructive'
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90';
// livewire-confirm targets its action under its own name; print one data-slot.
$slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-action');
$isNative = in_array($native, [true, 'modal', 'true', '1', 'inline'], true);
$actionClasses = 'inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none '.$fill;
@endphp
@if ($isNative)
{{-- Native modes: a submit button. With the root's form-action it posts the
real form; without it, it closes the <form method="dialog">. A `name` and
`value` on the button are sent with the form. --}}
<button
type="submit"
data-slot="{{ $slotName }}"
data-variant="{{ $variant }}"
{{ $attributes->except('data-slot')->merge(['class' => $actionClasses]) }}
>
{{ $slot }}
</button>
@else
{{-- Confirms: runs the root's `wire-action` (if any) and closes. While the
call is in flight the button is disabled, announces busy and swaps its
label for `busyLabel`; without an action it closes immediately. --}}
<button
type="button"
data-slot="{{ $slotName }}"
data-variant="{{ $variant }}"
x-bind:disabled="busy"
x-bind:aria-busy="busy"
@click="run()"
{{ $attributes->except('data-slot')->merge(['class' => 'inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none '.$fill]) }}
>
<span x-show="busy" x-cloak class="size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" aria-hidden="true"></span>
<span x-show="!busy">{{ $slot }}</span>
<span x-show="busy" x-cloak>{{ $busyLabel ?? __('Confirming…') }}</span>
</button>
@endif
// The dialog registry dependency installs this module beside this file.
import {
announceOverlaySubjectChange,
focusOverlayInitial,
listenForOverlayName,
lockOverlayInert,
lockOverlayScroll,
overlayFocusables,
overlayReturnFocusTarget,
popOverlayEscapeLayer,
pushOverlayEscapeLayer,
resolveOverlayEscape,
trapOverlayFocus,
unlockOverlayInert,
unlockOverlayScroll,
} from './overlay-lifecycle.js';
/**
* Alert Dialog behavior (spec §17: focus trap, Escape, focus return).
*
* On open we remember the element that had focus, move focus into the panel,
* and lock body scroll. Tab is trapped inside the panel. On close we restore
* focus to the trigger. Escape closes; overlay click does not.
*
* `config.action` (the Blade `wire-action` prop) names a Livewire component
* method: run() calls it through `$wire` with `config.params`, holds the
* dialog open and busy until it settles, then closes. Without Livewire (or
* without an action) run() is a plain close, so the same markup is inert in
* a static page.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAlertDialog', (config = {}) => ({
open: false,
busy: false,
action: config.action ?? null,
params: Array.isArray(config.params) ? config.params : [],
previouslyFocused: null,
scrollLocked: false,
inertLocked: false,
inertPortal: null,
escapeLayers: [],
// Polite live region text — see announceSubjectChange().
subjectAnnouncement: '',
name: typeof config.name === 'string' && config.name !== '' ? config.name : null,
stopListeningForName: null,
init() {
this.stopListeningForName = listenForOverlayName(this.name, {
open: () => {
if (!this.open) this.show();
},
close: () => this.hide(),
});
},
lockScroll() {
lockOverlayScroll(this);
},
unlockScroll() {
unlockOverlayScroll(this);
},
lockInert() {
lockOverlayInert(this, this.$refs.portal);
},
unlockInert() {
unlockOverlayInert(this);
},
/** A nested layer (an inline form, a stepper) claims Escape while active. */
pushEscapeLayer(close) {
pushOverlayEscapeLayer(this, close);
},
popEscapeLayer(close) {
popOverlayEscapeLayer(this, close);
},
/** Announce an in-place subject change without re-announcing on open. */
announceSubjectChange(message) {
announceOverlaySubjectChange(this, message);
},
destroy() {
this.stopListeningForName?.();
this.stopListeningForName = null;
this.unlockScroll();
this.unlockInert();
},
show() {
this.previouslyFocused = overlayReturnFocusTarget(this.$refs.trigger);
this.open = true;
this.lockScroll();
this.lockInert();
this.$nextTick(() => this.focusInitial());
},
/** The confirm path: call the wired action (if any), then close. */
async run() {
if (this.busy) {
return;
}
if (!this.action || !window.Livewire || !this.$wire) {
this.hide();
return;
}
this.busy = true;
try {
await this.$wire.call(this.action, ...this.params);
} finally {
this.busy = false;
}
this.hide();
},
hide() {
if (!this.open || this.busy) {
return;
}
this.open = false;
this.unlockScroll();
this.unlockInert();
const target = this.previouslyFocused || this.$refs.trigger;
if (target) {
this.$nextTick(() => target.focus());
}
},
/** Escape unwinds one layer at a time before it closes the dialog. */
escape() {
if (!this.open) return;
if (resolveOverlayEscape(this)) return;
this.hide();
},
focusables() {
return overlayFocusables(this.$refs.panel);
},
focusInitial() {
focusOverlayInitial(this.$refs.panel);
},
/** Tab is always prevented in markup; we move focus manually. */
trap(event) {
trapOverlayFocus(this.$refs.panel, event);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.10.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0