|
|
5 months ago | |
|---|---|---|
| .. | ||
| components | 5 months ago | |
| README.md | 5 months ago | |
| alert-dialog-provider.svelte.test.ts | 5 months ago | |
| alert-dialog-provider.svelte.ts | 5 months ago | |
| exports.ts | 6 months ago | |
| index.ts | 6 months ago | |
| langs.ts | 6 months ago | |
| types.ts | 5 months ago | |
README.md
AlertDialog
A modal dialog with role="alertdialog" used for destructive or consequential confirmations (delete, overwrite, discard). It is a thin specialisation of Dialog — the state machinery, focus trap, scroll lock and portal layers are all inherited unchanged.
Three things make an AlertDialog different from a plain Dialog:
- Semantics.
Contentemitsrole="alertdialog"so assistive technology announces it with alert semantics. - Modality is fixed.
modal=trueandvariant='alertdialog'are forced byAlertDialog.Provider. Non-modal alert dialogs are not a concept. - No outside dismissal. Clicking the overlay does not close the dialog — the user must pick
ActionorCancelexplicitly. This matches the WAI-ARIA Authoring Practices for alert dialogs.
Anatomy
<AlertDialog.Provider bind:open>
<AlertDialog.Trigger>Delete account</AlertDialog.Trigger>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>Delete account?</AlertDialog.Title>
<AlertDialog.Description>
This is permanent and cannot be undone.
</AlertDialog.Description>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={deleteAccount}>Delete</AlertDialog.Action>
</AlertDialog.Content>
</AlertDialog.Provider>
Wrap Overlay + Content in a Portal for body-level rendering when z-index stacking demands it.
By convention, place Cancel before Action in DOM order: the focus scope lands on the first focusable element when the dialog opens, so Cancel (the safe choice) receives initial focus — the WAI-ARIA recommendation for destructive confirmations.
Parts
| Part | Element | Origin | Description |
|---|---|---|---|
Provider |
none | AlertDialog | Root context. Forces variant='alertdialog' + modal=true on Dialog. |
Trigger |
<button> |
Dialog | Opens the dialog on click. |
Content |
<div> |
Dialog | Dialog container. Focus trap, scroll lock, dismissal layers integrated. |
Overlay |
<div> |
Dialog | Backdrop. Click-outside does NOT close (forced by modal=true + alertdialog). |
Title |
<div> |
Dialog | Dialog heading. Linked via aria-labelledby. |
Description |
<div> |
Dialog | Supporting text. Linked via aria-describedby. |
Action |
<button> |
AlertDialog | Primary / destructive confirmation. Closes the dialog on click. |
Cancel |
<button> |
AlertDialog | Safe escape button. Closes the dialog on click. |
Props
Provider
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id for the root. |
open |
boolean |
false |
Bindable open state. |
onOpenChange |
(v: boolean) => void |
— | Called when open state changes. |
onOpenChangeComplete |
(v: boolean) => void |
— | Called after the open/close animation settles. |
disabled |
boolean |
false |
Disables the trigger (the dialog stays closed). |
Action / Cancel
Both accept every <button> attribute plus:
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
aria-label |
string |
"Confirm" / "Cancel" |
Accessible name. Overrides the translated default. |
Trigger, Content, Overlay, Title, Description are re-exported from Dialog — see Dialog's props for the complete list. Of particular interest:
AlertDialog.ContentdefaultsescapeKeydownBehaviorto'close'(Escape acts as Cancel — matches WAI-ARIA and Radix / Ark / bits). PassescapeKeydownBehavior='ignore'for the "must click a button" variant.AlertDialog.Contentalso acceptsinteractOutsideBehavior, but it is always resolved toignorebecausemodal=trueandvariant='alertdialog'both force it.
ARIA
| Part | Attribute | Value |
|---|---|---|
| Trigger | aria-haspopup |
dialog |
| Trigger | aria-expanded |
true | false |
| Trigger | aria-controls |
ID of Content |
| Content | role |
alertdialog |
| Content | aria-modal |
true |
| Content | aria-labelledby |
ID of Title |
| Content | aria-describedby |
ID of Description |
| Overlay | aria-hidden |
true |
| Title | role |
heading |
| Title | aria-level |
Number (default 2) |
| Action | aria-label |
Translated 'Confirm' (override via prop) |
| Cancel | aria-label |
Translated 'Cancel' (override via prop) |
Data Attributes
| Part | Attribute | Values |
|---|---|---|
| Trigger | data-dialog-trigger |
Always present |
| Trigger | data-state |
open | closed |
| Content | data-dialog-content |
Always present |
| Content | data-state |
open | closed |
| Content | data-nested |
Present when nested |
| Content | data-nested-open |
Present when a child dialog is open |
| Content | data-starting-style |
Present during the open animation (one frame) |
| Content | data-ending-style |
Present during the close animation |
| Overlay | data-dialog-overlay |
Always present |
| Overlay | data-state |
open | closed |
| Action | data-alert-dialog-action |
Always present |
| Cancel | data-alert-dialog-cancel |
Always present |
Because AlertDialog composes Dialog, the Trigger / Content / Overlay / Title / Description emit the data-dialog-* attributes inherited from that component. The two AlertDialog-specific parts (Action, Cancel) emit their own data-alert-dialog-* attributes so themes can style the primary vs secondary buttons independently.
Keyboard
| Key | Action |
|---|---|
Escape |
Closes (acts as Cancel). Default escapeKeydownBehavior='close'. Pass 'ignore' to force explicit Action/Cancel. |
Tab |
Cycle focus within the dialog (trapped). |
Shift+Tab |
Cycle focus backwards. |
Enter |
Fires onclick on the focused button. |
Space |
Fires onclick on the focused button. |
Focus lands on the first focusable child when the dialog opens (Cancel by DOM order convention). Focus returns to the trigger on close.
i18n
Default button labels resolve through the UIX lang system. The component catalog is owned by morfo.translations under components.alert-dialog.
| Key | English | Spanish |
|---|---|---|
action |
Confirm |
Confirmar |
cancel |
Cancel |
Cancelar |
Consumers override per-instance via the aria-label prop on Action / Cancel, or the visible label via children.
Comparison
| Feature | Soma | Radix | Ark UI | bits-ui |
|---|---|---|---|---|
role="alertdialog" emitted |
✅ | ✅ | ✅ | ✅ |
| Always modal | ✅ | ✅ | ✅ | ✅ |
Action + Cancel as first-class parts |
✅ | ✅ | ✅ | ✅ |
| Click-outside disabled | ✅ | ✅ | ✅ | ✅ |
| Escape closes by default | ✅ | ✅ | ✅ | ✅ |
| Translated default button labels | ✅ | ❌ | ⚠️² | ❌ |
| Nested alert dialogs | ✅ | ✅ | ✅ | ✅ |
| Portal-agnostic (consumer picks) | ✅ | ✅ | ✅ | ✅ |
data-* attribute contract |
✅ | ✅ | ✅ | ✅ |
| Shared anatomy with plain Dialog | ✅ | ❌ | ✅ | ✅ |
| Built-in Form integration (confirm on submit) | ✅³ | ❌ | ❌ | ❌ |
² Ark has a machine-level translator seam but no built-in English/Spanish strings.
³ Demonstrated on the test page: onValidSubmit returns a Promise gated on a confirmation dialog.
Usage
Basic destructive confirmation
<script lang="ts">
import { AlertDialog } from '$soma/components';
let open = $state(false);
function deleteAccount() {
/* … */
}
</script>
<AlertDialog.Provider bind:open>
<AlertDialog.Trigger>Delete account</AlertDialog.Trigger>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>Delete account?</AlertDialog.Title>
<AlertDialog.Description>
All your data will be permanently removed.
</AlertDialog.Description>
<AlertDialog.Cancel>Keep it</AlertDialog.Cancel>
<AlertDialog.Action onclick={deleteAccount}>Delete</AlertDialog.Action>
</AlertDialog.Content>
</AlertDialog.Provider>
Force explicit Action/Cancel (Escape ignored)
<AlertDialog.Content escapeKeydownBehavior="ignore">
…
</AlertDialog.Content>
Gating a form submission
<script lang="ts">
import { AlertDialog, Form, Field } from '$soma/components';
import { createForm } from '$soma/components/form';
let confirmOpen = $state(false);
let resolve: ((ok: boolean) => void) | null = null;
const form = createForm({
defaults: { email: '' },
async onValidSubmit(values) {
const ok = await new Promise<boolean>((r) => {
resolve = r;
confirmOpen = true;
});
if (ok) await api.delete(values.email);
}
});
</script>
<Form.Provider {form}>…<Form.Submit>Delete</Form.Submit></Form.Provider>
<AlertDialog.Provider bind:open={confirmOpen}
onOpenChange={(v) => !v && resolve?.(false)}
>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>Really?</AlertDialog.Title>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => { resolve?.(true); confirmOpen = false; }}>
Confirm
</AlertDialog.Action>
</AlertDialog.Content>
</AlertDialog.Provider>
Implementation notes
AlertDialog.Provideris a ~30-line Svelte file that creates aDialogProviderwithvariant='alertdialog'andmodal=true. No separate state class — the Dialog state machine is authoritative.ActionandCancelcallDialogProvider.handleClose()on click. Consumeronclickhandlers merged viamergePropsrun first; the close fires last.- Because AlertDialog piggybacks on Dialog's context,
Dialog.*parts still work inside an<AlertDialog.Provider>— but consumer code should stick to theAlertDialog.*alias for clarity.