Files
apparel-designer/src/components/Toast.jsx
2026-05-24 08:44:29 -05:00

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>
);
}