@mihcm/ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Toast.tsx CHANGED
@@ -3,42 +3,250 @@
3
3
  /**
4
4
  * Toast (web variant — React DOM).
5
5
  *
6
+ * Built on top of [sonner](https://sonner.emilkowal.ski/) so MiHCM apps get
7
+ * every feature the upstream library ships — promise toasts, loading state,
8
+ * action + cancel buttons, swipe-to-dismiss, hover-to-expand stacks, rich
9
+ * colours per type, theme switching, full a11y wiring — without forking.
10
+ *
11
+ * Surface is themed with MiHCM tokens (`bg-card`, `border-border`,
12
+ * `text-foreground`, `text-success-foreground`, etc.) via sonner's
13
+ * `toastOptions.classNames` so the toasts read as part of the rest of the
14
+ * design system regardless of light/dark mode or custom `colorScheme`s.
15
+ *
6
16
  * Two-part API:
7
- * 1. `<ToastProvider>` wraps your app, manages the queue, renders toasts
8
- * in a fixed container at bottom-right.
9
- * 2. `toast()` imperative function — call from anywhere to show a toast.
17
+ * 1. `<Toaster />` mount once near the app root. It renders the queue
18
+ * in a portal at the chosen `position`. Pass `richColors`, `expand`,
19
+ * `closeButton`, `visibleToasts`, `theme`, etc. to tune behaviour.
20
+ * 2. `toast()` — call from anywhere to enqueue. Sub-APIs:
10
21
  *
11
- * Sub-components (`Toast`, `ToastTitle`, `ToastDescription`, `ToastAction`,
12
- * `ToastClose`) are used internally by the provider but exported for
13
- * advanced composition.
22
+ * toast('Title')
23
+ * toast.success('Saved')
24
+ * toast.error('Failed', { description: '...' })
25
+ * toast.warning('Heads up')
26
+ * toast.info('Just so you know')
27
+ * toast.message('Plain')
28
+ * toast.loading('Working…')
29
+ * toast.promise(myPromise, {
30
+ * loading: 'Saving…',
31
+ * success: (data) => `Saved ${data.name}`,
32
+ * error: (err) => `Failed: ${err.message}`,
33
+ * })
34
+ * toast.custom((id) => <MyJSX onDismiss={() => toast.dismiss(id)} />)
35
+ * toast.dismiss() // dismiss all
36
+ * toast.dismiss(id) // dismiss one
14
37
  *
15
- * Variants: default, success, error, warning.
16
- * Auto-dismiss after configurable duration (default 5 000 ms).
17
- * Pause timer on hover; resume on mouse leave.
38
+ * Backward-compat shims for the previous imperative MiHCM API are below —
39
+ * `ToastProvider` is a deprecated alias for `Toaster`; the legacy
40
+ * `Toast`/`ToastTitle`/`ToastDescription`/`ToastAction`/`ToastClose`
41
+ * sub-components remain so any pre-sonner consumer keeps rendering.
18
42
  *
19
43
  * Wiki: docs/components/Toast.md
20
44
  */
21
45
  import {
22
46
  forwardRef,
23
- useCallback,
24
- useEffect,
25
- useRef,
26
- useState,
27
- useSyncExternalStore,
28
47
  type ButtonHTMLAttributes,
48
+ type ComponentProps,
29
49
  type HTMLAttributes,
30
- type ReactNode,
31
50
  } from 'react';
51
+ import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner';
32
52
  import { cva, type VariantProps } from 'class-variance-authority';
33
53
  import { cn } from './internal/cn.js';
34
54
 
35
55
  /* -------------------------------------------------------------------------- */
