fabro/apps/fabro-web/app/components/toast.tsx
2026-07-25 15:15:25 -04:00

139 lines
4.1 KiB
TypeScript

import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from "@heroicons/react/16/solid";
import type { ReactNode } from "react";
import { Toaster as SonnerToaster, toast as sonnerToast, useSonner } from "sonner";
export type ToastTone = "info" | "error";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastInput {
message: string;
tone?: ToastTone;
/** Pass `Infinity` for a toast that stays until dismissed or acted on. */
autoDismissMs?: number;
action?: ToastAction;
}
interface ToastContextValue {
push: (toast: ToastInput) => string;
dismiss: (id: string) => void;
clear: () => void;
}
let nextToastId = 0;
function push(toast: ToastInput): string {
const id = `toast-${nextToastId++}`;
const options = {
id,
...(toast.action ? { action: toast.action } : {}),
...(toast.tone === "error"
? { duration: Infinity }
: toast.autoDismissMs != null
? { duration: toast.autoDismissMs }
: {}),
};
if (toast.tone === "error") {
sonnerToast.error(toast.message, options);
} else {
sonnerToast(toast.message, options);
}
return id;
}
const toastApi: ToastContextValue = {
push,
dismiss: (id) => {
sonnerToast.dismiss(id);
},
clear: () => {
sonnerToast.dismiss();
},
};
/**
* No-op wrapper retained so existing test harnesses and the standalone terminal
* route can keep their <ToastProvider> mount points. In a browser the real
* <Toaster /> is mounted globally at the entry point; in non-DOM test
* environments we render an aria-live fallback that subscribes to the Sonner
* store so test assertions can read the toast text.
*/
export function ToastProvider({ children }: { children: ReactNode }) {
if (typeof document !== "undefined") {
return <>{children}</>;
}
return (
<>
{children}
<NonDomToastOutput />
</>
);
}
export function useToast(): ToastContextValue {
return toastApi;
}
/**
* App-themed Sonner host. Renders toasts on the Fabro dark panel surface with
* accent-colored type icons (coral errors, mint success, teal info, amber
* warning) instead of Sonner's default green/red palette. The `group`/`toaster`
* + `group`/`toast` class pairing lets the `group-[.toaster]:` and
* `group-[.toast]:` utilities out-specify Sonner's own `[data-sonner-toast]`
* defaults without `!important`.
*/
export function FabroToaster() {
return (
<SonnerToaster
theme="dark"
position="bottom-right"
className="toaster group"
closeButton
icons={{
success: <CheckCircleIcon className="size-4 shrink-0 fill-mint" />,
error: <XCircleIcon className="size-4 shrink-0 fill-coral" />,
info: <InformationCircleIcon className="size-4 shrink-0 fill-teal-500" />,
warning: (
<ExclamationTriangleIcon className="size-4 shrink-0 fill-amber" />
),
}}
toastOptions={{
classNames: {
toast:
"group toast font-sans text-sm group-[.toaster]:bg-panel group-[.toaster]:text-fg-2 group-[.toaster]:rounded-lg group-[.toaster]:border-line-strong group-[.toaster]:shadow-2xl group-[.toaster]:shadow-black/40",
title: "font-medium text-fg",
description: "group-[.toast]:text-fg-3",
closeButton:
"group-[.toast]:bg-panel group-[.toast]:border-line-strong group-[.toast]:text-fg-3 group-[.toast]:hover:bg-overlay group-[.toast]:hover:text-fg",
actionButton:
"group-[.toast]:rounded-md group-[.toast]:bg-teal-500 group-[.toast]:text-on-primary group-[.toast]:text-xs group-[.toast]:font-medium",
cancelButton:
"group-[.toast]:rounded-md group-[.toast]:bg-overlay group-[.toast]:text-fg-2 group-[.toast]:text-xs",
},
}}
/>
);
}
function NonDomToastOutput() {
const { toasts } = useSonner();
if (toasts.length === 0) return null;
return (
<output aria-live="polite">
{toasts.map((toast) => (
<p key={toast.id}>
{typeof toast.title === "function" ? toast.title() : toast.title}
</p>
))}
</output>
);
}