@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.
@@ -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
+ });