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/fuzzy-toasts-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

Correct Toast manager usage guidance and preserve typed return values from `add`, `update`, and `promise`.
2 changes: 1 addition & 1 deletion packages/kumo/ai/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { Button, Input, Dialog } from "@cloudflare/kumo";
| `Table` | Display | Data table with selection | `.Header`, `.Head`, `.Body`, `.Row`, `.Cell`, `.Footer`, `.CheckCell`, `.CheckHead`, `.ResizeHandle`; `layout`: auto, fixed |
| `Tabs` | Navigation | Tabbed navigation | `tabs: { value, label, render? }[]`; `variant`: segmented, underline; `value`, `onValueChange` |
| `Text` | Display | Themed text with semantic variants | `variant`: heading1, heading2, heading3, body, secondary, success, error, mono, mono-secondary; `size`: xs, sm, base, lg; `as` |
| `Toast` / `Toasty` | Feedback | Toast notification system | Wrap app with `<Toasty>`, use `Toast.useToastManager().notify()` |
| `Toast` / `Toasty` | Feedback | Toast notification system | Wrap app with `<Toasty>`, use `Toast.useToastManager().add()` |
| `Tooltip` | Overlay | Hover/focus tooltip | `content`, `side`, `align`, `asChild` |

## Semantic Token Reference
Expand Down
20 changes: 20 additions & 0 deletions packages/kumo/src/components/toast/toast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import { useEffect } from "react";
import { Toasty, createKumoToastManager, useKumoToastManager } from "./toast";

