83 lines
3.3 KiB
JavaScript
83 lines
3.3 KiB
JavaScript
import { t } from '../i18n/t';
|
|
import '../styles/Toast.css';
|
|
|
|
/**
|
|
* Toast — host-level notification surface.
|
|
*
|
|
* Why this exists
|
|
* ───────────────
|
|
* The host occasionally needs to tell the user something: "link
|
|
* copied", "upload failed", "add something to your design first."
|
|
* Before the module split there was an in-App toast system that did
|
|
* this; it got stripped along with the editor surface during the
|
|
* split, and host operations have been silently throwing or
|
|
* console.logging ever since. This restores the surface but scopes
|
|
* it strictly to host concerns \u2014 the editor module has its own
|
|
* internal toast catalog for editor concerns (crop blocked, etc.)
|
|
* and that is deliberately not shared.
|
|
*
|
|
* Architecture
|
|
* ────────────
|
|
* State + scheduling live in App.jsx (showToast / dismissToast); this
|
|
* file is just the renderer. App owns the state because:
|
|
* \u2022 Multiple host components could need to toast eventually
|
|
* (currently only App's own handlers do).
|
|
* \u2022 Lifting matches the existing host pattern (cart, isExporting,
|
|
* etc. all live in App) and avoids introducing a Context for a
|
|
* surface this small.
|
|
* \u2022 Timer lifecycle is co-located with the state it modifies,
|
|
* which keeps the cancel-on-unmount + re-schedule-cancels-prior
|
|
* semantics straightforward.
|
|
*
|
|
* If the toast surface grows (more callers, error boundaries that
|
|
* need to surface from anywhere in the tree), refactor to a
|
|
* ToastContext + useToast() hook. For now, prop-passing the
|
|
* showToast callback to anything outside App that needs it is the
|
|
* simpler path.
|
|
*
|
|
* Toast shape
|
|
* ───────────
|
|
* { message: string, kind: 'info' | 'success' | 'error', id: number }
|
|
*
|
|
* The `id` field is a freshly-stamped Date.now() per call \u2014 used as
|
|
* a React key so that a new toast retriggers the fade-in animation
|
|
* even when the previous toast is still on screen.
|
|
*
|
|
* Rendering null
|
|
* ──────────────
|
|
* Renders null when toast is null so the consumer can always include
|
|
* <Toast toast={toast} onDismiss={dismissToast} /> in their JSX
|
|
* without an outer conditional. Simpler caller, same DOM.
|
|
*/
|
|
export function Toast({ toast, onDismiss }) {
|
|
if (!toast) return null;
|
|
|
|
return (
|
|
// Wrap exists so the inner pill can use a fade-in animation that
|
|
// includes a horizontal translateX(-50%) for centering \u2014 if the
|
|
// animation were on the pill itself, the centering math would
|
|
// collide with the keyframe transforms. The wrap holds the
|
|
// positioning, the pill holds the visual chrome.
|
|
<div className="app-toast-wrap" key={toast.id}>
|
|
<div
|
|
className={`app-toast app-toast--${toast.kind}`}
|
|
role="status"
|
|
// 'polite' so screen readers announce the message after the
|
|
// current utterance finishes \u2014 'assertive' would interrupt,
|
|
// which is too aggressive for "link copied" type messages.
|
|
aria-live="polite"
|
|
>
|
|
<span className="app-toast__message">{toast.message}</span>
|
|
<button
|
|
type="button"
|
|
className="app-toast__close"
|
|
onClick={onDismiss}
|
|
aria-label={t('toast.dismiss')}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|