Break editor into a module
This commit is contained in:
82
src/components/Toast.jsx
Normal file
82
src/components/Toast.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user