panelui-native 0.2.0 → 0.5.1

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.
Files changed (106) hide show
  1. package/README.md +258 -0
  2. package/lib/module/components/accordion/index.js +312 -0
  3. package/lib/module/components/accordion/index.js.map +1 -0
  4. package/lib/module/components/alert/index.js +106 -13
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/avatar/index.js +58 -17
  7. package/lib/module/components/avatar/index.js.map +1 -1
  8. package/lib/module/components/badge/index.js +29 -8
  9. package/lib/module/components/badge/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +87 -26
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/checkbox/index.js +59 -18
  13. package/lib/module/components/checkbox/index.js.map +1 -1
  14. package/lib/module/components/empty-state/index.js +178 -0
  15. package/lib/module/components/empty-state/index.js.map +1 -0
  16. package/lib/module/components/frame/index.js +64 -13
  17. package/lib/module/components/frame/index.js.map +1 -1
  18. package/lib/module/components/input-group/index.js +132 -0
  19. package/lib/module/components/input-group/index.js.map +1 -0
  20. package/lib/module/components/label/index.js +117 -0
  21. package/lib/module/components/label/index.js.map +1 -0
  22. package/lib/module/components/progress/index.js +138 -0
  23. package/lib/module/components/progress/index.js.map +1 -0
  24. package/lib/module/components/steps/index.js +354 -0
  25. package/lib/module/components/steps/index.js.map +1 -0
  26. package/lib/module/components/tabs/index.js +9 -3
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/timeline/index.js +633 -0
  29. package/lib/module/components/timeline/index.js.map +1 -0
  30. package/lib/module/components/toast/index.js +569 -0
  31. package/lib/module/components/toast/index.js.map +1 -0
  32. package/lib/module/components/toast/toast-store.js +81 -0
  33. package/lib/module/components/toast/toast-store.js.map +1 -0
  34. package/lib/module/components/typography/index.js +126 -0
  35. package/lib/module/components/typography/index.js.map +1 -0
  36. package/lib/module/icons/index.js +590 -8
  37. package/lib/module/icons/index.js.map +1 -1
  38. package/lib/module/index.js +11 -2
  39. package/lib/module/index.js.map +1 -1
  40. package/lib/module/providers/panel-ui-provider.js +2 -1
  41. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  42. package/lib/module/theme/use-theme.js +90 -3
  43. package/lib/module/theme/use-theme.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +56 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/alert/index.d.ts +56 -4
  47. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/avatar/index.d.ts +8 -1
  49. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/badge/index.d.ts +38 -0
  51. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/button/index.d.ts +57 -0
  53. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/checkbox/index.d.ts +50 -0
  55. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/empty-state/index.d.ts +73 -0
  57. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -0
  58. package/lib/typescript/src/components/frame/index.d.ts +39 -5
  59. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/input-group/index.d.ts +34 -0
  61. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -0
  62. package/lib/typescript/src/components/label/index.d.ts +28 -0
  63. package/lib/typescript/src/components/label/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/progress/index.d.ts +132 -0
  65. package/lib/typescript/src/components/progress/index.d.ts.map +1 -0
  66. package/lib/typescript/src/components/steps/index.d.ts +62 -0
  67. package/lib/typescript/src/components/steps/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/timeline/index.d.ts +96 -0
  70. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/toast/index.d.ts +176 -0
  72. package/lib/typescript/src/components/toast/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/toast/toast-store.d.ts +65 -0
  74. package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -0
  75. package/lib/typescript/src/components/typography/index.d.ts +77 -0
  76. package/lib/typescript/src/components/typography/index.d.ts.map +1 -0
  77. package/lib/typescript/src/icons/index.d.ts +60 -0
  78. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  79. package/lib/typescript/src/index.d.ts +13 -4
  80. package/lib/typescript/src/index.d.ts.map +1 -1
  81. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  82. package/lib/typescript/src/theme/use-theme.d.ts +64 -4
  83. package/lib/typescript/src/theme/use-theme.d.ts.map +1 -1
  84. package/package.json +22 -4
  85. package/src/components/accordion/index.tsx +344 -0
  86. package/src/components/alert/index.tsx +102 -16
  87. package/src/components/avatar/index.tsx +68 -20
  88. package/src/components/badge/index.tsx +38 -9
  89. package/src/components/button/index.tsx +102 -19
  90. package/src/components/checkbox/index.tsx +64 -12
  91. package/src/components/empty-state/index.tsx +136 -0
  92. package/src/components/frame/index.tsx +101 -31
  93. package/src/components/input-group/index.tsx +164 -0
  94. package/src/components/label/index.tsx +128 -0
  95. package/src/components/progress/index.tsx +147 -0
  96. package/src/components/steps/index.tsx +343 -0
  97. package/src/components/tabs/index.tsx +10 -2
  98. package/src/components/timeline/index.tsx +456 -0
  99. package/src/components/toast/index.tsx +568 -0
  100. package/src/components/toast/toast-store.ts +120 -0
  101. package/src/components/typography/index.tsx +121 -0
  102. package/src/icons/index.tsx +423 -7
  103. package/src/index.ts +114 -4
  104. package/src/providers/panel-ui-provider.tsx +3 -0
  105. package/src/theme/use-theme.ts +119 -4
  106. package/theme.css +330 -7
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Toast — transient notification.
3
+ *
4
+ * Adapted from: heroui-inc/heroui-native src/components/toast/
5
+ * (the three usage patterns — string, config object, custom component — the
6
+ * Title / Description / Action / Close anatomy, placement, and swipe-to-
7
+ * dismiss). Styled with Coss UI tokens and PanelUI's Alert status palette.
8
+ *
9
+ * The viewport is mounted for you by PanelUIProvider; `useToast()` works
10
+ * anywhere below it.
11
+ */
12
+ import {
13
+ createContext,
14
+ useCallback,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useSyncExternalStore,
19
+ type ReactNode,
20
+ } from 'react';
21
+ import { View, type ViewProps } from 'react-native';
22
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
23
+ import Animated, {
24
+ Easing,
25
+ Extrapolation,
26
+ FadeInDown,
27
+ FadeInUp,
28
+ Keyframe,
29
+ interpolate,
30
+ runOnJS,
31
+ useAnimatedStyle,
32
+ useSharedValue,
33
+ withDecay,
34
+ withSpring,
35
+ withTiming,
36
+ type SharedValue,
37
+ } from 'react-native-reanimated';
38
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
39
+ import { tv, type VariantProps } from 'tailwind-variants';
40
+ import { useCSSVariable } from 'uniwind';
41
+ import { Portal } from '../../primitives/portal';
42
+ import { Text, type TextProps } from '../../primitives/text';
43
+ import { cn } from '../../utils/cn';
44
+ import { AlertTriangleIcon, CheckCircleIcon, InfoIcon, XIcon } from '../../icons';
45
+ import { Button, type ButtonProps } from '../button';
46
+ import {
47
+ toast,
48
+ toastStore,
49
+ type ToastHandle,
50
+ type ToastItem,
51
+ type ToastPlacement,
52
+ type ToastVariant,
53
+ } from './toast-store';
54
+
55
+ const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
56
+ /** Drag distance past which a release dismisses. */
57
+ const DISMISS_DISTANCE = 50;
58
+ /** Fling speed past which a release dismisses regardless of distance. */
59
+ const DISMISS_VELOCITY = 500;
60
+ /** How far a toast can be dragged away from its edge before it stops moving. */
61
+ const RUBBER_BAND_LIMIT = 40;
62
+ /** How far a toast travels on entry. Short, so the spring has little to settle. */
63
+ const ENTER_OFFSET = 100;
64
+ /** Newest toasts only; older ones fade out rather than stacking off-screen. */
65
+ const MAX_VISIBLE = 3;
66
+ /** How far each toast behind the front one peeks out. */
67
+ const STACK_OFFSET = 10;
68
+ /** Scale of the toast one step back in the deck. */
69
+ const STACK_SCALE_STEP = 0.97;
70
+ /** Gap between the deck and the screen edges, on top of the safe-area inset. */
71
+ const EDGE_INSET = 16;
72
+
73
+ /*
74
+ * Entering/exiting animations, from heroui-inc/heroui-native
75
+ * src/components/toast/toast.animation.ts.
76
+ *
77
+ * `mass(3)` is doing the important work: a heavy spring settles without the
78
+ * visible overshoot a default-mass one gives. `withInitialValues` keeps
79
+ * opacity at 1 so the toast slides rather than fades, and starts it
80
+ * ENTER_OFFSET px away instead of off-screen.
81
+ */
82
+ const enteringTop = FadeInUp.springify()
83
+ .withInitialValues({ opacity: 1, transform: [{ translateY: -ENTER_OFFSET }] })
84
+ .mass(3);
85
+
86
+ const enteringBottom = FadeInDown.springify()
87
+ .withInitialValues({ opacity: 1, transform: [{ translateY: ENTER_OFFSET }] })
88
+ .mass(3);
89
+
90
+ const exitKeyframe = (offset: number) =>
91
+ new Keyframe({
92
+ 0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
93
+ 100: {
94
+ opacity: 0.5,
95
+ transform: [{ translateY: offset }, { scale: 0.97 }],
96
+ easing: Easing.bezier(0.4, 0, 1, 1),
97
+ },
98
+ }).duration(150);
99
+
100
+ const exitingTop = exitKeyframe(-ENTER_OFFSET);
101
+ const exitingBottom = exitKeyframe(ENTER_OFFSET);
102
+
103
+ const toastVariants = tv({
104
+ slots: {
105
+ root: 'w-full flex-row items-start gap-3 rounded-2xl border px-4 py-3.5 shadow-lg',
106
+ indicator: 'pt-px',
107
+ content: 'flex-1 gap-0.5',
108
+ title: 'text-sm font-medium',
109
+ description: 'text-sm text-muted-foreground',
110
+ },
111
+ variants: {
112
+ variant: {
113
+ default: { root: 'border-border bg-popover', title: 'text-popover-foreground' },
114
+ info: { root: 'border-info/32 bg-popover', title: 'text-info-foreground' },
115
+ success: { root: 'border-success/32 bg-popover', title: 'text-success-foreground' },
116
+ warning: { root: 'border-warning/32 bg-popover', title: 'text-warning-foreground' },
117
+ destructive: {
118
+ root: 'border-destructive/32 bg-popover',
119
+ title: 'text-destructive-foreground',
120
+ },
121
+ },
122
+ },
123
+ defaultVariants: {
124
+ variant: 'default',
125
+ },
126
+ });
127
+
128
+ const INDICATOR_COLOR_VAR: Record<ToastVariant, string> = {
129
+ default: '--color-muted-foreground',
130
+ info: '--color-info-foreground',
131
+ success: '--color-success-foreground',
132
+ warning: '--color-warning-foreground',
133
+ destructive: '--color-destructive-foreground',
134
+ };
135
+
136
+ const INDICATOR_ICON: Record<ToastVariant, typeof InfoIcon> = {
137
+ default: InfoIcon,
138
+ info: InfoIcon,
139
+ success: CheckCircleIcon,
140
+ warning: AlertTriangleIcon,
141
+ destructive: AlertTriangleIcon,
142
+ };
143
+
144
+ interface ToastContextValue {
145
+ variant: ToastVariant;
146
+ hide: () => void;
147
+ }
148
+
149
+ const ToastContext = createContext<ToastContextValue>({
150
+ variant: 'default',
151
+ hide: () => {},
152
+ });
153
+
154
+ export interface ToastProps extends ViewProps, VariantProps<typeof toastVariants> {
155
+ className?: string;
156
+ /** Called when the close button is pressed or the toast is swiped away. */
157
+ onHide?: () => void;
158
+ children?: ReactNode;
159
+ }
160
+
161
+ /**
162
+ * Presentational toast surface. Render it yourself only for custom toasts —
163
+ * `toast.show(...)` builds one for you from a config object.
164
+ */
165
+ const ToastRoot = ({ className, variant = 'default', onHide, children, ...props }: ToastProps) => {
166
+ const context = useMemo<ToastContextValue>(
167
+ () => ({ variant, hide: onHide ?? (() => {}) }),
168
+ [variant, onHide]
169
+ );
170
+ const { root } = toastVariants({ variant });
171
+
172
+ return (
173
+ <ToastContext.Provider value={context}>
174
+ <View accessibilityRole="alert" accessibilityLiveRegion="polite" className={root({ className })} {...props}>
175
+ {children}
176
+ </View>
177
+ </ToastContext.Provider>
178
+ );
179
+ };
180
+ ToastRoot.displayName = 'Toast';
181
+
182
+ export interface ToastIndicatorProps extends ViewProps {
183
+ className?: string;
184
+ iconProps?: { size?: number; color?: string };
185
+ children?: ReactNode;
186
+ }
187
+
188
+ const ToastIndicator = ({ className, iconProps, children, ...props }: ToastIndicatorProps) => {
189
+ const { variant } = useContext(ToastContext);
190
+ const { indicator } = toastVariants({ variant });
191
+ const themeColor = useCSSVariable(INDICATOR_COLOR_VAR[variant]);
192
+ const Icon = INDICATOR_ICON[variant];
193
+ const color = iconProps?.color ?? (typeof themeColor === 'string' ? themeColor : undefined);
194
+
195
+ return (
196
+ <View className={indicator({ className })} {...props}>
197
+ {children ?? <Icon size={iconProps?.size ?? 18} color={color} />}
198
+ </View>
199
+ );
200
+ };
201
+ ToastIndicator.displayName = 'Toast.Indicator';
202
+
203
+ const ToastContent = ({ className, ...props }: ViewProps & { className?: string }) => {
204
+ const { variant } = useContext(ToastContext);
205
+ const { content } = toastVariants({ variant });
206
+ return <View className={content({ className })} {...props} />;
207
+ };
208
+ ToastContent.displayName = 'Toast.Content';
209
+
210
+ const ToastTitle = ({ className, ...props }: TextProps) => {
211
+ const { variant } = useContext(ToastContext);
212
+ const { title } = toastVariants({ variant });
213
+ return <Text className={cn(title(), className)} {...props} />;
214
+ };
215
+ ToastTitle.displayName = 'Toast.Title';
216
+
217
+ const ToastDescription = ({ className, ...props }: TextProps) => {
218
+ const { variant } = useContext(ToastContext);
219
+ const { description } = toastVariants({ variant });
220
+ return <Text className={cn(description(), className)} {...props} />;
221
+ };
222
+ ToastDescription.displayName = 'Toast.Description';
223
+
224
+ /** Trailing action button. Defaults to a subtle variant that reads on the popover surface. */
225
+ const ToastAction = ({ variant = 'secondary', size = 'sm', onPress, ...props }: ButtonProps) => {
226
+ const { hide } = useContext(ToastContext);
227
+ return (
228
+ <Button
229
+ variant={variant}
230
+ size={size}
231
+ onPress={(event) => {
232
+ onPress?.(event);
233
+ hide();
234
+ }}
235
+ {...props}
236
+ />
237
+ );
238
+ };
239
+ ToastAction.displayName = 'Toast.Action';
240
+
241
+ export interface ToastCloseProps extends ViewProps {
242
+ className?: string;
243
+ }
244
+
245
+ const ToastClose = ({ className, ...props }: ToastCloseProps) => {
246
+ const { hide } = useContext(ToastContext);
247
+ const color = useCSSVariable('--color-muted-foreground');
248
+
249
+ return (
250
+ <View className={cn('pt-px', className)} {...props}>
251
+ <Button
252
+ variant="ghost"
253
+ size="icon"
254
+ accessibilityLabel="Dismiss notification"
255
+ className="h-6 w-6 rounded-full"
256
+ onPress={hide}
257
+ >
258
+ <XIcon size={14} color={typeof color === 'string' ? color : undefined} />
259
+ </Button>
260
+ </View>
261
+ );
262
+ };
263
+ ToastClose.displayName = 'Toast.Close';
264
+
265
+ export const Toast = Object.assign(ToastRoot, {
266
+ Indicator: ToastIndicator,
267
+ Content: ToastContent,
268
+ Title: ToastTitle,
269
+ Description: ToastDescription,
270
+ Action: ToastAction,
271
+ Close: ToastClose,
272
+ });
273
+
274
+ /* -------------------------------------------------------------------------- */
275
+ /* Viewport */
276
+ /* -------------------------------------------------------------------------- */
277
+
278
+ /**
279
+ * One queued toast.
280
+ *
281
+ * The entering spring is deliberately heavy (`mass(3)`) and travels only
282
+ * ENTER_OFFSET px rather than the full screen height. A default-mass spring
283
+ * across that distance overshoots visibly — it reads as a bounce, and arrives
284
+ * too fast to follow.
285
+ *
286
+ * Swiping is vertical and direction-aware: dragging toward the screen edge the
287
+ * toast came from dismisses it, dragging the other way rubber-bands against a
288
+ * hard limit. Matches heroui-native's toast.animation.ts.
289
+ */
290
+ function ToastSlot({
291
+ item,
292
+ index,
293
+ total,
294
+ frontId,
295
+ heights,
296
+ }: {
297
+ item: ToastItem;
298
+ /** Position in the stack; the newest toast is `total - 1`. */
299
+ index: number;
300
+ total: number;
301
+ /** Id of the newest toast — its height sets the size of the whole deck. */
302
+ frontId: string;
303
+ /** Measured natural height per toast id, shared with the UI thread. */
304
+ heights: SharedValue<Record<string, number>>;
305
+ }) {
306
+ const placement = item.placement ?? 'bottom';
307
+ const translateY = useSharedValue(0);
308
+ const scale = useSharedValue(1);
309
+ const hide = useCallback(() => toastStore.hide(item.id), [item.id]);
310
+
311
+ // Drop this toast's measurement when it leaves, so the map cannot grow
312
+ // without bound over a long session.
313
+ useEffect(
314
+ () => () => {
315
+ heights.modify((value: Record<string, number>) => {
316
+ 'worklet';
317
+ const next = { ...value };
318
+ delete next[item.id];
319
+ return next;
320
+ });
321
+ },
322
+ [heights, item.id]
323
+ );
324
+
325
+ /** +1 when a downward drag dismisses (bottom), -1 when an upward one does. */
326
+ const dismissSign = placement === 'top' ? -1 : 1;
327
+
328
+ const pan = Gesture.Pan()
329
+ .activeOffsetY([-12, 12])
330
+ .onBegin(() => {
331
+ scale.value = withTiming(0.995, { duration: 120 });
332
+ })
333
+ .onChange((event) => {
334
+ const next = translateY.value + event.changeY;
335
+ // Toward the edge: follow the finger. Away from it: rubber-band, so the
336
+ // toast never detaches from where it belongs.
337
+ translateY.value =
338
+ next * dismissSign > 0
339
+ ? next
340
+ : interpolate(
341
+ Math.abs(next),
342
+ [0, 400],
343
+ [0, RUBBER_BAND_LIMIT],
344
+ Extrapolation.CLAMP
345
+ ) * -dismissSign;
346
+ })
347
+ .onFinalize((event) => {
348
+ scale.value = withTiming(1, { duration: 120 });
349
+
350
+ const towardEdge = translateY.value * dismissSign > 0;
351
+ const shouldDismiss =
352
+ towardEdge &&
353
+ (Math.abs(translateY.value) > DISMISS_DISTANCE ||
354
+ Math.abs(event.velocityY) > DISMISS_VELOCITY);
355
+
356
+ if (shouldDismiss) {
357
+ // Carry the flick's momentum instead of stopping dead, clamped so it
358
+ // can only continue toward the edge.
359
+ translateY.value = withDecay({
360
+ velocity: event.velocityY * 1.5,
361
+ clamp:
362
+ dismissSign > 0
363
+ ? [0, Number.POSITIVE_INFINITY]
364
+ : [Number.NEGATIVE_INFINITY, 0],
365
+ });
366
+ runOnJS(hide)();
367
+ } else {
368
+ translateY.value = withSpring(0, SPRING);
369
+ }
370
+ });
371
+
372
+ /** Older toasts slide away from the edge the stack is pinned to. */
373
+ const stackSign = placement === 'top' ? 1 : -1;
374
+
375
+ /**
376
+ * Stack position plus the gesture transform.
377
+ *
378
+ * The whole deck takes the *newest* toast's height so the cards behind read
379
+ * as a uniform stack rather than a ragged pile — matching heroui-native.
380
+ */
381
+ const stackStyle = useAnimatedStyle(() => {
382
+ // Fall back to this toast's own height until the front one is measured.
383
+ const frontHeight = heights.value[frontId] ?? heights.value[item.id];
384
+
385
+ // Newest sits at 0; each one behind steps back by STACK_OFFSET and shrinks.
386
+ const inputRange = [total - 1, total - 2];
387
+ const offset = interpolate(index, inputRange, [0, STACK_OFFSET * stackSign], {
388
+ extrapolateLeft: Extrapolation.CLAMP,
389
+ });
390
+ const stackScale = interpolate(index, inputRange, [1, STACK_SCALE_STEP], {
391
+ extrapolateLeft: Extrapolation.CLAMP,
392
+ });
393
+ // Anything past MAX_VISIBLE fades out rather than piling up forever.
394
+ const opacity = interpolate(
395
+ index,
396
+ [total - MAX_VISIBLE, total - MAX_VISIBLE - 1],
397
+ [1, 0],
398
+ Extrapolation.CLAMP
399
+ );
400
+
401
+ return {
402
+ opacity,
403
+ height: frontHeight,
404
+ transform: [
405
+ { translateY: offset + translateY.value },
406
+ { scale: stackScale * scale.value },
407
+ ],
408
+ };
409
+ });
410
+
411
+ const handle: ToastHandle = { id: item.id, hide };
412
+
413
+ const content = item.component ? (
414
+ item.component(handle)
415
+ ) : (
416
+ <Toast variant={item.variant ?? 'default'} onHide={hide}>
417
+ {item.icon === null ? null : (
418
+ <Toast.Indicator>{item.icon ?? undefined}</Toast.Indicator>
419
+ )}
420
+ <Toast.Content>
421
+ {item.label ? <Toast.Title>{item.label}</Toast.Title> : null}
422
+ {item.description ? (
423
+ <Toast.Description>{item.description}</Toast.Description>
424
+ ) : null}
425
+ </Toast.Content>
426
+ {item.actionLabel ? (
427
+ <Toast.Action onPress={() => item.onActionPress?.(handle)}>
428
+ {item.actionLabel}
429
+ </Toast.Action>
430
+ ) : null}
431
+ {(item.closable ?? !item.actionLabel) ? <Toast.Close /> : null}
432
+ </Toast>
433
+ );
434
+
435
+ return (
436
+ <Animated.View
437
+ entering={placement === 'top' ? enteringTop : enteringBottom}
438
+ exiting={placement === 'top' ? exitingTop : exitingBottom}
439
+ pointerEvents="box-none"
440
+ // Every toast is pinned to the same edge, so they overlap as a deck.
441
+ // Newest on top. Horizontal inset lives here rather than as padding on
442
+ // the anchor: Yoga positions absolute children against the border box,
443
+ // so padding on the parent would not move them.
444
+ className="absolute"
445
+ style={{
446
+ left: EDGE_INSET,
447
+ right: EDGE_INSET,
448
+ [placement === 'top' ? 'top' : 'bottom']: 0,
449
+ zIndex: index,
450
+ }}
451
+ >
452
+ <GestureDetector gesture={pan}>
453
+ <Animated.View style={stackStyle}>{content}</Animated.View>
454
+ </GestureDetector>
455
+
456
+ {/*
457
+ A hidden copy, purely to measure. The visible one above has its height
458
+ forced by the animated style, so it can never report its natural size.
459
+ */}
460
+ <View
461
+ pointerEvents="none"
462
+ accessibilityElementsHidden
463
+ importantForAccessibility="no-hide-descendants"
464
+ className="absolute left-0 right-0 opacity-0"
465
+ onLayout={(event) => {
466
+ const measured = event.nativeEvent.layout.height;
467
+ heights.modify((value: Record<string, number>) => {
468
+ 'worklet';
469
+ if (value[item.id] === measured) return value;
470
+ return { ...value, [item.id]: measured };
471
+ });
472
+ }}
473
+ >
474
+ {content}
475
+ </View>
476
+ </Animated.View>
477
+ );
478
+ }
479
+
480
+
481
+ /**
482
+ * The deck for one edge of the screen.
483
+ *
484
+ * Toasts are not laid out in flow — each one is absolutely pinned to the same
485
+ * edge and offset by its stack position, so they overlap like a deck of cards
486
+ * with only the newest fully visible.
487
+ */
488
+ function ToastStack({
489
+ items,
490
+ placement,
491
+ }: {
492
+ items: readonly ToastItem[];
493
+ placement: ToastPlacement;
494
+ }) {
495
+ const insets = useSafeAreaInsets();
496
+ // Natural heights, keyed by toast id. Written from the UI thread by each
497
+ // slot's hidden measuring copy.
498
+ const heights = useSharedValue<Record<string, number>>({});
499
+
500
+ if (items.length === 0) return null;
501
+
502
+ // Keep a couple beyond MAX_VISIBLE mounted so the ones fading out still
503
+ // animate rather than popping.
504
+ const visible = items.slice(-(MAX_VISIBLE + 1));
505
+ const frontId = visible[visible.length - 1]!.id;
506
+
507
+ return (
508
+ // A zero-height anchor sitting at the safe-area edge. The toasts hang off
509
+ // it — upward for bottom placement, downward for top — so the inset is
510
+ // part of the anchor's position rather than padding, which Yoga would
511
+ // ignore for absolutely positioned children.
512
+ <View
513
+ pointerEvents="box-none"
514
+ className="absolute left-0 right-0"
515
+ style={{
516
+ [placement === 'top' ? 'top' : 'bottom']:
517
+ (placement === 'top' ? insets.top : insets.bottom) + EDGE_INSET,
518
+ height: 0,
519
+ }}
520
+ >
521
+ {visible.map((item, index) => (
522
+ <ToastSlot
523
+ key={item.id}
524
+ item={item}
525
+ index={index}
526
+ total={visible.length}
527
+ frontId={frontId}
528
+ heights={heights}
529
+ />
530
+ ))}
531
+ </View>
532
+ );
533
+ }
534
+
535
+ /**
536
+ * Renders the toast queue. PanelUIProvider mounts this automatically — you
537
+ * only need it directly if you build your own provider.
538
+ */
539
+ export function ToastViewport() {
540
+ const items = useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot);
541
+
542
+ if (items.length === 0) return null;
543
+
544
+ const top = items.filter((item) => item.placement === 'top');
545
+ const bottom = items.filter((item) => item.placement !== 'top');
546
+
547
+ return (
548
+ <Portal>
549
+ <ToastStack items={top} placement="top" />
550
+ <ToastStack items={bottom} placement="bottom" />
551
+ </Portal>
552
+ );
553
+ }
554
+
555
+ /**
556
+ * Access the toast API.
557
+ *
558
+ * ```tsx
559
+ * const { toast } = useToast();
560
+ * toast.show({ variant: 'success', label: 'Saved' });
561
+ * ```
562
+ */
563
+ export function useToast() {
564
+ return { toast };
565
+ }
566
+
567
+ export { toast, type ToastItem, type ToastPlacement, type ToastVariant, type ToastHandle };
568
+ export type { ToastOptions } from './toast-store';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Toast queue.
3
+ *
4
+ * A module-level store rather than React state, so `toast.show()` works from
5
+ * anywhere — event handlers, API clients, code outside the component tree —
6
+ * without threading a context through. The viewport subscribes with
7
+ * `useSyncExternalStore`.
8
+ */
9
+ import type { ReactNode } from 'react';
10
+
11
+ export type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'destructive';
12
+ export type ToastPlacement = 'top' | 'bottom';
13
+
14
+ /** Handle given to action callbacks so they can dismiss their own toast. */
15
+ export interface ToastHandle {
16
+ id: string;
17
+ hide: () => void;
18
+ }
19
+
20
+ export interface ToastOptions {
21
+ variant?: ToastVariant;
22
+ placement?: ToastPlacement;
23
+ /** Title line. */
24
+ label?: string;
25
+ description?: string;
26
+ /** Overrides the variant's default status icon. Pass `null` for no icon. */
27
+ icon?: ReactNode;
28
+ /** Renders a trailing action button when set. */
29
+ actionLabel?: string;
30
+ onActionPress?: (handle: ToastHandle) => void;
31
+ /** Show a close button. Defaults to true when there is no action. */
32
+ closable?: boolean;
33
+ /** Milliseconds before auto-dismiss. `0` keeps it up until dismissed. */
34
+ duration?: number;
35
+ /** Fully custom rendering. Receives the same handle as action callbacks. */
36
+ component?: (handle: ToastHandle) => ReactNode;
37
+ }
38
+
39
+ export interface ToastItem extends ToastOptions {
40
+ id: string;
41
+ }
42
+
43
+ const DEFAULT_DURATION = 4000;
44
+
45
+ let counter = 0;
46
+
47
+ class ToastStore {
48
+ private items: ToastItem[] = [];
49
+ private snapshot: readonly ToastItem[] = this.items;
50
+ private listeners = new Set<() => void>();
51
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
52
+
53
+ subscribe = (listener: () => void) => {
54
+ this.listeners.add(listener);
55
+ return () => {
56
+ this.listeners.delete(listener);
57
+ };
58
+ };
59
+
60
+ getSnapshot = (): readonly ToastItem[] => this.snapshot;
61
+
62
+ show = (options: ToastOptions | string): string => {
63
+ const resolved: ToastOptions =
64
+ typeof options === 'string' ? { label: options } : options;
65
+ const id = `toast-${++counter}`;
66
+
67
+ this.items = [...this.items, { ...resolved, id }];
68
+ this.emit();
69
+
70
+ const duration = resolved.duration ?? DEFAULT_DURATION;
71
+ if (duration > 0) {
72
+ this.timers.set(
73
+ id,
74
+ setTimeout(() => this.hide(id), duration)
75
+ );
76
+ }
77
+
78
+ return id;
79
+ };
80
+
81
+ hide = (id: string) => {
82
+ const timer = this.timers.get(id);
83
+ if (timer) {
84
+ clearTimeout(timer);
85
+ this.timers.delete(id);
86
+ }
87
+ if (!this.items.some((item) => item.id === id)) return;
88
+ this.items = this.items.filter((item) => item.id !== id);
89
+ this.emit();
90
+ };
91
+
92
+ hideAll = () => {
93
+ this.timers.forEach(clearTimeout);
94
+ this.timers.clear();
95
+ if (this.items.length === 0) return;
96
+ this.items = [];
97
+ this.emit();
98
+ };
99
+
100
+ private emit() {
101
+ this.snapshot = this.items;
102
+ this.listeners.forEach((listener) => listener());
103
+ }
104
+ }
105
+
106
+ export const toastStore = new ToastStore();
107
+
108
+ /**
109
+ * Imperative toast API. Also returned from `useToast()`.
110
+ *
111
+ * ```ts
112
+ * toast.show('Saved');
113
+ * toast.show({ variant: 'success', label: 'Deployed', actionLabel: 'View' });
114
+ * ```
115
+ */
116
+ export const toast = {
117
+ show: toastStore.show,
118
+ hide: toastStore.hide,
119
+ hideAll: toastStore.hideAll,
120
+ };