mega-plant/components/Toast.tsx
David b0b31ad830 Invoices: toast to the top, solid teal
The confirmation now arrives at the top of the screen instead of the bottom,
and is a solid teal panel rather than a white card with a green icon — at a
glance it reads as a system success message rather than a quiet note.

Teal rather than a literal green: #00798C is already this app's success
colour (the "All booked" badge, the income tiles, the import button), so a new
hue would have been off-palette. Solid fill with white text reads unambiguously
as confirmation, where the previous white card could be missed.

Slides down from above rather than up from below. Honours the notch on mobile
via env(safe-area-inset-top) and keeps a 44px dismiss target on the new fill.

The unused tone prop went with it — failures stay in the dialog, so the toast
is only ever a success.

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

69 lines
No EOL
2.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { CheckCircle2, X } from "lucide-react";
// A minimal toast for a confirmed import. Deliberately not a dependency: the app needs
// exactly one, and a full toast library would ship far more surface than that justifies.
//
// Solid teal rather than a new green: #00798C is already this app's success colour (the
// "All booked" badge, the income tiles, the import button), so it reads as success without
// introducing a hue outside the palette.
//
// 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,
onDismiss,
duration = 6000,
}: {
message: string;
detail?: string;
onDismiss: () => void;
duration?: number;
}) {
const [leaving, setLeaving] = useState(false);
const close = () => {
setLeaving(true);
setTimeout(onDismiss, 200); // let the transition 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={`pointer-events-none fixed z-[60] inset-x-3 sm:inset-x-auto sm:right-6 sm:w-auto sm:max-w-sm
pt-[max(0.75rem,env(safe-area-inset-top))] sm:pt-0 sm:top-6
transition-all duration-200 ease-out
${leaving ? "opacity-0 -translate-y-2" : "opacity-100 translate-y-0"}`}
>
<div
className={`pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-xl
bg-[#00798C] text-white shadow-xl shadow-teal-900/20 border border-white/15
${leaving ? "" : "ring-1 ring-[#00798C]/30"}`}
>
<CheckCircle2 className="w-5 h-5 text-white shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{message}</div>
{detail && <div className="text-sm text-white/85 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-white/80 hover:bg-white/15 hover:text-white transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}