36
- /* CVA variants */
56
+ /* Re-exports — the canonical sonner API */
57
+ /* -------------------------------------------------------------------------- */
58
+
59
+ /**
60
+ * Imperative toast API. Backed by sonner.
61
+ *
62
+ * @example
63
+ * toast('Saved');
64
+ * toast.success('Profile updated');
65
+ * toast.error('Network error', { description: 'Retrying in 5s…' });
66
+ * toast.loading('Compiling…');
67
+ * toast.promise(savePost(), {
68
+ * loading: 'Saving…',
69
+ * success: 'Saved!',
70
+ * error: 'Save failed',
71
+ * });
72
+ * toast.dismiss(id);
73
+ */
74
+ export const toast: typeof sonnerToast = sonnerToast;
75
+
76
+ export type { ExternalToast, ToastT, ToasterProps } from 'sonner';
77
+
78
+ /* -------------------------------------------------------------------------- */
79
+ /* Toaster — themed wrapper around sonner's <Toaster /> */
80
+ /* -------------------------------------------------------------------------- */
81
+
82
+ export interface MihcmToasterProps extends ComponentProps<typeof SonnerToaster> {
83
+ /** Default visual treatment for toasts without an explicit appearance. */
84
+ appearance?: 'soft' | 'solid' | 'outline' | 'minimal';
85
+ }
86
+
87
+ /**
88
+ * Mount `<Toaster />` once near the root of your app (e.g. in `layout.tsx`).
89
+ * Forwards every prop to sonner's `<Toaster />`; the only addition is the
90
+ * MiHCM token styling applied via `toastOptions.classNames`.
91
+ *
92
+ * @example
93
+ * // app/layout.tsx
94
+ * import { Toaster } from '@mihcm/ui/Toast';
95
+ * <Toaster richColors closeButton position="bottom-right" />
96
+ */
97
+ export function Toaster({
98
+ position = 'bottom-right',
99
+ expand = false,
100
+ richColors = false,
101
+ closeButton = false,
102
+ visibleToasts = 3,
103
+ duration = 5000,
104
+ theme = 'system',
105
+ toastOptions,
106
+ className,
107
+ appearance,
108
+ ...rest
109
+ }: MihcmToasterProps) {
110
+ void appearance; /* reserved — sonner already handles visual variants via richColors */
111
+
112
+ /*
113
+ * Sonner injects its own CSS that uses these variables to colour each
114
+ * row. By mapping them to MiHCM design-system tokens here, the surface,
115
+ * border, and type tints inherit the brand palette AND light/dark mode
116
+ * flips automatically — no need to fight specificity with Tailwind.
117
+ */
118
+ const sonnerCssVars = {
119
+ '--normal-bg': 'var(--color-card)',
120
+ '--normal-text': 'var(--color-card-foreground)',
121
+ '--normal-border': 'var(--color-border)',
122
+ '--success-bg': 'var(--color-success-50, color-mix(in oklab, var(--color-success) 12%, var(--color-card)))',
123
+ '--success-text': 'var(--color-success-700, var(--color-success))',
124
+ '--success-border': 'var(--color-success)',
125
+ '--error-bg': 'var(--color-destructive-50, color-mix(in oklab, var(--color-destructive) 12%, var(--color-card)))',
126
+ '--error-text': 'var(--color-destructive-700, var(--color-destructive))',
127
+ '--error-border': 'var(--color-destructive)',
128
+ '--warning-bg': 'var(--color-warning-50, color-mix(in oklab, var(--color-warning) 12%, var(--color-card)))',
129
+ '--warning-text': 'var(--color-warning-700, var(--color-warning))',
130
+ '--warning-border': 'var(--color-warning)',
131
+ '--info-bg': 'var(--color-primary-50, color-mix(in oklab, var(--color-primary) 12%, var(--color-card)))',
132
+ '--info-text': 'var(--color-primary-700, var(--color-primary))',
133
+ '--info-border': 'var(--color-primary)',
134
+ } as React.CSSProperties;
135
+
136
+ return (
137
+ <SonnerToaster
138
+ position={position}
139
+ expand={expand}
140
+ richColors={richColors}
141
+ closeButton={closeButton}
142
+ visibleToasts={visibleToasts}
143
+ duration={duration}
144
+ theme={theme}
145
+ className={cn('mihcm-toaster', className)}
146
+ style={{ ...sonnerCssVars, ...(rest.style ?? {}) }}
147
+ toastOptions={{
148
+ ...toastOptions,
149
+ classNames: {
150
+ /*
151
+ * Surface — the toast row itself. Polished default look:
152
+ * - bg-card / text-card-foreground (theme-aware)
153
+ * - shadow-mi-modal for a soft floating elevation
154
+ * - rounded-xl + slightly bigger padding for a modern feel
155
+ * - ring-1 ring-border for crisp edge separation in light mode
156
+ * (the border-border on its own can look washed out)
157
+ * - max-width capped so long messages don't sprawl
158
+ */
159
+ toast: cn(
160
+ 'group toast pointer-events-auto flex w-full items-start gap-3 rounded-xl border border-border bg-card p-4 pr-12',
161
+ 'text-card-foreground shadow-mi-modal ring-1 ring-border/40',
162
+ toastOptions?.classNames?.toast,
163
+ ),
164
+ title: cn('text-sm font-semibold leading-snug text-foreground', toastOptions?.classNames?.title),
165
+ description: cn(
166
+ 'text-sm text-muted-foreground leading-snug',
167
+ toastOptions?.classNames?.description,
168
+ ),
169
+ actionButton: cn(
170
+ 'inline-flex shrink-0 items-center rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
171
+ toastOptions?.classNames?.actionButton,
172
+ ),
173
+ cancelButton: cn(
174
+ 'inline-flex shrink-0 items-center rounded-md border border-border bg-transparent px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
175
+ toastOptions?.classNames?.cancelButton,
176
+ ),
177
+ closeButton: cn(
178
+ 'absolute right-2 top-2 grid size-6 place-items-center rounded-md border border-transparent bg-transparent text-foreground/60 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
179
+ toastOptions?.classNames?.closeButton,
180
+ ),
181
+ icon: cn(
182
+ 'mt-0.5 shrink-0 [&_svg]:size-5',
183
+ toastOptions?.classNames?.icon,
184
+ ),
185
+ /*
186
+ * Per-type styling — sonner adds `data-type="success|error|…"`
187
+ * to the toast root. Use richColors-like surface treatment so
188
+ * tone is unmistakeable even without sonner's own richColors
189
+ * (which forces hard-coded colours).
190
+ */
191
+ default: cn('border-border bg-card text-card-foreground', toastOptions?.classNames?.default),
192
+ success: cn(
193
+ 'border-success/30 bg-success/10 text-foreground [&_[data-icon]]:text-success',
194
+ toastOptions?.classNames?.success,
195
+ ),
196
+ error: cn(
197
+ 'border-destructive/30 bg-destructive/10 text-foreground [&_[data-icon]]:text-destructive',
198
+ toastOptions?.classNames?.error,
199
+ ),
200
+ warning: cn(
201
+ 'border-warning/30 bg-warning/10 text-foreground [&_[data-icon]]:text-warning',
202
+ toastOptions?.classNames?.warning,
203
+ ),
204
+ info: cn(
205
+ 'border-primary/30 bg-primary/10 text-foreground [&_[data-icon]]:text-primary',
206
+ toastOptions?.classNames?.info,
207
+ ),
208
+ loading: cn(
209
+ 'border-border bg-card text-foreground',
210
+ toastOptions?.classNames?.loading,
211
+ ),
212
+ },
213
+ }}
214
+ {...rest}
215
+ />
216
+ );
217
+ }
218
+
219
+ /* -------------------------------------------------------------------------- */
220
+ /* Backward-compat layer */
221
+ /* */
222
+ /* The previous MiHCM Toast API exported `ToastProvider`, `Toast`, */
223
+ /* `ToastTitle`, `ToastDescription`, `ToastAction`, `ToastClose`. Keep them */
224
+ /* as thin shims so existing call sites stay green. New code should import */
225
+ /* `Toaster` + `toast` only. */
37
226
  /* -------------------------------------------------------------------------- */
