Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TriangleAlert } from "lucide-react";
import { expect, userEvent, waitFor, within } from "storybook/test";

import { Button } from "../../ui/button";
import {
AlertDialog,
AlertDialogActions,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogIntro,
AlertDialogTitle,
AlertDialogTrigger,
} from "./index";
Expand All @@ -23,8 +28,12 @@ const meta = {
subcomponents: {
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogIntro,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogActions,
AlertDialogClose,
},
} satisfies Meta<typeof AlertDialog>;
Expand All @@ -40,29 +49,26 @@ export const Default: Story = {
Delete project
</Button>
<AlertDialogContent>
{/*
* Two layout groups, separated by the parent's gap (never a margin on a
* child): an "intro" (title + description) and the "actions" row. These
* boundaries are the future anatomy surfaces — e.g. AlertDialogIntro and
* AlertDialogActions — so define them correctly here before hardening.
*/}
<div className="flex w-80 flex-col gap-4">
<div className="flex flex-col gap-2">
<AlertDialogHeader>
<AlertDialogIcon tone="danger">
<TriangleAlert />
</AlertDialogIcon>
<AlertDialogIntro>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the project and all of its work items. This action can&apos;t
be undone.
</AlertDialogDescription>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" tone="neutral" magnitude="xl" render={<AlertDialogClose />}>
Cancel
</Button>
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
Delete
</Button>
</div>
</div>
</AlertDialogIntro>
</AlertDialogHeader>
<AlertDialogActions>
<Button variant="secondary" tone="neutral" magnitude="xl" render={<AlertDialogClose />}>
Cancel
</Button>
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
Delete
</Button>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
),
Expand Down
9 changes: 9 additions & 0 deletions packages/propel/src/components/alert-dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ export { AlertDialogContent, type AlertDialogContentProps } from "./alert-dialog
export {
AlertDialog,
type AlertDialogProps,
AlertDialogActions,
type AlertDialogActionsProps,
AlertDialogClose,
type AlertDialogCloseProps,
AlertDialogDescription,
type AlertDialogDescriptionProps,
AlertDialogHeader,
type AlertDialogHeaderProps,
AlertDialogIcon,
type AlertDialogIconProps,
type AlertDialogIconTone,
AlertDialogIntro,
type AlertDialogIntroProps,
AlertDialogTitle,
type AlertDialogTitleProps,
AlertDialogTrigger,
Expand Down
13 changes: 13 additions & 0 deletions packages/propel/src/ui/alert-dialog/alert-dialog-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from "react";

import { alertDialogActionsVariants } from "./variants";

export type AlertDialogActionsProps = React.HTMLAttributes<HTMLDivElement>;

/**
* Right-aligns action buttons with consistent horizontal spacing. The placement and gap are always
* the same — baked in by the design system.
*/
export function AlertDialogActions({ ...props }: AlertDialogActionsProps) {
return <div className={alertDialogActionsVariants()} {...props} />;
}
17 changes: 17 additions & 0 deletions packages/propel/src/ui/alert-dialog/alert-dialog-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type * as React from "react";

import { alertDialogHeaderVariants } from "./variants";

export type AlertDialogHeaderProps = Omit<
React.ComponentPropsWithoutRef<"div">,
"className" | "style"
>;

/**
* The top region of the popup: lays out the leading `AlertDialogIcon` at the inline-start of the
* `AlertDialogIntro` (icon left of title), per the design spec. The icon and intro are passed as
* children.
*/
export function AlertDialogHeader(props: AlertDialogHeaderProps) {
return <div className={alertDialogHeaderVariants()} {...props} />;
}
26 changes: 26 additions & 0 deletions packages/propel/src/ui/alert-dialog/alert-dialog-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type VariantProps } from "class-variance-authority";
import type * as React from "react";

import { alertDialogIconVariants } from "./variants";

export type AlertDialogIconTone = NonNullable<VariantProps<typeof alertDialogIconVariants>["tone"]>;

export type AlertDialogIconProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
> & {
/**
* Intent of the alert, the destructive-vs-informational axis the spec marks adjustable. Drives
* the icon's color; required so the caller always states the intent.
*/
tone: AlertDialogIconTone;
};

/**
* The decorative leading glyph shown at the inline-start of the title. Sizes its single child to
* `--node-size`, so the caller passes a bare icon (warning, error, info, …). Decorative — the title
* carries the accessible name — so it is `aria-hidden`.
*/
export function AlertDialogIcon({ tone, ...props }: AlertDialogIconProps) {
return <span aria-hidden className={alertDialogIconVariants({ tone })} {...props} />;
}
13 changes: 13 additions & 0 deletions packages/propel/src/ui/alert-dialog/alert-dialog-intro.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from "react";

import { alertDialogIntroVariants } from "./variants";

export type AlertDialogIntroProps = React.HTMLAttributes<HTMLDivElement>;

/**
* Groups the title and description with consistent vertical spacing. The gap between title and
* description is always the same — baked in by the design system.
*/
export function AlertDialogIntro({ ...props }: AlertDialogIntroProps) {
return <div className={alertDialogIntroVariants()} {...props} />;
}
57 changes: 29 additions & 28 deletions packages/propel/src/ui/alert-dialog/alert-dialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TriangleAlert } from "lucide-react";
import { expect, userEvent, waitFor, within } from "storybook/test";

