@nexub/react-native 0.0.0-stage → 2.2.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.
Files changed (134) hide show
  1. package/README.md +113 -2
  2. package/lib/module/components/actions.js +222 -0
  3. package/lib/module/components/actions.js.map +1 -0
  4. package/lib/module/components/containers.js +300 -0
  5. package/lib/module/components/containers.js.map +1 -0
  6. package/lib/module/components/display.js +299 -0
  7. package/lib/module/components/display.js.map +1 -0
  8. package/lib/module/components/feedback.js +484 -0
  9. package/lib/module/components/feedback.js.map +1 -0
  10. package/lib/module/components/inputs.js +792 -0
  11. package/lib/module/components/inputs.js.map +1 -0
  12. package/lib/module/components/mobile.js +818 -0
  13. package/lib/module/components/mobile.js.map +1 -0
  14. package/lib/module/components/overlays.js +421 -0
  15. package/lib/module/components/overlays.js.map +1 -0
  16. package/lib/module/components/shared.js +57 -0
  17. package/lib/module/components/shared.js.map +1 -0
  18. package/lib/module/hooks/useControllableState.js +23 -0
  19. package/lib/module/hooks/useControllableState.js.map +1 -0
  20. package/lib/module/index.js +31 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/module/patterns/app.js +709 -0
  24. package/lib/module/patterns/app.js.map +1 -0
  25. package/lib/module/patterns/auth.js +224 -0
  26. package/lib/module/patterns/auth.js.map +1 -0
  27. package/lib/module/patterns/home.js +201 -0
  28. package/lib/module/patterns/home.js.map +1 -0
  29. package/lib/module/patterns/index.js +8 -0
  30. package/lib/module/patterns/index.js.map +1 -0
  31. package/lib/module/patterns/money.js +419 -0
  32. package/lib/module/patterns/money.js.map +1 -0
  33. package/lib/module/primitives/Icon.js +17 -0
  34. package/lib/module/primitives/Icon.js.map +1 -0
  35. package/lib/module/primitives/Layout.js +79 -0
  36. package/lib/module/primitives/Layout.js.map +1 -0
  37. package/lib/module/primitives/Pressable.js +79 -0
  38. package/lib/module/primitives/Pressable.js.map +1 -0
  39. package/lib/module/primitives/ScreenContainer.js +58 -0
  40. package/lib/module/primitives/ScreenContainer.js.map +1 -0
  41. package/lib/module/primitives/Surface.js +57 -0
  42. package/lib/module/primitives/Surface.js.map +1 -0
  43. package/lib/module/primitives/Text.js +70 -0
  44. package/lib/module/primitives/Text.js.map +1 -0
  45. package/lib/module/theme/ThemeProvider.js +127 -0
  46. package/lib/module/theme/ThemeProvider.js.map +1 -0
  47. package/lib/module/theme/fonts.js +56 -0
  48. package/lib/module/theme/fonts.js.map +1 -0
  49. package/lib/module/theme/haptics.js +56 -0
  50. package/lib/module/theme/haptics.js.map +1 -0
  51. package/lib/module/theme/motion.js +89 -0
  52. package/lib/module/theme/motion.js.map +1 -0
  53. package/lib/module/theme/navigation.js +99 -0
  54. package/lib/module/theme/navigation.js.map +1 -0
  55. package/lib/typescript/components/actions.d.ts +53 -0
  56. package/lib/typescript/components/actions.d.ts.map +1 -0
  57. package/lib/typescript/components/containers.d.ts +59 -0
  58. package/lib/typescript/components/containers.d.ts.map +1 -0
  59. package/lib/typescript/components/display.d.ts +68 -0
  60. package/lib/typescript/components/display.d.ts.map +1 -0
  61. package/lib/typescript/components/feedback.d.ts +96 -0
  62. package/lib/typescript/components/feedback.d.ts.map +1 -0
  63. package/lib/typescript/components/inputs.d.ts +141 -0
  64. package/lib/typescript/components/inputs.d.ts.map +1 -0
  65. package/lib/typescript/components/mobile.d.ts +173 -0
  66. package/lib/typescript/components/mobile.d.ts.map +1 -0
  67. package/lib/typescript/components/overlays.d.ts +74 -0
  68. package/lib/typescript/components/overlays.d.ts.map +1 -0
  69. package/lib/typescript/components/shared.d.ts +20 -0
  70. package/lib/typescript/components/shared.d.ts.map +1 -0
  71. package/lib/typescript/hooks/useControllableState.d.ts +6 -0
  72. package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
  73. package/lib/typescript/index.d.ts +39 -0
  74. package/lib/typescript/index.d.ts.map +1 -0
  75. package/lib/typescript/package.json +1 -0
  76. package/lib/typescript/patterns/app.d.ts +139 -0
  77. package/lib/typescript/patterns/app.d.ts.map +1 -0
  78. package/lib/typescript/patterns/auth.d.ts +54 -0
  79. package/lib/typescript/patterns/auth.d.ts.map +1 -0
  80. package/lib/typescript/patterns/home.d.ts +50 -0
  81. package/lib/typescript/patterns/home.d.ts.map +1 -0
  82. package/lib/typescript/patterns/index.d.ts +9 -0
  83. package/lib/typescript/patterns/index.d.ts.map +1 -0
  84. package/lib/typescript/patterns/money.d.ts +106 -0
  85. package/lib/typescript/patterns/money.d.ts.map +1 -0
  86. package/lib/typescript/primitives/Icon.d.ts +12 -0
  87. package/lib/typescript/primitives/Icon.d.ts.map +1 -0
  88. package/lib/typescript/primitives/Layout.d.ts +26 -0
  89. package/lib/typescript/primitives/Layout.d.ts.map +1 -0
  90. package/lib/typescript/primitives/Pressable.d.ts +21 -0
  91. package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
  92. package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
  93. package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
  94. package/lib/typescript/primitives/Surface.d.ts +23 -0
  95. package/lib/typescript/primitives/Surface.d.ts.map +1 -0
  96. package/lib/typescript/primitives/Text.d.ts +21 -0
  97. package/lib/typescript/primitives/Text.d.ts.map +1 -0
  98. package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
  99. package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
  100. package/lib/typescript/theme/fonts.d.ts +25 -0
  101. package/lib/typescript/theme/fonts.d.ts.map +1 -0
  102. package/lib/typescript/theme/haptics.d.ts +3 -0
  103. package/lib/typescript/theme/haptics.d.ts.map +1 -0
  104. package/lib/typescript/theme/motion.d.ts +79 -0
  105. package/lib/typescript/theme/motion.d.ts.map +1 -0
  106. package/lib/typescript/theme/navigation.d.ts +158 -0
  107. package/lib/typescript/theme/navigation.d.ts.map +1 -0
  108. package/package.json +138 -3
  109. package/src/components/actions.tsx +259 -0
  110. package/src/components/containers.tsx +352 -0
  111. package/src/components/display.tsx +345 -0
  112. package/src/components/feedback.tsx +541 -0
  113. package/src/components/inputs.tsx +899 -0
  114. package/src/components/mobile.tsx +883 -0
  115. package/src/components/overlays.tsx +488 -0
  116. package/src/components/shared.ts +54 -0
  117. package/src/hooks/useControllableState.ts +27 -0
  118. package/src/index.ts +177 -0
  119. package/src/patterns/app.tsx +715 -0
  120. package/src/patterns/auth.tsx +266 -0
  121. package/src/patterns/home.tsx +211 -0
  122. package/src/patterns/index.ts +59 -0
  123. package/src/patterns/money.tsx +458 -0
  124. package/src/primitives/Icon.tsx +15 -0
  125. package/src/primitives/Layout.tsx +71 -0
  126. package/src/primitives/Pressable.tsx +97 -0
  127. package/src/primitives/ScreenContainer.tsx +65 -0
  128. package/src/primitives/Surface.tsx +75 -0
  129. package/src/primitives/Text.tsx +79 -0
  130. package/src/theme/ThemeProvider.tsx +210 -0
  131. package/src/theme/fonts.ts +52 -0
  132. package/src/theme/haptics.ts +57 -0
  133. package/src/theme/motion.ts +105 -0
  134. package/src/theme/navigation.ts +68 -0
