diff --git a/src/uix/eidos/components/alert-dialog/README.md b/src/uix/eidos/components/alert-dialog/README.md new file mode 100644 index 000000000..194dd33a7 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/README.md @@ -0,0 +1,143 @@ +# `` — eidos + +Visual wrapper over `soma/components/alert-dialog`. A modal Dialog with +`role="alertdialog"`, forced `modal=true` and click-outside disabled +(`interactOutsideBehavior='ignore'`). The user MUST choose +`Action` or `Cancel`. Escape still cancels. + +## Usage + +```svelte + + + + Delete project… + + + + + Delete project? + + This action cannot be undone. All data will be lost. + + + + Cancel + Delete + + + + +``` + +## Differences from `` + +| Concern | Dialog | AlertDialog | +|---|---|---| +| role | `dialog` | `alertdialog` | +| modal | optional | **forced** `true` | +| click-outside | `'close'` (default) | `'ignore'` (forced) | +| Escape | `'close'` | `'close'` (preserved) | +| Initial focus | first focusable | **Cancel** (by convention; render it first) | +| Footer pattern | open-ended | always `Cancel + Action` for destructive choices | +| Doctrinal intent | full set | narrowed: `neutral \| risk \| threat` | + +## Parts + +| Part | Origin | Notes | +|---|---|---| +| `AlertDialog` (root) | eidos | Wraps soma's AlertDialog.Provider | +| `Trigger` | inherited from Dialog | Identical contract | +| `Portal` | inherited from Dialog | Identical contract | +| `Overlay` | inherited from Dialog | Identical contract | +| `Content` | eidos (alert-dialog) | Wraps soma's AlertDialog.Content (forces `escapeKeydownBehavior='close'`) | +| `Title` | inherited from Dialog | Identical contract | +| `Description` | inherited from Dialog | Identical contract | +| `Header` | inherited from Dialog | Identical contract | +| `Footer` | inherited from Dialog | Identical contract | +| `Action` | eidos (alert-dialog) | Primary destructive button — auto-colors from dialog's `intent` | +| `Cancel` | eidos (alert-dialog) | Secondary safe button — stays neutral | + +## Recipe + +`alert-dialog.css` styles only the Action + Cancel buttons. Chrome +(overlay, panel, header, etc.) comes from `dialog.css` — soma's +AlertDialog re-uses the Dialog runtime, so the same `data-dialog-*` +markers reach the DOM. + +Buttons consume the Toggle palette tokens (`--toggle-color-*`) — same +"interactive button" vocabulary used across ``, +`` and other action surfaces. + +## Passive justification + +Passive **at the alert-dialog morfo level** — the morfo declares only +the Action / Cancel button parts (Provider is virtual). All +behavioural events (open/close presence, escape, focus trap, …) come +from the Dialog runtime that soma's AlertDialog delegates to. There +is no alert-dialog-specific sema event to fire; the +`commit-confirm` and `close-cancel` flows live inside the Dialog +morfo's vocabulary. + +The component IS interactive from the user's perspective. "Passive" +here is a contract-layer classification, not a UX one. + +## Baseline + +WAI-ARIA APG Alert Dialog: + + +Soma docs at `src/uix/soma/components/alert-dialog/README.md` cover +focus management (Cancel-first), nesting, and the `interactOutside` +behavioural locking. + +## Comparativa + +| Lib | Role forced | Modal forced | Click-outside | Intent narrowing | Auto-Color Action | +|---|---|---|---|---|---| +| **Radix Primitives** | ✓ | ✓ | ignore (forced) | — | — | +| **Bits UI** | ✓ | ✓ | ignore | — | — | +| **Ark UI** | ✓ | ✓ | ignore | — | — | +| **Chakra v3** | ✓ | ✓ | option | `colorPalette` | by colorPalette | +| **Radix Themes** | ✓ | ✓ | ignore | `color` | by `color` | +| **shadcn/ui** | ✓ | ✓ | ignore | — | manual `variant="destructive"` | +| **Eidos** | ✓ | ✓ | ignore | `neutral\|risk\|threat` | ✓ — cascades from dialog's `data-color` | + +## Decisiones + +- **Intent narrowing.** The morfo restricts to `neutral | risk | threat` + (no `affirm` / `fulfill`) because alert dialogs gate destructive or + irreversible actions — celebrating them is the wrong affordance. + The recipe still picks up `affirm` if a consumer overrides the color + by hand (defensive fallback in the cascade). +- **Auto-color the Action button** based on the dialog's `data-color`. + An `intent="risk"` AlertDialog gets a red Action without manual + recipe code. Chakra and Radix Themes do this implicitly via + `colorPalette` — Eidos makes it explicit via the intent contract. +- **Cancel stays neutral.** Contrast with the Action is the canonical + visual cue for "this is the safe choice" (Radix Themes, Material + Design, iOS Human Interface Guidelines all agree). +- **Re-use Dialog's eidos parts** rather than duplicate Trigger / + Overlay / Title / etc. Soma already delegates to Dialog under the + hood; the visual layer mirrors that. + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| Soma's `commit-confirm` / `close-cancel` events not yet wired to a per-component sema cascade | **diferir** | The `commit` family base ships sound; suffices for now. Add cascade if we want different sounds for confirm-destroy vs confirm-affirm. | +| No async `onAction` returning Promise to gate close | **diferir** | Soma's wrapper closes synchronously; consumers gate via a parent state machine instead. Open spec question. | +| `size='xs'` / `'sm'` shaping for very small confirmations | **diferir** | Inherits Dialog's size scale (`sm/md/lg/xl/full`). Smaller would require Dialog-level changes. | + +## Reference + +- Radix UI Primitives: + +- Bits UI: +- Ark UI: +- Chakra v3: +- Radix Themes: +- shadcn/ui: diff --git a/src/uix/eidos/components/alert-dialog/alert-dialog-action.svelte b/src/uix/eidos/components/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 000000000..357091e4d --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,17 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/alert-dialog/alert-dialog-cancel.svelte b/src/uix/eidos/components/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 000000000..78f8a830c --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/alert-dialog/alert-dialog-content.svelte b/src/uix/eidos/components/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 000000000..634766cc4 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,85 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/alert-dialog/alert-dialog.css b/src/uix/eidos/components/alert-dialog/alert-dialog.css new file mode 100644 index 000000000..cd8805648 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/alert-dialog.css @@ -0,0 +1,134 @@ +/* + * AlertDialog recipe — Action + Cancel buttons only. + * + * The dialog chrome (overlay, content panel, header, title, etc.) is + * painted by `dialog.css`. Soma's AlertDialog re-uses the Dialog runtime + * (variant='alertdialog', modal=true), so the same `data-dialog-*` + * markers reach the DOM and the same recipe applies. + * + * What's alert-dialog-specific: + * + * [data-alert-dialog-action] → primary / destructive confirmation + * [data-alert-dialog-cancel] → secondary / safe escape + * + * The buttons consume the Toggle palette tokens (--toggle-*) like + * and — same "interactive button" visual + * vocabulary throughout the system. + * + * Intent propagation: the Dialog content carries + * `data-color="risk|threat|…"` (resolved from the AlertDialog's + * `intent` prop). Selectors below pick the destructive palette only + * when the dialog is flagged as such, so a `neutral` confirm dialog + * stays primary-colored instead of red. + */ + +[data-alert-dialog-action], +[data-alert-dialog-cancel] { + --_toggle-height: var(--toggle-height-md); + --_toggle-padding-inline: var(--toggle-px-md); + --_toggle-radius: var(--toggle-radius-md); + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--_toggle-height); + padding-inline: var(--_toggle-padding-inline); + border: var(--toggle-border-width) solid transparent; + border-radius: var(--_toggle-radius); + font-family: var(--toggle-font-family); + font-size: var(--toggle-font-size-md); + font-weight: var(--toggle-font-weight-md); + line-height: var(--toggle-line-height); + white-space: nowrap; + cursor: pointer; + transition: + background var(--toggle-transition-duration) var(--toggle-transition-ease), + border-color var(--toggle-transition-duration) var(--toggle-transition-ease), + color var(--toggle-transition-duration) var(--toggle-transition-ease); +} + +[data-alert-dialog-action]:focus-visible, +[data-alert-dialog-cancel]:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +/* ── Action — primary / destructive confirmation ─────────────────── */ +/* + * Defaults to the dialog's intent palette: a `risk`-flagged + * AlertDialog renders a red Action automatically because + * `[data-dialog-content][data-color='risk']` cascades the risk palette + * to its descendants. The selectors below pin the Action to a SOLID + * variant of whichever palette is active. + */ + +[data-alert-dialog-action] { + background: var(--toggle-color-primary-solid); + color: var(--toggle-color-primary-contrast); + border-color: var(--toggle-color-primary-solid); +} + +[data-alert-dialog-action]:hover:not([data-disabled]) { + background: var(--toggle-color-primary-solid-hover); + border-color: var(--toggle-color-primary-solid-hover); +} + +/* When the dialog itself signals a destructive intent, Action picks + * the matching palette. Looking at the closest ancestor with the + * `data-color` projected by the morfo (lives on `data-dialog-content`). */ + +[data-dialog-content][data-color='risk'] [data-alert-dialog-action] { + background: var(--toggle-color-risk-solid); + border-color: var(--toggle-color-risk-solid); + color: var(--toggle-color-risk-contrast); +} + +[data-dialog-content][data-color='risk'] [data-alert-dialog-action]:hover:not([data-disabled]) { + background: var(--toggle-color-risk-solid-hover); + border-color: var(--toggle-color-risk-solid-hover); +} + +[data-dialog-content][data-color='threat'] [data-alert-dialog-action] { + background: var(--toggle-color-threat-solid); + border-color: var(--toggle-color-threat-solid); + color: var(--toggle-color-threat-contrast); +} + +[data-dialog-content][data-color='threat'] [data-alert-dialog-action]:hover:not([data-disabled]) { + background: var(--toggle-color-threat-solid-hover); + border-color: var(--toggle-color-threat-solid-hover); +} + +[data-dialog-content][data-color='affirm'] [data-alert-dialog-action] { + background: var(--toggle-color-affirm-solid); + border-color: var(--toggle-color-affirm-solid); + color: var(--toggle-color-affirm-contrast); +} + +[data-dialog-content][data-color='affirm'] [data-alert-dialog-action]:hover:not([data-disabled]) { + background: var(--toggle-color-affirm-solid-hover); + border-color: var(--toggle-color-affirm-solid-hover); +} + +/* ── Cancel — secondary / safe escape ─────────────────────────────── */ +/* + * Stays neutral-outlined regardless of the dialog's intent. The + * contrast between a colored Action and a neutral Cancel is the + * canonical visual cue for "this is the safe choice". + */ + +[data-alert-dialog-cancel] { + background: var(--toggle-color-neutral-track); + color: var(--color-content-primary); + border-color: var(--toggle-color-neutral-border); +} + +[data-alert-dialog-cancel]:hover:not([data-disabled]) { + background: var(--toggle-color-neutral-hover); + border-color: var(--toggle-color-neutral-border); +} + +[data-alert-dialog-action][data-disabled], +[data-alert-dialog-cancel][data-disabled] { + cursor: default; + opacity: var(--toggle-disabled-opacity, 0.55); +} diff --git a/src/uix/eidos/components/alert-dialog/alert-dialog.svelte b/src/uix/eidos/components/alert-dialog/alert-dialog.svelte new file mode 100644 index 000000000..012c78314 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/alert-dialog.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/alert-dialog/index.ts b/src/uix/eidos/components/alert-dialog/index.ts new file mode 100644 index 000000000..2c1371ed2 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/index.ts @@ -0,0 +1,85 @@ +// AlertDialog — eidos compound API. +// +// import { AlertDialog } from '$uix/eidos/components/alert-dialog'; +// +// +// Delete project… +// +// +// +// +// Delete project? +// +// This cannot be undone. +// +// +// +// Cancel +// Delete +// +// +// +// +// +// Architecturally: soma's AlertDialog re-uses Dialog's runtime (variant= +// alertdialog, modal=true forced). Eidos parts that map 1:1 to Dialog +// (Trigger / Overlay / Title / Description / Header / Footer / Portal) +// re-export the Dialog wrappers directly. Provider, Content, Action and +// Cancel are alert-dialog-specific. +import AlertDialogComponent from './alert-dialog.svelte'; +import Content from './alert-dialog-content.svelte'; +import Action from './alert-dialog-action.svelte'; +import Cancel from './alert-dialog-cancel.svelte'; +import { + default as Dialog +} from '$uix/eidos/components/dialog'; +import { Portal } from '$soma/components/internal'; + +const Trigger = Dialog.Trigger; +const Overlay = Dialog.Overlay; +const Title = Dialog.Title; +const Description = Dialog.Description; +const Header = Dialog.Header; +const Footer = Dialog.Footer; + +type AlertDialogNamespace = typeof AlertDialogComponent & { + Trigger: typeof Trigger; + Portal: typeof Portal; + Overlay: typeof Overlay; + Content: typeof Content; + Title: typeof Title; + Description: typeof Description; + Header: typeof Header; + Footer: typeof Footer; + Action: typeof Action; + Cancel: typeof Cancel; +}; + +const AlertDialog = AlertDialogComponent as AlertDialogNamespace; +AlertDialog.Trigger = Trigger; +AlertDialog.Portal = Portal; +AlertDialog.Overlay = Overlay; +AlertDialog.Content = Content; +AlertDialog.Title = Title; +AlertDialog.Description = Description; +AlertDialog.Header = Header; +AlertDialog.Footer = Footer; +AlertDialog.Action = Action; +AlertDialog.Cancel = Cancel; + +export { AlertDialog }; + +export default AlertDialog; + +export type { + AlertDialogProps, + AlertDialogActionProps as ActionProps, + AlertDialogCancelProps as CancelProps, + AlertDialogTriggerProps as TriggerProps, + AlertDialogContentProps as ContentProps, + AlertDialogOverlayProps as OverlayProps, + AlertDialogTitleProps as TitleProps, + AlertDialogDescriptionProps as DescriptionProps, + AlertDialogHeaderProps as HeaderProps, + AlertDialogFooterProps as FooterProps +} from './types'; diff --git a/src/uix/eidos/components/alert-dialog/types.ts b/src/uix/eidos/components/alert-dialog/types.ts new file mode 100644 index 000000000..b644e4dd3 --- /dev/null +++ b/src/uix/eidos/components/alert-dialog/types.ts @@ -0,0 +1,50 @@ +import type { + ProviderProps, + ActionProps, + CancelProps +} from '$soma/components/alert-dialog'; + +/** + * Eidos `` — visual wrapper over `soma/alert-dialog`. A + * modal Dialog with `role="alertdialog"`, forced `modal=true` and + * `interactOutsideBehavior='ignore'` (Radix-style: the user MUST choose + * Action or Cancel — click-outside is not enough). Escape still cancels. + * + * Compound shape: + * + * + * Delete project… + * + * + * + * + * Delete project? + * + * This action cannot be undone. All data will be lost. + * + * + * + * Cancel + * Delete + * + * + * + * + */ + +export type AlertDialogProps = ProviderProps; +export type AlertDialogActionProps = ActionProps; +export type AlertDialogCancelProps = CancelProps; + +// Trigger / Overlay / Content / Title / Description re-use Dialog's +// eidos wrappers — same recipe applies because soma's AlertDialog +// delegates to Dialog under the hood (`variant='alertdialog'`). +export type { + DialogTriggerProps as AlertDialogTriggerProps, + DialogOverlayProps as AlertDialogOverlayProps, + DialogContentProps as AlertDialogContentProps, + DialogTitleProps as AlertDialogTitleProps, + DialogDescriptionProps as AlertDialogDescriptionProps, + DialogHeaderProps as AlertDialogHeaderProps, + DialogFooterProps as AlertDialogFooterProps +} from '$uix/eidos/components/dialog/types'; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index e1a7f2387..4c4fd1505 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -90,6 +90,7 @@ @import './components/breadcrumb/breadcrumb.css'; @import './components/toast/toast.css'; @import './components/dialog/dialog.css'; +@import './components/alert-dialog/alert-dialog.css'; @import './components/drawer/drawer.css'; @import './components/field/field.css'; @import './components/form/form.css'; diff --git a/src/uix/morfo/components/alert-dialog.ts b/src/uix/morfo/components/alert-dialog.ts index 076c2fc04..1989d8941 100644 --- a/src/uix/morfo/components/alert-dialog.ts +++ b/src/uix/morfo/components/alert-dialog.ts @@ -4,7 +4,11 @@ import { v } from '../types'; export const alertDialogMorfo: Morfo = { name: 'AlertDialog', kebab: 'alert-dialog', - scope: ['soma'], + // Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/alert-dialog/`). + // Sema picks up Action/Cancel button events via the Dialog morfo's + // shared `commit-*` / `close-*` events — alert-dialog's morfo only + // declares the Action + Cancel button parts (Provider is virtual). + scope: ['soma', 'sema', 'eidos'], apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/', texts: { label: '#?components.alert-dialog.label|Alert Dialog', diff --git a/web/routes/uix/components/alert-dialog/+page.svelte b/web/routes/uix/components/alert-dialog/+page.svelte new file mode 100644 index 000000000..8b9617616 --- /dev/null +++ b/web/routes/uix/components/alert-dialog/+page.svelte @@ -0,0 +1,529 @@ + + +
+
+
Feedback · AlertDialog
+

