@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,352 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Switch as RNSwitch,
4
+ View,
5
+ type StyleProp,
6
+ type ViewProps,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+ import Animated, { LayoutAnimationConfig } from 'react-native-reanimated';
10
+ import { Check, ChevronDown, ChevronRight } from '@nexub/icons/native';
11
+ import { useControllableState } from '../hooks/useControllableState';
12
+ import { Pressable } from '../primitives/Pressable';
13
+ import { Surface, type SurfaceProps } from '../primitives/Surface';
14
+ import { Text } from '../primitives/Text';
15
+ import { useTheme } from '../theme/ThemeProvider';
16
+ import { nxEnter, nxExit, nxLayout } from '../theme/motion';
17
+ import { Divider } from './display';
18
+ import { disabledOpacity } from './shared';
19
+
20
+ // ─── Card ────────────────────────────────────────────────────────────────────
21
+
22
+ export interface CardProps extends SurfaceProps {
23
+ /** Makes the whole card one pressable target (navigates to detail). */
24
+ onPress?: () => void;
25
+ /** Accessible name when pressable. */
26
+ accessibilityLabel?: string;
27
+ }
28
+
29
+ /** Content container on a tonal tier. Hierarchy through tone, not shadow (elevation for floating cards only). */
30
+ export function Card({
31
+ onPress,
32
+ accessibilityLabel,
33
+ tone = 'surface-container-low',
34
+ radius = 'container',
35
+ style,
36
+ children,
37
+ ...rest
38
+ }: CardProps) {
39
+ const { tokens } = useTheme();
40
+ const surface = (
41
+ <Surface
42
+ tone={tone}
43
+ radius={radius}
44
+ style={[{ padding: tokens.spacing[4], gap: tokens.spacing[2] }, style]}
45
+ {...rest}
46
+ >
47
+ {children}
48
+ </Surface>
49
+ );
50
+ if (!onPress) return surface;
51
+ return (
52
+ <Pressable
53
+ accessibilityLabel={accessibilityLabel}
54
+ onPress={onPress}
55
+ style={{ borderRadius: tokens.shape[radius as 'container'] ?? radius }}
56
+ >
57
+ {surface}
58
+ </Pressable>
59
+ );
60
+ }
61
+
62
+ // ─── ListItem ────────────────────────────────────────────────────────────────
63
+
64
+ export type ListItemVariant =
65
+ 'static' | 'navigation' | 'switch' | 'checkbox' | 'radio' | 'button' | 'value';
66
+
67
+ export interface ListItemProps {
68
+ title: string;
69
+ subtitle?: string;
70
+ /** Icon, Avatar or flag before the text. */
71
+ leading?: React.ReactNode;
72
+ /** Trailing text (variant 'value' or 'navigation'), e.g. "English" or an amount. */
73
+ value?: string;
74
+ /** Custom trailing content (overrides the variant's). */
75
+ trailing?: React.ReactNode;
76
+ /** @default 'navigation' when onPress is set, else 'static' */
77
+ variant?: ListItemVariant;
78
+ /** For switch / checkbox / radio rows. */
79
+ checked?: boolean;
80
+ onCheckedChange?: (checked: boolean) => void;
81
+ onPress?: () => void;
82
+ /** Button rows: red, for destructive actions (Sign out, Delete). */
83
+ destructive?: boolean;
84
+ disabled?: boolean;
85
+ style?: StyleProp<ViewStyle>;
86
+ }
87
+
88
+ /**
89
+ * One row of a list (Wise "List Item" model). The whole row is a single
90
+ * target and a single screen-reader element: "Title, subtitle, value".
91
+ */
92
+ export function ListItem({
93
+ title,
94
+ subtitle,
95
+ leading,
96
+ value,
97
+ trailing,
98
+ variant: variantProp,
99
+ checked,
100
+ onCheckedChange,
101
+ onPress,
102
+ destructive,
103
+ disabled,
104
+ style,
105
+ }: ListItemProps) {
106
+ const theme = useTheme();
107
+ const { roles, tokens, dir } = theme;
108
+ const variant: ListItemVariant = variantProp ?? (onPress ? 'navigation' : 'static');
109
+ const toggles = variant === 'switch' || variant === 'checkbox' || variant === 'radio';
110
+ const press = toggles ? () => onCheckedChange?.(variant === 'radio' ? true : !checked) : onPress;
111
+ const interactive = !!press && variant !== 'static' && variant !== 'value';
112
+ const role = (
113
+ {
114
+ switch: 'switch',
115
+ checkbox: 'checkbox',
116
+ radio: 'radio',
117
+ navigation: 'button',
118
+ button: 'button',
119
+ } as const
120
+ )[variant as 'switch'];
121
+
122
+ const trail =
123
+ trailing ??
124
+ (variant === 'switch' ? (
125
+ <View
126
+ pointerEvents="none"
127
+ importantForAccessibility="no-hide-descendants"
128
+ accessibilityElementsHidden
129
+ >
130
+ <RNSwitch
131
+ value={!!checked}
132
+ trackColor={{ false: roles['surface-container-highest'], true: roles.primary }}
133
+ ios_backgroundColor={roles['surface-container-highest']}
134
+ thumbColor={roles['surface-container-lowest']}
135
+ />
136
+ </View>
137
+ ) : variant === 'checkbox' || variant === 'radio' ? (
138
+ checked ? (
139
+ <Check size="md" color={roles.primary} />
140
+ ) : null
141
+ ) : variant === 'navigation' ? (
142
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
143
+ {value && (
144
+ <Text variant="body-md" color="on-surface-variant" tabular numberOfLines={1}>
145
+ {value}
146
+ </Text>
147
+ )}
148
+ <ChevronRight
149
+ size="sm"
150
+ color={roles['on-surface-muted']}
151
+ style={dir === 'rtl' ? { transform: [{ scaleX: -1 }] } : undefined}
152
+ />
153
+ </View>
154
+ ) : variant === 'value' && value ? (
155
+ <Text variant="body-md" color="on-surface-variant" tabular numberOfLines={1}>
156
+ {value}
157
+ </Text>
158
+ ) : null);
159
+
160
+ const content = (
161
+ <View
162
+ style={[
163
+ {
164
+ minHeight: subtitle ? 72 : 56,
165
+ paddingHorizontal: tokens.spacing[4],
166
+ paddingVertical: tokens.spacing[3],
167
+ flexDirection: 'row',
168
+ alignItems: 'center',
169
+ gap: tokens.spacing[3],
170
+ opacity: disabled ? disabledOpacity(theme) : 1,
171
+ },
172
+ style,
173
+ ]}
174
+ >
175
+ {leading}
176
+ <View style={{ flex: 1, gap: 2, alignItems: variant === 'button' ? 'center' : 'flex-start' }}>
177
+ <Text
178
+ variant="body-lg"
179
+ color={variant === 'button' ? (destructive ? 'error' : 'primary') : 'on-surface'}
180
+ >
181
+ {title}
182
+ </Text>
183
+ {subtitle && (
184
+ <Text variant="body-sm" color="on-surface-variant">
185
+ {subtitle}
186
+ </Text>
187
+ )}
188
+ </View>
189
+ {trail}
190
+ </View>
191
+ );
192
+
193
+ const a11yLabel = [title, subtitle, variant === 'switch' || toggles ? undefined : value]
194
+ .filter(Boolean)
195
+ .join(', ');
196
+ if (!interactive) {
197
+ return (
198
+ <View accessible accessibilityLabel={a11yLabel}>
199
+ {content}
200
+ </View>
201
+ );
202
+ }
203
+ return (
204
+ <Pressable
205
+ accessibilityRole={role}
206
+ accessibilityLabel={a11yLabel}
207
+ accessibilityState={{ disabled: !!disabled, checked: toggles ? !!checked : undefined }}
208
+ disabled={disabled}
209
+ haptic={
210
+ variant === 'radio' && !checked
211
+ ? 'selection'
212
+ : variant === 'checkbox'
213
+ ? 'selection'
214
+ : undefined
215
+ }
216
+ onPress={press}
217
+ pressScale={false}
218
+ >
219
+ {content}
220
+ </Pressable>
221
+ );
222
+ }
223
+
224
+ // ─── ListSection ─────────────────────────────────────────────────────────────
225
+
226
+ export interface ListSectionProps extends ViewProps {
227
+ /** Section title above the group. */
228
+ header?: string;
229
+ /** Helper text below the group. */
230
+ footer?: string;
231
+ /** Divider inset: 16 for text rows, 56 when rows have a leading icon. @default 16 */
232
+ inset?: number;
233
+ children: React.ReactNode;
234
+ }
235
+
236
+ /** Inset-grouped rows on a container tier, hairlines between rows only. */
237
+ export function ListSection({
238
+ header,
239
+ footer,
240
+ inset = 16,
241
+ children,
242
+ style,
243
+ ...rest
244
+ }: ListSectionProps) {
245
+ const { roles, tokens } = useTheme();
246
+ const rows = React.Children.toArray(children).filter(Boolean);
247
+ return (
248
+ <View style={[{ gap: tokens.spacing[2] }, style]} {...rest}>
249
+ {header && (
250
+ <Text
251
+ variant="title-sm"
252
+ color="on-surface-variant"
253
+ accessibilityRole="header"
254
+ style={{ paddingHorizontal: tokens.spacing[4] }}
255
+ >
256
+ {header}
257
+ </Text>
258
+ )}
259
+ <View
260
+ style={{
261
+ borderRadius: tokens.shape.container,
262
+ overflow: 'hidden',
263
+ backgroundColor: roles['surface-container-lowest'],
264
+ }}
265
+ >
266
+ {/* Rows added later grow in, removed rows fade out and the rest glide
267
+ into place. Rows present on first render don't animate. */}
268
+ <LayoutAnimationConfig skipEntering>
269
+ {rows.map((row, i) => (
270
+ <Animated.View
271
+ key={React.isValidElement(row) && row.key != null ? row.key : i}
272
+ entering={nxEnter('fade')}
273
+ exiting={nxExit('fade')}
274
+ layout={nxLayout()}
275
+ >
276
+ {i > 0 && <Divider inset={inset} />}
277
+ {row}
278
+ </Animated.View>
279
+ ))}
280
+ </LayoutAnimationConfig>
281
+ </View>
282
+ {footer && (
283
+ <Text
284
+ variant="body-sm"
285
+ color="on-surface-variant"
286
+ style={{ paddingHorizontal: tokens.spacing[4] }}
287
+ >
288
+ {footer}
289
+ </Text>
290
+ )}
291
+ </View>
292
+ );
293
+ }
294
+
295
+ // ─── Accordion ───────────────────────────────────────────────────────────────
296
+
297
+ export interface AccordionProps {
298
+ title: string;
299
+ expanded?: boolean;
300
+ defaultExpanded?: boolean;
301
+ onExpandedChange?: (expanded: boolean) => void;
302
+ children: React.ReactNode;
303
+ style?: StyleProp<ViewStyle>;
304
+ }
305
+
306
+ /** Disclosure: a header row that shows or hides its content. */
307
+ export function Accordion({
308
+ title,
309
+ expanded,
310
+ defaultExpanded = false,
311
+ onExpandedChange,
312
+ children,
313
+ style,
314
+ }: AccordionProps) {
315
+ const { roles, tokens } = useTheme();
316
+ const [open, setOpen] = useControllableState({
317
+ value: expanded,
318
+ defaultValue: defaultExpanded,
319
+ onChange: onExpandedChange,
320
+ });
321
+ return (
322
+ <View style={style}>
323
+ <Pressable
324
+ accessibilityLabel={title}
325
+ accessibilityState={{ expanded: open }}
326
+ onPress={() => setOpen(!open)}
327
+ pressScale={false}
328
+ style={{
329
+ minHeight: 56,
330
+ paddingHorizontal: tokens.spacing[4],
331
+ flexDirection: 'row',
332
+ alignItems: 'center',
333
+ gap: tokens.spacing[3],
334
+ }}
335
+ >
336
+ <Text variant="title-md" style={{ flex: 1 }}>
337
+ {title}
338
+ </Text>
339
+ <ChevronDown
340
+ size="sm"
341
+ color={roles['on-surface-variant']}
342
+ style={open ? { transform: [{ rotate: '180deg' }] } : undefined}
343
+ />
344
+ </Pressable>
345
+ {open && (
346
+ <View style={{ paddingHorizontal: tokens.spacing[4], paddingBottom: tokens.spacing[4] }}>
347
+ {children}
348
+ </View>
349
+ )}
350
+ </View>
351
+ );
352
+ }
@@ -0,0 +1,345 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Image,
4
+ StyleSheet,
5
+ View,
6
+ type StyleProp,
7
+ type ViewProps,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import Animated, { LayoutAnimationConfig } from 'react-native-reanimated';
11
+ import { moneyFormatter, type ToneValue } from '@nexub/core';
12
+ import type { NativeIconProps } from '@nexub/icons/native';
13
+ import { Text } from '../primitives/Text';
14
+ import { useTheme } from '../theme/ThemeProvider';
15
+ import { nxEnter } from '../theme/motion';
16
+ import { TONE_CONTAINERS, TONE_ROLES, TONE_TEXT } from './shared';
17
+
18
+ // ─── Amount ──────────────────────────────────────────────────────────────────
19
+
20
+ export interface AmountProps {
21
+ /**
22
+ * Animate value changes: the new figure rises in on the spatial spring.
23
+ * Wraps the text in a view, so leave it off when Amount sits inside other text.
24
+ * @default false
25
+ */
26
+ animate?: boolean;
27
+ value: number;
28
+ /** ISO 4217 code, e.g. 'USD', 'EUR', 'INR'. */
29
+ currency: string;
30
+ /** BCP 47 locale. @default the provider's */
31
+ locale?: string;
32
+ /** `display`: a hero figure in the brand face with a quieter symbol and minor units. @default 'md' */
33
+ size?: 'sm' | 'md' | 'lg' | 'display';
34
+ /** `auto` colours gains success and losses error (use with signDisplay). @default 'neutral' */
35
+ tone?: 'neutral' | 'auto';
36
+ /** e.g. 'exceptZero' to show "+" on gains. */
37
+ signDisplay?: Intl.NumberFormatOptions['signDisplay'];
38
+ style?: StyleProp<ViewStyle>;
39
+ }
40
+
41
+ const AMOUNT_TYPE = {
42
+ sm: 'body-sm',
43
+ md: 'body-md',
44
+ lg: 'title-lg',
45
+ display: 'display-sm',
46
+ } as const;
47
+
48
+ /** Money, formatted by Intl with tabular figures. Same API as web `Amount`. */
49
+ export function Amount({
50
+ value,
51
+ currency,
52
+ locale,
53
+ size = 'md',
54
+ tone = 'neutral',
55
+ signDisplay,
56
+ animate = false,
57
+ style,
58
+ }: AmountProps) {
59
+ const figure = (
60
+ <AmountFigure value={value} currency={currency} locale={locale} size={size} tone={tone} signDisplay={signDisplay} style={style} />
61
+ );
62
+ if (!animate) return figure;
63
+ return (
64
+ <LayoutAnimationConfig skipEntering>
65
+ <Animated.View key={value} entering={nxEnter('up', 'fast')} style={{ alignSelf: 'flex-start' }}>
66
+ {figure}
67
+ </Animated.View>
68
+ </LayoutAnimationConfig>
69
+ );
70
+ }
71
+
72
+ function AmountFigure({
73
+ value,
74
+ currency,
75
+ locale,
76
+ size = 'md',
77
+ tone = 'neutral',
78
+ signDisplay,
79
+ style,
80
+ }: Omit<AmountProps, 'animate'>) {
81
+ const theme = useTheme();
82
+ const fmt = moneyFormatter(currency, locale ?? theme.locale, signDisplay ? { signDisplay } : {});
83
+ const color = tone === 'auto' && value !== 0 ? (value > 0 ? 'success' : 'error') : 'on-surface';
84
+ const variant = AMOUNT_TYPE[size];
85
+ // Hermes on iOS has no formatToParts: show the hero figure as one string there.
86
+ if (size !== 'display' || typeof fmt.formatToParts !== 'function') {
87
+ return (
88
+ <Text variant={variant} color={color} tabular style={style as never}>
89
+ {fmt.format(value)}
90
+ </Text>
91
+ );
92
+ }
93
+ // Hero figure: symbol and minor units quieter, like web.
94
+ const minor = (t: string) => t === 'currency' || t === 'decimal' || t === 'fraction';
95
+ return (
96
+ <Text
97
+ variant={variant}
98
+ color={color}
99
+ tabular
100
+ accessibilityLabel={fmt.format(value)}
101
+ style={style as never}
102
+ >
103
+ {fmt.formatToParts(value).map((p, i) =>
104
+ minor(p.type) ? (
105
+ <Text key={i} variant="title-lg" color="on-surface-variant">
106
+ {p.value}
107
+ </Text>
108
+ ) : (
109
+ p.value
110
+ ),
111
+ )}
112
+ </Text>
113
+ );
114
+ }
115
+
116
+ // ─── Badge ───────────────────────────────────────────────────────────────────
117
+
118
+ export interface BadgeProps extends ViewProps {
119
+ children?: string | number;
120
+ /** @default 'neutral' */
121
+ tone?: ToneValue;
122
+ /** @default 'soft' */
123
+ variant?: 'solid' | 'soft' | 'outline';
124
+ /** @default 'md' */
125
+ size?: 'sm' | 'md';
126
+ /** A status dot before the label (replaces web StatusPill). */
127
+ dot?: boolean;
128
+ leadingIcon?: React.ComponentType<NativeIconProps>;
129
+ }
130
+
131
+ /** Status, count or category label. Never tappable; use `Chip` for anything a user toggles. */
132
+ export function Badge({
133
+ children,
134
+ tone = 'neutral',
135
+ variant = 'soft',
136
+ size = 'md',
137
+ dot,
138
+ leadingIcon: Lead,
139
+ style,
140
+ ...rest
141
+ }: BadgeProps) {
142
+ const { roles, tokens } = useTheme();
143
+ const [bg, fg] = variant === 'solid' ? TONE_ROLES[tone] : TONE_CONTAINERS[tone];
144
+ const fgColor = variant === 'outline' ? TONE_TEXT[tone] : fg;
145
+ const height = size === 'sm' ? 20 : 24;
146
+ return (
147
+ <View
148
+ style={[
149
+ {
150
+ height,
151
+ minWidth: height,
152
+ paddingHorizontal: size === 'sm' ? 6 : 8,
153
+ borderRadius: tokens.shape.control,
154
+ flexDirection: 'row',
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ gap: 4,
158
+ alignSelf: 'flex-start',
159
+ backgroundColor: variant === 'outline' ? 'transparent' : roles[bg],
160
+ },
161
+ variant === 'outline' && { borderWidth: 1, borderColor: roles['outline-variant'] },
162
+ style,
163
+ ]}
164
+ {...rest}
165
+ >
166
+ {dot && (
167
+ <View
168
+ style={{
169
+ width: 6,
170
+ height: 6,
171
+ borderRadius: 3,
172
+ backgroundColor:
173
+ roles[TONE_TEXT[tone] === 'on-surface-variant' ? 'outline' : TONE_TEXT[tone]],
174
+ }}
175
+ />
176
+ )}
177
+ {Lead && <Lead size="xs" color={roles[fgColor]} />}
178
+ {children !== undefined && (
179
+ <Text
180
+ variant={size === 'sm' ? 'label-sm' : 'label-md'}
181
+ color={fgColor}
182
+ tabular
183
+ numberOfLines={1}
184
+ >
185
+ {String(children)}
186
+ </Text>
187
+ )}
188
+ </View>
189
+ );
190
+ }
191
+
192
+ // ─── Avatar ──────────────────────────────────────────────────────────────────
193
+
194
+ export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
195
+
196
+ export interface AvatarProps extends ViewProps {
197
+ /** Image URL. Falls back to initials when missing or broken. */
198
+ src?: string;
199
+ /** Person or org name: initials and the accessible name. */
200
+ name?: string;
201
+ /** @default 'md' */
202
+ size?: AvatarSize;
203
+ /** Online/status dot. */
204
+ badge?: boolean;
205
+ }
206
+
207
+ const initials = (name = '') =>
208
+ name
209
+ .trim()
210
+ .split(/\s+/)
211
+ .slice(0, 2)
212
+ .map((w) => w[0]?.toUpperCase() ?? '')
213
+ .join('');
214
+
215
+ export function Avatar({ src, name, size = 'md', badge, style, ...rest }: AvatarProps) {
216
+ const { roles, tokens } = useTheme();
217
+ const [broken, setBroken] = React.useState(false);
218
+ const px = tokens.size.avatar[size];
219
+ return (
220
+ <View
221
+ accessible={!!name}
222
+ accessibilityRole={name ? 'image' : undefined}
223
+ accessibilityLabel={name}
224
+ style={[{ width: px, height: px }, style]}
225
+ {...rest}
226
+ >
227
+ <View
228
+ style={{
229
+ width: px,
230
+ height: px,
231
+ borderRadius: px / 2,
232
+ overflow: 'hidden',
233
+ alignItems: 'center',
234
+ justifyContent: 'center',
235
+ backgroundColor: roles['secondary-container'],
236
+ }}
237
+ >
238
+ {src && !broken ? (
239
+ <Image
240
+ source={{ uri: src }}
241
+ style={StyleSheet.absoluteFill}
242
+ onError={() => setBroken(true)}
243
+ />
244
+ ) : (
245
+ <Text
246
+ variant={px >= 48 ? 'title-md' : 'label-md'}
247
+ color="on-secondary-container"
248
+ style={{ fontSize: px * 0.4, lineHeight: px * 0.5 }}
249
+ maxFontSizeMultiplier={1}
250
+ >
251
+ {initials(name)}
252
+ </Text>
253
+ )}
254
+ </View>
255
+ {badge && (
256
+ <View
257
+ style={{
258
+ position: 'absolute',
259
+ right: 0,
260
+ bottom: 0,
261
+ width: px / 4,
262
+ height: px / 4,
263
+ borderRadius: px / 8,
264
+ backgroundColor: roles.success,
265
+ borderWidth: 2,
266
+ borderColor: roles.surface,
267
+ }}
268
+ />
269
+ )}
270
+ </View>
271
+ );
272
+ }
273
+
274
+ export interface AvatarGroupProps extends ViewProps {
275
+ children: React.ReactElement<AvatarProps>[];
276
+ /** Avatars shown before "+N". @default 4 */
277
+ max?: number;
278
+ size?: AvatarSize;
279
+ }
280
+
281
+ export function AvatarGroup({ children, max = 4, size = 'sm', style, ...rest }: AvatarGroupProps) {
282
+ const { roles, tokens, messages } = useTheme();
283
+ const px = tokens.size.avatar[size];
284
+ const shown = children.slice(0, max);
285
+ const extra = children.length - shown.length;
286
+ const ring = { borderWidth: 2, borderColor: roles.surface, borderRadius: px / 2 + 2 };
287
+ return (
288
+ <View style={[{ flexDirection: 'row' }, style]} {...rest}>
289
+ {shown.map((child, i) => (
290
+ <View key={i} style={[ring, i > 0 && { marginStart: -px / 3 }]}>
291
+ {React.cloneElement(child, { size })}
292
+ </View>
293
+ ))}
294
+ {extra > 0 && (
295
+ <View
296
+ accessible
297
+ accessibilityLabel={messages.moreCount(extra)}
298
+ style={[
299
+ ring,
300
+ {
301
+ marginStart: -px / 3,
302
+ width: px + 4,
303
+ height: px + 4,
304
+ alignItems: 'center',
305
+ justifyContent: 'center',
306
+ backgroundColor: roles['surface-container-high'],
307
+ },
308
+ ]}
309
+ >
310
+ <Text variant="label-sm" color="on-surface-variant" tabular>
311
+ +{extra}
312
+ </Text>
313
+ </View>
314
+ )}
315
+ </View>
316
+ );
317
+ }
318
+
319
+ // ─── Divider ─────────────────────────────────────────────────────────────────
320
+
321
+ export interface DividerProps extends ViewProps {
322
+ /** Indent from the start edge (aligns with list text after a leading icon). */
323
+ inset?: number;
324
+ /** @default 'horizontal' */
325
+ orientation?: 'horizontal' | 'vertical';
326
+ }
327
+
328
+ /** Hairline separator. Prefer spacing and tonal tiers first (Calm Depth: remove dividers before adding them). */
329
+ export function Divider({ inset = 0, orientation = 'horizontal', style, ...rest }: DividerProps) {
330
+ const { roles } = useTheme();
331
+ return (
332
+ <View
333
+ accessibilityElementsHidden
334
+ importantForAccessibility="no-hide-descendants"
335
+ style={[
336
+ orientation === 'horizontal'
337
+ ? { height: StyleSheet.hairlineWidth, marginStart: inset, alignSelf: 'stretch' }
338
+ : { width: StyleSheet.hairlineWidth, alignSelf: 'stretch' },
339
+ { backgroundColor: roles['outline-variant'] },
340
+ style,
341
+ ]}
342
+ {...rest}
343
+ />
344
+ );
345
+ }