diff --git a/.changeset/tough-experts-glow.md b/.changeset/tough-experts-glow.md new file mode 100644 index 0000000000..5a10a1ef14 --- /dev/null +++ b/.changeset/tough-experts-glow.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Added a configurable toast positioning that default to bottom-right with per-toast overrides diff --git a/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx b/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx index 0b6cd5f11a..43d1928658 100644 --- a/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx @@ -304,3 +304,28 @@ export function ToastPromiseDemo() { ); } + +function ToastPositionButton() { + const toastManager = useKumoToastManager(); + + return ( + + ); +} + +export function ToastPositionDemo() { + return ( + + + + ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/toast.mdx b/packages/kumo-docs-astro/src/pages/components/toast.mdx index ebf1a427d4..71a90ed185 100644 --- a/packages/kumo-docs-astro/src/pages/components/toast.mdx +++ b/packages/kumo-docs-astro/src/pages/components/toast.mdx @@ -14,6 +14,7 @@ import { ToastBasicDemo, ToastTitleOnlyDemo, ToastDescriptionOnlyDemo, + ToastPositionDemo, ToastSuccessDemo, ToastMultipleDemo, ToastErrorDemo, @@ -160,6 +161,30 @@ description: "Your changes have been saved successfully." + Position +

+ Use the `position` prop on `Toasty` to control where toast notifications appear by default. The default value is `bottom-right`. +

+ + +`} + > + + +

+ Supported values are `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`, and `bottom-right`. Individual toasts can also override the provider default by passing `position` to `toastManager.add()`, so different toasts can render in different lanes at the same time. +

+ Success Action

Toasts work well for confirming user actions. @@ -280,6 +305,9 @@ error: (err) => ({ title: "Failed", description: err.message, variant: "error" } Toasty

The provider component that wraps your app and manages the toast system.

+

+ Toasty also accepts a position prop to control the default toast viewport placement. Supported values are "top-left", "top-center", "top-right", "bottom-left", "bottom-center", and "bottom-right". The default is "bottom-right". Individual toasts can override that default with their own position value. +

useKumoToastManager()

diff --git a/packages/kumo/src/components/toast/index.ts b/packages/kumo/src/components/toast/index.ts index b7735126cd..add2a9c727 100644 --- a/packages/kumo/src/components/toast/index.ts +++ b/packages/kumo/src/components/toast/index.ts @@ -1,4 +1,9 @@ export { Toasty, ToastProvider } from "./toast"; export { Toast } from "@base-ui/react/toast"; export { useKumoToastManager, createKumoToastManager } from "./toast"; -export type { KumoToastOptions, KumoToastManagerAddOptions } from "./toast"; +export type { + KumoToastOptions, + KumoToastManagerAddOptions, + KumoToastPosition, + ToastyProps, +} from "./toast"; diff --git a/packages/kumo/src/components/toast/toast.test.tsx b/packages/kumo/src/components/toast/toast.test.tsx new file mode 100644 index 0000000000..1f145f02ff --- /dev/null +++ b/packages/kumo/src/components/toast/toast.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Toasty, type KumoToastPosition, useKumoToastManager } from "./toast"; + +const EXPECTED_VIEWPORT_ANCHOR_CLASSES: Record = { + "top-left": ["top-4", "left-4"], + "top-center": ["top-4", "left-4", "right-4"], + "top-right": ["top-4", "right-4"], + "bottom-left": ["bottom-4", "left-4"], + "bottom-center": ["bottom-4", "left-4", "right-4"], + "bottom-right": ["bottom-4", "right-4"], +}; + +function TriggerToastButton({ + label, + title, + description, + position, +}: { + label: string; + title: string; + description: string; + position?: KumoToastPosition; +}) { + const toastManager = useKumoToastManager(); + + return ( + + ); +} + +function findViewport(position: KumoToastPosition) { + return document.querySelector( + `[data-kumo-toast-position="${position}"]`, + ) as HTMLElement | null; +} + +describe("Toasty position", () => { + it("uses bottom-right by default", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Show toast" })); + await screen.findByText("Toast title"); + + const viewport = findViewport("bottom-right"); + expect(viewport).toBeTruthy(); + expect(viewport?.dataset.kumoToastViewport).toBe("true"); + expect(viewport?.className).toContain("bottom-4"); + expect(viewport?.className).toContain("right-4"); + }); + + it.each( + Object.entries(EXPECTED_VIEWPORT_ANCHOR_CLASSES) as Array< + [KumoToastPosition, string[]] + >, + )("uses provider position %s", async (position, expectedClasses) => { + render( + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Show positioned toast" }), + ); + await screen.findByText(`Toast in ${position}`); + + const viewport = findViewport(position); + expect(viewport).toBeTruthy(); + + for (const className of expectedClasses) { + expect(viewport?.className).toContain(className); + } + }); + + it("allows per-toast position override", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Show top-left toast" })); + await screen.findByText("Override position toast"); + + expect(findViewport("top-left")).toBeTruthy(); + expect(findViewport("bottom-right")).toBeNull(); + }); + + it("renders toasts in multiple lanes", async () => { + render( + + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Show default lane toast" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Show top lane toast" })); + + await screen.findByText("Default lane toast"); + await screen.findByText("Top lane toast"); + + expect(findViewport("bottom-right")).toBeTruthy(); + expect(findViewport("top-left")).toBeTruthy(); + }); +}); diff --git a/packages/kumo/src/components/toast/toast.tsx b/packages/kumo/src/components/toast/toast.tsx index cf4c1d9609..c4736ed2ae 100644 --- a/packages/kumo/src/components/toast/toast.tsx +++ b/packages/kumo/src/components/toast/toast.tsx @@ -96,6 +96,74 @@ export const KUMO_TOAST_STYLING = { // Derived types from KUMO_TOAST_VARIANTS export type KumoToastVariant = keyof typeof KUMO_TOAST_VARIANTS.variant; +export type KumoToastPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +const KUMO_TOAST_POSITIONS = [ + "top-left", + "top-center", + "top-right", + "bottom-left", + "bottom-center", + "bottom-right", +] as const satisfies ReadonlyArray; + +const TOAST_VIEWPORT_BASE_CLASSES = + "fixed z-1 flex w-[calc(100%-2rem)] sm:w-[340px]"; + +const TOAST_VIEWPORT_POSITION_CLASSES: Record = { + "top-left": "top-4 left-4 sm:top-8 sm:left-8", + "top-center": + "top-4 right-4 left-4 mx-auto sm:top-8 sm:right-auto sm:left-1/2 sm:-translate-x-1/2", + "top-right": "top-4 right-4 sm:top-8 sm:right-8", + "bottom-left": "bottom-4 left-4 sm:bottom-8 sm:left-8", + "bottom-center": + "right-4 bottom-4 left-4 mx-auto sm:right-auto sm:bottom-8 sm:left-1/2 sm:-translate-x-1/2", + "bottom-right": "right-4 bottom-4 sm:right-8 sm:bottom-8", +}; + +const TOAST_ROOT_POSITION_CLASSES: Record = { + "top-left": "left-0 right-auto top-0 bottom-auto origin-top", + "top-center": "left-0 right-0 top-0 bottom-auto mx-auto origin-top", + "top-right": "right-0 left-auto top-0 bottom-auto origin-top", + "bottom-left": "left-0 right-auto bottom-0 top-auto origin-bottom", + "bottom-center": "left-0 right-0 bottom-0 top-auto mx-auto origin-bottom", + "bottom-right": "right-0 left-auto bottom-0 top-auto origin-bottom", +}; + +type ToastVerticalEdge = "top" | "bottom"; +type AnyKumoToast = KumoToastOptions; +type ToastLanes = Record>; + +function getToastVerticalEdge(position: KumoToastPosition): ToastVerticalEdge { + return position.startsWith("top") ? "top" : "bottom"; +} + +function groupToastsByPosition( + toasts: Array, + defaultPosition: KumoToastPosition, +): ToastLanes { + const lanes: ToastLanes = { + "top-left": [], + "top-center": [], + "top-right": [], + "bottom-left": [], + "bottom-center": [], + "bottom-right": [], + }; + + for (const toast of toasts) { + const resolvedPosition = toast.position ?? defaultPosition; + lanes[resolvedPosition].push(toast); + } + + return lanes; +} export interface KumoToastVariantsProps { variant?: KumoToastVariant; @@ -133,10 +201,14 @@ export function toastVariants({ export interface ToastyProps extends KumoToastVariantsProps { /** Application content. Toasts render via a portal above this. */ children: React.ReactNode; + /** Default position used for toasts unless overridden per toast. @default "bottom-right" */ + position?: KumoToastPosition; } type KumoToastOptionsBase = { variant?: KumoToastVariant; + /** Optional per-toast placement override. */ + position?: KumoToastPosition; content?: React.ReactNode; actions?: Array; bump?: boolean; @@ -250,7 +322,7 @@ export const createKumoToastManager = () => { /** * Toasty — toast notification provider and viewport. * - * Renders a `Toast.Provider` with a fixed-position viewport in the bottom-right corner. + * Renders a `Toast.Provider` with fixed-position viewport lanes. * Toasts stack with smooth enter/exit animations, swipe-to-dismiss, and expand-on-hover. * * Built on `@base-ui/react/toast`. @@ -262,14 +334,12 @@ export const createKumoToastManager = () => { * * ``` */ -export function Toasty({ children }: ToastyProps) { +export function Toasty({ children, position = "bottom-right" }: ToastyProps) { return ( {children} - - - + ); @@ -278,24 +348,78 @@ export function Toasty({ children }: ToastyProps) { /** Alias for Toasty — provided for discoverability when migrating from other libraries */ export const ToastProvider = Toasty; -function ToastList() { +function ToastViewports({ + defaultPosition, +}: { + defaultPosition: KumoToastPosition; +}) { const { toasts } = useKumoToastManager(); + const lanes = groupToastsByPosition(toasts, defaultPosition); + + return KUMO_TOAST_POSITIONS.map((position) => { + return ( + + ); + }); +} + +function ToastViewportLane({ + position, + toasts, +}: { + position: KumoToastPosition; + toasts: Array; +}) { + if (toasts.length === 0) return null; + + return ( + + + + ); +} + +function ToastList({ + position, + toasts, +}: { + position: KumoToastPosition; + toasts: Array; +}) { + const verticalEdge = getToastVerticalEdge(position); + return toasts.map((toast) => (