Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tough-experts-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Added a configurable toast positioning that default to bottom-right with per-toast overrides
25 changes: 25 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,28 @@ export function ToastPromiseDemo() {
</Toasty>
);
}

function ToastPositionButton() {
const toastManager = useKumoToastManager();

return (
<Button
onClick={() =>
toastManager.add({
title: "Toast positioned",
description: "This toast uses the Toasty provider position.",
})
}
>
Show top-right toast
</Button>
);
}

export function ToastPositionDemo() {
return (
<Toasty position="top-right">
<ToastPositionButton />
</Toasty>
);
}
28 changes: 28 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/toast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ToastBasicDemo,
ToastTitleOnlyDemo,
ToastDescriptionOnlyDemo,
ToastPositionDemo,
ToastSuccessDemo,
ToastMultipleDemo,
ToastErrorDemo,
Expand Down Expand Up @@ -160,6 +161,30 @@ description: "Your changes have been saved successfully."
<ToastDescriptionOnlyDemo client:visible />
</ComponentExample>

<Heading level={3}>Position</Heading>
<p>
Use the `position` prop on `Toasty` to control where toast notifications appear by default. The default value is `bottom-right`.
</p>
<ComponentExample
code={`<Toasty position="top-right">
<Button
onClick={() =>
toastManager.add({
title: "Toast positioned",
description: "This toast uses the Toasty provider position.",
})
}
>
Show top-right toast
</Button>
</Toasty>`}
>
<ToastPositionDemo client:visible />
</ComponentExample>
<p>
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.
</p>

<Heading level={3}>Success Action</Heading>
<p>
Toasts work well for confirming user actions.
Expand Down Expand Up @@ -280,6 +305,9 @@ error: (err) => ({ title: "Failed", description: err.message, variant: "error" }
<Heading level={3}>Toasty</Heading>
<p>The provider component that wraps your app and manages the toast system.</p>
<PropsTable component="Toasty" />
<p>
<code class="text-kumo-default">Toasty</code> also accepts a <code class="text-kumo-default">position</code> prop to control the default toast viewport placement. Supported values are <code class="text-kumo-default">"top-left"</code>, <code class="text-kumo-default">"top-center"</code>, <code class="text-kumo-default">"top-right"</code>, <code class="text-kumo-default">"bottom-left"</code>, <code class="text-kumo-default">"bottom-center"</code>, and <code class="text-kumo-default">"bottom-right"</code>. The default is <code class="text-kumo-default">"bottom-right"</code>. Individual toasts can override that default with their own <code class="text-kumo-default">position</code> value.
</p>

<Heading level={3}>useKumoToastManager()</Heading>
<p>
Expand Down
7 changes: 6 additions & 1 deletion packages/kumo/src/components/toast/index.ts
Original file line number Diff line number Diff line change
@@ -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";
140 changes: 140 additions & 0 deletions packages/kumo/src/components/toast/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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<KumoToastPosition, string[]> = {
"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 (
<button
type="button"
onClick={() => toastManager.add({ title, description, position })}
>
{label}
</button>
);
}

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(
<Toasty>
<TriggerToastButton
label="Show toast"
title="Toast title"
description="Toast description"
/>
</Toasty>,
);

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(
<Toasty position={position}>
<TriggerToastButton
label="Show positioned toast"
title={`Toast in ${position}`}
description="Toast description"
/>
</Toasty>,
);

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(
<Toasty position="bottom-right">
<TriggerToastButton
label="Show top-left toast"
title="Override position toast"
description="Toast description"
position="top-left"
/>
</Toasty>,
);

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(
<Toasty position="bottom-right">
<TriggerToastButton
label="Show default lane toast"
title="Default lane toast"
description="Toast description"
/>
<TriggerToastButton
label="Show top lane toast"
title="Top lane toast"
description="Toast description"
position="top-left"
/>
</Toasty>,
);

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();
});
});
Loading