< Summary

Information
Class: toasts.tsx
Assembly: app.lib
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/lib/toasts.tsx
Tag: 58_25416222083
Line coverage
96%
Covered lines: 63
Uncovered lines: 2
Coverable lines: 65
Total lines: 217
Line coverage: 96.9%
Branch coverage
73%
Covered branches: 19
Total branches: 26
Branch coverage: 73%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/lib/toasts.tsx

#LineLine coverage
 1import {
 2  createContext,
 3  useCallback,
 4  useContext,
 5  useEffect,
 6  useMemo,
 7  useRef,
 8  useState,
 9} from "react";
 10
 11export type ToastKind = "success" | "error" | "info";
 12
 13export interface ToastInput {
 14  kind: ToastKind;
 15  message: string;
 16  title?: string;
 17  ttlMs?: number;
 18}
 19
 20export interface Toast extends ToastInput {
 21  id: string;
 22}
 23
 24interface ToastContextValue {
 25  toasts: readonly Toast[];
 26  push: (input: ToastInput) => string;
 27  dismiss: (id: string) => void;
 28}
 29
 130const DEFAULT_TTL_MS = 5000;
 31
 132const ToastContext = createContext<ToastContextValue | undefined>(undefined);
 33
 134let nextId = 0;
 735function makeId(): string {
 736  nextId += 1;
 737  return `t-${Date.now().toString(36)}-${nextId}`;
 38}
 39
 2040export function ToastProvider({
 41  children,
 42}: {
 43  readonly children: React.ReactNode;
 44}) {
 2045  const [toasts, setToasts] = useState<readonly Toast[]>([]);
 46
 2047  const dismiss = useCallback((id: string) => {
 248    setToasts((prev) => prev.filter((t) => t.id !== id));
 49  }, []);
 50
 2051  const push = useCallback((input: ToastInput): string => {
 752    const id = makeId();
 753    setToasts((prev) => [...prev, { ...input, id }]);
 754    return id;
 55  }, []);
 56
 2057  const value = useMemo<ToastContextValue>(
 1758    () => ({ toasts, push, dismiss }),
 59    [toasts, push, dismiss],
 60  );
 61
 2062  return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
 63}
 64
 65/**
 66 * Drains server-side flash toasts (from the root loader) onto the client
 67 * toast queue. Lives inside the provider, watches an array of flashes that
 68 * may change on every navigation, and uses array identity to detect a fresh
 69 * batch. Render this in the layout — once.
 70 */
 771export function FlashToasts({
 72  flashes,
 73}: {
 74  readonly flashes: readonly ToastInput[];
 75}) {
 776  const { push } = useToasts();
 777  const lastSeen = useRef<readonly ToastInput[] | null>(null);
 778  useEffect(() => {
 379    if (flashes === lastSeen.current) return;
 380    lastSeen.current = flashes;
 381    for (const f of flashes) push(f);
 82  }, [flashes, push]);
 783  return null;
 84}
 85
 86// eslint-disable-next-line react-refresh/only-export-components
 5087export function useToasts(): ToastContextValue {
 5088  const ctx = useContext(ToastContext);
 5089  if (!ctx) throw new Error("useToasts must be used inside <ToastProvider>");
 5090  return ctx;
 91}
 92
 93/**
 94 * When a route action returns `{ ok: false, error }` (the in-page validation
 95 * shape from action-helpers.server), push it as an error toast — once per
 96 * actionData identity so re-renders don't spam.
 97 */
 98// eslint-disable-next-line react-refresh/only-export-components
 699export function useToastFromActionData(actionData: unknown): void {
 6100  const { push } = useToasts();
 6101  const lastSeen = useRef<unknown>(null);
 6102  useEffect(() => {
 3103    if (actionData == null) {
 0104      lastSeen.current = null;
 0105      return;
 106    }
 3107    if (actionData === lastSeen.current) return;
 3108    lastSeen.current = actionData;
 3109    if (typeof actionData === "object" && "error" in actionData) {
 2110      const error = (actionData as { error?: unknown }).error;
 2111      if (typeof error === "string" && error.length > 0) {
 2112        push({ kind: "error", message: error });
 113      }
 114    }
 115  }, [actionData, push]);
 116}
 117
 11118function ToastItem({ toast }: { readonly toast: Toast }) {
 11119  const { dismiss } = useToasts();
 11120  const ttl = toast.ttlMs ?? DEFAULT_TTL_MS;
 11121  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
 122
 11123  const start = useCallback(() => {
 7124    if (timer.current) clearTimeout(timer.current);
 7125    timer.current = setTimeout(() => dismiss(toast.id), ttl);
 126  }, [dismiss, toast.id, ttl]);
 127
 11128  const stop = useCallback(() => {
 7129    if (timer.current) {
 7130      clearTimeout(timer.current);
 7131      timer.current = null;
 132    }
 133  }, []);
 134
 11135  useEffect(() => {
 7136    start();
 7137    return stop;
 138  }, [start, stop]);
 139
 140  const accent =
 11141    toast.kind === "success"
 142      ? "var(--c-accent)"
 143      : toast.kind === "error"
 144        ? "var(--c-danger)"
 145        : "var(--c-fg-2)";
 146
 11147  return (
 148    <div
 149      role={toast.kind === "error" ? "alert" : "status"}
 150      aria-live={toast.kind === "error" ? "assertive" : "polite"}
 151      onMouseEnter={stop}
 152      onMouseLeave={start}
 153      style={{
 154        background: "var(--c-bg-2)",
 155        color: "var(--c-fg)",
 156        border: "1px solid var(--c-border)",
 157        borderLeft: `3px solid ${accent}`,
 158        padding: "0.625rem 0.75rem",
 159        minWidth: "16rem",
 160        maxWidth: "24rem",
 161        display: "flex",
 162        gap: "0.5rem",
 163        alignItems: "flex-start",
 164        boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
 165      }}
 166    >
 167      <div style={{ flex: 1, minWidth: 0 }}>
 168        {toast.title && (
 169          <div style={{ fontWeight: 600, marginBottom: "0.125rem" }}>{toast.title}</div>
 170        )}
 171        <div style={{ wordWrap: "break-word" }}>{toast.message}</div>
 172      </div>
 173      <button
 174        type="button"
 1175        onClick={() => dismiss(toast.id)}
 176        aria-label="Dismiss"
 177        style={{
 178          background: "transparent",
 179          border: 0,
 180          color: "var(--c-fg-3)",
 181          cursor: "pointer",
 182          padding: "0 0.25rem",
 183          fontSize: "1rem",
 184          lineHeight: 1,
 185        }}
 186      >
 187        ×
 188      </button>
 189    </div>
 190  );
 191}
 192
 20193export function Toaster() {
 20194  const { toasts } = useToasts();
 20195  if (toasts.length === 0) return null;
 10196  return (
 197    <div
 198      // Fixed bottom-right viewport. Inherits the active data-theme via CSS vars.
 199      style={{
 200        position: "fixed",
 201        bottom: "1rem",
 202        right: "1rem",
 203        display: "flex",
 204        flexDirection: "column",
 205        gap: "0.5rem",
 206        zIndex: 1000,
 207        pointerEvents: "none",
 208      }}
 209    >
 11210      {toasts.map((t) => (
 11211        <div key={t.id} style={{ pointerEvents: "auto" }}>
 212          <ToastItem toast={t} />
 213        </div>
 214      ))}
 215    </div>
 216  );
 217}