diff --git a/src/components/announcements/LiveAnnouncementNotifications.tsx b/src/components/announcements/LiveAnnouncementNotifications.tsx index cd859fea..62428844 100644 --- a/src/components/announcements/LiveAnnouncementNotifications.tsx +++ b/src/components/announcements/LiveAnnouncementNotifications.tsx @@ -53,7 +53,6 @@ export function LiveAnnouncementNotifications({ audience }: { audience: Announce recordAnnouncementImpression(announcement.id); const dismiss = () => dismissAnnouncements([announcement.id]); const controls = { - close: announcement.controls?.close !== false, action: announcement.controls?.action !== false, }; const action = announcement.action; @@ -74,7 +73,6 @@ export function LiveAnnouncementNotifications({ audience }: { audience: Announce duration: (announcement.displayDurationSeconds ?? DEFAULT_NOTIFICATION_DURATION_SECONDS) * 1_000, - closeButton: controls.close, onDismiss: dismiss, action: action && controls.action diff --git a/src/components/ui/toast.test.ts b/src/components/ui/toast.test.ts new file mode 100644 index 00000000..1be41d7c --- /dev/null +++ b/src/components/ui/toast.test.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +const native = vi.hoisted(() => + Object.assign( + vi.fn(() => "toast-key"), + { + success: vi.fn(), + danger: vi.fn(), + update: vi.fn(() => "toast-key"), + clear: vi.fn(), + close: vi.fn(), + }, + ), +); +vi.mock("@heroui/react", () => ({ Toast: { Provider: () => null }, toast: native })); +import { toast } from "./toast"; +afterEach(() => { + toast.dismiss(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); +it("copies the error title and details through the native action", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + toast.error("Export failed", { description: "Encoder unavailable" }); + const options = ( + native.mock.calls as unknown as [ + string, + { variant: string; actionProps: { children: string; onPress: () => void } }, + ][] + )[0][1]; + expect(options.variant).toBe("danger"); + expect(options.actionProps.children).toBe("Copy"); + options.actionProps.onPress(); + await vi.waitFor(() => expect(native.success).toHaveBeenCalledWith("Error copied")); + expect(writeText).toHaveBeenCalledWith("Export failed\n\nEncoder unavailable"); +}); +it("preserves explicit actions and updates named notifications", () => { + const onClick = vi.fn(); + toast.error("Retry", { id: "job", action: { label: "Retry", onClick } }); + toast.success("Done", { id: "job" }); + expect(native.update).toHaveBeenCalledWith( + "toast-key", + "Done", + expect.objectContaining({ variant: "success" }), + ); + expect(native).toHaveBeenCalledWith( + "Retry", + expect.objectContaining({ actionProps: { children: "Retry", onPress: onClick } }), + ); +}); diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a38340dd..135a22de 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,43 +1,64 @@ -import { Toast, ToastQueue } from "@heroui/react"; -import type { ReactNode } from "react"; +import { Toast, toast as heroToast } from "@heroui/react"; +import { isValidElement, type ReactNode } from "react"; -type ToastContent = { - title: ReactNode; - description?: ReactNode; - variant: "default" | "accent" | "success" | "warning" | "danger"; - action?: { label: ReactNode; onClick: () => void }; - closeButton?: boolean; -}; type Options = { id?: string | number; description?: ReactNode; duration?: number; - closeButton?: boolean; action?: { label: ReactNode; onClick: () => void }; onDismiss?: () => void; }; -const queue = new ToastQueue(); +type Variant = "default" | "accent" | "success" | "warning" | "danger"; const ids = new Map(); -function notify( - title: ReactNode, - options: Options = {}, - variant: ToastContent["variant"] = "default", -) { - const content = { - title, - description: options.description, + +function plainText(value: ReactNode): string { + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map(plainText).join(""); + if (isValidElement<{ children?: ReactNode }>(value)) return plainText(value.props.children); + return ""; +} + +// Keep existing callers compatible while HeroUI owns the queue and presentation. +function notify(title: ReactNode, options: Options = {}, variant: Variant = "default") { + const errorText = [plainText(title), plainText(options.description)] + .filter(Boolean) + .join("\n\n"); + const action = options.action; + const nativeOptions = { variant, - action: options.action, - closeButton: options.closeButton, - }; - const timeout = options.duration === Infinity ? 0 : (options.duration ?? 4000); - const onClose = () => { - if (options.id !== undefined) ids.delete(options.id); - options.onDismiss?.(); + description: options.description, + timeout: + options.duration === Infinity + ? 0 + : (options.duration ?? (variant === "danger" ? 8000 : 4000)), + onClose: () => { + if (options.id !== undefined) ids.delete(options.id); + options.onDismiss?.(); + }, + actionProps: action + ? { + children: action.label, + onPress: action.onClick, + } + : variant === "danger" && errorText + ? { + children: "Copy", + onPress: () => { + void navigator.clipboard.writeText(errorText).then( + () => heroToast.success("Error copied"), + () => + heroToast.danger("Could not copy error", { + description: errorText, + }), + ); + }, + } + : undefined, }; const previous = options.id === undefined ? undefined : ids.get(options.id); - if (previous && queue.update(previous, content, { timeout, onClose })) return previous; - const key = queue.add(content, { timeout, onClose }); + const key = previous + ? heroToast.update(previous, title, nativeOptions) + : heroToast(title, nativeOptions); if (options.id !== undefined) ids.set(options.id, key); return key; } @@ -48,38 +69,11 @@ export const toast = Object.assign(notify, { warning: (message: ReactNode, options?: Options) => notify(message, options, "warning"), dismiss: (id?: string | number) => { if (id === undefined) { - queue.clear(); + heroToast.clear(); ids.clear(); - } else queue.close(ids.get(id) ?? String(id)); + } else heroToast.close(ids.get(id) ?? String(id)); }, }); export function Toaster({ className }: { className?: string }) { - return ( - - {({ toast: item }) => ( - - - -
- {item.content.title} - {item.content.description && ( - {item.content.description} - )} -
- {item.content.action && ( - { - item.content.action?.onClick(); - queue.close(item.key); - }} - > - {item.content.action.label} - - )} - {item.content.closeButton !== false && } -
-
- )} -
- ); + return ; }