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