diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 755742f6..b6b93bc6 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -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"; diff --git a/packages/components/src/components/toast/index.ts b/packages/components/src/components/toast/index.ts new file mode 100644 index 00000000..3d1110b1 --- /dev/null +++ b/packages/components/src/components/toast/index.ts @@ -0,0 +1,2 @@ +export type { ToastAction, ToastProps, ToastVariant } from "./toast"; +export { Toast } from "./toast"; diff --git a/packages/components/src/components/toast/toast.stories.tsx b/packages/components/src/components/toast/toast.stories.tsx new file mode 100644 index 00000000..94c47466 --- /dev/null +++ b/packages/components/src/components/toast/toast.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Toast } from "./toast"; + +const meta: Meta = { + 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; + +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: () => ( +
+ {(["info", "warning", "note", "tip", "check", "danger"] as const).map( + (variant) => ( + + A toast with the {variant} variant. + + ) + )} +
+ ), +}; + +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: () => ( +
+ + In a narrow container, the action and close button move below the + message. + +
+ ), +}; + +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.", + }, +}; diff --git a/packages/components/src/components/toast/toast.tsx b/packages/components/src/components/toast/toast.tsx new file mode 100644 index 00000000..c382a474 --- /dev/null +++ b/packages/components/src/components/toast/toast.tsx @@ -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">) => ( +
+); + +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 ( +
+ + + +
+
+ {children} +
+ {(action || dismissible) && ( + + {action && ( + + )} + {dismissible && ( + + )} + + )} +
+
+ ); +}; + +export { Toast }; +export type { ToastAction, ToastProps, ToastVariant }; diff --git a/packages/components/src/constants/selectors.ts b/packages/components/src/constants/selectors.ts index 49989598..c11a876c 100644 --- a/packages/components/src/constants/selectors.ts +++ b/packages/components/src/constants/selectors.ts @@ -18,6 +18,7 @@ const _classes = { Tabs: "tabs", TabIcon: "tab-icon", Update: "update", + Toast: "toast", Tooltip: "tooltip", Panel: "panel", APISection: "api-section", diff --git a/packages/components/src/icons/index.tsx b/packages/components/src/icons/index.tsx index 326b7dfe..045cf8c2 100644 --- a/packages/components/src/icons/index.tsx +++ b/packages/components/src/icons/index.tsx @@ -332,6 +332,145 @@ const CopyButtonIcon = ({ ); }; +const CircleInfoFilledIcon = ({ + "aria-label": ariaLabel = "Info", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const TriangleWarningFilledIcon = ({ + "aria-label": ariaLabel = "Warning", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const NoteFilledIcon = ({ + "aria-label": ariaLabel = "Note", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const LightbulbFilledIcon = ({ + "aria-label": ariaLabel = "Tip", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const CircleCheckFilledIcon = ({ + "aria-label": ariaLabel = "Check", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const CircleWarningFilledIcon = ({ + "aria-label": ariaLabel = "Danger", + className, + ...props +}: SVGProps) => { + return ( + + + + ); +}; + +const XIcon = ({ className, ...props }: SVGProps) => { + return ( + + ); +}; + export { ActiveCopyButtonIcon, CopyButtonIcon, @@ -346,4 +485,11 @@ export { TipIcon, DangerIcon, WarningIcon, + CircleCheckFilledIcon, + CircleInfoFilledIcon, + CircleWarningFilledIcon, + LightbulbFilledIcon, + NoteFilledIcon, + TriangleWarningFilledIcon, + XIcon, };