38
227
 
228
+ /**
229
+ * @deprecated Use `<Toaster />` instead. Kept as a wrapping component for
230
+ * pre-sonner consumers — renders children, then mounts the Toaster portal.
231
+ */
232
+ export function ToastProvider({
233
+ children,
234
+ ...rest
235
+ }: MihcmToasterProps & { children?: React.ReactNode }) {
236
+ return (
237
+ <>
238
+ {children}
239
+ <Toaster {...rest} />
240
+ </>
241
+ );
242
+ }
243
+
244
+ export type ToastProviderProps = MihcmToasterProps & { children?: React.ReactNode };
245
+
246
+ /* The original `toastVariants` CVA — kept for any consumer that referenced
247
+ * its variant union types or used it to style a stand-alone Toast row. */
39
248
  export const toastVariants = cva(
40
- 'pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg ' +
41
- 'transition-all duration-300 ease-out',
249
+ 'pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg transition-all duration-300 ease-out',
42
250
  {
43
251
  variants: {
44
252
  variant: {
@@ -71,338 +279,106 @@ export const toastVariants = cva(
71
279
  },
72
280
  );
73
281
 
74
- /* -------------------------------------------------------------------------- */
75
- /* Types */
76
- /* -------------------------------------------------------------------------- */
77
-
78
282
  export type ToastVariant = NonNullable<VariantProps<typeof toastVariants>['variant']>;
79
283
  export type ToastAppearance = NonNullable<VariantProps<typeof toastVariants>['appearance']>;
80
284
 
81
- export interface ToastData {
82
- id: string;
83
- title: string;
84
- description?: string;
85
- variant?: ToastVariant;
86
- appearance?: ToastAppearance;
87
- duration?: number;
88
- action?: { label: string; onClick: () => void };
89
- icon?: ReactNode;
90
- }
91
-
92
285
  export interface ToastProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof toastVariants> {
93
286
  className?: string;
94
287
  }
95
288
 
96
- export interface ToastTitleProps extends HTMLAttributes<HTMLDivElement> {
97
- className?: string;
98
- }
99
-
100
- export interface ToastDescriptionProps extends HTMLAttributes<HTMLDivElement> {
101
- className?: string;
102
- }
103
-
104
- export interface ToastActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {
105
- className?: string;
106
- }
107
-
108
- export interface ToastCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
109
- className?: string;
110
- }
111
-
112
- export interface ToastProviderProps {
113
- children: ReactNode;
114
- /** Maximum number of visible toasts. @default 5 */
115
- max?: number;
116
- /** Screen corner for the toast stack. */
117
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
118
- /** Default visual treatment for toasts without an explicit appearance. */
119
- appearance?: ToastAppearance;
120
- }
121
-
122
- /* -------------------------------------------------------------------------- */
123
- /* Global singleton store */
124
- /* */
125
- /* Bundlers that transpile this package (e.g. Next.js `transpilePackages`) */
126
- /* may create multiple module instances. Using `globalThis` ensures toast() */
127
- /* and ToastProvider always share the same queue, even across chunks. */
128
- /* -------------------------------------------------------------------------- */
129
-
130
- type Listener = () => void;
131
-
132
- interface ToastStore {
133
- toasts: ToastData[];
134
- listeners: Set<Listener>;
135
- viewports: Set<number>;
136
- counter: number;
137
- viewportCounter: number;
138
- }
139
-
140
- const STORE_KEY = '__mihcm_toast_store__';
141
-
142
- function getStore(): ToastStore {
143
- const g = globalThis as unknown as Record<string, ToastStore>;
144
- if (!g[STORE_KEY]) {
145
- g[STORE_KEY] = { toasts: [], listeners: new Set(), viewports: new Set(), counter: 0, viewportCounter: 0 };
146
- }
147
- return g[STORE_KEY];
148
- }
149
-
150
- function notify() {
151
- for (const l of getStore().listeners) l();
152
- }
153
-
154
- function getSnapshot(): ToastData[] {
155
- return getStore().toasts;
156
- }
157
-
158
- function getViewportSnapshot(): number | undefined {
159
- return Array.from(getStore().viewports).at(-1);
160
- }
161
-
162
- function subscribe(listener: Listener): () => void {
163
- const store = getStore();
164
- store.listeners.add(listener);
165
- return () => store.listeners.delete(listener);
166
- }
167
-
168
- /** Imperative API — call from anywhere to show a toast. */
169
- export function toast(data: Omit<ToastData, 'id'>): string {
170
- const store = getStore();
171
- const id = `toast-${++store.counter}-${Date.now()}`;
172
- const entry: ToastData = { id, ...data };
173
- store.toasts = [...store.toasts, entry];
174
- notify();
175
- return id;
176
- }
177
-
178
- function dismiss(id: string) {
179
- const store = getStore();
180
- store.toasts = store.toasts.filter((t) => t.id !== id);
181
- notify();
182
- }
183
-
184
- /* -------------------------------------------------------------------------- */
185
- /* Sub-components */
186
- /* -------------------------------------------------------------------------- */
187
-
188
- const ACCENT: Record<ToastVariant, string> = {
189
- default: 'border-l-border',
190
- success: 'border-l-success',
191
- error: 'border-l-destructive',
192
- warning: 'border-l-warning',
193
- accent: 'border-l-accent',
194
- };
195
-
289
+ /** @deprecated Sonner renders rows internally. Kept for stand-alone use. */
196
290
  export const Toast = forwardRef<HTMLDivElement, ToastProps>(function Toast(
197
291
  { className, variant = 'default', appearance = 'soft', children, ...props },
198
292
  ref,
199
293
  ) {
200
294
  return (
201
- <div
202
- ref={ref}
203
- className={cn(toastVariants({ variant, appearance }), 'border-l-4', ACCENT[variant ?? 'default'], className)}
204
- {...props}
205
- >
295
+ <div ref={ref} className={cn(toastVariants({ variant, appearance }), className)} {...props}>
206
296
  {children}
207
297
  </div>
208
298
  );
209
299
  });
210
300
 
211
- export const ToastTitle = forwardRef<HTMLDivElement, ToastTitleProps>(function ToastTitle(
212
- { className, ...props },
213
- ref,
214
- ) {
215
- return <div ref={ref} className={cn('text-sm font-semibold', className)} {...props} />;
216
- });
301
+ /** @deprecated Use sonner's title slot in `toast()` instead. */
302
+ export const ToastTitle = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
303
+ function ToastTitle({ className, ...props }, ref) {
304
+ return <div ref={ref} className={cn('text-sm font-semibold', className)} {...props} />;
305
+ },
306
+ );
217
307
 
218
- export const ToastDescription = forwardRef<HTMLDivElement, ToastDescriptionProps>(
308
+ /** @deprecated Use sonner's `description` option in `toast()` instead. */
309
+ export const ToastDescription = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
219
310
  function ToastDescription({ className, ...props }, ref) {
220
311
  return <div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
221
312
  },
222
313
  );
223
314
 
224
- export const ToastAction = forwardRef<HTMLButtonElement, ToastActionProps>(function ToastAction(
225
- { className, ...props },
226
- ref,
227
- ) {
228
- return (
229
- <button
230
- ref={ref}
231
- type="button"
232
- className={cn(
233
- 'inline-flex shrink-0 items-center rounded-md border border-border bg-transparent px-3 py-1 ' +
234
- 'text-sm font-medium transition-colors duration-150 hover:bg-muted focus-visible:outline-none ' +
235
- 'focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
236
- className,
237
- )}
238
- {...props}
239
- />
240
- );
241
- });
315
+ /** @deprecated Use sonner's `action` option in `toast()` instead. */
316
+ export const ToastAction = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
317
+ function ToastAction({ className, ...props }, ref) {
318
+ return (
319
+ <button
320
+ ref={ref}
321
+ type="button"
322
+ className={cn(
323
+ 'inline-flex shrink-0 items-center rounded-md border border-border bg-transparent px-3 py-1 text-sm font-medium transition-colors duration-150 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
324
+ className,
325
+ )}
326
+ {...props}
327
+ />
328
+ );
329
+ },
330
+ );
242
331
 