describe("Toasty", () => {
it("preserves toast manager return values", async () => {
const mgr = createKumoToastManager();

const id = mgr.add({ id: "return-types", title: "Created" });
expect(id).toBe("return-types");

const updateResult = mgr.update(id, { title: "Updated" });
expect(updateResult).toBeUndefined();

const sourcePromise = Promise.resolve({ status: "complete" });
const returnedPromise = mgr.promise(sourcePromise, {
loading: { title: "Loading" },
success: (result) => ({ title: result.status }),
error: { title: "Failed" },
});

expect(returnedPromise).toBe(sourcePromise);
await expect(returnedPromise).resolves.toEqual({ status: "complete" });
});

// Regression guard: existing callers that don't pass `toastManager`
// must continue to work via the in-tree `useKumoToastManager` hook.
it("renders without a toastManager prop and accepts in-tree dispatch", async () => {
Expand Down
98 changes: 70 additions & 28 deletions packages/kumo/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
Toast,
ToastManagerAddOptions,
ToastObject,
type ToastManager,
type ToastManagerAddOptions,
type ToastObject,
} from "@base-ui/react/toast";
import type React from "react";
import { cn } from "../../utils/cn";
Expand Down Expand Up @@ -139,7 +140,7 @@ export function toastVariants({
* Toasty component props.
*
* Wrap your app with `<Toasty>` to enable toast notifications.
* Use `Toast.useToastManager().notify(…)` to create toasts.
* Use `Toast.useToastManager().add(…)` to create toasts.
*
* @example
* ```tsx
Expand All @@ -150,7 +151,7 @@ export function toastVariants({
*
* // 2. Show a toast from any child component
* const toasts = Toast.useToastManager();
* toasts.notify({ title: "Saved", description: "Changes saved successfully." });
* toasts.add({ title: "Saved", description: "Changes saved successfully." });
* ```
*
* @example Dispatching toasts from non-React-component code
Expand Down Expand Up @@ -204,38 +205,76 @@ export type KumoToastOptions<Data extends object> = ToastObject<Data> &
export type KumoToastManagerAddOptions<Data extends object> =
ToastManagerAddOptions<Data> & KumoToastOptionsBase;

function wrapManagerMethods<
T extends { add: Function; update: Function; promise: Function },
>(manager: T) {
type KumoToastManagerMethods = {
add: <Data extends object = any>(
options: KumoToastManagerAddOptions<Data>,
) => string;
update: <Data extends object = any>(
id: string,
options: Partial<KumoToastManagerAddOptions<Data>>,
) => void;
promise: <Value, Data extends object = any>(
promise: Promise<Value>,
options: {
loading: KumoToastManagerAddOptions<Data>;
success:
| KumoToastManagerAddOptions<Data>
| ((data: Value) => KumoToastManagerAddOptions<Data>);
error:
| KumoToastManagerAddOptions<Data>
| ((error: Error) => KumoToastManagerAddOptions<Data>);
},
) => Promise<Value>;
};

type BaseToastManagerMethods = Pick<
ToastManager,
keyof KumoToastManagerMethods
>;

type WrappedToastManager<Manager extends BaseToastManagerMethods> = Manager &
KumoToastManagerMethods;

function wrapManagerMethods<Manager extends BaseToastManagerMethods>(
manager: Manager,
): WrappedToastManager<Manager> {
return {
...manager,

add: (options: KumoToastManagerAddOptions<any>) => {
if (options.id) {
add: <Data extends object = any>(
options: KumoToastManagerAddOptions<Data>,
): string => {
const id = options.id;

if (id) {
const toasts = (manager as any).toasts as
Array<ToastObject<any>> | undefined;

if (toasts) {
const existingToast = toasts.find((toast) => toast.id === options.id);
const existingToast = toasts.find((toast) => toast.id === id);

// If toast exists and is not exiting, trigger bump and prevent duplicate
if (existingToast && existingToast.transitionStatus !== "ending") {
// Reset animation by disabling then re-enabling
manager.update(options.id, { bump: false });
const resetBump: Partial<KumoToastManagerAddOptions<Data>> = {
bump: false,
};
manager.update(id, resetBump);
requestAnimationFrame(() => {
manager.update(options.id, {
const restartBump: Partial<KumoToastManagerAddOptions<Data>> = {
bump: true,
...(options.timeout !== undefined && {
timeout: options.timeout,
}),
});
};
manager.update(id, restartBump);
});
return options.id;
return id;
}

// If toast exists and is exiting, let it finish - don't add duplicate
if (existingToast && existingToast.transitionStatus === "ending") {
return options.id;
return id;
}
}
}
Expand All @@ -245,33 +284,36 @@ function wrapManagerMethods<
});
},

update: (id: string, options: Partial<KumoToastManagerAddOptions<any>>) => {
update: <Data extends object = any>(
id: string,
options: Partial<KumoToastManagerAddOptions<Data>>,
): void => {
return manager.update(id, {
...options,
});
},

promise: <T,>(
promise: Promise<T>,
promise: <Value, Data extends object = any>(
promise: Promise<Value>,
options: {
loading: KumoToastManagerAddOptions<any>;
loading: KumoToastManagerAddOptions<Data>;
success:
| KumoToastManagerAddOptions<any>
| ((data: T) => KumoToastManagerAddOptions<any>);
| KumoToastManagerAddOptions<Data>
| ((data: Value) => KumoToastManagerAddOptions<Data>);
error:
| KumoToastManagerAddOptions<any>
| ((error: Error) => KumoToastManagerAddOptions<any>);
| KumoToastManagerAddOptions<Data>
| ((error: Error) => KumoToastManagerAddOptions<Data>);
},
) => {
): Promise<Value> => {
return manager.promise(promise, {
loading: { ...options.loading },
success:
typeof options.success === "function"
? (data: T) => ({
? (data: Value) => ({
...(
options.success as (
data: T,
) => KumoToastManagerAddOptions<any>
data: Value,
) => KumoToastManagerAddOptions<Data>
)(data),
})
: { ...options.success },
Expand All @@ -281,7 +323,7 @@ function wrapManagerMethods<
...(
options.error as (
error: Error,
) => KumoToastManagerAddOptions<any>
) => KumoToastManagerAddOptions<Data>
)(error),
})
: { ...options.error },
Expand Down
36 changes: 36 additions & 0 deletions packages/kumo/src/components/toast/toast.type-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expectTypeOf } from "vite-plus/test";
import { createKumoToastManager, useKumoToastManager } from "./toast";

const sourcePromise = Promise.resolve({ status: "complete" as const });

function assertManagerReturnTypes(
manager:
| ReturnType<typeof createKumoToastManager>
| ReturnType<typeof useKumoToastManager>,
) {
expectTypeOf(manager.add({ title: "Created" })).toEqualTypeOf<string>();
expectTypeOf(
manager.update("toast-id", { title: "Updated" }),
).toEqualTypeOf<void>();

const returnedPromise = manager.promise(sourcePromise, {
loading: { title: "Loading" },
success: (result) => {
expectTypeOf(result).toEqualTypeOf<{ status: "complete" }>();
return { title: result.status };
},
error: { title: "Failed" },
});

expectTypeOf(returnedPromise).toEqualTypeOf<
Promise<{ status: "complete" }>
>();
}

export function assertCreatedToastManagerReturnTypes() {
assertManagerReturnTypes(createKumoToastManager());
}

export function useAssertHookToastManagerReturnTypes() {
assertManagerReturnTypes(useKumoToastManager());
}