import { type CSSProperties, type ReactNode, useEffect } from 'react'; import type { ExternalToast } from 'sonner'; import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'; import { useBreakpointAtLeast } from '../../../hooks/use-breakpoint'; import { ThemedIcon } from '../../icon/themed-icon'; import { GlassToast } from './glass-toast'; import { TOAST_STATUS_ICON } from './toast-icons'; import { TOAST_SIZE, TOAST_SIZE_DEFAULT } from './toast-scale'; import type { ToastApi, ToasterProps, ToastOptions, ToastPosition, ToastVariant } from './toast-types'; import { TOAST_FILL_TOKEN, TOAST_FOREGROUND_TOKEN } from './toast-variants'; /** * The web twin of the `Toaster` — a thin adapter over Sonner. * * `rn-motion-ui/toaster` resolves to this module on web (and to `./toaster.native` * on native), so a consumer writes the same `` + `toast()` call on every * platform. The adapter's job is to reconcile Sonner's API with the shared * contract in {@link ./toast-types}: * * - `position: 'top' | 'bottom'` maps to Sonner's centred corners — the default is * `top` on web (bottom on native) * - `duration: 0` (sticky) maps to Sonner's `Infinity` * - `variant` fills the pill with the matching Button colour (see * {@link ./toast-variants}) — the same palette the native twin uses * - `action.onPress` maps to Sonner's `action.onClick` * - `onClose` follows Sonner's dismissal callbacks * - `glass` uses the same Surface blur, rim, and compact shadow as native; * `glassTone="neutral"` uses a neutral surface and high-contrast status glyph * * Sonner's surface/foreground colours are re-pointed at the repo's theme tokens * (see {@link THEME_VARS}) and its border is dropped, so the web toast follows the * active theme and any consumer `@theme` overrides instead of Sonner's hardcoded * palette. * * The `testID` prop is ignored here — Sonner owns the DOM on web. */ const SONNER_POSITION: Record = { top: 'top-center', bottom: 'bottom-center', }; /** * Corner radius (px) a pill toast uses — large enough that the browser clamps it * to half the toast's height, i.e. a capsule (matching the native `Surface`). */ const PILL_RADIUS = 9999; /** * Sonner's default CSS adds margins to the toast icon — `[data-icon]` gets a * `-3px`/`4px` start/end margin and its inner `svg` a further `-1px`/`0px` — * which nudges the icon off the toast's padding edge and stacks 4px of extra * space on top of the toast `gap`. Drop those margins so `gap` is the single * source of icon↔text spacing, and re-point the `[data-icon]` box from Sonner's * fixed 16px to hug its glyph instead, so the per-toast icon size (see * {@link TOAST_SIZE}) drives the layout box. Each rule matches Sonner's selector * specificity, so the injected rule — rendered later in the body — wins at equal * specificity. */ const ICON_OVERRIDE_CSS = `[data-sonner-toast][data-styled='true'] [data-icon]{width:auto;height:auto;margin-left:0;margin-right:0} [data-sonner-toast][data-styled='true'] [data-icon] svg{margin-left:0;margin-right:0} [data-sonner-toast][data-styled='true'] [data-title]{font-weight:600;line-height:inherit} [data-sonner-toast][data-styled='true'] [data-description]{line-height:inherit} [data-sonner-toast][data-styled='true'] [data-content]{gap:0} [data-sonner-toast].rn-glass-toast{transition:transform 220ms ease-out,opacity 180ms ease-out,height 220ms ease-out} [data-sonner-toast].rn-glass-toast[data-mounted='false']{transform:translateY(calc(var(--lift) * -12px))} [data-sonner-toast].rn-glass-toast[data-removed='true'][data-front='true'][data-swipe-out='false']{transform:translateY(calc(var(--lift) * -12px))} @media(prefers-reduced-motion:reduce){[data-sonner-toast].rn-glass-toast{transition:none!important;transform:none!important}}`; /** * Sonner's theme vars re-pointed at the repo's semantic tokens, so the toast's * surface/foreground colours adapt to the theme and its border is dropped * (Sonner draws a 1px border with `--normal-border`, so it is keyed to * `transparent`). Custom CSS properties aren't part of `CSSProperties`, hence * the cast. */ // biome-ignore lint/plugin: ts/no-as-cast — CSS custom properties (`--normal-bg`, …) aren't part of the closed `CSSProperties` index const THEME_VARS = { '--normal-bg': 'var(--color-surface-3)', '--normal-border': 'transparent', '--normal-text': 'var(--color-foreground)', } as CSSProperties; /** * Shrink the web toast to its content instead of Sonner's fixed 356px box, and * re-centre it (Sonner centres by filling a fixed-width column, so a narrower * toast would otherwise hug the left edge). `maxWidth` keeps a long message * wrapping at a readable measure. */ const TOAST_STYLE: CSSProperties = { width: 'fit-content', maxWidth: 'min(356px, 100%)', left: 0, right: 0, marginLeft: 'auto', marginRight: 'auto', }; /** * The solid (non-glass) pill's inline style — the variant's fill and ink, * resolved to the same `--color-*` tokens the native twin reads. Sonner's title * inherits the toast `color`, and `richColors` makes the description inherit it * too. */ function solidStyle(variant: ToastVariant): CSSProperties { return { backgroundColor: `var(--color-${TOAST_FILL_TOKEN[variant]})`, color: `var(--color-${TOAST_FOREGROUND_TOKEN[variant]})`, }; } /** The default status glyph for a semantic variant, themed to the pill's ink — * the same legible foreground on both the solid and frosted fills, since a glass * pill now keeps its variant's hue in the fill itself. Neutral and the Button * fills render no glyph. */ function statusIcon(variant: ToastVariant, size: number): ReactNode { const Icon = TOAST_STATUS_ICON[variant]; if (!Icon) return null; return ; } /** * The ``, `` and `` defaults, * mirrored into module variables so `toast()` (which has no access to the * mounted ``'s props) can resolve them — the same hand-off the native * twin does through `setToastDefaults`. */ let defaultGlass = false; let defaultGlassTone: ToastOptions['glassTone'] = 'variant'; let sequence = 0; const closeCallbacks = new Map void>(); function dismiss(id?: string) { const ids = id ? [id] : [...closeCallbacks.keys()]; for (const key of ids) closeCallbacks.get(key)?.(); sonnerToast.dismiss(id); } let defaultPill = false; let defaultSize = TOAST_SIZE_DEFAULT; /** Translate a shared {@link ToastOptions} into Sonner's `ExternalToast`. */ function toSonnerOptions(options?: ToastOptions): ExternalToast { const { variant = 'neutral', position, duration, description, action, onClose, pill, size } = options ?? {}; const isPill = pill ?? defaultPill; const geometry = TOAST_SIZE[size ?? defaultSize]; return { position: position === undefined ? undefined : SONNER_POSITION[position], duration: duration === 0 ? Number.POSITIVE_INFINITY : duration, description, action: action === undefined ? undefined : { label: action.label, onClick: () => action.onPress() }, onDismiss: onClose, // Sonner's variant methods (success/error/…) only colour an icon, so every // toast routes through the plain `sonnerToast` and the variant is drawn by // the fill/ink inline style instead — matching the native pill. richColors: true, icon: statusIcon(variant, geometry.icon), style: { ...solidStyle(variant), ...(isPill ? { borderRadius: PILL_RADIUS } : {}), fontSize: geometry.message.px, lineHeight: `${geometry.lineHeight}px`, paddingBlock: geometry.padY, paddingInlineStart: geometry.padX, paddingInlineEnd: isPill ? geometry.pillPadEnd : geometry.padX, gap: geometry.gap, }, }; } /** Route a toast through Sonner with the shared `variant` fill/ink applied. */ function show(message: string, options?: ToastOptions): string { const resolved = { ...options, glass: options?.glass ?? defaultGlass, glassTone: options?.glassTone ?? defaultGlassTone, size: options?.size ?? defaultSize, pill: options?.pill ?? defaultPill, }; sequence += 1; const id = `rn-toast-${sequence}`; const onClose = () => { if (!closeCallbacks.delete(id)) return; resolved.onClose?.(); }; closeCallbacks.set(id, onClose); const handleDismiss = () => dismiss(id); if (resolved.glass) { sonnerToast.custom( // biome-ignore lint/performance/noJsxPropsBind: handleDismiss is allocated once per toast, outside React render () => , { id, position: resolved.position === undefined ? undefined : SONNER_POSITION[resolved.position], duration: resolved.duration === 0 ? Number.POSITIVE_INFINITY : resolved.duration, onDismiss: onClose, onAutoClose: onClose, style: TOAST_STYLE, className: 'rn-glass-toast', }, ); return id; } sonnerToast(message, { ...toSonnerOptions(options), id, onDismiss: onClose, onAutoClose: onClose }); return id; } export function Toaster({ position = 'top', duration, glass = false, glassTone = 'variant', pill = false, size = TOAST_SIZE_DEFAULT, smallScreenPosition, largeScreenPosition, wideBreakpoint = 'sm', offset, }: ToasterProps) { const isWide = useBreakpointAtLeast(wideBreakpoint); const effectivePosition = isWide ? (largeScreenPosition ?? position) : (smallScreenPosition ?? position); // biome-ignore lint/plugin: sync the Toaster's glass/pill/size defaults into the module variables toast() reads — an external system whose change only matters post-commit useEffect(() => { defaultGlass = glass; defaultGlassTone = glassTone; defaultPill = pill; defaultSize = size; }, [glass, glassTone, pill, size]); return ( <> ); } // biome-ignore lint/style/useComponentExportOnlyModules: `toast` is the imperative half of the toaster's public API and must ship from the same subpath as `` export const toast: ToastApi = Object.assign((message: string, options?: ToastOptions) => show(message, options), { success: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'success' }), error: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'danger' }), warning: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'warning' }), info: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'info' }), dismiss, });