@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.
- package/README.md +113 -2
- package/lib/module/components/actions.js +222 -0
- package/lib/module/components/actions.js.map +1 -0
- package/lib/module/components/containers.js +300 -0
- package/lib/module/components/containers.js.map +1 -0
- package/lib/module/components/display.js +299 -0
- package/lib/module/components/display.js.map +1 -0
- package/lib/module/components/feedback.js +484 -0
- package/lib/module/components/feedback.js.map +1 -0
- package/lib/module/components/inputs.js +792 -0
- package/lib/module/components/inputs.js.map +1 -0
- package/lib/module/components/mobile.js +818 -0
- package/lib/module/components/mobile.js.map +1 -0
- package/lib/module/components/overlays.js +421 -0
- package/lib/module/components/overlays.js.map +1 -0
- package/lib/module/components/shared.js +57 -0
- package/lib/module/components/shared.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +23 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/patterns/app.js +709 -0
- package/lib/module/patterns/app.js.map +1 -0
- package/lib/module/patterns/auth.js +224 -0
- package/lib/module/patterns/auth.js.map +1 -0
- package/lib/module/patterns/home.js +201 -0
- package/lib/module/patterns/home.js.map +1 -0
- package/lib/module/patterns/index.js +8 -0
- package/lib/module/patterns/index.js.map +1 -0
- package/lib/module/patterns/money.js +419 -0
- package/lib/module/patterns/money.js.map +1 -0
- package/lib/module/primitives/Icon.js +17 -0
- package/lib/module/primitives/Icon.js.map +1 -0
- package/lib/module/primitives/Layout.js +79 -0
- package/lib/module/primitives/Layout.js.map +1 -0
- package/lib/module/primitives/Pressable.js +79 -0
- package/lib/module/primitives/Pressable.js.map +1 -0
- package/lib/module/primitives/ScreenContainer.js +58 -0
- package/lib/module/primitives/ScreenContainer.js.map +1 -0
- package/lib/module/primitives/Surface.js +57 -0
- package/lib/module/primitives/Surface.js.map +1 -0
- package/lib/module/primitives/Text.js +70 -0
- package/lib/module/primitives/Text.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +127 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/fonts.js +56 -0
- package/lib/module/theme/fonts.js.map +1 -0
- package/lib/module/theme/haptics.js +56 -0
- package/lib/module/theme/haptics.js.map +1 -0
- package/lib/module/theme/motion.js +89 -0
- package/lib/module/theme/motion.js.map +1 -0
- package/lib/module/theme/navigation.js +99 -0
- package/lib/module/theme/navigation.js.map +1 -0
- package/lib/typescript/components/actions.d.ts +53 -0
- package/lib/typescript/components/actions.d.ts.map +1 -0
- package/lib/typescript/components/containers.d.ts +59 -0
- package/lib/typescript/components/containers.d.ts.map +1 -0
- package/lib/typescript/components/display.d.ts +68 -0
- package/lib/typescript/components/display.d.ts.map +1 -0
- package/lib/typescript/components/feedback.d.ts +96 -0
- package/lib/typescript/components/feedback.d.ts.map +1 -0
- package/lib/typescript/components/inputs.d.ts +141 -0
- package/lib/typescript/components/inputs.d.ts.map +1 -0
- package/lib/typescript/components/mobile.d.ts +173 -0
- package/lib/typescript/components/mobile.d.ts.map +1 -0
- package/lib/typescript/components/overlays.d.ts +74 -0
- package/lib/typescript/components/overlays.d.ts.map +1 -0
- package/lib/typescript/components/shared.d.ts +20 -0
- package/lib/typescript/components/shared.d.ts.map +1 -0
- package/lib/typescript/hooks/useControllableState.d.ts +6 -0
- package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +39 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/patterns/app.d.ts +139 -0
- package/lib/typescript/patterns/app.d.ts.map +1 -0
- package/lib/typescript/patterns/auth.d.ts +54 -0
- package/lib/typescript/patterns/auth.d.ts.map +1 -0
- package/lib/typescript/patterns/home.d.ts +50 -0
- package/lib/typescript/patterns/home.d.ts.map +1 -0
- package/lib/typescript/patterns/index.d.ts +9 -0
- package/lib/typescript/patterns/index.d.ts.map +1 -0
- package/lib/typescript/patterns/money.d.ts +106 -0
- package/lib/typescript/patterns/money.d.ts.map +1 -0
- package/lib/typescript/primitives/Icon.d.ts +12 -0
- package/lib/typescript/primitives/Icon.d.ts.map +1 -0
- package/lib/typescript/primitives/Layout.d.ts +26 -0
- package/lib/typescript/primitives/Layout.d.ts.map +1 -0
- package/lib/typescript/primitives/Pressable.d.ts +21 -0
- package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
- package/lib/typescript/primitives/Surface.d.ts +23 -0
- package/lib/typescript/primitives/Surface.d.ts.map +1 -0
- package/lib/typescript/primitives/Text.d.ts +21 -0
- package/lib/typescript/primitives/Text.d.ts.map +1 -0
- package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
- package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/theme/fonts.d.ts +25 -0
- package/lib/typescript/theme/fonts.d.ts.map +1 -0
- package/lib/typescript/theme/haptics.d.ts +3 -0
- package/lib/typescript/theme/haptics.d.ts.map +1 -0
- package/lib/typescript/theme/motion.d.ts +79 -0
- package/lib/typescript/theme/motion.d.ts.map +1 -0
- package/lib/typescript/theme/navigation.d.ts +158 -0
- package/lib/typescript/theme/navigation.d.ts.map +1 -0
- package/package.json +138 -3
- package/src/components/actions.tsx +259 -0
- package/src/components/containers.tsx +352 -0
- package/src/components/display.tsx +345 -0
- package/src/components/feedback.tsx +541 -0
- package/src/components/inputs.tsx +899 -0
- package/src/components/mobile.tsx +883 -0
- package/src/components/overlays.tsx +488 -0
- package/src/components/shared.ts +54 -0
- package/src/hooks/useControllableState.ts +27 -0
- package/src/index.ts +177 -0
- package/src/patterns/app.tsx +715 -0
- package/src/patterns/auth.tsx +266 -0
- package/src/patterns/home.tsx +211 -0
- package/src/patterns/index.ts +59 -0
- package/src/patterns/money.tsx +458 -0
- package/src/primitives/Icon.tsx +15 -0
- package/src/primitives/Layout.tsx +71 -0
- package/src/primitives/Pressable.tsx +97 -0
- package/src/primitives/ScreenContainer.tsx +65 -0
- package/src/primitives/Surface.tsx +75 -0
- package/src/primitives/Text.tsx +79 -0
- package/src/theme/ThemeProvider.tsx +210 -0
- package/src/theme/fonts.ts +52 -0
- package/src/theme/haptics.ts +57 -0
- package/src/theme/motion.ts +105 -0
- 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
|
+
}
|