feat(alert-dialog): port from soma to eidos (Tier 1 sprint, 2/5)

Modal Dialog variant with role=alertdialog, forced modal=true, and
click-outside disabled. Soma delegates to the Dialog runtime
(variant='alertdialog') so DOM markers are Dialog's (data-dialog-*)
— eidos reuses Dialog's recipe for chrome and adds two
alert-dialog-specific buttons: Action + Cancel.

Eidos shape:
- Provider, Content, Action, Cancel — alert-dialog-specific wrappers
- Trigger, Overlay, Title, Description, Header, Footer, Portal —
  re-exported from Dialog's eidos barrel (zero duplication)

Recipe (alert-dialog.css):
- Only styles Action + Cancel
- Buttons consume Toggle palette tokens (same "interactive button"
  vocabulary used by <Toggle> and <ToggleGroup.Item>)
- Auto-color cascade: Action picks up the dialog's data-color so a
  risk/threat-flagged dialog gets a red/amber Action button without
  manual recipe code
- Cancel stays neutral so the safe-choice contrast is preserved

Morfo:
- scope: ['soma'] → ['soma', 'sema', 'eidos']
- The morfo declares Action + Cancel parts only (Provider is virtual,
  other parts inherited via Dialog runtime delegation)
- Classified passive at this contract level (Dialog owns the events);
  README documents this in §"Passive justification"

Demo: 6-tab canonical template with intent / size / position chips,
soma + eidos snippets, references comparison table vs Radix
Primitives / Bits UI / Ark UI / Chakra v3 / Radix Themes / shadcn.

Checks: svelte-check 0 errors, component:audit 91/91 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 61ffbade46
commit d5630a528f

