From 23b56c2eabe543a295842a83a96f326c3d07ebf9 Mon Sep 17 00:00:00 2001 From: gitpush-gitpaid Date: Thu, 5 Mar 2026 03:37:19 -0500 Subject: [PATCH 1/3] Add configurable Toast position --- packages/kumo/src/components/toast/index.ts | 7 +- .../kumo/src/components/toast/toast.test.tsx | 140 +++++++++++++++++ packages/kumo/src/components/toast/toast.tsx | 148 ++++++++++++++++-- 3 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 packages/kumo/src/components/toast/toast.test.tsx 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 50cc52372c..9c0b57c514 100644 --- a/packages/kumo/src/components/toast/toast.tsx +++ b/packages/kumo/src/components/toast/toast.tsx @@ -95,6 +95,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; @@ -132,10 +200,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; @@ -249,7 +321,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`. @@ -261,14 +333,12 @@ export const createKumoToastManager = () => { * * ``` */ -export function Toasty({ children }: ToastyProps) { +export function Toasty({ children, position = "bottom-right" }: ToastyProps) { return ( {children} - - - + ); @@ -277,24 +347,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) => ( From 0e5aef6a98f6fa25764a374c3874b6a7b8ebd326 Mon Sep 17 00:00:00 2001 From: gitpush-gitpaid Date: Thu, 5 Mar 2026 03:43:40 -0500 Subject: [PATCH 2/3] chore(changeset): add toast position changeset --- .changeset/tough-experts-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tough-experts-glow.md 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 From fd101803906944619ebabbb1691d082b80182eb6 Mon Sep 17 00:00:00 2001 From: gitpush-gitpaid Date: Wed, 18 Mar 2026 17:43:01 -0400 Subject: [PATCH 3/3] docs(toast): document Toasty position prop --- .../src/components/demos/ToastDemo.tsx | 25 ++++++++ .../src/pages/components/toast.astro | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+) 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.astro b/packages/kumo-docs-astro/src/pages/components/toast.astro index 1d3f56d1a9..1e91fa838b 100644 --- a/packages/kumo-docs-astro/src/pages/components/toast.astro +++ b/packages/kumo-docs-astro/src/pages/components/toast.astro @@ -16,6 +16,7 @@ import { ToastCustomContentDemo, ToastActionsDemo, ToastPromiseDemo, + ToastPositionDemo, } from "../../components/demos/ToastDemo"; --- @@ -168,6 +169,45 @@ export function Layout({ children }) { +
+ 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

@@ -285,6 +325,7 @@ toastManager.add({ title: "Third toast" });`}

+ @@ -298,6 +339,21 @@ toastManager.add({ title: "Third toast" });`} 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. +

@@ -377,5 +433,6 @@ toastManager.promise(asyncFn(), {
+