deadhead-space-freight-tycoon/apps/web/src/components/Toasts.tsx

147 lines
4.8 KiB
TypeScript

import { useEffect, useRef, useState, type PointerEvent } from "react";
import { useNavigate } from "react-router";
import { clientConfig } from "../client-config";
import { reducedMotionOn, usePreferences } from "../state/preferences";
import { useToasts, type Toast } from "../state/toasts";
import { Icon } from "../ui";
const { durationMs, maxVisible, desktopMinWidthPx } = clientConfig.toasts;
/** A sideways drag this far dismisses a toast. */
const SWIPE_PX = 60;
/** The viewport's width, kept current on resize. */
function useViewportWidth(): number {
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
const TONES: Record<Toast["tone"], string> = {
good: "border-emerald-400/40 text-emerald-50",
info: "border-cyan-400/40 text-cyan-50",
warn: "border-amber-400/50 text-amber-50",
};
/**
* One toast: closes after 5 s, paused while hovered or touched; ✕ or a swipe
* closes it early; tapping opens its screen (decision 0.5.161).
*/
function ToastItem({
toast,
motion,
desktop,
}: {
toast: Toast;
motion: boolean;
desktop: boolean;
}) {
const dismiss = useToasts((s) => s.dismiss);
const navigate = useNavigate();
const [paused, setPaused] = useState(false);
const left = useRef<number>(durationMs);
const swipe = useRef<{ x: number; moved: boolean } | null>(null);
useEffect(() => {
if (paused) return;
const started = Date.now();
const timer = window.setTimeout(() => dismiss(toast.id), left.current);
return () => {
window.clearTimeout(timer);
left.current = Math.max(0, left.current - (Date.now() - started));
};
}, [paused, dismiss, toast.id]);
const onPointerDown = (e: PointerEvent) => {
swipe.current = { x: e.clientX, moved: false };
};
const onPointerUp = (e: PointerEvent) => {
const start = swipe.current;
if (start && Math.abs(e.clientX - start.x) >= SWIPE_PX) {
start.moved = true;
dismiss(toast.id);
}
};
const open = () => {
if (swipe.current?.moved) return;
dismiss(toast.id);
void navigate(toast.to);
};
return (
<li
data-toast
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => setPaused(false)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-space-900/95 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
>
<button type="button" onClick={open} className="min-w-0 flex-1 py-0.5 text-left">
{toast.text}
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(toast.id)}
className="grid size-7 shrink-0 place-items-center rounded-lg text-ink-400 hover:bg-space-700 hover:text-ink-100"
>
<Icon name="close" className="size-4" />
</button>
</li>
);
}
/**
* The toasts on screen (decision 0.5.161): top-right on desktop, top-center
* below the safe area on phones; the newest on top, at most 3, and the rest as
* "+N more", which opens the Logbook. Announced politely; no sliding with
* reduced motion.
*/
export function Toasts() {
const toasts = useToasts((s) => s.toasts);
const dropOverflow = useToasts((s) => s.dropOverflow);
const navigate = useNavigate();
const motion = !usePreferences(reducedMotionOn);
const desktop = useViewportWidth() >= desktopMinWidthPx;
const shown = toasts.slice(0, maxVisible);
const more = toasts.length - shown.length;
return (
<section
aria-label="Notifications"
aria-live="polite"
data-placement={desktop ? "top-right" : "top-center"}
data-motion={motion ? "slide" : "none"}
className={`pointer-events-none fixed z-[60] flex flex-col gap-2 ${
desktop
? "top-[calc(env(safe-area-inset-top)+4rem)] right-4 w-80"
: "inset-x-0 top-[calc(env(safe-area-inset-top)+4rem)] mx-auto w-[min(24rem,calc(100%-2rem))]"
}`}
>
<ul className="flex flex-col gap-2">
{shown.map((toast) => (
<ToastItem key={toast.id} toast={toast} motion={motion} desktop={desktop} />
))}
</ul>
{more > 0 && (
<button
type="button"
onClick={() => {
dropOverflow();
void navigate("/logbook");
}}
className="pointer-events-auto self-end rounded-full border border-space-600 bg-space-900/95 px-3 py-1 font-display text-xs tracking-wide text-ink-200 shadow-panel"
>
+{more} more
</button>
)}
</section>
);
}