243
- export const ToastClose = forwardRef<HTMLButtonElement, ToastCloseProps>(function ToastClose(
244
- { className, ...props },
245
- ref,
246
- ) {
247
- return (
248
- <button
249
- ref={ref}
250
- type="button"
251
- aria-label="Close"
252
- className={cn(
253
- 'absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-opacity hover:text-foreground ' +
254
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
255
- className,
256
- )}
257
- {...props}
258
- >
259
- <svg
260
- xmlns="http://www.w3.org/2000/svg"
261
- width="16"
262
- height="16"
263
- viewBox="0 0 24 24"
264
- fill="none"
265
- stroke="currentColor"
266
- strokeWidth={2}
267
- strokeLinecap="round"
268
- strokeLinejoin="round"
269
- aria-hidden
332
+ /** @deprecated `closeButton` prop on `<Toaster />` renders this automatically. */
333
+ export const ToastClose = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
334
+ function ToastClose({ className, ...props }, ref) {
335
+ return (
336
+ <button
337
+ ref={ref}
338
+ type="button"
339
+ aria-label="Close"
340
+ className={cn(
341
+ 'absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-opacity hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
342
+ className,
343
+ )}
344
+ {...props}
270
345
  >
271
- <path d="M18 6 6 18" />
272
- <path d="m6 6 12 12" />
273
- </svg>
274
- </button>
275
- );
276
- });
277
-
278
- /* -------------------------------------------------------------------------- */
279
- /* Individual toast item (manages timer + animation) */
280
- /* -------------------------------------------------------------------------- */
281
-
282
- const DEFAULT_DURATION = 5000;
283
-
284
- function ToastItem({
285
- data,
286
- onDismiss,
287
- appearance,
288
- }: {
289
- data: ToastData;
290
- onDismiss: (id: string) => void;
291
- appearance: ToastAppearance;
292
- }) {
293
- const [visible, setVisible] = useState(false);
294
- const [exiting, setExiting] = useState(false);
295
- const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
296
- const remainingRef = useRef(data.duration ?? DEFAULT_DURATION);
297
- // eslint-disable-next-line react-hooks/purity -- initial value only, never re-read during render
298
- const startRef = useRef(Date.now());
299
-
300
- const startTimer = useCallback(() => {
301
- startRef.current = Date.now();
302
- timerRef.current = setTimeout(() => {
303
- setExiting(true);
304
- setTimeout(() => onDismiss(data.id), 300);
305
- }, remainingRef.current);
306
- }, [data.id, onDismiss]);
307
-
308
- const pauseTimer = useCallback(() => {
309
- if (timerRef.current) {
310
- clearTimeout(timerRef.current);
311
- remainingRef.current -= Date.now() - startRef.current;
312
- if (remainingRef.current < 0) remainingRef.current = 0;
313
- }
314
- }, []);
315
-
316
- useEffect(() => {
317
- // Trigger entry animation on next frame
318
- const raf = requestAnimationFrame(() => setVisible(true));
319
- startTimer();
320
- return () => {
321
- cancelAnimationFrame(raf);
322
- if (timerRef.current) clearTimeout(timerRef.current);
323
- };
324
- }, [startTimer]);
325
-
326
- const handleClose = () => {
327
- if (timerRef.current) clearTimeout(timerRef.current);
328
- setExiting(true);
329
- setTimeout(() => onDismiss(data.id), 300);
330
- };
331
-
332
- const isError = data.variant === 'error';
346
+ <svg
347
+ xmlns="http://www.w3.org/2000/svg"
348
+ width="16"
349
+ height="16"
350
+ viewBox="0 0 24 24"
351
+ fill="none"
352
+ stroke="currentColor"
353
+ strokeWidth={2}
354
+ strokeLinecap="round"
355
+ strokeLinejoin="round"
356
+ aria-hidden
357
+ >
358
+ <path d="M18 6 6 18" />
359
+ <path d="m6 6 12 12" />
360
+ </svg>
361
+ </button>
362
+ );
363
+ },
364
+ );
333
365
 
