Files
siftlode/frontend/src/components/Toaster.tsx
T
peter bd2d001774 fix(theme): R10 S1 follow-ups — dedicated info token + restore collapsed hovers
- Add --info/--info-fg (blue; dark #60a5fa / light #2563eb) so info notifications
  stop borrowing --accent, which collided with a status colour in schemes whose
  accent shares its hue (YouTube red = error, Slate orange = warn, Matrix/Forest
  green = success). Toaster info tone accent -> info.
- Restore hover feedback the codemod flattened: 'bg-X ... hover:bg-X' /
  'text-X ... hover:text-X' collapsed to a no-op once both shades mapped to one
  shadeless token. Reuse the house hover:opacity-{80,90} idiom (ConfirmProvider
  danger button, DownloadCenter error link).
2026-08-09 01:22:14 +02:00

80 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications";
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
info: { icon: Info, color: "text-info", bar: "bg-info" },
success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" },
warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" },
error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" },
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
};
export default function Toaster() {
const { t } = useTranslation();
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return (
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
// screen reader to announce content later inserted into it (a region created together with its
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
// additionally carry role="alert", which announces assertively on insertion regardless.
<div
aria-live="polite"
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
>
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
const urgent = toast.level === "error" || toast.level === "fatal";
return (
<div
key={toast.id}
role={urgent ? "alert" : undefined}
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm wrap-break-word">
{toast.message}
{toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
×{toast.repeat}
</span>
)}
</div>
{toast.action && (
<button
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title={t("notifications.dismiss")}
>
<X className="w-4 h-4" />
</button>
{toast.duration && (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${toast.duration}ms linear forwards` }}
/>
)}
</div>
);
})}
</div>
);
}