@@ -0,0 +1,541 @@
1
+ import * as React from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ ActivityIndicator,
5
+ Modal,
6
+ Pressable as RNPressable,
7
+ View,
8
+ type StyleProp,
9
+ type ViewProps,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import Animated, {
13
+ FadeInDown,
14
+ FadeOutDown,
15
+ ReduceMotion,
16
+ useAnimatedStyle,
17
+ useSharedValue,
18
+ withRepeat,
19
+ withTiming,
20
+ } from 'react-native-reanimated';
21
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
22
+ import Svg, { Circle } from 'react-native-svg';
23
+ import type { ToneValue } from '@nexub/core';
24
+ import {
25
+ AlertCircle,
26
+ AlertTriangle,
27
+ CheckCircle2,
28
+ Info,
29
+ X,
30
+ type NativeIconProps,
31
+ } from '@nexub/icons/native';
32
+ import { Surface } from '../primitives/Surface';
33
+ import { Text } from '../primitives/Text';
34
+ import { useTheme } from '../theme/ThemeProvider';
35
+ import { nxEnter, nxExit, nxLayout } from '../theme/motion';
36
+ import { Button, IconButton } from './actions';
37
+ import { TONE_CONTAINERS, TONE_TEXT } from './shared';
38
+
39
+ const TONE_ICON: Partial<Record<ToneValue, React.ComponentType<NativeIconProps>>> = {
40
+ info: Info,
41
+ success: CheckCircle2,
42
+ warning: AlertTriangle,
43
+ danger: AlertCircle,
44
+ };
45
+
46
+ // ─── Banner ──────────────────────────────────────────────────────────────────
47
+
48
+ export interface BannerProps extends ViewProps {
49
+ title?: string;
50
+ children?: React.ReactNode;
51
+ /** @default 'info' */
52
+ tone?: ToneValue;
53
+ /** One action, e.g. <Button variant="text">Review</Button>. */
54
+ action?: React.ReactNode;
55
+ onDismiss?: () => void;
56
+ }
57
+
58
+ /** Persistent in-page message (web Banner / Alert / Callout). */
59
+ export function Banner({
60
+ title,
61
+ children,
62
+ tone = 'info',
63
+ action,
64
+ onDismiss,
65
+ style,
66
+ ...rest
67
+ }: BannerProps) {
68
+ const { roles, tokens, messages } = useTheme();
69
+ const [bg, fg] = TONE_CONTAINERS[tone];
70
+ const Glyph = TONE_ICON[tone] ?? Info;
71
+ return (
72
+ // Drops in when shown, fades out when dismissed; content below glides up.
73
+ <Animated.View
74
+ entering={nxEnter('down')}
75
+ exiting={nxExit('fade')}
76
+ layout={nxLayout()}
77
+ accessibilityRole={tone === 'danger' || tone === 'warning' ? 'alert' : 'summary'}
78
+ style={[
79
+ {
80
+ flexDirection: 'row',
81
+ gap: tokens.spacing[3],
82
+ padding: tokens.spacing[4],
83
+ borderRadius: tokens.shape.container,
84
+ backgroundColor: roles[bg],
85
+ },
86
+ style,
87
+ ]}
88
+ {...rest}
89
+ >
90
+ <Glyph size="md" color={roles[fg]} />
91
+ <View style={{ flex: 1, gap: 4 }}>
92
+ {title && (
93
+ <Text variant="title-sm" color={fg}>
94
+ {title}
95
+ </Text>
96
+ )}
97
+ {typeof children === 'string' ? (
98
+ <Text variant="body-sm" color={fg}>
99
+ {children}
100
+ </Text>
101
+ ) : (
102
+ children
103
+ )}
104
+ {action && <View style={{ marginTop: 4 }}>{action}</View>}
105
+ </View>
106
+ {onDismiss && (
107
+ <IconButton
108
+ icon={X}
109
+ label={messages.dismissBanner}
110
+ size="xs"
111
+ onPress={onDismiss}
112
+ style={{ marginTop: -8, marginEnd: -8 }}
113
+ />
114
+ )}
115
+ </Animated.View>
116
+ );
117
+ }
118
+
119
+ // ─── Toast ───────────────────────────────────────────────────────────────────
120
+
121
+ export interface ToastOptions {
122
+ message: string;
123
+ /** One short action ("Undo"). */
124
+ action?: { label: string; onPress: () => void };
125
+ tone?: ToneValue;
126
+ /** 'short' 4 s, 'long' 10 s (Material snackbar timings). @default 'long' with an action (time to reach it, WCAG 2.2.1), else 'short' */
127
+ duration?: 'short' | 'long';
128
+ }
129
+
130
+ const ToastCtx = React.createContext<(t: ToastOptions) => void>(() => {});
131
+
132
+ export interface ToastProviderProps {
133
+ children: React.ReactNode;
134
+ /** Extra space above the bottom inset, e.g. the tab bar height. @default 0 */
135
+ bottomOffset?: number;
136
+ }
137
+
138
+ /** Hosts one toast at a time above the bottom safe area. Put it inside NexubNativeProvider. */
139
+ export function ToastProvider({ children, bottomOffset = 0 }: ToastProviderProps) {
140
+ const { roles, tokens } = useTheme();
141
+ const insets = useSafeAreaInsets();
142
+ const [toast, setToast] = React.useState<(ToastOptions & { id: number }) | null>(null);
143
+ const show = React.useCallback((t: ToastOptions) => {
144
+ setToast({ ...t, id: Date.now() });
145
+ AccessibilityInfo.announceForAccessibility(t.message);
146
+ }, []);
147
+ React.useEffect(() => {
148
+ if (!toast) return;
149
+ const timer = setTimeout(
150
+ () => setToast(null),
151
+ (toast.duration ?? (toast.action ? 'long' : 'short')) === 'long' ? 10_000 : 4_000,
152
+ );
153
+ return () => clearTimeout(timer);
154
+ }, [toast]);
155
+ const Glyph = toast?.tone ? TONE_ICON[toast.tone] : undefined;
156
+ return (
157
+ <ToastCtx.Provider value={show}>
158
+ {children}
159
+ {toast && (
160
+ // Reanimated's stock fade, as in 2.1.0. With the nxEnter/nxExit presets
161
+ // (spring or timed) iOS UI tests never found the toast on screen.
162
+ <Animated.View
163
+ key={toast.id}
164
+ entering={FadeInDown}
165
+ exiting={FadeOutDown}
166
+ pointerEvents="box-none"
167
+ style={{
168
+ position: 'absolute',
169
+ start: 16,
170
+ end: 16,
171
+ bottom: insets.bottom + 8 + bottomOffset,
172
+ }}
173
+ >
174
+ <View
175
+ accessibilityLiveRegion="polite"
176
+ style={{
177
+ minHeight: 48,
178
+ flexDirection: 'row',
179
+ alignItems: 'center',
180
+ gap: tokens.spacing[3],
181
+ paddingStart: tokens.spacing[4],
182
+ paddingEnd: toast.action ? tokens.spacing[1] : tokens.spacing[4],
183
+ paddingVertical: tokens.spacing[2],
184
+ borderRadius: tokens.shape.container,
185
+ backgroundColor: roles['inverse-surface'],
186
+ boxShadow: tokens.elevation[3].light,
187
+ }}
188
+ >
189
+ {Glyph && toast.tone && (
190
+ <Glyph
191
+ size="md"
192
+ color={roles[toast.tone === 'neutral' ? 'inverse-on-surface' : 'inverse-primary']}
193
+ />
194
+ )}
195
+ <Text variant="body-md" color="inverse-on-surface" style={{ flex: 1 }}>
196
+ {toast.message}
197
+ </Text>
198
+ {toast.action && (
199
+ <Button
200
+ variant="text"
201
+ size="sm"
202
+ onPress={() => {
203
+ toast.action!.onPress();
204
+ setToast(null);
205
+ }}
206
+ style={{ backgroundColor: 'transparent' }}
207
+ >
208
+ {toast.action.label}
209
+ </Button>
210
+ )}
211
+ </View>
212
+ </Animated.View>
213
+ )}
214
+ </ToastCtx.Provider>
215
+ );
216
+ }
217
+
218
+ /** `const toast = useToast(); toast({ message: 'Saved' })`. Needs ToastProvider. */
219
+ export const useToast = () => React.useContext(ToastCtx);
220
+
221
+ // ─── Dialog ──────────────────────────────────────────────────────────────────
222
+
223
+ export interface DialogProps {
224
+ open: boolean;
225
+ onOpenChange: (open: boolean) => void;
226
+ title: string;
227
+ description?: string;
228
+ /** Extra content between the description and the actions. */
229
+ children?: React.ReactNode;
230
+ /** 1–2 Buttons; put the confirming action last. */
231
+ actions: React.ReactNode;
232
+ /** Tapping the scrim or Android back closes it. @default true */
233
+ dismissable?: boolean;
234
+ }
235
+
236
+ /** Modal alert for decisions that block the flow. Prefer a sheet for anything longer. */
237
+ export function Dialog({
238
+ open,
239
+ onOpenChange,
240
+ title,
241
+ description,
242
+ children,
243
+ actions,
244
+ dismissable = true,
245
+ }: DialogProps) {
246
+ const { roles, tokens } = useTheme();
247
+ return (
248
+ <Modal
249
+ transparent
250
+ visible={open}
251
+ animationType="fade"
252
+ statusBarTranslucent
253
+ navigationBarTranslucent
254
+ onRequestClose={() => {
255
+ if (dismissable) onOpenChange(false);
256
+ }}
257
+ >
258
+ <View
259
+ style={{
260
+ flex: 1,
261
+ alignItems: 'center',
262
+ justifyContent: 'center',
263
+ padding: tokens.spacing[6],
264
+ }}
265
+ >
266
+ <RNPressable
267
+ accessibilityElementsHidden
268
+ importantForAccessibility="no-hide-descendants"
269
+ onPress={() => dismissable && onOpenChange(false)}
270
+ style={{
271
+ position: 'absolute',
272
+ top: 0,
273
+ bottom: 0,
274
+ start: 0,
275
+ end: 0,
276
+ backgroundColor: roles.scrim,
277
+ opacity: 0.32,
278
+ }}
279
+ />
280
+ <Surface
281
+ accessibilityViewIsModal
282
+ accessibilityRole="alert"
283
+ tone="surface-elevated"
284
+ elevation={3}
285
+ radius="overlay"
286
+ style={{
287
+ width: '100%',
288
+ maxWidth: 400,
289
+ padding: tokens.spacing[6],
290
+ gap: tokens.spacing[4],
291
+ }}
292
+ >
293
+ <View style={{ gap: tokens.spacing[2] }}>
294
+ <Text variant="headline-sm" accessibilityRole="header">
295
+ {title}
296
+ </Text>
297
+ {description && (
298
+ <Text variant="body-md" color="on-surface-variant">
299
+ {description}
300
+ </Text>
301
+ )}
302
+ </View>
303
+ {children}
304
+ <View
305
+ style={{
306
+ flexDirection: 'row',
307
+ justifyContent: 'flex-end',
308
+ flexWrap: 'wrap',
309
+ gap: tokens.spacing[2],
310
+ }}
311
+ >
312
+ {actions}
313
+ </View>
314
+ </Surface>
315
+ </View>
316
+ </Modal>
317
+ );
318
+ }
319
+
320
+ // ─── Progress ────────────────────────────────────────────────────────────────
321
+
322
+ export interface ProgressProps {
323
+ /** 0–1. */
324
+ value: number;
325
+ label: string;
326
+ /** @default 'linear' */
327
+ variant?: 'linear' | 'circular';
328
+ /** Circular diameter. @default 40 */
329
+ size?: number;
330
+ style?: StyleProp<ViewStyle>;
331
+ }
332
+
333
+ /** Determinate progress. For unknown waits use LoadingIndicator. */
334
+ export function Progress({ value, label, variant = 'linear', size = 40, style }: ProgressProps) {
335
+ const { roles } = useTheme();
336
+ const v = Math.min(1, Math.max(0, value));
337
+ const a11y = {
338
+ accessible: true,
339
+ accessibilityRole: 'progressbar' as const,
340
+ accessibilityLabel: label,
341
+ accessibilityValue: { min: 0, max: 100, now: Math.round(v * 100) },
342
+ };
343
+ if (variant === 'circular') {
344
+ const stroke = 4;
345
+ const r = (size - stroke) / 2;
346
+ const c = 2 * Math.PI * r;
347
+ return (
348
+ <View {...a11y} style={[{ width: size, height: size }, style]}>
349
+ <Svg width={size} height={size} style={{ transform: [{ rotate: '-90deg' }] }}>
350
+ <Circle
351
+ cx={size / 2}
352
+ cy={size / 2}
353
+ r={r}
354
+ stroke={roles['surface-container-highest']}
355
+ strokeWidth={stroke}
356
+ fill="none"
357
+ />
358
+ <Circle
359
+ cx={size / 2}
360
+ cy={size / 2}
361
+ r={r}
362
+ stroke={roles.primary}
363
+ strokeWidth={stroke}
364
+ fill="none"
365
+ strokeLinecap="round"
366
+ strokeDasharray={`${c * v} ${c}`}
367
+ />
368
+ </Svg>
369
+ </View>
370
+ );
371
+ }
372
+ return (
373
+ <View
374
+ {...a11y}
375
+ style={[
376
+ {
377
+ height: 6,
378
+ borderRadius: 3,
379
+ overflow: 'hidden',
380
+ backgroundColor: roles['surface-container-highest'],
381
+ },
382
+ style,
383
+ ]}
384
+ >
385
+ <View
386
+ style={{ width: `${v * 100}%`, height: 6, borderRadius: 3, backgroundColor: roles.primary }}
387
+ />
388
+ </View>
389
+ );
390
+ }
391
+
392
+ /** The platform activity indicator in the primary role. */
393
+ export function LoadingIndicator({
394
+ size = 'large',
395
+ label,
396
+ }: {
397
+ size?: 'small' | 'large';
398
+ label?: string;
399
+ }) {
400
+ const { roles, messages } = useTheme();
401
+ return (
402
+ <ActivityIndicator
403
+ size={size}
404
+ color={roles.primary}
405
+ accessibilityLabel={label ?? messages.loading}
406
+ />
407
+ );
408
+ }
409
+
410
+ // ─── Skeleton ────────────────────────────────────────────────────────────────
411
+
412
+ export interface SkeletonProps {
413
+ width?: ViewStyle['width'];
414
+ /** @default 16 */
415
+ height?: number;
416
+ /** @default 'field' */
417
+ radius?: 'field' | 'control' | 'container' | number;
418
+ style?: StyleProp<ViewStyle>;
419
+ }
420
+
421
+ /**
422
+ * Loading placeholder. Breathes between full and half opacity (a slow, calm
423
+ * shimmer); static under Reduce Motion. Hidden from screen readers.
424
+ */
425
+ export function Skeleton({ width = '100%', height = 16, radius = 'field', style }: SkeletonProps) {
426
+ const { roles, tokens, reducedMotion } = useTheme();
427
+ const pulse = useSharedValue(1);
428
+ React.useEffect(() => {
429
+ if (reducedMotion) return;
430
+ pulse.value = withRepeat(
431
+ withTiming(0.5, { duration: SHIMMER_MS, reduceMotion: ReduceMotion.System }),
432
+ -1,
433
+ true,
434
+ );
435
+ }, [reducedMotion, pulse]);
436
+ const animated = useAnimatedStyle(() => ({ opacity: pulse.value }));
437
+ return (
438
+ <Animated.View
439
+ accessibilityElementsHidden
440
+ importantForAccessibility="no-hide-descendants"
441
+ style={[
442
+ {
443
+ width,
444
+ height,
445
+ borderRadius: typeof radius === 'number' ? radius : tokens.shape[radius],
446
+ backgroundColor: roles['surface-container-high'],
447
+ },
448
+ animated,
449
+ style,
450
+ ]}
451
+ />
452
+ );
453
+ }
454
+
455
+ // Half a skeleton breath, matching web's animate-pulse (2 s per cycle).
456
+ const SHIMMER_MS = 1000;
457
+
458
+ // ─── EmptyState / ErrorState ─────────────────────────────────────────────────
459
+
460
+ export interface EmptyStateProps extends ViewProps {
461
+ title: string;
462
+ description?: string;
463
+ icon?: React.ComponentType<NativeIconProps>;
464
+ /** @default 'neutral' */
465
+ tone?: ToneValue;
466
+ /** Primary next step, e.g. <Button>Add money</Button>. */
467
+ action?: React.ReactNode;
468
+ }
469
+
470
+ /** Nothing here yet: say why and offer the next step. */
471
+ export function EmptyState({
472
+ title,
473
+ description,
474
+ icon: Glyph = Info,
475
+ tone = 'neutral',
476
+ action,
477
+ style,
478
+ ...rest
479
+ }: EmptyStateProps) {
480
+ const { roles, tokens } = useTheme();
481
+ const [bg] = TONE_CONTAINERS[tone];
482
+ return (
483
+ <View
484
+ style={[
485
+ {
486
+ alignItems: 'center',
487
+ gap: tokens.spacing[3],
488
+ paddingVertical: tokens.spacing[10],
489
+ paddingHorizontal: tokens.spacing[6],
490
+ },
491
+ style,
492
+ ]}
493
+ {...rest}
494
+ >
495
+ <View
496
+ style={{
497
+ width: 64,
498
+ height: 64,
499
+ borderRadius: 32,
500
+ alignItems: 'center',
501
+ justifyContent: 'center',
502
+ backgroundColor: roles[bg],
503
+ }}
504
+ >
505
+ <Glyph size="lg" color={roles[TONE_TEXT[tone]]} />
506
+ </View>
507
+ <Text variant="title-lg" accessibilityRole="header" style={{ textAlign: 'center' }}>
508
+ {title}
509
+ </Text>
510
+ {description && (
511
+ <Text variant="body-md" color="on-surface-variant" style={{ textAlign: 'center' }}>
512
+ {description}
513
+ </Text>
514
+ )}
515
+ {action && <View style={{ marginTop: tokens.spacing[2] }}>{action}</View>}
516
+ </View>
517
+ );
518
+ }
519
+
520
+ export interface ErrorStateProps extends Omit<EmptyStateProps, 'tone' | 'action'> {
521
+ /** Shows a Retry button (messages.retry). */
522
+ onRetry?: () => void;
523
+ }
524
+
525
+ export function ErrorState({ onRetry, icon = AlertTriangle, ...rest }: ErrorStateProps) {
526
+ const { messages } = useTheme();
527
+ return (
528
+ <EmptyState
529
+ tone="danger"
530
+ icon={icon}
531
+ action={
532
+ onRetry && (
533
+ <Button variant="tonal" onPress={onRetry}>
534
+ {messages.retry}
535
+ </Button>
536
+ )
537
+ }
538
+ {...rest}
539
+ />
540
+ );
541
+ }