dorfteich/apps/web/src/components/Toast.tsx
Claude Fable 5 31b59f0fb6 #170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung
Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer
kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter
Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die
Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4).
prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die
Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren
(WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst
KEIN zusätzliches role=status (legal.spec-Locator-Falle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:39:03 +02:00

72 lines
2.4 KiB
TypeScript

import { createContext, useCallback, useContext, useRef, useState } from 'react';
/**
* App-wide toast notifications (issue #130). One provider near the root owns
* the stack; `useToast()` hands out a fire-and-forget `showToast`. Toasts are
* announced politely (`aria-live` on the stack container), stack bottom
* center above the footer, and dismiss themselves — clicking one dismisses
* it early. The plugin host's `ui.toast` capability feeds the same stack.
*/
export type ToastVariant = 'success' | 'error';
export type ShowToast = (message: string, variant?: ToastVariant) => void;
interface ToastItem {
id: number;
message: string;
variant: ToastVariant;
}
// 6 s statt 2,5 s (issue #170, WCAG 2.2.1): kurzlebige Statusmeldungen
// waren für Screenreader-/Vergrößerungs-Nutzer kaum erfassbar.
const TOAST_DURATION_MS = 6000;
const ToastContext = createContext<ShowToast>(() => {});
export function useToast(): ShowToast {
return useContext(ToastContext);
}
export function ToastProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(0);
const dismiss = useCallback((id: number): void => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback<ShowToast>(
(message, variant = 'success') => {
nextId.current += 1;
const id = nextId.current;
setToasts((prev) => [...prev, { id, message, variant }]);
window.setTimeout(() => dismiss(id), TOAST_DURATION_MS);
},
[dismiss],
);
return (
<ToastContext.Provider value={showToast}>
{children}
{/* The live region exists permanently, so screen readers pick up
toasts added to it; individual toasts must not carry their own
role="status" (a region appearing WITH its content isn't read).
Deliberately aria-live WITHOUT role="status": the region is
global and always mounted, and a second status role broke every
page-scoped getByRole('status') locator (legal.spec, CI). */}
<div className="toast-stack" aria-live="polite">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast toast--${toast.variant}`}
onClick={() => dismiss(toast.id)}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}