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
72 lines
2.4 KiB
TypeScript
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>
|
|
);
|
|
}
|