@compsych/mobile-ui 1.0.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 +1015 -0
- package/package.json +26 -0
- package/src/Alert.tsx +372 -0
- package/src/Avatar.tsx +255 -0
- package/src/Badge.tsx +189 -0
- package/src/BodyText.tsx +62 -0
- package/src/Breadcrumb.tsx +192 -0
- package/src/Button.tsx +228 -0
- package/src/Card.tsx +152 -0
- package/src/Checkbox.tsx +182 -0
- package/src/Chip.tsx +291 -0
- package/src/Divider.tsx +60 -0
- package/src/EmptyState.tsx +364 -0
- package/src/HeaderText.tsx +69 -0
- package/src/Input.tsx +228 -0
- package/src/Pagination.tsx +353 -0
- package/src/ProgressTracker.tsx +160 -0
- package/src/RadioButton.tsx +177 -0
- package/src/Slider.tsx +347 -0
- package/src/Switch.tsx +183 -0
- package/src/Tooltip.tsx +214 -0
- package/src/index.ts +62 -0
- package/src/tokens.ts +121 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import { sys } from './tokens';
|
|
10
|
+
|
|
11
|
+
export type EmptyStateStyle = 'icon' | 'illustration';
|
|
12
|
+
export type EmptyStateViewport = 'desktop' | 'mobile';
|
|
13
|
+
|
|
14
|
+
export interface EmptyStateProps {
|
|
15
|
+
/** Visual style of the graphic area */
|
|
16
|
+
style?: EmptyStateStyle;
|
|
17
|
+
/** desktop = larger type + 60px icon circle; mobile = smaller type + 48px circle */
|
|
18
|
+
viewport?: EmptyStateViewport;
|
|
19
|
+
/** Heading text (default: "No results") */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Body copy below the title */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Hide the description even when `description` is provided */
|
|
24
|
+
showDescription?: boolean;
|
|
25
|
+
/** Custom icon rendered inside the icon circle (icon style only) */
|
|
26
|
+
icon?: React.ReactNode;
|
|
27
|
+
/** Label for the optional action button */
|
|
28
|
+
actionLabel?: string;
|
|
29
|
+
onAction?: () => void;
|
|
30
|
+
/** Whether the action button is shown (default: true when actionLabel is set) */
|
|
31
|
+
showAction?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
35
|
+
|
|
36
|
+
// ── Size tokens by viewport ───────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const VIEWPORT = {
|
|
39
|
+
desktop: {
|
|
40
|
+
iconCircle: 60,
|
|
41
|
+
iconSize: 32,
|
|
42
|
+
titleFontSize: ts.titleMedium.sysFontSize,
|
|
43
|
+
titleLineHeight: ts.titleMedium.sysLineHeight,
|
|
44
|
+
descFontSize: ts.bodyMedium.sysFontSize,
|
|
45
|
+
descLineHeight: ts.bodyMedium.sysLineHeight,
|
|
46
|
+
contentGap: dim.spacing.padding.sysPadding24,
|
|
47
|
+
btnHeight: 40,
|
|
48
|
+
btnPaddingL: dim.spacing.padding.sysPadding16,
|
|
49
|
+
btnPaddingR: dim.spacing.padding.sysPadding24,
|
|
50
|
+
btnGap: dim.spacing.padding.sysPadding8,
|
|
51
|
+
btnFontSize: ts.labelMedium.sysFontSize,
|
|
52
|
+
btnLineHeight: ts.labelMedium.sysLineHeight,
|
|
53
|
+
btnTracking: ts.labelMedium.sysTracking,
|
|
54
|
+
},
|
|
55
|
+
mobile: {
|
|
56
|
+
iconCircle: 48,
|
|
57
|
+
iconSize: 24,
|
|
58
|
+
titleFontSize: ts.labelLarge.sysFontSize,
|
|
59
|
+
titleLineHeight: ts.labelLarge.sysLineHeight,
|
|
60
|
+
descFontSize: ts.bodySmall.sysFontSize,
|
|
61
|
+
descLineHeight: ts.bodySmall.sysLineHeight,
|
|
62
|
+
contentGap: dim.spacing.padding.sysPadding16,
|
|
63
|
+
btnHeight: 32,
|
|
64
|
+
btnPaddingL: dim.spacing.padding.sysPadding12,
|
|
65
|
+
btnPaddingR: dim.spacing.padding.sysPadding16,
|
|
66
|
+
btnGap: dim.spacing.padding.sysPadding4,
|
|
67
|
+
btnFontSize: ts.labelSmall.sysFontSize,
|
|
68
|
+
btnLineHeight: ts.labelSmall.sysLineHeight,
|
|
69
|
+
btnTracking: ts.labelSmall.sysTracking,
|
|
70
|
+
},
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
// ── Illustration ──────────────────────────────────────────────────────────────
|
|
74
|
+
// A pure-View recreation of the Figma "collage of cards" illustration.
|
|
75
|
+
// Built at 280×157 px (scaled from the Figma 398×224 desktop version).
|
|
76
|
+
|
|
77
|
+
const ILLUS_W = 280;
|
|
78
|
+
const ILLUS_H = 157;
|
|
79
|
+
const SCALE = ILLUS_W / 398; // ≈ 0.703
|
|
80
|
+
|
|
81
|
+
const CARD_W = Math.round(160 * SCALE); // 112
|
|
82
|
+
const CARD_H = Math.round(98 * SCALE); // 69
|
|
83
|
+
|
|
84
|
+
function scaledPos(left: number, top: number) {
|
|
85
|
+
return {
|
|
86
|
+
left: Math.round(left * SCALE),
|
|
87
|
+
top: Math.round(top * SCALE),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function EmptyIllustration() {
|
|
92
|
+
// Purely decorative — hidden from accessibility tree
|
|
93
|
+
// Central active card
|
|
94
|
+
const central = scaledPos(119, 63);
|
|
95
|
+
// Surrounding faded cards
|
|
96
|
+
const positions = [
|
|
97
|
+
scaledPos(-53, 63),
|
|
98
|
+
scaledPos(291, 63),
|
|
99
|
+
scaledPos(33, 173),
|
|
100
|
+
scaledPos(205, 173),
|
|
101
|
+
scaledPos(33, -47),
|
|
102
|
+
scaledPos(205, -47),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<View
|
|
107
|
+
accessible={false}
|
|
108
|
+
importantForAccessibility="no-hide-descendants"
|
|
109
|
+
style={[styles.illusContainer, { width: ILLUS_W, height: ILLUS_H }]}
|
|
110
|
+
>
|
|
111
|
+
{/* Faded background cards */}
|
|
112
|
+
{positions.map((pos, i) => (
|
|
113
|
+
<View
|
|
114
|
+
key={i}
|
|
115
|
+
style={[
|
|
116
|
+
styles.card,
|
|
117
|
+
{
|
|
118
|
+
left: pos.left,
|
|
119
|
+
top: pos.top,
|
|
120
|
+
width: CARD_W,
|
|
121
|
+
height: CARD_H,
|
|
122
|
+
borderColor: cr.outline.sysOutlineVariant,
|
|
123
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
124
|
+
},
|
|
125
|
+
styles.cardFaded,
|
|
126
|
+
]}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
|
|
130
|
+
{/* Central focused card */}
|
|
131
|
+
<View
|
|
132
|
+
style={[
|
|
133
|
+
styles.card,
|
|
134
|
+
styles.cardActive,
|
|
135
|
+
{
|
|
136
|
+
left: central.left,
|
|
137
|
+
top: central.top,
|
|
138
|
+
width: CARD_W,
|
|
139
|
+
height: CARD_H,
|
|
140
|
+
borderColor: cr.accent.primary.sysPrimary,
|
|
141
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
142
|
+
},
|
|
143
|
+
]}
|
|
144
|
+
>
|
|
145
|
+
{/* Skeleton content lines */}
|
|
146
|
+
<View
|
|
147
|
+
style={[
|
|
148
|
+
styles.skeletonLine,
|
|
149
|
+
{
|
|
150
|
+
top: Math.round(15 * SCALE),
|
|
151
|
+
left: Math.round(15 * SCALE),
|
|
152
|
+
width: Math.round(32 * SCALE),
|
|
153
|
+
},
|
|
154
|
+
]}
|
|
155
|
+
/>
|
|
156
|
+
<View
|
|
157
|
+
style={[
|
|
158
|
+
styles.skeletonLine,
|
|
159
|
+
{
|
|
160
|
+
top: Math.round(51 * SCALE),
|
|
161
|
+
left: Math.round(15 * SCALE),
|
|
162
|
+
width: Math.round(96 * SCALE),
|
|
163
|
+
},
|
|
164
|
+
]}
|
|
165
|
+
/>
|
|
166
|
+
<View
|
|
167
|
+
style={[
|
|
168
|
+
styles.skeletonLine,
|
|
169
|
+
{
|
|
170
|
+
top: Math.round(63 * SCALE),
|
|
171
|
+
left: Math.round(15 * SCALE),
|
|
172
|
+
width: Math.round(122 * SCALE),
|
|
173
|
+
},
|
|
174
|
+
]}
|
|
175
|
+
/>
|
|
176
|
+
</View>
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function EmptyState({
|
|
184
|
+
style = 'icon',
|
|
185
|
+
viewport = 'mobile',
|
|
186
|
+
title = 'No results',
|
|
187
|
+
description = 'Description if needed',
|
|
188
|
+
showDescription = true,
|
|
189
|
+
icon,
|
|
190
|
+
actionLabel,
|
|
191
|
+
onAction,
|
|
192
|
+
showAction,
|
|
193
|
+
}: EmptyStateProps) {
|
|
194
|
+
const vp = VIEWPORT[viewport];
|
|
195
|
+
const hasAction = showAction !== undefined ? showAction : !!actionLabel;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<View style={styles.root}>
|
|
199
|
+
{/* ── Graphic ───────────────────────────────────────────────────────── */}
|
|
200
|
+
{style === 'illustration' ? (
|
|
201
|
+
<EmptyIllustration />
|
|
202
|
+
) : (
|
|
203
|
+
<View
|
|
204
|
+
style={[
|
|
205
|
+
styles.iconCircle,
|
|
206
|
+
{
|
|
207
|
+
width: vp.iconCircle,
|
|
208
|
+
height: vp.iconCircle,
|
|
209
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLow,
|
|
210
|
+
},
|
|
211
|
+
]}
|
|
212
|
+
>
|
|
213
|
+
{icon ?? (
|
|
214
|
+
<Ionicons
|
|
215
|
+
name="apps-outline"
|
|
216
|
+
size={vp.iconSize}
|
|
217
|
+
color={cr.surface.surface.sysOnSurfaceVariant}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
</View>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* ── Content ───────────────────────────────────────────────────────── */}
|
|
224
|
+
<View style={[styles.content, { gap: vp.contentGap }]}>
|
|
225
|
+
{/* Text block */}
|
|
226
|
+
<View style={styles.textBlock}>
|
|
227
|
+
<Text
|
|
228
|
+
style={{
|
|
229
|
+
color: cr.surface.surface.sysOnSurface,
|
|
230
|
+
fontSize: vp.titleFontSize,
|
|
231
|
+
lineHeight: vp.titleLineHeight,
|
|
232
|
+
fontWeight: '400',
|
|
233
|
+
textAlign: 'center',
|
|
234
|
+
includeFontPadding: false,
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{title}
|
|
238
|
+
</Text>
|
|
239
|
+
|
|
240
|
+
{showDescription && description && (
|
|
241
|
+
<Text
|
|
242
|
+
style={{
|
|
243
|
+
color: cr.surface.surface.sysOnSurfaceVariant,
|
|
244
|
+
fontSize: vp.descFontSize,
|
|
245
|
+
lineHeight: vp.descLineHeight,
|
|
246
|
+
fontWeight: '400',
|
|
247
|
+
textAlign: 'center',
|
|
248
|
+
includeFontPadding: false,
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{description}
|
|
252
|
+
</Text>
|
|
253
|
+
)}
|
|
254
|
+
</View>
|
|
255
|
+
|
|
256
|
+
{/* Action button */}
|
|
257
|
+
{hasAction && actionLabel && (
|
|
258
|
+
<Pressable
|
|
259
|
+
onPress={onAction}
|
|
260
|
+
accessibilityRole="button"
|
|
261
|
+
accessibilityLabel={actionLabel}
|
|
262
|
+
style={({ pressed }) => [
|
|
263
|
+
styles.actionBtn,
|
|
264
|
+
{
|
|
265
|
+
height: vp.btnHeight,
|
|
266
|
+
paddingLeft: vp.btnPaddingL,
|
|
267
|
+
paddingRight: vp.btnPaddingR,
|
|
268
|
+
gap: vp.btnGap,
|
|
269
|
+
borderColor: cr.outline.sysOutline,
|
|
270
|
+
},
|
|
271
|
+
pressed && { opacity: 0.76 },
|
|
272
|
+
]}
|
|
273
|
+
>
|
|
274
|
+
<Ionicons
|
|
275
|
+
name="add"
|
|
276
|
+
size={16}
|
|
277
|
+
color={cr.surface.surface.sysOnSurface}
|
|
278
|
+
/>
|
|
279
|
+
<Text
|
|
280
|
+
style={{
|
|
281
|
+
color: cr.surface.surface.sysOnSurface,
|
|
282
|
+
fontSize: vp.btnFontSize,
|
|
283
|
+
lineHeight: vp.btnLineHeight,
|
|
284
|
+
letterSpacing: vp.btnTracking,
|
|
285
|
+
fontWeight: '400',
|
|
286
|
+
includeFontPadding: false,
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{actionLabel}
|
|
290
|
+
</Text>
|
|
291
|
+
</Pressable>
|
|
292
|
+
)}
|
|
293
|
+
</View>
|
|
294
|
+
</View>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const styles = StyleSheet.create({
|
|
299
|
+
root: {
|
|
300
|
+
padding: dim.spacing.padding.sysPadding24,
|
|
301
|
+
borderRadius: dim.borderRadius.sysRadiusLg,
|
|
302
|
+
alignItems: 'center',
|
|
303
|
+
gap: dim.spacing.padding.sysPadding24,
|
|
304
|
+
alignSelf: 'center',
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// ── Icon circle ───────────────────────────────────────────────────────────
|
|
308
|
+
iconCircle: {
|
|
309
|
+
borderRadius: 9999,
|
|
310
|
+
alignItems: 'center',
|
|
311
|
+
justifyContent: 'center',
|
|
312
|
+
overflow: 'hidden',
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
// ── Content block ─────────────────────────────────────────────────────────
|
|
316
|
+
content: {
|
|
317
|
+
alignItems: 'center',
|
|
318
|
+
alignSelf: 'stretch',
|
|
319
|
+
},
|
|
320
|
+
textBlock: {
|
|
321
|
+
alignSelf: 'stretch',
|
|
322
|
+
gap: dim.spacing.padding.sysPadding4,
|
|
323
|
+
alignItems: 'center',
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// ── Action button ─────────────────────────────────────────────────────────
|
|
327
|
+
actionBtn: {
|
|
328
|
+
flexDirection: 'row',
|
|
329
|
+
alignItems: 'center',
|
|
330
|
+
justifyContent: 'center',
|
|
331
|
+
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
332
|
+
borderRadius: 9999,
|
|
333
|
+
overflow: 'hidden',
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// ── Illustration ──────────────────────────────────────────────────────────
|
|
337
|
+
illusContainer: {
|
|
338
|
+
overflow: 'hidden',
|
|
339
|
+
position: 'relative',
|
|
340
|
+
},
|
|
341
|
+
card: {
|
|
342
|
+
position: 'absolute',
|
|
343
|
+
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
344
|
+
borderRadius: dim.borderRadius.sysRadiusMd,
|
|
345
|
+
overflow: 'hidden',
|
|
346
|
+
},
|
|
347
|
+
cardFaded: {
|
|
348
|
+
opacity: 0.48,
|
|
349
|
+
},
|
|
350
|
+
cardActive: {
|
|
351
|
+
// Active ring: sysPrimary08 spread shadow
|
|
352
|
+
shadowColor: cr.accent.primary.sysPrimary,
|
|
353
|
+
shadowOffset: { width: 0, height: 0 },
|
|
354
|
+
shadowOpacity: 0.08,
|
|
355
|
+
shadowRadius: 0,
|
|
356
|
+
elevation: 0,
|
|
357
|
+
},
|
|
358
|
+
skeletonLine: {
|
|
359
|
+
position: 'absolute',
|
|
360
|
+
height: Math.round(6 * SCALE),
|
|
361
|
+
borderRadius: 9999,
|
|
362
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainer,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, type TextProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { sys } from './tokens';
|
|
5
|
+
|
|
6
|
+
export type HeaderVariant =
|
|
7
|
+
| 'large'
|
|
8
|
+
| 'medium'
|
|
9
|
+
| 'small'
|
|
10
|
+
| 'headlineLarge'
|
|
11
|
+
| 'headlineMedium'
|
|
12
|
+
| 'headlineSmall'
|
|
13
|
+
| 'titleLarge'
|
|
14
|
+
| 'titleMedium'
|
|
15
|
+
| 'titleSmall';
|
|
16
|
+
|
|
17
|
+
export interface HeaderTextProps extends TextProps {
|
|
18
|
+
variant?: HeaderVariant;
|
|
19
|
+
emphasized?: boolean;
|
|
20
|
+
color?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FONT_FAMILY: Record<string, string> = {
|
|
24
|
+
regular: 'GoogleSans_400Regular',
|
|
25
|
+
medium: 'GoogleSans_500Medium',
|
|
26
|
+
semibold: 'GoogleSans_600SemiBold',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const VARIANT_TOKEN_KEY: Record<HeaderVariant, string> = {
|
|
30
|
+
large: 'displayLarge',
|
|
31
|
+
medium: 'displayMedium',
|
|
32
|
+
small: 'displaySmall',
|
|
33
|
+
headlineLarge: 'headlineLarge',
|
|
34
|
+
headlineMedium: 'headlineMedium',
|
|
35
|
+
headlineSmall: 'headlineSmall',
|
|
36
|
+
titleLarge: 'titleLarge',
|
|
37
|
+
titleMedium: 'titleMedium',
|
|
38
|
+
titleSmall: 'titleSmall',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const { typeScale: ts } = sys;
|
|
42
|
+
|
|
43
|
+
export function HeaderText({
|
|
44
|
+
variant = 'medium',
|
|
45
|
+
emphasized = false,
|
|
46
|
+
color,
|
|
47
|
+
style,
|
|
48
|
+
...rest
|
|
49
|
+
}: HeaderTextProps) {
|
|
50
|
+
const token = ts[VARIANT_TOKEN_KEY[variant]];
|
|
51
|
+
const weight = emphasized ? token.sysFontWeightEmphasized : token.sysFontWeight;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Text
|
|
55
|
+
accessibilityRole="header"
|
|
56
|
+
style={[
|
|
57
|
+
{
|
|
58
|
+
fontFamily: FONT_FAMILY[weight],
|
|
59
|
+
fontSize: token.sysFontSize,
|
|
60
|
+
lineHeight: token.sysLineHeight,
|
|
61
|
+
letterSpacing: token.sysTracking,
|
|
62
|
+
color,
|
|
63
|
+
},
|
|
64
|
+
style,
|
|
65
|
+
]}
|
|
66
|
+
{...rest}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/Input.tsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
TextInputProps,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import { sys } from './tokens';
|
|
10
|
+
|
|
11
|
+
export type InputSize = 'sm' | 'md' | 'lg';
|
|
12
|
+
|
|
13
|
+
export interface InputProps extends Omit<TextInputProps, 'style'> {
|
|
14
|
+
size?: InputSize;
|
|
15
|
+
label?: string;
|
|
16
|
+
helperText?: string;
|
|
17
|
+
errorText?: string;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
leadingIcon?: React.ReactNode;
|
|
20
|
+
trailingIcon?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
24
|
+
|
|
25
|
+
const SIZE_TOKENS = {
|
|
26
|
+
sm: {
|
|
27
|
+
height: 40,
|
|
28
|
+
paddingH: dim.spacing.padding.sysPadding12,
|
|
29
|
+
paddingV: dim.spacing.padding.sysPadding8,
|
|
30
|
+
gap: dim.spacing.padding.sysPadding4,
|
|
31
|
+
fontSize: ts.bodySmall.sysFontSize,
|
|
32
|
+
lineHeight: ts.bodySmall.sysLineHeight,
|
|
33
|
+
radius: dim.borderRadius.sysRadiusSm,
|
|
34
|
+
iconSize: 20,
|
|
35
|
+
},
|
|
36
|
+
md: {
|
|
37
|
+
height: 48,
|
|
38
|
+
paddingH: dim.spacing.padding.sysPadding16,
|
|
39
|
+
paddingV: dim.spacing.padding.sysPadding12,
|
|
40
|
+
gap: dim.spacing.padding.sysPadding8,
|
|
41
|
+
fontSize: ts.bodyMedium.sysFontSize,
|
|
42
|
+
lineHeight: ts.bodyMedium.sysLineHeight,
|
|
43
|
+
radius: dim.borderRadius.sysRadiusSm,
|
|
44
|
+
iconSize: 20,
|
|
45
|
+
},
|
|
46
|
+
lg: {
|
|
47
|
+
height: 56,
|
|
48
|
+
paddingH: dim.spacing.padding.sysPadding20,
|
|
49
|
+
paddingV: dim.spacing.padding.sysPadding16,
|
|
50
|
+
gap: dim.spacing.padding.sysPadding8,
|
|
51
|
+
fontSize: ts.bodyMedium.sysFontSize,
|
|
52
|
+
lineHeight: ts.bodyMedium.sysLineHeight,
|
|
53
|
+
radius: dim.borderRadius.sysRadiusMd,
|
|
54
|
+
iconSize: 20,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Focus-ring halo: 4px spread, sysPrimary08 colour — achieved with a fixed
|
|
59
|
+
// 4px-padded wrapper whose background switches on focus.
|
|
60
|
+
const RING_SIZE = 4;
|
|
61
|
+
|
|
62
|
+
export function Input({
|
|
63
|
+
size = 'md',
|
|
64
|
+
label,
|
|
65
|
+
helperText,
|
|
66
|
+
errorText,
|
|
67
|
+
invalid = false,
|
|
68
|
+
leadingIcon,
|
|
69
|
+
trailingIcon,
|
|
70
|
+
editable = true,
|
|
71
|
+
onFocus,
|
|
72
|
+
onBlur,
|
|
73
|
+
...rest
|
|
74
|
+
}: InputProps) {
|
|
75
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
76
|
+
const s = SIZE_TOKENS[size];
|
|
77
|
+
const isDisabled = editable === false;
|
|
78
|
+
const hasError = invalid || !!errorText;
|
|
79
|
+
|
|
80
|
+
// ── Border ──────────────────────────────────────────────────────────────
|
|
81
|
+
const borderWidth = isFocused || hasError
|
|
82
|
+
? dim.borderWidth.sysStrokeMedium
|
|
83
|
+
: dim.borderWidth.sysStrokeThin;
|
|
84
|
+
|
|
85
|
+
const borderColor = hasError
|
|
86
|
+
? cr.error.sysError
|
|
87
|
+
: isFocused
|
|
88
|
+
? cr.accent.primary.sysPrimary
|
|
89
|
+
: cr.outline.sysOutlineVariant;
|
|
90
|
+
|
|
91
|
+
// ── Text colours ─────────────────────────────────────────────────────────
|
|
92
|
+
const textColor = cr.surface.surface.sysOnSurface;
|
|
93
|
+
const placeholderColor = cr.surface.surface.sysOnSurfaceVariant;
|
|
94
|
+
|
|
95
|
+
// ── Helper / error text ──────────────────────────────────────────────────
|
|
96
|
+
const supportingText = errorText ?? helperText;
|
|
97
|
+
const supportingColor = hasError
|
|
98
|
+
? cr.error.sysError
|
|
99
|
+
: cr.surface.surface.sysOnSurfaceVariant;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<View style={[styles.wrapper, isDisabled && styles.disabled]}>
|
|
103
|
+
{/* Label */}
|
|
104
|
+
{label && (
|
|
105
|
+
<Text
|
|
106
|
+
style={[
|
|
107
|
+
styles.label,
|
|
108
|
+
{
|
|
109
|
+
color: hasError
|
|
110
|
+
? cr.error.sysError
|
|
111
|
+
: cr.surface.surface.sysOnSurfaceVariant,
|
|
112
|
+
fontSize: ts.labelMedium.sysFontSize,
|
|
113
|
+
lineHeight: ts.labelMedium.sysLineHeight,
|
|
114
|
+
},
|
|
115
|
+
]}
|
|
116
|
+
>
|
|
117
|
+
{label}
|
|
118
|
+
</Text>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Focus-ring halo wrapper — always 4px padding, bg appears on focus */}
|
|
122
|
+
<View
|
|
123
|
+
style={{
|
|
124
|
+
padding: RING_SIZE,
|
|
125
|
+
borderRadius: s.radius + RING_SIZE,
|
|
126
|
+
backgroundColor:
|
|
127
|
+
isFocused && !hasError
|
|
128
|
+
? cr.transparent.primary.sysPrimary08
|
|
129
|
+
: 'transparent',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{/* Input container */}
|
|
133
|
+
<View
|
|
134
|
+
style={{
|
|
135
|
+
height: s.height,
|
|
136
|
+
borderRadius: s.radius,
|
|
137
|
+
borderWidth,
|
|
138
|
+
borderColor,
|
|
139
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
140
|
+
flexDirection: 'row',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
paddingHorizontal: s.paddingH,
|
|
143
|
+
paddingVertical: s.paddingV,
|
|
144
|
+
gap: s.gap,
|
|
145
|
+
overflow: 'hidden',
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
{/* Leading icon */}
|
|
149
|
+
{leadingIcon && (
|
|
150
|
+
<View style={{ width: s.iconSize, height: s.iconSize, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
151
|
+
{leadingIcon}
|
|
152
|
+
</View>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Text input */}
|
|
156
|
+
<TextInput
|
|
157
|
+
{...rest}
|
|
158
|
+
editable={editable}
|
|
159
|
+
accessibilityLabel={label ?? rest.placeholder}
|
|
160
|
+
accessibilityHint={supportingText}
|
|
161
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
162
|
+
onFocus={(e) => {
|
|
163
|
+
setIsFocused(true);
|
|
164
|
+
onFocus?.(e);
|
|
165
|
+
}}
|
|
166
|
+
onBlur={(e) => {
|
|
167
|
+
setIsFocused(false);
|
|
168
|
+
onBlur?.(e);
|
|
169
|
+
}}
|
|
170
|
+
style={{
|
|
171
|
+
flex: 1,
|
|
172
|
+
fontSize: s.fontSize,
|
|
173
|
+
lineHeight: s.lineHeight,
|
|
174
|
+
color: textColor,
|
|
175
|
+
includeFontPadding: false,
|
|
176
|
+
// Reset RN TextInput default padding/margin
|
|
177
|
+
padding: 0,
|
|
178
|
+
margin: 0,
|
|
179
|
+
}}
|
|
180
|
+
placeholderTextColor={placeholderColor}
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
{/* Trailing icon */}
|
|
184
|
+
{trailingIcon && (
|
|
185
|
+
<View style={{ width: s.iconSize, height: s.iconSize, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
186
|
+
{trailingIcon}
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
</View>
|
|
190
|
+
</View>
|
|
191
|
+
|
|
192
|
+
{/* Supporting text: error message or helper text */}
|
|
193
|
+
{supportingText && (
|
|
194
|
+
<Text
|
|
195
|
+
style={[
|
|
196
|
+
styles.supporting,
|
|
197
|
+
{
|
|
198
|
+
color: supportingColor,
|
|
199
|
+
fontSize: ts.bodySmall.sysFontSize,
|
|
200
|
+
lineHeight: ts.bodySmall.sysLineHeight,
|
|
201
|
+
},
|
|
202
|
+
]}
|
|
203
|
+
>
|
|
204
|
+
{supportingText}
|
|
205
|
+
</Text>
|
|
206
|
+
)}
|
|
207
|
+
</View>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const styles = StyleSheet.create({
|
|
212
|
+
wrapper: {
|
|
213
|
+
gap: 4,
|
|
214
|
+
},
|
|
215
|
+
disabled: {
|
|
216
|
+
opacity: 0.48,
|
|
217
|
+
},
|
|
218
|
+
label: {
|
|
219
|
+
fontWeight: '500',
|
|
220
|
+
includeFontPadding: false,
|
|
221
|
+
// indent to align with the input text (ring padding + horizontal padding)
|
|
222
|
+
paddingLeft: RING_SIZE,
|
|
223
|
+
},
|
|
224
|
+
supporting: {
|
|
225
|
+
includeFontPadding: false,
|
|
226
|
+
paddingLeft: RING_SIZE,
|
|
227
|
+
},
|
|
228
|
+
});
|