mega-plant/components/Toast.tsx
David fe4b1fc3dd Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.

It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.

Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.

No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:46:55 +02:00

80 lines
No EOL
2.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { CheckCircle2, X, AlertTriangle } from "lucide-react";
// A minimal toast. Deliberately not a dependency: the app needs exactly one, and a
// full toast library would ship far more surface than that justifies.
//
// Announced politely rather than assertively — a confirmation is not urgent, and it
// should not interrupt whatever a screen reader is currently saying.
export function Toast({
message,
detail,
tone = "success",
onDismiss,
duration = 6000,
}: {
message: string;
detail?: string;
tone?: "success" | "error";
onDismiss: () => void;
duration?: number;
}) {
const [leaving, setLeaving] = useState(false);
const close = () => {
setLeaving(true);
setTimeout(onDismiss, 200); // let the fade-out run before unmounting
};
useEffect(() => {
const t = setTimeout(close, duration);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message, detail, duration]);
return (
<div
role="status"
aria-live="polite"
className={`fixed z-[60] inset-x-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:w-auto sm:max-w-sm
transition-all duration-200 ease-out
${leaving ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"}`}
>
<div
className={`flex items-start gap-3 px-4 py-3 rounded-xl shadow-xl border backdrop-blur ${
tone === "success"
? "bg-white dark:bg-[#131a21] border-[#00798C]/30"
: "bg-white dark:bg-[#131a21] border-[#D1495B]/30"
}`}
>
{tone === "success" ? (
<CheckCircle2 className="w-5 h-5 text-[#00798C] shrink-0 mt-0.5" />
) : (
<AlertTriangle className="w-5 h-5 text-[#D1495B] shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div
className={`text-sm font-semibold ${
tone === "success" ? "text-[#00798C]" : "text-[#D1495B]"
}`}
>
{message}
</div>
{detail && (
<div className="text-sm text-gray-600 dark:text-gray-300 mt-0.5 break-words">{detail}</div>
)}
</div>
<button
type="button"
onClick={close}
aria-label="Dismiss"
className="grid place-items-center w-11 h-11 -mr-2 -my-1 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}