Skip to content

Commit aa9a463

Browse files
Align alert-dialog to architecture goals (#202)
* Align alert-dialog anatomy to design-system spec Per issue #117, the designer marked spacing, right-aligned action placement, and max-width constraint as "always the same". These were previously left to the consumer via ad-hoc layout divs inside the stories. This commit bakes them in via two new anatomy parts and updates the popup's baked styles. * Extend alert-dialog anatomy: Header + Icon parts The popup now exposes the leading-icon region the design spec calls out ("icon placement, left of title"; "icon type warning/error/info"). Two new ui parts: - AlertDialogIcon: a decorative single-element node slot for the leading glyph, with a required `tone` (danger/warning/info/success) — the destructive-vs-informational axis the spec marks adjustable, no default. - AlertDialogHeader: the row that places the icon at the inline-start of the intro so the icon sits left of the title. Styling stays in ui/.../variants.ts (cva); the components tier only composes parts. Stories compose the new parts and register them in subcomponents.
1 parent 4d6a25e commit aa9a463

9 files changed

Lines changed: 179 additions & 50 deletions

File tree

packages/propel/src/components/alert-dialog/alert-dialog.stories.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { TriangleAlert } from "lucide-react";
23
import { expect, userEvent, waitFor, within } from "storybook/test";
34

45
import { Button } from "../../ui/button";
56
import {
67
AlertDialog,
8+
AlertDialogActions,
79
AlertDialogClose,
810
AlertDialogContent,
911
AlertDialogDescription,
12+
AlertDialogHeader,
13+
AlertDialogIcon,
14+
AlertDialogIntro,
1015
AlertDialogTitle,
1116
AlertDialogTrigger,
1217
} from "./index";
@@ -23,8 +28,12 @@ const meta = {
2328
subcomponents: {
2429
AlertDialogTrigger,
2530
AlertDialogContent,
31+
AlertDialogHeader,
32+
AlertDialogIcon,
33+
AlertDialogIntro,
2634
AlertDialogTitle,
2735
AlertDialogDescription,
36+
AlertDialogActions,
2837
AlertDialogClose,
2938
},
3039
} satisfies Meta<typeof AlertDialog>;
@@ -40,29 +49,26 @@ export const Default: Story = {
4049
Delete project
4150
</Button>
4251
<AlertDialogContent>
43-
{/*
44-
* Two layout groups, separated by the parent's gap (never a margin on a
45-
* child): an "intro" (title + description) and the "actions" row. These
46-
* boundaries are the future anatomy surfaces — e.g. AlertDialogIntro and
47-
* AlertDialogActions — so define them correctly here before hardening.
48-
*/}
49-
<div className="flex w-80 flex-col gap-4">
50-
<div className="flex flex-col gap-2">
52+
<AlertDialogHeader>
53+
<AlertDialogIcon tone="danger">
54+
<TriangleAlert />
55+
</AlertDialogIcon>
56+
<AlertDialogIntro>
5157
<AlertDialogTitle>Delete project?</AlertDialogTitle>
5258
<AlertDialogDescription>
5359
This permanently removes the project and all of its work items. This action can&apos;t
5460
be undone.
5561
</AlertDialogDescription>
56-
</div>
57-
<div className="flex justify-end gap-2">
58-
<Button variant="secondary" tone="neutral" magnitude="xl" render={<AlertDialogClose />}>
59-
Cancel
60-
</Button>
61-
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
62-
Delete
63-
</Button>
64-
</div>
65-
</div>
62+
</AlertDialogIntro>
63+
</AlertDialogHeader>
64+
<AlertDialogActions>
65+
<Button variant="secondary" tone="neutral" magnitude="xl" render={<AlertDialogClose />}>
66+
Cancel
67+
</Button>
68+
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
69+
Delete
70+
</Button>
71+
</AlertDialogActions>
6672
</AlertDialogContent>
6773
</AlertDialog>
6874
),

packages/propel/src/components/alert-dialog/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@ export { AlertDialogContent, type AlertDialogContentProps } from "./alert-dialog
33
export {
44
AlertDialog,
55
type AlertDialogProps,
6+
AlertDialogActions,
7+
type AlertDialogActionsProps,
68
AlertDialogClose,
79
type AlertDialogCloseProps,
810
AlertDialogDescription,
911
type AlertDialogDescriptionProps,
12+
AlertDialogHeader,
13+
type AlertDialogHeaderProps,
14+
AlertDialogIcon,
15+
type AlertDialogIconProps,
16+
type AlertDialogIconTone,
17+
AlertDialogIntro,
18+
type AlertDialogIntroProps,
1019
AlertDialogTitle,
1120
type AlertDialogTitleProps,
1221
AlertDialogTrigger,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as React from "react";
2+
3+
import { alertDialogActionsVariants } from "./variants";
4+
5+
export type AlertDialogActionsProps = React.HTMLAttributes<HTMLDivElement>;
6+
7+
/**
8+
* Right-aligns action buttons with consistent horizontal spacing. The placement and gap are always
9+
* the same — baked in by the design system.
10+
*/
11+
export function AlertDialogActions({ ...props }: AlertDialogActionsProps) {
12+
return <div className={alertDialogActionsVariants()} {...props} />;
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type * as React from "react";
2+
3+
import { alertDialogHeaderVariants } from "./variants";
4+
5+
export type AlertDialogHeaderProps = Omit<
6+
React.ComponentPropsWithoutRef<"div">,
7+
"className" | "style"
8+
>;
9+
10+
/**
11+
* The top region of the popup: lays out the leading `AlertDialogIcon` at the inline-start of the
12+
* `AlertDialogIntro` (icon left of title), per the design spec. The icon and intro are passed as
13+
* children.
14+
*/
15+
export function AlertDialogHeader(props: AlertDialogHeaderProps) {
16+
return <div className={alertDialogHeaderVariants()} {...props} />;
17+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { type VariantProps } from "class-variance-authority";
2+
import type * as React from "react";
3+
4+
import { alertDialogIconVariants } from "./variants";
5+
6+
export type AlertDialogIconTone = NonNullable<VariantProps<typeof alertDialogIconVariants>["tone"]>;
7+
8+
export type AlertDialogIconProps = Omit<
9+
React.ComponentPropsWithoutRef<"span">,
10+
"className" | "style"
11+
> & {
12+
/**
13+
* Intent of the alert, the destructive-vs-informational axis the spec marks adjustable. Drives
14+
* the icon's color; required so the caller always states the intent.
15+
*/
16+
tone: AlertDialogIconTone;
17+
};
18+
19+
/**
20+
* The decorative leading glyph shown at the inline-start of the title. Sizes its single child to
21+
* `--node-size`, so the caller passes a bare icon (warning, error, info, …). Decorative — the title
22+
* carries the accessible name — so it is `aria-hidden`.
23+
*/
24+
export function AlertDialogIcon({ tone, ...props }: AlertDialogIconProps) {
25+
return <span aria-hidden className={alertDialogIconVariants({ tone })} {...props} />;
26+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as React from "react";
2+
3+
import { alertDialogIntroVariants } from "./variants";
4+
5+
export type AlertDialogIntroProps = React.HTMLAttributes<HTMLDivElement>;
6+
7+
/**
8+
* Groups the title and description with consistent vertical spacing. The gap between title and
9+
* description is always the same — baked in by the design system.
10+
*/
11+
export function AlertDialogIntro({ ...props }: AlertDialogIntroProps) {
12+
return <div className={alertDialogIntroVariants()} {...props} />;
13+
}

packages/propel/src/ui/alert-dialog/alert-dialog.stories.tsx

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { TriangleAlert } from "lucide-react";
23
import { expect, userEvent, waitFor, within } from "storybook/test";
34

45
import { Button } from "../button";
56
import {
67
AlertDialog,
8+
AlertDialogActions,
79
AlertDialogBackdrop,
810
AlertDialogClose,
911
AlertDialogDescription,
12+
AlertDialogHeader,
13+
AlertDialogIcon,
14+
AlertDialogIntro,
1015
AlertDialogPopup,
1116
AlertDialogPortal,
1217
AlertDialogTitle,
@@ -30,8 +35,12 @@ const meta = {
3035
AlertDialogBackdrop,
3136
AlertDialogViewport,
3237
AlertDialogPopup,
38+
AlertDialogHeader,
39+
AlertDialogIcon,
40+
AlertDialogIntro,
3341
AlertDialogTitle,
3442
AlertDialogDescription,
43+
AlertDialogActions,
3544
AlertDialogClose,
3645
},
3746
} satisfies Meta<typeof AlertDialog>;
@@ -50,38 +59,30 @@ export const Anatomy: Story = {
5059
<AlertDialogBackdrop />
5160
<AlertDialogViewport>
5261
<AlertDialogPopup>
53-
{/*
54-
* Two layout groups, separated by the parent's gap (never a margin on a
55-
* child): an "intro" (title + description) and the "actions" row. These
56-
* boundaries are the future anatomy surfaces — e.g. AlertDialogIntro and
57-
* AlertDialogActions — so define them correctly here before hardening.
58-
*/}
59-
<div className="flex w-80 flex-col gap-4">
60-
<div className="flex flex-col gap-2">
62+
<AlertDialogHeader>
63+
<AlertDialogIcon tone="danger">
64+
<TriangleAlert />
65+
</AlertDialogIcon>
66+
<AlertDialogIntro>
6167
<AlertDialogTitle>Delete account?</AlertDialogTitle>
6268
<AlertDialogDescription>
6369
This permanently deletes your account and cannot be undone.
6470
</AlertDialogDescription>
65-
</div>
66-
<div className="flex justify-end gap-2">
67-
<Button
68-
variant="secondary"
69-
tone="neutral"
70-
magnitude="xl"
71-
render={<AlertDialogClose />}
72-
>
73-
Cancel
74-
</Button>
75-
<Button
76-
variant="primary"
77-
tone="danger"
78-
magnitude="xl"
79-
render={<AlertDialogClose />}
80-
>
81-
Delete
82-
</Button>
83-
</div>
84-
</div>
71+
</AlertDialogIntro>
72+
</AlertDialogHeader>
73+
<AlertDialogActions>
74+
<Button
75+
variant="secondary"
76+
tone="neutral"
77+
magnitude="xl"
78+
render={<AlertDialogClose />}
79+
>
80+
Cancel
81+
</Button>
82+
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
83+
Delete
84+
</Button>
85+
</AlertDialogActions>
8586
</AlertDialogPopup>
8687
</AlertDialogViewport>
8788
</AlertDialogPortal>

packages/propel/src/ui/alert-dialog/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
export { AlertDialog, type AlertDialogProps } from "./alert-dialog";
2+
export { AlertDialogActions, type AlertDialogActionsProps } from "./alert-dialog-actions";
23
export { AlertDialogBackdrop, type AlertDialogBackdropProps } from "./alert-dialog-backdrop";
34
export { AlertDialogClose, type AlertDialogCloseProps } from "./alert-dialog-close";
45
export {
56
AlertDialogDescription,
67
type AlertDialogDescriptionProps,
78
} from "./alert-dialog-description";
9+
export { AlertDialogHeader, type AlertDialogHeaderProps } from "./alert-dialog-header";
10+
export {
11+
AlertDialogIcon,
12+
type AlertDialogIconProps,
13+
type AlertDialogIconTone,
14+
} from "./alert-dialog-icon";
15+
export { AlertDialogIntro, type AlertDialogIntroProps } from "./alert-dialog-intro";
816
export { AlertDialogPopup, type AlertDialogPopupProps } from "./alert-dialog-popup";
917
export { AlertDialogPortal } from "./alert-dialog-portal";
1018
export { AlertDialogTitle, type AlertDialogTitleProps } from "./alert-dialog-title";

packages/propel/src/ui/alert-dialog/variants.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { cva, cx } from "class-variance-authority";
22

3+
import { nodeSlotClass } from "../../internal/node-slot";
4+
35
// AlertDialog is a structural overlay primitive. It is always modal and
46
// non-dismissible, and Base UI drives every interactive state (open/closed,
5-
// starting/ending transition styles) as data attributes — so there are no
6-
// styling axes (variant/tone/magnitude) to expose. The cva pairings below hold
7-
// the static chrome so each part is styled in one place, with no `className` at
8-
// the boundary. Root, Trigger, and Portal carry no styling.
7+
// starting/ending transition styles) as data attributes. The one design axis the
8+
// spec calls out is the leading icon's intent (destructive vs informational), which
9+
// the `AlertDialogIcon` exposes as a required `tone` — every other part is static
10+
// chrome held in a single cva so there is no `className` at the boundary. Root,
11+
// Trigger, and Portal carry no styling.
912

1013
export const alertDialogBackdropVariants = cva(
1114
cx(
@@ -20,6 +23,9 @@ export const alertDialogViewportVariants = cva(
2023

2124
export const alertDialogPopupVariants = cva(
2225
cx(
26+
// Fixed width and layout: the max-width constraint and internal spacing are
27+
// always the same per spec — baked here, not left to the consumer.
28+
"flex w-80 flex-col gap-4",
2329
"rounded-lg border-sm border-subtle bg-layer-1 p-4 shadow-overlay-100 outline-none",
2430
"origin-(--transform-origin) transition-[opacity,transform] duration-200",
2531
"data-starting-style:scale-95 data-starting-style:opacity-0",
@@ -31,6 +37,36 @@ export const alertDialogTitleVariants = cva("text-16 font-semibold text-primary"
3137

3238
export const alertDialogDescriptionVariants = cva("text-14 text-secondary");
3339

40+
// Header: the top region that places the leading icon at the inline-start of the
41+
// intro (icon left of title, per spec). Items align to the start so a multi-line
42+
// description keeps the icon level with the title.
43+
export const alertDialogHeaderVariants = cva("flex items-start gap-3");
44+
45+
// Icon: the decorative leading glyph beside the title. Sizes its single child to
46+
// `--node-size` (via the shared node-slot class) and tints it by `tone` — the
47+
// destructive-vs-informational axis the spec marks as adjustable. The glyph itself
48+
// (warning/error/info) is whatever child the caller passes. No default tone: the
49+
// caller must state the intent.
50+
export const alertDialogIconVariants = cva(cx(nodeSlotClass, "mt-0.5 [--node-size:1.25rem]"), {
51+
variants: {
52+
tone: {
53+
danger: "text-icon-danger",
54+
warning: "text-icon-warning-primary",
55+
info: "text-icon-info-primary",
56+
success: "text-icon-success-primary",
57+
},
58+
},
59+
});
60+
61+
// Intro: groups the title and description with consistent vertical spacing.
62+
// Always the same per spec (spacing between title and description). `min-w-0` lets
63+
// long copy wrap instead of pushing the icon out of the header row.
64+
export const alertDialogIntroVariants = cva("flex min-w-0 flex-col gap-2");
65+
66+
// Actions: right-aligns action buttons with consistent horizontal spacing.
67+
// Always the same per spec (action button placement, right-aligned in footer).
68+
export const alertDialogActionsVariants = cva("flex justify-end gap-2");
69+
3470
export const alertDialogCloseVariants = cva(
3571
cx(
3672
"inline-flex items-center justify-center rounded-md text-icon-secondary outline-none",

0 commit comments

Comments
 (0)