diff --git a/.changeset/fuzzy-toasts-return.md b/.changeset/fuzzy-toasts-return.md new file mode 100644 index 0000000000..d9f889c3f0 --- /dev/null +++ b/.changeset/fuzzy-toasts-return.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Correct Toast manager usage guidance and preserve typed return values from `add`, `update`, and `promise`. diff --git a/packages/kumo/ai/USAGE.md b/packages/kumo/ai/USAGE.md index c5f32da522..4d306fcb91 100644 --- a/packages/kumo/ai/USAGE.md +++ b/packages/kumo/ai/USAGE.md @@ -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 ``, use `Toast.useToastManager().notify()` | +| `Toast` / `Toasty` | Feedback | Toast notification system | Wrap app with ``, use `Toast.useToastManager().add()` | | `Tooltip` | Overlay | Hover/focus tooltip | `content`, `side`, `align`, `asChild` | ## Semantic Token Reference diff --git a/packages/kumo/src/components/toast/toast.test.tsx b/packages/kumo/src/components/toast/toast.test.tsx index 56940ba42a..59bfbd8d22 100644 --- a/packages/kumo/src/components/toast/toast.test.tsx +++ b/packages/kumo/src/components/toast/toast.test.tsx @@ -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 () => { diff --git a/packages/kumo/src/components/toast/toast.tsx b/packages/kumo/src/components/toast/toast.tsx index 812298fc04..1476b96fa5 100644 --- a/packages/kumo/src/components/toast/toast.tsx +++ b/packages/kumo/src/components/toast/toast.tsx @@ -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"; @@ -139,7 +140,7 @@ export function toastVariants({ * Toasty component props. * * Wrap your app with `` to enable toast notifications. - * Use `Toast.useToastManager().notify(…)` to create toasts. + * Use `Toast.useToastManager().add(…)` to create toasts. * * @example * ```tsx @@ -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 @@ -204,38 +205,76 @@ export type KumoToastOptions = ToastObject & export type KumoToastManagerAddOptions = ToastManagerAddOptions & KumoToastOptionsBase; -function wrapManagerMethods< - T extends { add: Function; update: Function; promise: Function }, ->(manager: T) { +type KumoToastManagerMethods = { + add: ( + options: KumoToastManagerAddOptions, + ) => string; + update: ( + id: string, + options: Partial>, + ) => void; + promise: ( + promise: Promise, + options: { + loading: KumoToastManagerAddOptions; + success: + | KumoToastManagerAddOptions + | ((data: Value) => KumoToastManagerAddOptions); + error: + | KumoToastManagerAddOptions + | ((error: Error) => KumoToastManagerAddOptions); + }, + ) => Promise; +}; + +type BaseToastManagerMethods = Pick< + ToastManager, + keyof KumoToastManagerMethods +>; + +type WrappedToastManager = Manager & + KumoToastManagerMethods; + +function wrapManagerMethods( + manager: Manager, +): WrappedToastManager { return { ...manager, - add: (options: KumoToastManagerAddOptions) => { - if (options.id) { + add: ( + options: KumoToastManagerAddOptions, + ): string => { + const id = options.id; + + if (id) { const toasts = (manager as any).toasts as Array> | 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> = { + bump: false, + }; + manager.update(id, resetBump); requestAnimationFrame(() => { - manager.update(options.id, { + const restartBump: Partial> = { 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; } } } @@ -245,33 +284,36 @@ function wrapManagerMethods< }); }, - update: (id: string, options: Partial>) => { + update: ( + id: string, + options: Partial>, + ): void => { return manager.update(id, { ...options, }); }, - promise: ( - promise: Promise, + promise: ( + promise: Promise, options: { - loading: KumoToastManagerAddOptions; + loading: KumoToastManagerAddOptions; success: - | KumoToastManagerAddOptions - | ((data: T) => KumoToastManagerAddOptions); + | KumoToastManagerAddOptions + | ((data: Value) => KumoToastManagerAddOptions); error: - | KumoToastManagerAddOptions - | ((error: Error) => KumoToastManagerAddOptions); + | KumoToastManagerAddOptions + | ((error: Error) => KumoToastManagerAddOptions); }, - ) => { + ): Promise => { return manager.promise(promise, { loading: { ...options.loading }, success: typeof options.success === "function" - ? (data: T) => ({ + ? (data: Value) => ({ ...( options.success as ( - data: T, - ) => KumoToastManagerAddOptions + data: Value, + ) => KumoToastManagerAddOptions )(data), }) : { ...options.success }, @@ -281,7 +323,7 @@ function wrapManagerMethods< ...( options.error as ( error: Error, - ) => KumoToastManagerAddOptions + ) => KumoToastManagerAddOptions )(error), }) : { ...options.error }, diff --git a/packages/kumo/src/components/toast/toast.type-spec.ts b/packages/kumo/src/components/toast/toast.type-spec.ts new file mode 100644 index 0000000000..d608f09ee0 --- /dev/null +++ b/packages/kumo/src/components/toast/toast.type-spec.ts @@ -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 + | ReturnType, +) { + expectTypeOf(manager.add({ title: "Created" })).toEqualTypeOf(); + expectTypeOf( + manager.update("toast-id", { title: "Updated" }), + ).toEqualTypeOf(); + + 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()); +}