AlertDialog

+

+ Modal Dialog with role="alertdialog", forced + modal=true and click-outside disabled. The user MUST + choose Action or Cancel (Escape still + cancels). soma + delegates to the Dialog runtime — only the Action + Cancel + buttons are alert-dialog-specific. +

+
+ + parts{compiled.parts.order.length} + + + intent{intent} + + + open{open ? 'true' : 'false'} + + {#if lastChoice} + + last{lastChoice} + + {/if} +
+
+ +
+
+ + (lastChoice = null)}> + Open AlertDialog + + + + + + {title} + {description} + + + {cancelText} + {actionText} + + + + +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + {#if trace[0]} + · + last + {trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)} + {/if} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ intent is the key knob — switching to + risk / threat auto-colors the Action + button (red / amber) and primes the sema layer for a + destructive confirmation. +

+ +
+ soma props · semantic +
+
+ + + + + +
+ +
+ eidos props · shape (forwarded to Dialog) +
+
+ + +
+ +
+
+ soma + headless · forced modal + role=alertdialog + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · full composition + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ AlertDialog re-uses Dialog's parts for Trigger / Overlay / + Title / Description / Header / Footer / Portal. The + alert-dialog-specific parts are Provider (sets the variant + + modal forcing), Content (forces Escape close), Action and + Cancel. +

+
AlertDialog (root)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
open somabooleanfalseBindable visibility.
intent soma'neutral' | 'risk' | 'threat''neutral'Narrowed from Dialog's full set — alert dialogs gate destructive actions.
disabled somabooleanfalseDisables the Trigger (dialog stays closed).
onOpenChange soma(open: boolean) => void—Fired on open/close transition start.
+
+ +
AlertDialog.Content
+
+ + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
size eidos'sm' | 'md' | 'lg' | 'xl' | 'full''md'Modal width preset. Responsive.
position eidos3×3 anchor grid'middle-center'Same as Dialog.
escapeKeydownBehavior soma'close' | 'ignore''close'Overrides Dialog default. Escape cancels by convention.
+
+ +
AlertDialog.Action / AlertDialog.Cancel
+
+ + + + + + + + + + + + + + +
NameTypeNotes
aria-label somastringOverrides the default accessible name ("Confirm" / "Cancel").
onclick(ev: MouseEvent) => voidBusiness handler. Default close runs LAST — consumer logic gets first chance.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+

+ The alert-dialog morfo declares only the Action + Cancel + button parts (Provider is virtual). Trigger / Content / + Overlay / Title / Description / Header / Footer come from + the Dialog morfo via soma's runtime delegation + (variant='alertdialog' on the Dialog provider). +

+
Parts ({partsList.length})
+
+ + + + {#each partsList as part} + {@const partAny = part as unknown as Record} + + + + + + + {/each} + +
KebabRoleMarkerKind
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'}{partAny.kind ?? '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · perceptual flow +

+

+ The alert-dialog morfo declares zero events of its own. + Perceptual feedback (open whoosh, button-press tick, close + exit) comes from the Dialog runtime that soma's AlertDialog + delegates to. See the Dialog demo's Sema tab for the full + event vocabulary. +

+
+ + + + + + + + + + + + +
SourceEvents
alert-dialog morfo{events.length} (none)
dialog morfo (via runtime delegation)open, close-*, commit-*
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ alert-dialog.css only styles the Action + Cancel + buttons. Chrome (overlay, content panel, header) comes from + dialog.css — same DOM markers reach both. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-alert-dialog-action]morfoSolid button. Inherits Toggle palette.
[data-alert-dialog-cancel]morfoOutline button. Stays neutral.
[data-dialog-content][data-color='risk'] [data-alert-dialog-action]eidosAuto-color cascade: risk-flagged dialog gets a red Action.
[data-dialog-content][data-color='threat'] [data-alert-dialog-action]eidosSame cascade for threat-flagged.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

A11y contract

+

+ Pattern: WAI-ARIA APG Alert Dialog. The Content emits role="alertdialog" (vs + dialog) — screen readers announce it with alert + semantics. +

+
+ + + + + + + + + +
KeyEffect
EscapeCancel (closes via Cancel's onclick).
TabMove focus within the dialog (focus trap).
Shift+TabReverse focus within the dialog.
Enter on ActionConfirm (closes via Action's onclick).
Click outsideIgnored. Must choose Action or Cancel.
+
+

+ By convention, Cancel receives initial focus — + render it BEFORE Action in DOM order. This makes + the safe choice the default when the user just presses + Enter. +

+
+ {/if} +