shadimage

Toast

registry/pouf/Toast.tsx

import { useEffect } from 'react'
import clsx from 'clsx'
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
import type { ReactNode } from 'react'
import { Icon } from './Icon'

/** How long a routine alert stays. Long enough to read one line without hurrying;
 *  NN/g's guidance on transient messages puts the floor around 5s, and every alert
 *  here also survives in the panel, so erring short costs nothing. */
const AUTO_DISMISS_MS = 6_000

export type ToastSeverity = 'critical' | 'info'

/** Render `*bold*` markers as emphasis.
 *
 * When the same message string is reused across surfaces it may carry lightweight
 * markdown. Rendering it raw would put literal asterisks on screen ("*Saved*");
 * re-writing every call site to strip them would fork the message per surface. Splitting
 * on the delimiter keeps one string and no HTML injection: the parts are React children,
 * never innerHTML.
 */
function emphasise(text: string): ReactNode[] {
  return text.split(/\*([^*]+)\*/g).map((part, i) =>
    // Odd indices are the captured groups — i.e. what was inside the asterisks.
    i % 2 === 1 ? <strong key={i}>{part}</strong> : <span key={i}>{part}</span>,
  )
}

interface ToastProps {
  id: number
  severity: ToastSeverity
  children: ReactNode
  /** MUST be referentially stable — see the timer note in Toast. Pass a store action
   *  or a useCallback, never an inline arrow. */
  onDismiss: (id: number) => void
}

/**
 * One alert.
 *
 * Critical toasts do NOT auto-dismiss. That is the entire point of the severity split:
 * these are the `force: true` alerts — payment declined, upload failed, a job that died
 * halfway — and a message that fades after six seconds can be missed by someone who
 * looked away, which is exactly the failure `force` was invented to prevent. Routine
 * alerts fade, because a "saved" you missed is still true and costs nothing.
 */
/* Shared with toaster.tsx's programmatic toasts: comes in from the side, tiny,
 * and fades up to full size on a gentle spring, collapsing to a plain fade under
 * prefers-reduced-motion. Tuned in docs/toast-anim-playground.html. */
function toastMotion(reduce: boolean) {
  if (reduce) {
    return {
      initial: { opacity: 0 },
      animate: { opacity: 1 },
      exit: { opacity: 0 },
      transition: { duration: 0.12 },
    }
  }
  return {
    initial: { opacity: 0, x: 140, scale: 0.7 },
    animate: { opacity: 1, x: 0, scale: 1 },
    exit: { opacity: 0, x: 100, scale: 0.8, transition: { duration: 0.14, ease: 'easeIn' as const } },
    transition: { type: 'spring' as const, stiffness: 280, damping: 26, mass: 1 },
  }
}

export function Toast({ id, severity, children, onDismiss }: ToastProps) {
  const critical = severity === 'critical'
  const reduce = useReducedMotion() ?? false

  // Every dep here MUST be referentially stable, or this timer never fires.
  //
  // This effect re-runs whenever a dep changes, and re-running clears the pending
  // timeout and starts a new one. `onDismiss` used to be an inline arrow built fresh on
  // each render of the viewport — and if the app polls on an interval (say every 5s), it
  // re-renders App and therefore the viewport. A 6s timer reset every 5s never reaches
  // 6s: routine toasts hung around forever, and only a real browser with a real clock
  // showed it. That is why onDismiss takes an id instead of closing over one.
  useEffect(() => {
    if (critical) return // sticky by design — see above
    const t = setTimeout(() => onDismiss(id), AUTO_DISMISS_MS)
    return () => clearTimeout(t)
  }, [critical, id, onDismiss])

  return (
    <motion.div
      className={clsx('pouf-toast', critical ? 'tone-down' : 'tone-info')}
      role={critical ? 'alert' : 'status'}
      aria-live={critical ? 'assertive' : 'polite'}
      layout="position"
      {...toastMotion(reduce)}
    >
      <div className="pouf-toast__icon">
        <Icon name={critical ? 'warn' : 'ok'} size="sm" />
      </div>
      <div className="pouf-toast__body">{children}</div>
      <button type="button" className="pouf-toast__close" onClick={() => onDismiss(id)} aria-label="Dismiss notification">
        <Icon name="close" size="sm" />
      </button>
    </motion.div>
  )
}

export interface ToastItem {
  id: number
  severity: ToastSeverity
  text: string
}

/** Bare items — the shell owns the single .pouf-toasts stack and mounts this
 * inside it, beside the programmatic Toaster. Two components each bringing
 * their own fixed stack meant two overlapping stacks at the same corner. */
export function ToastViewport({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss: (id: number) => void }) {
  if (toasts.length === 0) return null
  return (
    <AnimatePresence mode="sync">
      {toasts.map((t) => (
        <Toast key={t.id} id={t.id} severity={t.severity} onDismiss={onDismiss}>
          {emphasise(t.text)}
        </Toast>
      ))}
    </AnimatePresence>
  )
}

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · medium

animation
framer-motion