147 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|