Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from "./search";
export * from "./steps";
export * from "./tabs";
export * from "./tile";
export * from "./toast";
export * from "./tooltip";
export * from "./tree";
export * from "./update";
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/components/toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { ToastAction, ToastProps, ToastVariant } from "./toast";
export { Toast } from "./toast";
111 changes: 111 additions & 0 deletions packages/components/src/components/toast/toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Toast } from "./toast";

const meta: Meta<typeof Toast> = {
title: "Components/Toast",
component: Toast,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["info", "warning", "note", "tip", "check", "danger"],
description: "Predefined toast variant",
},
action: {
control: "object",
description:
"Optional call to action. Pressing it runs onClick and closes the toast",
},
dismissible: {
control: "boolean",
description: "Show a close button that dismisses the toast",
},
onDismiss: {
action: "dismissed",
description: "Called when the close button is pressed",
},
dismissLabel: {
control: "text",
description: "Accessible label for the close button",
},
className: {
control: "text",
description: "Additional CSS classes for the toast",
},
},
};

export default meta;
type Story = StoryObj<typeof Toast>;

const action = { label: "Continue", onClick: () => undefined };

export const Warning: Story = {
args: {
variant: "warning",
children: "Your unsaved changes will be discarded.",
action,
},
};

export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-3">
{(["info", "warning", "note", "tip", "check", "danger"] as const).map(
(variant) => (
<Toast action={action} key={variant} variant={variant}>
A toast with the {variant} variant.
</Toast>
)
)}
</div>
),
};

export const LongText: Story = {
args: {
variant: "warning",
children:
"Long messages wrap beside the icon while the action and close button stay centered on the first line of text, even when the message runs onto several lines.",
action,
},
};

export const Narrow: Story = {
render: () => (
<div className="w-72">
<Toast action={action} variant="warning">
In a narrow container, the action and close button move below the
message.
</Toast>
</div>
),
};

export const EdgeToEdge: Story = {
args: {
variant: "warning",
children: "Remove the rounding to run the toast edge to edge.",
action,
className: "rounded-none",
},
};

export const NotDismissible: Story = {
args: {
variant: "warning",
children: "Only the action can close this toast.",
action,
dismissible: false,
},
};

export const DismissOnly: Story = {
args: {
variant: "info",
children: "A toast without an action.",
},
};
156 changes: 156 additions & 0 deletions packages/components/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { cn } from "cn";
import { type ComponentProps, type ReactNode, useState } from "react";
import { Classes } from "@/constants/selectors";
import {
CircleCheckFilledIcon,
CircleInfoFilledIcon,
CircleWarningFilledIcon,
LightbulbFilledIcon,
NoteFilledIcon,
TriangleWarningFilledIcon,
XIcon,
} from "@/icons";

type ToastVariant = "info" | "warning" | "note" | "tip" | "check" | "danger";

type ToastAction = {
label: string;
onClick: () => void;
};

type ToastProps = {
children: ReactNode;
variant?: ToastVariant;
action?: ToastAction;
dismissible?: boolean;
onDismiss?: () => void;
dismissLabel?: string;
className?: string;
};

const variantConfig = {
info: {
icon: CircleInfoFilledIcon,
className: "bg-stone-600/20",
iconClassName: "text-stone-800 dark:text-stone-300",
},
warning: {
icon: TriangleWarningFilledIcon,
className: "bg-yellow-600/20",
iconClassName: "text-yellow-800 dark:text-yellow-300",
},
note: {
icon: NoteFilledIcon,
className: "bg-blue-600/20",
iconClassName: "text-blue-800 dark:text-blue-300",
},
tip: {
icon: LightbulbFilledIcon,
className: "bg-green-600/20",
iconClassName: "text-green-800 dark:text-green-300",
},
check: {
icon: CircleCheckFilledIcon,
className: "bg-green-600/20",
iconClassName: "text-green-800 dark:text-green-300",
},
danger: {
icon: CircleWarningFilledIcon,
className: "bg-red-600/20",
iconClassName: "text-red-800 dark:text-red-300",
},
};

const FOCUS_RING_CLASSNAME =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:focus-visible:ring-primary-light";

const LineCenter = ({ className, ...props }: ComponentProps<"div">) => (
<div
className={cn("flex h-[1lh] shrink-0 items-center", className)}
{...props}
/>
);

const Toast = ({
children,
variant = "info",
action,
dismissible = true,
onDismiss,
dismissLabel = "Dismiss",
className,
}: ToastProps) => {
const [open, setOpen] = useState(true);

if (!open) {
return null;
}

const config = variantConfig[variant];
const IconComponent = config.icon;

return (
<div
className={cn(
Classes.Toast,
"flex items-start gap-2 rounded-xl py-2 pr-2 pl-2.5 font-medium text-sm text-stone-950 leading-5 tracking-[-0.1px] dark:text-stone-100",
config.className,
className
)}
data-toast-type={variant}
role={variant === "warning" || variant === "danger" ? "alert" : "status"}
>
<LineCenter className="my-0.5" data-component-part="toast-icon">
<IconComponent className={config.iconClassName} />
</LineCenter>
<div className="flex min-w-0 flex-1 flex-wrap items-start justify-end gap-x-2 gap-y-1">
<div
className="overflow-wrap-anywhere min-w-0 flex-[1_1_18rem] py-0.5"
data-component-part="toast-content"
>
{children}
</div>
{(action || dismissible) && (
<LineCenter className="my-0.5 gap-1.5">
{action && (
<button
className={cn(
"inline-flex h-6 items-center rounded-lg bg-white px-2 text-stone-950 text-xs leading-4 tracking-normal transition-colors hover:bg-white/70 dark:bg-white/10 dark:text-stone-100 dark:hover:bg-white/20",
FOCUS_RING_CLASSNAME
)}
data-component-part="toast-action"
onClick={() => {
setOpen(false);
action.onClick();
}}
type="button"
>
{action.label}
</button>
)}
{dismissible && (
<button
aria-label={dismissLabel}
className={cn(
"flex size-[23px] items-center justify-center rounded-lg text-stone-500 transition-colors hover:bg-stone-950/5 hover:text-stone-700 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-200",
FOCUS_RING_CLASSNAME
)}
data-component-part="toast-dismiss"
onClick={() => {
setOpen(false);
onDismiss?.();
}}
type="button"
>
<XIcon />
</button>
)}
</LineCenter>
)}
</div>
</div>
);
};

export { Toast };
export type { ToastAction, ToastProps, ToastVariant };
1 change: 1 addition & 0 deletions packages/components/src/constants/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const _classes = {
Tabs: "tabs",
TabIcon: "tab-icon",
Update: "update",
Toast: "toast",
Tooltip: "tooltip",
Panel: "panel",
APISection: "api-section",
Expand Down
Loading
Loading