@ -0,0 +1,143 @@
# `<AlertDialog>` — 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
<script lang="ts">
import { AlertDialog } from '$uix/eidos/components/alert-dialog';
let open = $state(false);
</script>
<AlertDialog bind:open intent="risk">
<AlertDialog.Trigger>Delete project…</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete project?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. All data will be lost.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
```
## Differences from `<Dialog>`
| 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 `<Toggle>`,
`<ToggleGroup.Item>` 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:
<https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/>
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:
<https://www.radix-ui.com/primitives/docs/components/alert-dialog>
- Bits UI: <https://bits-ui.com/docs/components/alert-dialog>
- Ark UI: <https://ark-ui.com/docs/components/alert-dialog>
- Chakra v3: <https://chakra-ui.com/docs/components/dialog>
- Radix Themes: <https://www.radix-ui.com/themes/docs/components/alert-dialog>
- shadcn/ui: <https://ui.shadcn.com/docs/components/alert-dialog>

@ -0,0 +1,17 @@
<script lang="ts">
/**
* Eidos `<AlertDialog.Action>` — destructive / primary confirmation
* button. Pass-through over soma's `<AlertDialog.Action>`. Visual
* treatment lives in `alert-dialog.css` (solid button, defaults to
* the dialog's `intent` color so a `risk`-flagged dialog gets a red
* Action automatically).
*/
import * as AlertDialog from '$soma/components/alert-dialog';
import type { AlertDialogActionProps } from './types';
let { children, ...rest }: AlertDialogActionProps = $props();
</script>
<AlertDialog.Action {...rest}>
{@render children?.()}
</AlertDialog.Action>

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Eidos `<AlertDialog.Cancel>` — secondary / safe escape button.
* By WAI-ARIA convention it receives initial focus when the dialog
* opens (the safest choice is selected by default for destructive
* confirmations). Render `Cancel` BEFORE `Action` in DOM order so
* the focus scope lands here first.
*
* Pass-through over soma's `<AlertDialog.Cancel>`. Visual treatment:
* outline button, always neutral palette so it visually contrasts
* with the (potentially destructive) Action.
*/
import * as AlertDialog from '$soma/components/alert-dialog';
import type { AlertDialogCancelProps } from './types';
let { children, ...rest }: AlertDialogCancelProps = $props();
</script>
<AlertDialog.Cancel {...rest}>
{@render children?.()}
</AlertDialog.Cancel>

@ -0,0 +1,85 @@
<script lang="ts">
/**
* Eidos `<AlertDialog.Content>`. Mirrors `Dialog.Content`'s recipe
* (responsive `size`, auto-sheet on narrow viewports, dimension
* overrides) but wraps soma's `AlertDialog.Content` so the
* `escapeKeydownBehavior='close'` default from soma's wrapper takes
* effect.
*
* The underlying DOM still emits `data-dialog-content` markers
* (soma's AlertDialog re-uses the Dialog runtime), so the existing
* `dialog.css` recipe paints chrome.
*/
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as AlertDialog from '$soma/components/alert-dialog';
import type { AlertDialogContentProps } from './types';
let {
size = 'md',
position = 'middle-center',
width,
minWidth,
maxWidth,
height,
minHeight,
maxHeight,
style,
children: bodyContent,
...rest
}: AlertDialogContentProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedPosition = $derived(eidos.resolve(position, 'middle-center'));
const isSheet = $derived(resolvedSize !== 'full' && eidos.isBelow('sm'));
function serializeLength(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return undefined;
return value === 0 ? '0' : `${value}px`;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function dimensionVar(name: string, value: string | number | undefined): string | undefined {
const serialized = serializeLength(value);
return serialized ? `${name}: ${serialized};` : undefined;
}
const dimensionsStyle = $derived(
[
dimensionVar('--_dialog-content-width-override', serializeLength(eidos.resolve(width))),
dimensionVar('--_dialog-content-min-width-override', serializeLength(eidos.resolve(minWidth))),
dimensionVar('--_dialog-content-max-width-override', serializeLength(eidos.resolve(maxWidth))),
dimensionVar('--_dialog-content-height-override', serializeLength(eidos.resolve(height))),
dimensionVar(
'--_dialog-content-min-height-override',
serializeLength(eidos.resolve(minHeight))
),
dimensionVar(
'--_dialog-content-max-height-override',
serializeLength(eidos.resolve(maxHeight))
)
]
.filter(Boolean)
.join(' ')
);
const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle));
</script>
<AlertDialog.Content
{...rest}
style={composedStyle}
data-size={resolvedSize}
data-position={resolvedPosition}
data-sheet={isSheet ? '' : undefined}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</AlertDialog.Content>

@ -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
* <Toggle> and <ToggleGroup.Item> — 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);
}

@ -0,0 +1,20 @@
<script lang="ts">
/**
* Eidos `<AlertDialog>` — root. Wraps soma's
* `<AlertDialog.Provider>` which delegates to the Dialog runtime
* with `variant='alertdialog'`, `modal=true` baked in.
*
* The actual DOM uses Dialog's data-attrs (`data-dialog-content`,
* `data-dialog-overlay`, etc.) so the existing Dialog recipe paints
* the chrome. Only `Action` and `Cancel` get fresh selectors
* (`data-alert-dialog-action`, `data-alert-dialog-cancel`).
*/
import * as AlertDialog from '$soma/components/alert-dialog';
import type { AlertDialogProps } from './types';
let { open = $bindable(false), children, ...rest }: AlertDialogProps = $props();
</script>
<AlertDialog.Provider {...rest} bind:open>
{@render children?.()}
</AlertDialog.Provider>

@ -0,0 +1,85 @@
// AlertDialog — eidos compound API.
//
// import { AlertDialog } from '$uix/eidos/components/alert-dialog';
//
// <AlertDialog bind:open intent="risk">
// <AlertDialog.Trigger>Delete project…</AlertDialog.Trigger>
// <AlertDialog.Portal>
// <AlertDialog.Overlay />
// <AlertDialog.Content>
// <AlertDialog.Header>
// <AlertDialog.Title>Delete project?</AlertDialog.Title>
// <AlertDialog.Description>
// This cannot be undone.
// </AlertDialog.Description>
// </AlertDialog.Header>
// <AlertDialog.Footer>
// <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
// <AlertDialog.Action>Delete</AlertDialog.Action>
// </AlertDialog.Footer>
// </AlertDialog.Content>
// </AlertDialog.Portal>
// </AlertDialog>
//
// 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';

@ -0,0 +1,50 @@
import type {
ProviderProps,
ActionProps,
CancelProps
} from '$soma/components/alert-dialog';
/**
* Eidos `<AlertDialog>` — 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:
*
* <AlertDialog bind:open intent="risk">
* <AlertDialog.Trigger>Delete project…</AlertDialog.Trigger>
* <AlertDialog.Portal>
* <AlertDialog.Overlay />
* <AlertDialog.Content>
* <AlertDialog.Header>
* <AlertDialog.Title>Delete project?</AlertDialog.Title>
* <AlertDialog.Description>
* This action cannot be undone. All data will be lost.
* </AlertDialog.Description>
* </AlertDialog.Header>
* <AlertDialog.Footer>
* <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
* <AlertDialog.Action>Delete</AlertDialog.Action>
* </AlertDialog.Footer>
* </AlertDialog.Content>
* </AlertDialog.Portal>
* </AlertDialog>
*/
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';

@ -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';

@ -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',

@ -0,0 +1,529 @@
<script lang="ts">
import { AlertDialog } from '$uix/eidos/components/alert-dialog';
import type { DialogSize, DialogPosition } from '$uix/eidos/components/dialog';
import { compileMorfo } from '$uix/morfo';
import { alertDialogMorfo } from '@/uix/morfo/components/alert-dialog';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const intents: Array<'neutral' | 'risk' | 'threat'> = ['neutral', 'risk', 'threat'];
const sizes: DialogSize[] = ['sm', 'md', 'lg', 'xl', 'full'];
const positions: DialogPosition[] = [
'top-left',
'top-center',
'top-right',
'middle-left',
'middle-center',
'middle-right',
'bottom-left',
'bottom-center',
'bottom-right'
];
let open = $state(false);
let intent = $state<'neutral' | 'risk' | 'threat'>('risk');
let size = $state<DialogSize>('md');
let position = $state<DialogPosition>('middle-center');
let title = $state('Delete project?');
let description = $state(
'This action cannot be undone. All project data, including history and member access, will be permanently lost.'
);
let actionText = $state('Delete project');
let cancelText = $state('Cancel');
let lastChoice = $state<'action' | 'cancel' | null>(null);
function handleAction() {
lastChoice = 'action';
}
function handleCancel() {
lastChoice = 'cancel';
}
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const compiled = compileMorfo(alertDialogMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as AlertDialog from '$soma/components/alert-dialog';",
' let open = $state(false);',
'</' + 'script>',
'',
'<AlertDialog.Provider',
' bind:open',
intent !== 'neutral' && ` intent="${intent}"`,
'>',
' <AlertDialog.Trigger>Delete…</AlertDialog.Trigger>',
' <AlertDialog.Content>',
` <h2>${title}</h2>`,
` <p>${description}</p>`,
` <AlertDialog.Cancel>${cancelText}</AlertDialog.Cancel>`,
` <AlertDialog.Action>${actionText}</AlertDialog.Action>`,
' </AlertDialog.Content>',
'</AlertDialog.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { AlertDialog } from '$uix/eidos/components/alert-dialog';",
' let open = $state(false);',
'</' + 'script>',
'',
'<AlertDialog',
' bind:open',
intent !== 'neutral' && ` intent="${intent}"`,
'>',
' <AlertDialog.Trigger>Delete…</AlertDialog.Trigger>',
' <AlertDialog.Portal>',
' <AlertDialog.Overlay />',
' <AlertDialog.Content',
size !== 'md' && ` size="${size}"`,
position !== 'middle-center' && ` position="${position}"`,
' >',
' <AlertDialog.Header>',
` <AlertDialog.Title>${title}</AlertDialog.Title>`,
` <AlertDialog.Description>${description}</AlertDialog.Description>`,
' </AlertDialog.Header>',
' <AlertDialog.Footer>',
` <AlertDialog.Cancel>${cancelText}</AlertDialog.Cancel>`,
` <AlertDialog.Action>${actionText}</AlertDialog.Action>`,
' </AlertDialog.Footer>',
' </AlertDialog.Content>',
' </AlertDialog.Portal>',
'</AlertDialog>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Feedback · AlertDialog</div>
<h1 data-uix-page-title>AlertDialog</h1>
<p data-uix-page-lede>
Modal Dialog with <code>role="alertdialog"</code>, forced
<code>modal=true</code> and click-outside disabled. The user MUST
choose <code>Action</code> or <code>Cancel</code> (Escape still
cancels). <span data-uix-layer-badge="soma">soma</span>
delegates to the Dialog runtime — only the Action + Cancel
buttons are alert-dialog-specific.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>intent</span>{intent}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>open</span>{open ? 'true' : 'false'}
</span>
{#if lastChoice}
<span data-uix-meta-pill>
<span data-uix-meta-key>last</span>{lastChoice}
</span>
{/if}
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<AlertDialog bind:open {intent}>
<AlertDialog.Trigger onclick={() => (lastChoice = null)}>
Open AlertDialog
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content {size} {position}>
<AlertDialog.Header>
<AlertDialog.Title>{title}</AlertDialog.Title>
<AlertDialog.Description>{description}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={handleCancel}>{cancelText}</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleAction}>{actionText}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
{#if trace[0]}
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>last</span>
<span>{trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)}</span>
{/if}
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>3</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<code>intent</code> is the key knob — switching to
<code>risk</code> / <code>threat</code> auto-colors the Action
button (red / amber) and primes the sema layer for a
destructive confirmation.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · semantic
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>intent</span>
<span data-uix-chips role="radiogroup">
{#each intents as opt}
<button data-uix-chip data-active={intent === opt} onclick={() => (intent = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>title</span>
<input type="text" bind:value={title} />
</label>
<label data-uix-control>
<span data-uix-control-label>description</span>
<input type="text" bind:value={description} />
</label>
<label data-uix-control>
<span data-uix-control-label>action text</span>
<input type="text" bind:value={actionText} />
</label>
<label data-uix-control>
<span data-uix-control-label>cancel text</span>
<input type="text" bind:value={cancelText} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · shape (forwarded to Dialog)
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as opt}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>position</span>
<span data-uix-chips role="radiogroup">
{#each positions as opt}
<button data-uix-chip data-active={position === opt} onclick={() => (position = opt)}>{opt}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · forced modal + role=alertdialog</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · full composition</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
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.
</p>
<div data-uix-subsection-head>AlertDialog (root)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">open <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Bindable visibility.</td>
</tr>
<tr>
<td class="name">intent <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">'neutral' | 'risk' | 'threat'</td>
<td class="default">'neutral'</td>
<td>Narrowed from Dialog's full set — alert dialogs gate destructive actions.</td>
</tr>
<tr>
<td class="name">disabled <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Disables the Trigger (dialog stays closed).</td>
</tr>
<tr>
<td class="name">onOpenChange <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">(open: boolean) =&gt; void</td>
<td class="default empty">—</td>
<td>Fired on open/close transition start.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>AlertDialog.Content</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">'sm' | 'md' | 'lg' | 'xl' | 'full'</td>
<td class="default">'md'</td>
<td>Modal width preset. Responsive.</td>
</tr>
<tr>
<td class="name">position <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">3×3 anchor grid</td>
<td class="default">'middle-center'</td>
<td>Same as Dialog.</td>
</tr>
<tr>
<td class="name">escapeKeydownBehavior <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">'close' | 'ignore'</td>
<td class="default">'close'</td>
<td><strong>Overrides Dialog default.</strong> Escape cancels by convention.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>AlertDialog.Action / AlertDialog.Cancel</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">aria-label <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">string</td>
<td>Overrides the default accessible name ("Confirm" / "Cancel").</td>
</tr>
<tr>
<td class="name">onclick</td>
<td class="type">(ev: MouseEvent) =&gt; void</td>
<td>Business handler. Default close runs LAST — consumer logic gets first chance.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
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
(<code>variant='alertdialog'</code> on the Dialog provider).
</p>
<div data-uix-subsection-head>Parts ({partsList.length})</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Kebab</th><th>Role</th><th>Marker</th><th>Kind</th></tr></thead>
<tbody>
{#each partsList as part}
{@const partAny = part as unknown as Record<string, unknown>}
<tr>
<td class="name">{partAny.kebab}</td>
<td>{partAny.role ?? '—'}</td>
<td><code>{partAny.marker ?? '—'}</code></td>
<td>{partAny.kind ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · perceptual flow
</h2>
<p data-uix-section-desc>
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.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Source</th><th>Events</th></tr></thead>
<tbody>
<tr>
<td>alert-dialog morfo</td>
<td>{events.length} (none)</td>
</tr>
<tr>
<td>dialog morfo (via runtime delegation)</td>
<td>open, close-*, commit-*</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
<code>alert-dialog.css</code> only styles the Action + Cancel
buttons. Chrome (overlay, content panel, header) comes from
<code>dialog.css</code> — same DOM markers reach both.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-alert-dialog-action]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Solid button. Inherits Toggle palette.</td>
</tr>
<tr>
<td class="name"><code>[data-alert-dialog-cancel]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Outline button. Stays neutral.</td>
</tr>
<tr>
<td class="name"
><code>[data-dialog-content][data-color='risk'] [data-alert-dialog-action]</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Auto-color cascade: risk-flagged dialog gets a red Action.</td>
</tr>
<tr>
<td class="name"
><code>[data-dialog-content][data-color='threat'] [data-alert-dialog-action]</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Same cascade for threat-flagged.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>A11y contract</h2>
<p data-uix-section-desc>
Pattern: WAI-ARIA APG <a
href="https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/"
target="_blank"
rel="noopener">Alert Dialog</a
>. The Content emits <code>role="alertdialog"</code> (vs
<code>dialog</code>) — screen readers announce it with alert
semantics.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><kbd>Escape</kbd></td><td>Cancel (closes via Cancel's onclick).</td></tr>
<tr><td><kbd>Tab</kbd></td><td>Move focus within the dialog (focus trap).</td></tr>
<tr><td><kbd>Shift+Tab</kbd></td><td>Reverse focus within the dialog.</td></tr>
<tr><td><kbd>Enter</kbd> on Action</td><td>Confirm (closes via Action's onclick).</td></tr>
<tr><td>Click outside</td><td><strong>Ignored.</strong> Must choose Action or Cancel.</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
By convention, <code>Cancel</code> receives initial focus —
render it BEFORE <code>Action</code> in DOM order. This makes
the safe choice the default when the user just presses
<kbd>Enter</kbd>.
</p>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.