Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

3.2 kB Ruw bestand
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '../api/client';
import { useI18n } from '../i18n';

export type ToastTone = 'info' | 'error' | 'success';

export interface Toast {
  id: number;
  tone: ToastTone;
  message: string;
  /** A single inline action, e.g. "Undo". */
  action?: { label: string; run: () => void };
}

interface ToastApi {
  toasts: Toast[];
  push(message: string, tone?: ToastTone, action?: Toast['action']): void;
  /** Report a failed operation in the user's terms. */
  fail(error: unknown, fallback?: string): void;
  dismiss(id: number): void;
}

const ToastContext = createContext<ToastApi | null>(null);

const LIFETIME: Record<ToastTone, number> = { info: 4000, success: 3000, error: 9000 };

export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const nextId = useRef(1);

  const dismiss = useCallback((id: number) => {
    setToasts((list) => list.filter((t) => t.id !== id));
  }, []);

  const push = useCallback<ToastApi['push']>(
    (message, tone = 'info', action) => {
      const id = nextId.current++;
      setToasts((list) => [...list.slice(-3), { id, tone, message, action }]);
      // A toast that asks for a decision must live long enough to make one.
      const lifetime = action ? Math.max(LIFETIME[tone], LIFETIME.error) : LIFETIME[tone];
      window.setTimeout(() => dismiss(id), lifetime);
    },
    [dismiss],
  );

  const fail = useCallback<ToastApi['fail']>(
    (error, fallback = 'That did not work.') => {
      // Server messages are written for people; prefer them to a generic line.
      const message =
        error instanceof ApiError
          ? error.message
          : error instanceof Error
            ? error.message
            : fallback;
      push(message, 'error');
    },
    [push],
  );

  const value = useMemo(() => ({ toasts, push, fail, dismiss }), [toasts, push, fail, dismiss]);

  return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
}

export function useToasts(): ToastApi {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error('useToasts must be used inside a ToastProvider.');
  return ctx;
}

export function Toasts(): ReactNode {
  const { toasts, dismiss } = useToasts();
  // Rendered inside the I18nProvider even though the ToastProvider sits above
  // it: the provider stores messages, this component is what speaks.
  const { t } = useI18n();
  if (!toasts.length) return null;

  return (
    <div className="toasts" role="status" aria-live="polite">
      {toasts.map((toast) => (
        <div className={`toast ${toast.tone}`} key={toast.id}>
          <span>{toast.message}</span>
          {toast.action && (
            <button
              onClick={() => {
                toast.action?.run();
                dismiss(toast.id);
              }}
            >
              <strong>{toast.action.label}</strong>
            </button>
          )}
          <button onClick={() => dismiss(toast.id)} aria-label={t('common.dismiss')}>
            ×
          </button>
        </div>
      ))}
    </div>
  );
}