334
- return (
335
- <div
336
- role="status"
337
- aria-live={isError ? 'assertive' : 'polite'}
338
- className={cn(
339
- 'transform transition-all duration-300',
340
- visible && !exiting ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0',
341
- )}
342
- onMouseEnter={pauseTimer}
343
- onMouseLeave={startTimer}
344
- >
345
- <Toast variant={data.variant} appearance={data.appearance ?? appearance}>
346
- {data.icon ? <div className="mt-0.5 shrink-0 text-muted-foreground">{data.icon}</div> : null}
347
- <div className="flex-1 space-y-1 pr-6">
348
- <ToastTitle>{data.title}</ToastTitle>
349
- {data.description !== undefined && (
350
- <ToastDescription>{data.description}</ToastDescription>
351
- )}
352
- {data.action !== undefined && (
353
- <div className="mt-2">
354
- <ToastAction onClick={data.action.onClick}>{data.action.label}</ToastAction>
355
- </div>
356
- )}
357
- </div>
358
- <ToastClose onClick={handleClose} />
359
- </Toast>
360
- </div>
361
- );
366
+ /**
367
+ * Legacy `ToastData` shape kept for typing in any pre-sonner consumer.
368
+ * @deprecated Sonner uses its own `ExternalToast` shape; prefer that.
369
+ */
370
+ export interface ToastData {
371
+ id: string;
372
+ title: string;
373
+ description?: string;
374
+ variant?: ToastVariant;
375
+ appearance?: ToastAppearance;
376
+ duration?: number;
377
+ action?: { label: string; onClick: () => void };
378
+ icon?: React.ReactNode;
362
379
  }
