refactor(alert-dialog): Action/Cancel render <Button>; intent flows via snippet

#120 second pass. AlertDialog.Action and AlertDialog.Cancel now render
eidos `<Button>` internally via soma's `child` snippet, retiring the
per-recipe Toggle-token chrome and the CSS ancestor cascade
(`[data-dialog-content][data-color='risk'] [data-alert-dialog-action]`).

Intent propagation: soma's `AlertDialogActionProvider` exposes a new
`dialogIntent` derived getter that reads `DialogProvider.opts.intent`.
The Action component passes it to the `child` snippet alongside `props`
(via `WithChildNoChildrenSnippetProps` so plain `children` stays a
regular Snippet). The eidos Action wrapper forwards it to Button as
`intent={intentOverride ?? dialogIntent}` — so `<AlertDialog intent="risk">`
automatically paints a red Action without any cascade selector.

Defaults:
- Action: variant='solid', intent inherits from dialog
- Cancel: variant='outline', intent='neutral', color='neutral' so it
  reads as gray-outlined regardless of the dialog's intent (canonical
  "this is the safe choice" cue)

Cancel narrows variant to ControlVariant (3 chips: surface/outline/ghost)
per DEMO_AUTHORING_GUIDE §12.7. Action keeps the full ButtonVariant
because it's the primary call-to-action.

alert-dialog.css gutted to a doc-only stub; all chrome now comes from
button.css.

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

@ -1,17 +1,56 @@
<script lang="ts"> <script lang="ts">
/** /**
* Eidos `<AlertDialog.Action>` — destructive / primary confirmation * Eidos `<AlertDialog.Action>` — primary / destructive confirmation
* button. Pass-through over soma's `<AlertDialog.Action>`. Visual * button. Renders an eidos `<Button>` via soma's `child` snippet. The
* treatment lives in `alert-dialog.css` (solid button, defaults to * parent dialog's doctrinal intent (neutral · risk · threat) is
* the dialog's `intent` color so a `risk`-flagged dialog gets a red * resolved by soma and passed into the snippet; we forward it to
* Action automatically). * Button so a `risk`-flagged AlertDialog automatically paints a red
* Action without needing a CSS ancestor cascade.
*
* Defaults: `variant='solid'`, `intent` from the parent dialog. The
* consumer can still override by passing `intent` explicitly on
* AlertDialog.Action.
*
* Composition escape: pass your own `child` snippet to bypass Button
* entirely.
*/ */
import * as AlertDialog from '$soma/components/alert-dialog'; import * as AlertDialog from '$soma/components/alert-dialog';
import { Button } from '$uix/eidos/components/button';
import type { AlertDialogActionProps } from './types'; import type { AlertDialogActionProps } from './types';
let { children, ...rest }: AlertDialogActionProps = $props(); let {
children,
child: outerChild,
variant = 'solid',
size = 'md',
rounded,
intent: intentOverride,
color,
iconOnly,
icon,
endIcon,
...rest
}: AlertDialogActionProps = $props();
</script> </script>
<AlertDialog.Action {...rest}> <AlertDialog.Action {...rest}>
{@render children?.()} {#snippet child({ props, intent: dialogIntent })}
{#if outerChild}
{@render outerChild({ props, intent: dialogIntent })}
{:else}
<Button
{...props}
{variant}
{size}
{rounded}
intent={intentOverride ?? dialogIntent}
{color}
{iconOnly}
{icon}
{endIcon}
>
{@render children?.()}
</Button>
{/if}
{/snippet}
</AlertDialog.Action> </AlertDialog.Action>

@ -6,16 +6,50 @@
* confirmations). Render `Cancel` BEFORE `Action` in DOM order so * confirmations). Render `Cancel` BEFORE `Action` in DOM order so
* the focus scope lands here first. * the focus scope lands here first.
* *
* Pass-through over soma's `<AlertDialog.Cancel>`. Visual treatment: * Renders an eidos `<Button>` via soma's `child` snippet. Defaults:
* outline button, always neutral palette so it visually contrasts * `variant='outline'`, `intent='neutral'` — stays neutral regardless
* with the (potentially destructive) Action. * of the dialog's intent so it visually contrasts with the (potentially
* destructive) Action.
*
* Composition escape: pass your own `child` snippet to bypass Button.
*/ */
import * as AlertDialog from '$soma/components/alert-dialog'; import * as AlertDialog from '$soma/components/alert-dialog';
import { Button } from '$uix/eidos/components/button';
import type { AlertDialogCancelProps } from './types'; import type { AlertDialogCancelProps } from './types';
let { children, ...rest }: AlertDialogCancelProps = $props(); let {
children,
child: outerChild,
variant = 'outline',
size = 'md',
rounded,
intent = 'neutral',
color = 'neutral',
iconOnly,
icon,
endIcon,
...rest
}: AlertDialogCancelProps = $props();
</script> </script>
<AlertDialog.Cancel {...rest}> <AlertDialog.Cancel {...rest}>
{@render children?.()} {#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
{size}
{rounded}
{intent}
{color}
{iconOnly}
{icon}
{endIcon}
>
{@render children?.()}
</Button>
{/if}
{/snippet}
</AlertDialog.Cancel> </AlertDialog.Cancel>

@ -1,134 +1,21 @@
/* /*
* AlertDialog recipe — Action + Cancel buttons only. * AlertDialog recipe — Action + Cancel buttons.
* *
* The dialog chrome (overlay, content panel, header, title, etc.) is * The dialog chrome (overlay, content panel, header, title, etc.) is
* painted by `dialog.css`. Soma's AlertDialog re-uses the Dialog runtime * painted by `dialog.css`. Soma's AlertDialog re-uses the Dialog runtime
* (variant='alertdialog', modal=true), so the same `data-dialog-*` * (variant='alertdialog', modal=true), so the same `data-dialog-*`
* markers reach the DOM and the same recipe applies. * markers reach the DOM and the same recipe applies.
* *
* What's alert-dialog-specific: * Action + Cancel now render as eidos `<Button>` components internally
* (see `alert-dialog-action.svelte` / `alert-dialog-cancel.svelte`) —
* chrome (focus ring, padding, border, hover, size, colour intent) all
* comes from `button.css`. The dialog's doctrinal intent
* (neutral · risk · threat) is read by the Action wrapper from the
* DialogProvider and forwarded to Button via the `intent` prop, so a
* `risk`-flagged AlertDialog automatically paints a red Action without
* a CSS ancestor cascade.
* *
* [data-alert-dialog-action] → primary / destructive confirmation * Cancel stays neutral/outline regardless of the dialog's intent — the
* [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 * contrast between a colored Action and a neutral Cancel is the
* canonical visual cue for "this is the safe choice". * 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);
}

@ -3,6 +3,13 @@ import type {
ActionProps, ActionProps,
CancelProps CancelProps
} from '$soma/components/alert-dialog'; } from '$soma/components/alert-dialog';
import type {
ButtonVariant,
ButtonSize,
ButtonRounded,
ButtonProps as EidosButtonProps
} from '$uix/eidos/components/button';
import type { ControlVariant, ResponsiveProp } from '$uix/eidos/lib/types';
/** /**
* Eidos `<AlertDialog>` — visual wrapper over `soma/alert-dialog`. A * Eidos `<AlertDialog>` — visual wrapper over `soma/alert-dialog`. A
@ -33,8 +40,63 @@ import type {
*/ */
export type AlertDialogProps = ProviderProps; export type AlertDialogProps = ProviderProps;
export type AlertDialogActionProps = ActionProps;
export type AlertDialogCancelProps = CancelProps; /**
* Action button. Renders an eidos `<Button>` internally. Visual props
* are forwarded; `variant` defaults to `'solid'` (primary CTA) and
* `intent` defaults to the parent dialog's intent (so a risk-flagged
* AlertDialog paints a red Action automatically). The `variant` set is
* the full ButtonVariant since Action is the primary call-to-action of
* a destructive confirmation flow.
*/
export type AlertDialogActionProps = ActionProps & {
/** Button visual variant. @default 'solid' */
variant?: ButtonVariant;
/** Button size scale. @default 'md' */
size?: ResponsiveProp<ButtonSize>;
/** Button corner radius. */
rounded?: ResponsiveProp<ButtonRounded>;
/**
* Evaluative intent forwarded to Button. When omitted, inherits the
* parent AlertDialog's `intent` (neutral · risk · threat).
*/
intent?: EidosButtonProps['intent'];
/** Hierarchical color override (only applies under intent='neutral'). */
color?: EidosButtonProps['color'];
/** Square aspect with sr-only label; use `icon` for the visible glyph. */
iconOnly?: boolean;
/** Leading icon snippet, visible even when iconOnly is on. */
icon?: EidosButtonProps['icon'];
/** Trailing icon snippet; collapsed when iconOnly is on. */
endIcon?: EidosButtonProps['endIcon'];
};
/**
* Cancel button. Stays neutral/outline regardless of the dialog's intent
* so it visually contrasts with the (potentially destructive) Action.
* `variant` is narrowed to ControlVariant per DEMO_AUTHORING_GUIDE §12.7
* (canonical 3-variant set for form-control surfaces).
*/
export type AlertDialogCancelVariant = Extract<ButtonVariant, ControlVariant>;
export type AlertDialogCancelProps = CancelProps & {
/** Button visual variant. @default 'outline' */
variant?: AlertDialogCancelVariant;
/** Button size scale. @default 'md' */
size?: ResponsiveProp<ButtonSize>;
/** Button corner radius. */
rounded?: ResponsiveProp<ButtonRounded>;
/** Evaluative intent forwarded to Button. @default 'neutral' */
intent?: EidosButtonProps['intent'];
/** Hierarchical color override (only applies under intent='neutral'). */
color?: EidosButtonProps['color'];
/** Square aspect with sr-only label; use `icon` for the visible glyph. */
iconOnly?: boolean;
/** Leading icon snippet, visible even when iconOnly is on. */
icon?: EidosButtonProps['icon'];
/** Trailing icon snippet; collapsed when iconOnly is on. */
endIcon?: EidosButtonProps['endIcon'];
};
// Trigger / Overlay / Content / Title / Description re-use Dialog's // Trigger / Overlay / Content / Title / Description re-use Dialog's
// eidos wrappers — same recipe applies because soma's AlertDialog // eidos wrappers — same recipe applies because soma's AlertDialog

@ -57,6 +57,16 @@ export class AlertDialogActionProvider {
() => this.opts.ariaLabel.current || this.soma.langs.ts(ALERT_DIALOG_LANGS.ACTION) () => this.opts.ariaLabel.current || this.soma.langs.ts(ALERT_DIALOG_LANGS.ACTION)
); );
/**
* Resolved doctrinal intent of the parent AlertDialog. Exposed so the
* eidos `<AlertDialog.Action>` wrapper can forward it to its inner
* `<Button>` — a `risk`-flagged dialog produces a red Action automatically
* without needing a CSS ancestor cascade.
*/
readonly dialogIntent = $derived.by(
() => this.dialog.opts.intent.current ?? 'neutral'
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => { readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.dialog.handleClose(); this.dialog.handleClose();
}; };

@ -32,7 +32,7 @@
</script> </script>
{#if child} {#if child}
{@render child({ props: mergedProps })} {@render child({ props: mergedProps, intent: state.dialogIntent })}
{:else} {:else}
<button {...mergedProps}> <button {...mergedProps}>
{@render children?.()} {@render children?.()}

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types'; import type { WithChild, WithChildNoChildrenSnippetProps, Without, OnChangeFn } from '../../types';
import type { PrimitiveButtonAttributes } from '../../types'; import type { PrimitiveButtonAttributes } from '../../types';
// ── Root provider ────────────────────────────────────────────────────────── // ── Root provider ──────────────────────────────────────────────────────────
@ -55,11 +55,22 @@ export type AlertDialogProps = {
* runs first. To prevent close, avoid `AlertDialog.Action` and wire a custom * runs first. To prevent close, avoid `AlertDialog.Action` and wire a custom
* button that only calls the Provider's close when the business logic allows. * button that only calls the Provider's close when the business logic allows.
*/ */
export type AlertDialogActionProps = WithChild<{ export type AlertDialogActionProps = WithChildNoChildrenSnippetProps<
id?: string; {
/** Accessible name — overrides the default `'Confirm'` label. */ id?: string;
'aria-label'?: string; /** Accessible name — overrides the default `'Confirm'` label. */
}> & 'aria-label'?: string;
},
{
/**
* Doctrinal intent of the parent AlertDialog, resolved and forwarded
* to the `child` snippet so consumers (e.g. eidos `<Button>`) can
* paint the destructive palette automatically. Only typed on `child`
* — plain `children` stays a regular Snippet.
*/
intent: 'neutral' | 'risk' | 'threat';
}
> &
Without<PrimitiveButtonAttributes, { 'aria-label'?: string }>; Without<PrimitiveButtonAttributes, { 'aria-label'?: string }>;
// ── Cancel button (secondary / dismiss) ──────────────────────────────────── // ── Cancel button (secondary / dismiss) ────────────────────────────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.