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