363
380
 
364
- /* -------------------------------------------------------------------------- */
365
- /* ToastProvider */
366
- /* -------------------------------------------------------------------------- */
367
-
368
- const POSITION_CLASS: Record<NonNullable<ToastProviderProps['position']>, string> = {
369
- 'bottom-right': 'bottom-4 right-4',
370
- 'bottom-left': 'bottom-4 left-4',
371
- 'top-right': 'right-4 top-4',
372
- 'top-left': 'left-4 top-4',
373
- };
374
-
375
- export function ToastProvider({ children, max = 5, position = 'bottom-right', appearance = 'soft' }: ToastProviderProps) {
376
- const [viewportId] = useState(() => ++getStore().viewportCounter);
377
-
378
- useEffect(() => {
379
- const store = getStore();
380
- store.viewports.add(viewportId);
381
- notify();
382
- return () => {
383
- store.viewports.delete(viewportId);
384
- notify();
385
- };
386
- }, [viewportId]);
387
-
388
- const items = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
389
- const activeViewportId = useSyncExternalStore(subscribe, getViewportSnapshot, () => undefined);
390
- const visible = items.slice(-max);
391
- const isActiveViewport = viewportId === activeViewportId;
392
-
393
- return (
394
- <>
395
- {children}
396
- {isActiveViewport ? (
397
- <div
398
- aria-label="Notifications"
399
- className={cn('pointer-events-none fixed z-[100] flex max-w-[420px] flex-col gap-2', POSITION_CLASS[position])}
400
- >
401
- {visible.map((t) => (
402
- <ToastItem key={t.id} data={t} onDismiss={dismiss} appearance={appearance} />
403
- ))}
404
- </div>
405
- ) : null}
406
- </>
407
- );
408
- }
381
+ export interface ToastTitleProps extends HTMLAttributes<HTMLDivElement> {}
382
+ export interface ToastDescriptionProps extends HTMLAttributes<HTMLDivElement> {}
383
+ export interface ToastActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {}
384
+ export interface ToastCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {}