Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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>
);
}