| | | 1 | | import { |
| | | 2 | | createContext, |
| | | 3 | | useCallback, |
| | | 4 | | useContext, |
| | | 5 | | useEffect, |
| | | 6 | | useMemo, |
| | | 7 | | useRef, |
| | | 8 | | useState, |
| | | 9 | | } from "react"; |
| | | 10 | | |
| | | 11 | | export type ToastKind = "success" | "error" | "info"; |
| | | 12 | | |
| | | 13 | | export interface ToastInput { |
| | | 14 | | kind: ToastKind; |
| | | 15 | | message: string; |
| | | 16 | | title?: string; |
| | | 17 | | ttlMs?: number; |
| | | 18 | | } |
| | | 19 | | |
| | | 20 | | export interface Toast extends ToastInput { |
| | | 21 | | id: string; |
| | | 22 | | } |
| | | 23 | | |
| | | 24 | | interface ToastContextValue { |
| | | 25 | | toasts: readonly Toast[]; |
| | | 26 | | push: (input: ToastInput) => string; |
| | | 27 | | dismiss: (id: string) => void; |
| | | 28 | | } |
| | | 29 | | |
| | 1 | 30 | | const DEFAULT_TTL_MS = 5000; |
| | | 31 | | |
| | 1 | 32 | | const ToastContext = createContext<ToastContextValue | undefined>(undefined); |
| | | 33 | | |
| | 1 | 34 | | let nextId = 0; |
| | 7 | 35 | | function makeId(): string { |
| | 7 | 36 | | nextId += 1; |
| | 7 | 37 | | return `t-${Date.now().toString(36)}-${nextId}`; |
| | | 38 | | } |
| | | 39 | | |
| | 20 | 40 | | export function ToastProvider({ |
| | | 41 | | children, |
| | | 42 | | }: { |
| | | 43 | | readonly children: React.ReactNode; |
| | | 44 | | }) { |
| | 20 | 45 | | const [toasts, setToasts] = useState<readonly Toast[]>([]); |
| | | 46 | | |
| | 20 | 47 | | const dismiss = useCallback((id: string) => { |
| | 2 | 48 | | setToasts((prev) => prev.filter((t) => t.id !== id)); |
| | | 49 | | }, []); |
| | | 50 | | |
| | 20 | 51 | | const push = useCallback((input: ToastInput): string => { |
| | 7 | 52 | | const id = makeId(); |
| | 7 | 53 | | setToasts((prev) => [...prev, { ...input, id }]); |
| | 7 | 54 | | return id; |
| | | 55 | | }, []); |
| | | 56 | | |
| | 20 | 57 | | const value = useMemo<ToastContextValue>( |
| | 17 | 58 | | () => ({ toasts, push, dismiss }), |
| | | 59 | | [toasts, push, dismiss], |
| | | 60 | | ); |
| | | 61 | | |
| | 20 | 62 | | 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 | | */ |
| | 7 | 71 | | export function FlashToasts({ |
| | | 72 | | flashes, |
| | | 73 | | }: { |
| | | 74 | | readonly flashes: readonly ToastInput[]; |
| | | 75 | | }) { |
| | 7 | 76 | | const { push } = useToasts(); |
| | 7 | 77 | | const lastSeen = useRef<readonly ToastInput[] | null>(null); |
| | 7 | 78 | | useEffect(() => { |
| | 3 | 79 | | if (flashes === lastSeen.current) return; |
| | 3 | 80 | | lastSeen.current = flashes; |
| | 3 | 81 | | for (const f of flashes) push(f); |
| | | 82 | | }, [flashes, push]); |
| | 7 | 83 | | return null; |
| | | 84 | | } |
| | | 85 | | |
| | | 86 | | // eslint-disable-next-line react-refresh/only-export-components |
| | 50 | 87 | | export function useToasts(): ToastContextValue { |
| | 50 | 88 | | const ctx = useContext(ToastContext); |
| | 50 | 89 | | if (!ctx) throw new Error("useToasts must be used inside <ToastProvider>"); |
| | 50 | 90 | | 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 |
| | 6 | 99 | | export function useToastFromActionData(actionData: unknown): void { |
| | 6 | 100 | | const { push } = useToasts(); |
| | 6 | 101 | | const lastSeen = useRef<unknown>(null); |
| | 6 | 102 | | useEffect(() => { |
| | 3 | 103 | | if (actionData == null) { |
| | 0 | 104 | | lastSeen.current = null; |
| | 0 | 105 | | return; |
| | | 106 | | } |
| | 3 | 107 | | if (actionData === lastSeen.current) return; |
| | 3 | 108 | | lastSeen.current = actionData; |
| | 3 | 109 | | if (typeof actionData === "object" && "error" in actionData) { |
| | 2 | 110 | | const error = (actionData as { error?: unknown }).error; |
| | 2 | 111 | | if (typeof error === "string" && error.length > 0) { |
| | 2 | 112 | | push({ kind: "error", message: error }); |
| | | 113 | | } |
| | | 114 | | } |
| | | 115 | | }, [actionData, push]); |
| | | 116 | | } |
| | | 117 | | |
| | 11 | 118 | | function ToastItem({ toast }: { readonly toast: Toast }) { |
| | 11 | 119 | | const { dismiss } = useToasts(); |
| | 11 | 120 | | const ttl = toast.ttlMs ?? DEFAULT_TTL_MS; |
| | 11 | 121 | | const timer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| | | 122 | | |
| | 11 | 123 | | const start = useCallback(() => { |
| | 7 | 124 | | if (timer.current) clearTimeout(timer.current); |
| | 7 | 125 | | timer.current = setTimeout(() => dismiss(toast.id), ttl); |
| | | 126 | | }, [dismiss, toast.id, ttl]); |
| | | 127 | | |
| | 11 | 128 | | const stop = useCallback(() => { |
| | 7 | 129 | | if (timer.current) { |
| | 7 | 130 | | clearTimeout(timer.current); |
| | 7 | 131 | | timer.current = null; |
| | | 132 | | } |
| | | 133 | | }, []); |
| | | 134 | | |
| | 11 | 135 | | useEffect(() => { |
| | 7 | 136 | | start(); |
| | 7 | 137 | | return stop; |
| | | 138 | | }, [start, stop]); |
| | | 139 | | |
| | | 140 | | const accent = |
| | 11 | 141 | | toast.kind === "success" |
| | | 142 | | ? "var(--c-accent)" |
| | | 143 | | : toast.kind === "error" |
| | | 144 | | ? "var(--c-danger)" |
| | | 145 | | : "var(--c-fg-2)"; |
| | | 146 | | |
| | 11 | 147 | | 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" |
| | 1 | 175 | | 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 | | |
| | 20 | 193 | | export function Toaster() { |
| | 20 | 194 | | const { toasts } = useToasts(); |
| | 20 | 195 | | if (toasts.length === 0) return null; |
| | 10 | 196 | | 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 | | > |
| | 11 | 210 | | {toasts.map((t) => ( |
| | 11 | 211 | | <div key={t.id} style={{ pointerEvents: "auto" }}> |
| | | 212 | | <ToastItem toast={t} /> |
| | | 213 | | </div> |
| | | 214 | | ))} |
| | | 215 | | </div> |
| | | 216 | | ); |
| | | 217 | | } |