import { Button } from "../button";
import {
AlertDialog,
AlertDialogActions,
AlertDialogBackdrop,
AlertDialogClose,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogIntro,
AlertDialogPopup,
AlertDialogPortal,
AlertDialogTitle,
Expand All @@ -30,8 +35,12 @@ const meta = {
AlertDialogBackdrop,
AlertDialogViewport,
AlertDialogPopup,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogIntro,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogActions,
AlertDialogClose,
},
} satisfies Meta<typeof AlertDialog>;
Expand All @@ -50,38 +59,30 @@ export const Anatomy: Story = {
<AlertDialogBackdrop />
<AlertDialogViewport>
<AlertDialogPopup>
{/*
* Two layout groups, separated by the parent's gap (never a margin on a
* child): an "intro" (title + description) and the "actions" row. These
* boundaries are the future anatomy surfaces — e.g. AlertDialogIntro and
* AlertDialogActions — so define them correctly here before hardening.
*/}
<div className="flex w-80 flex-col gap-4">
<div className="flex flex-col gap-2">
<AlertDialogHeader>
<AlertDialogIcon tone="danger">
<TriangleAlert />
</AlertDialogIcon>
<AlertDialogIntro>
<AlertDialogTitle>Delete account?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes your account and cannot be undone.
</AlertDialogDescription>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
tone="neutral"
magnitude="xl"
render={<AlertDialogClose />}
>
Cancel
</Button>
<Button
variant="primary"
tone="danger"
magnitude="xl"
render={<AlertDialogClose />}
>
Delete
</Button>
</div>
</div>
</AlertDialogIntro>
</AlertDialogHeader>
<AlertDialogActions>
<Button
variant="secondary"
tone="neutral"
magnitude="xl"
render={<AlertDialogClose />}
>
Cancel
</Button>
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
Delete
</Button>
</AlertDialogActions>
</AlertDialogPopup>
</AlertDialogViewport>
</AlertDialogPortal>
Expand Down
8 changes: 8 additions & 0 deletions packages/propel/src/ui/alert-dialog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
export { AlertDialog, type AlertDialogProps } from "./alert-dialog";
export { AlertDialogActions, type AlertDialogActionsProps } from "./alert-dialog-actions";
export { AlertDialogBackdrop, type AlertDialogBackdropProps } from "./alert-dialog-backdrop";
export { AlertDialogClose, type AlertDialogCloseProps } from "./alert-dialog-close";
export {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from "./alert-dialog-description";
export { AlertDialogHeader, type AlertDialogHeaderProps } from "./alert-dialog-header";
export {
AlertDialogIcon,
type AlertDialogIconProps,
type AlertDialogIconTone,
} from "./alert-dialog-icon";
export { AlertDialogIntro, type AlertDialogIntroProps } from "./alert-dialog-intro";
export { AlertDialogPopup, type AlertDialogPopupProps } from "./alert-dialog-popup";
export { AlertDialogPortal } from "./alert-dialog-portal";
export { AlertDialogTitle, type AlertDialogTitleProps } from "./alert-dialog-title";
Expand Down
44 changes: 40 additions & 4 deletions packages/propel/src/ui/alert-dialog/variants.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { cva, cx } from "class-variance-authority";

import { nodeSlotClass } from "../../internal/node-slot";

// AlertDialog is a structural overlay primitive. It is always modal and
// non-dismissible, and Base UI drives every interactive state (open/closed,
// starting/ending transition styles) as data attributes — so there are no
// styling axes (variant/tone/magnitude) to expose. The cva pairings below hold
// the static chrome so each part is styled in one place, with no `className` at
// the boundary. Root, Trigger, and Portal carry no styling.
// starting/ending transition styles) as data attributes. The one design axis the
// spec calls out is the leading icon's intent (destructive vs informational), which
// the `AlertDialogIcon` exposes as a required `tone` — every other part is static
// chrome held in a single cva so there is no `className` at the boundary. Root,
// Trigger, and Portal carry no styling.

export const alertDialogBackdropVariants = cva(
cx(
Expand All @@ -20,6 +23,9 @@ export const alertDialogViewportVariants = cva(

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

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

// Header: the top region that places the leading icon at the inline-start of the
// intro (icon left of title, per spec). Items align to the start so a multi-line
// description keeps the icon level with the title.
export const alertDialogHeaderVariants = cva("flex items-start gap-3");

// Icon: the decorative leading glyph beside the title. Sizes its single child to
// `--node-size` (via the shared node-slot class) and tints it by `tone` — the
// destructive-vs-informational axis the spec marks as adjustable. The glyph itself
// (warning/error/info) is whatever child the caller passes. No default tone: the
// caller must state the intent.
export const alertDialogIconVariants = cva(cx(nodeSlotClass, "mt-0.5 [--node-size:1.25rem]"), {
variants: {
tone: {
danger: "text-icon-danger",
warning: "text-icon-warning-primary",
info: "text-icon-info-primary",
success: "text-icon-success-primary",
},
},
});

// Intro: groups the title and description with consistent vertical spacing.
// Always the same per spec (spacing between title and description). `min-w-0` lets
// long copy wrap instead of pushing the icon out of the header row.
export const alertDialogIntroVariants = cva("flex min-w-0 flex-col gap-2");

// Actions: right-aligns action buttons with consistent horizontal spacing.
// Always the same per spec (action button placement, right-aligned in footer).
export const alertDialogActionsVariants = cva("flex justify-end gap-2");

export const alertDialogCloseVariants = cva(
cx(
"inline-flex items-center justify-center rounded-md text-icon-secondary outline-none",
Expand Down