SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
2.8 KB · 67 lines tsx
Raw Blame History
1"use client";23import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";4import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";5import { cn } from "@/components/ui/cn";67export type ToastTone = "ok" | "warn" | "danger" | "info";8export interface ToastInput {9  title: string;10  description?: string;11  tone?: ToastTone;12  /** ms; default 4500, 0 = sticky */13  duration?: number;14}15interface ToastItem extends ToastInput {16  id: number;17}1819const Ctx = createContext<{ toast: (t: ToastInput) => void } | null>(null);2021export function useToast(): (t: ToastInput) => void {22  const ctx = useContext(Ctx);23  if (!ctx) throw new Error("useToast outside ToastProvider");24  return ctx.toast;25}2627const ICON: Record<ToastTone, typeof Info> = { ok: CheckCircle2, warn: AlertTriangle, danger: AlertTriangle, info: Info };28const TONE: Record<ToastTone, string> = { ok: "text-ok", warn: "text-warn", danger: "text-danger", info: "text-info" };2930export function ToastProvider({ children }: { children: ReactNode }) {31  const [items, setItems] = useState<ToastItem[]>([]);32  const seq = useRef(0);33  const dismiss = useCallback((id: number) => setItems((p) => p.filter((t) => t.id !== id)), []);34  const toast = useCallback(35    (t: ToastInput) => {36      const id = ++seq.current;37      setItems((p) => [...p.slice(-4), { ...t, id }]);38      const ms = t.duration ?? (t.tone === "danger" ? 7000 : 4500);39      if (ms > 0) setTimeout(() => dismiss(id), ms);40    },41    [dismiss],42  );43  const value = useMemo(() => ({ toast }), [toast]);44  return (45    <Ctx.Provider value={value}>46      {children}47      <div aria-live="polite" className="pointer-events-none fixed inset-x-3 bottom-3 z-[120] flex flex-col items-center gap-2 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:items-end">48        {items.map((t) => {49          const Icon = ICON[t.tone ?? "info"];50          return (51            <div key={t.id} role="status" className="pointer-events-auto flex w-full max-w-[420px] items-start gap-2.5 rounded-sm border border-hair-2 bg-surface px-3 py-2.5 text-[13px] text-ink animate-fade-in">52              <Icon className={cn("mt-0.5 size-4 shrink-0", TONE[t.tone ?? "info"])} strokeWidth={1.75} />53              <div className="min-w-0 flex-1">54                <div className="font-medium leading-snug">{t.title}</div>55                {t.description && <div className="mt-0.5 break-words text-[12px] leading-snug text-ink-3">{t.description}</div>}56              </div>57              <button type="button" onClick={() => dismiss(t.id)} aria-label="Dismiss" className="-mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-ink">58                <X className="size-3.5" />59              </button>60            </div>61          );62        })}63      </div>64    </Ctx.Provider>65  );66}67