zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, useState, useEffect } from "zeb/react";

/**
 * Toaster — shadcn's sonner wrapper, reimplemented from scratch: the engine
 * has no npm, so there is no `sonner` package underneath this one. A
 * `toast()` call and the mounted `Toaster` never share a parent, so they
 * talk through a module-level subscriber list instead of props or context —
 * `toast()` pushes into a shared queue and notifies every mounted `Toaster`;
 * `Toaster` subscribes to it in an effect, replaying whatever is already in
 * the queue at that moment (a `toast()` fired before the effect runs — from
 * another component's own mount effect, say — used to be lost until the
 * next push), and renders whatever the queue holds. Mount exactly one
 * `Toaster` per page (usually near the root) — mounting a second one just
 * means both render the same queue.
 *
 * `toast()` is a no-op when there is no `document` — the queue and its
 * timers are module-level state, so mutating them during SSR would leak
 * across requests in a reused V8 worker isolate. Error toasts announce with
 * `aria-live="assertive"`; every other type uses `polite`.
 */

let queue = [];
let nextId = 1;
let listeners = [];

function notify() {
  const snapshot = queue.slice();
  listeners.forEach((fn) => fn(snapshot));
}

function push(message, options) {
  if (typeof document === "undefined") return null;
  const opts = options || {};
  const item = {
    id: nextId++,
    type: opts.type || "default",
    title: typeof message === "string" ? message : opts.title,
    description: opts.description,
    duration: opts.duration == null ? 4000 : opts.duration,
  };
  queue = queue.concat([item]);
  notify();
  if (item.duration > 0) {
    setTimeout(() => dismiss(item.id), item.duration);
  }
  return item.id;
}

function dismiss(id) {
  queue = queue.filter((item) => item.id !== id);
  notify();
}

export function toast(message, options) {
  return push(message, options);
}
toast.success = (message, options) => push(message, { ...options, type: "success" });
toast.error = (message, options) => push(message, { ...options, type: "error" });
toast.warning = (message, options) => push(message, { ...options, type: "warning" });
toast.info = (message, options) => push(message, { ...options, type: "info" });
toast.dismiss = dismiss;

const TYPE_ACCENT = {
  default: "border-border",
  success: "border-success",
  error: "border-destructive",
  warning: "border-warning",
  info: "border-info",
};

const TYPE_ICON = {
  success: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4 shrink-0 text-success">
      <path d="M20 6 9 17l-5-5" />
    </svg>
  ),
  error: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4 shrink-0 text-destructive">
      <circle cx="12" cy="12" r="10" />
      <path d="m15 9-6 6M9 9l6 6" />
    </svg>
  ),
  warning: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4 shrink-0 text-warning">
      <path d="M12 9v4M12 17h.01" />
      <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
    </svg>
  ),
  info: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4 shrink-0 text-info">
      <circle cx="12" cy="12" r="10" />
      <path d="M12 16v-4M12 8h.01" />
    </svg>
  ),
};

export function Toaster({ className }) {
  const [toasts, setToasts] = useState([]);

  useEffect(() => {
    setToasts(queue.slice());
    listeners = listeners.concat([setToasts]);
    return () => {
      listeners = listeners.filter((fn) => fn !== setToasts);
    };
  }, []);

  if (toasts.length === 0) return null;

  return (
    <div data-slot="toaster" className={cx("fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2", className)}>
      {toasts.map((item) => (
        <div
          key={item.id}
          role="status"
          aria-live={item.type === "error" ? "assertive" : "polite"}
          aria-atomic="true"
          data-slot="toast"
          className={cx(
            "flex items-start gap-3 rounded-lg border bg-popover p-4 text-sm text-popover-foreground shadow-lg",
            TYPE_ACCENT[item.type] || TYPE_ACCENT.default
          )}
        >
          {TYPE_ICON[item.type] || null}
          <div className="flex-1">
            {item.title ? <p className="font-medium">{item.title}</p> : null}
            {item.description ? <p className="text-muted-foreground">{item.description}</p> : null}
          </div>
          <button
            type="button"
            aria-label="Dismiss"
            onClick={() => dismiss(item.id)}
            className="shrink-0 text-muted-foreground hover:text-foreground"
          >
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4">
              <path d="M18 6 6 18" />
              <path d="m6 6 12 12" />
            </svg>
          </button>
        </div>
      ))}
    </div>
  );
}

export default Toaster;