@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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@compsych/mobile-ui",
3
+ "version": "1.0.0",
4
+ "description": "ComPsych React Native Design System — 19 mobile UI components with self-contained design tokens",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "react-native": "src/index.ts",
8
+ "source": "src/index.ts",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "keywords": [
13
+ "react-native",
14
+ "compsych",
15
+ "design-system",
16
+ "components",
17
+ "expo"
18
+ ],
19
+ "license": "UNLICENSED",
20
+ "peerDependencies": {
21
+ "@expo-google-fonts/google-sans": ">=0.4.0",
22
+ "@expo/vector-icons": ">=15.0.0",
23
+ "react": ">=18.0.0",
24
+ "react-native": ">=0.73.0"
25
+ }
26
+ }
package/src/Alert.tsx ADDED
@@ -0,0 +1,372 @@
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 AlertVariant =
12
+ | 'default'
13
+ | 'elevated'
14
+ | 'informative'
15
+ | 'warning'
16
+ | 'positive'
17
+ | 'danger';
18
+
19
+ export type AlertSize = 'sm' | 'lg';
20
+
21
+ export interface AlertProps {
22
+ variant?: AlertVariant;
23
+ size?: AlertSize;
24
+ /** Bold title — lg size only */
25
+ title?: string;
26
+ /** Body / description text */
27
+ description: string;
28
+ /** Custom leading icon — overrides the default per-variant icon */
29
+ icon?: React.ReactNode;
30
+ /** Hide the leading icon entirely */
31
+ hideIcon?: boolean;
32
+ /** Label for the optional action button */
33
+ actionLabel?: string;
34
+ onAction?: () => void;
35
+ /** Show dismiss (×) button */
36
+ dismissible?: boolean;
37
+ onDismiss?: () => void;
38
+ }
39
+
40
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
41
+
42
+ // ── Variant color map ────────────────────────────────────────────────────────
43
+
44
+ type VariantColors = {
45
+ bg: string;
46
+ text: string;
47
+ iconColor: string;
48
+ elevated: boolean;
49
+ actionBorder: boolean;
50
+ };
51
+
52
+ function getVariantColors(variant: AlertVariant): VariantColors {
53
+ switch (variant) {
54
+ case 'default':
55
+ return {
56
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLow,
57
+ text: cr.surface.surface.sysOnSurface,
58
+ iconColor: cr.surface.surface.sysOnSurfaceVariant,
59
+ elevated: false,
60
+ actionBorder: false,
61
+ };
62
+ case 'elevated':
63
+ return {
64
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
65
+ text: cr.surface.surface.sysOnSurface,
66
+ iconColor: cr.surface.surface.sysOnSurfaceVariant,
67
+ elevated: true,
68
+ actionBorder: true,
69
+ };
70
+ case 'informative':
71
+ return {
72
+ bg: cr.custom.info.sysInfoContainer,
73
+ text: cr.custom.info.sysOnInfoContainer,
74
+ iconColor: cr.custom.info.sysOnInfoContainer,
75
+ elevated: false,
76
+ actionBorder: false,
77
+ };
78
+ case 'warning':
79
+ return {
80
+ bg: cr.custom.warning.sysWarningContainer,
81
+ text: cr.custom.warning.sysOnWarningContainer,
82
+ iconColor: cr.custom.warning.sysOnWarningContainer,
83
+ elevated: false,
84
+ actionBorder: false,
85
+ };
86
+ case 'positive':
87
+ return {
88
+ bg: cr.custom.success.sysSuccessContainer,
89
+ text: cr.custom.success.sysOnSuccessContainer,
90
+ iconColor: cr.custom.success.sysOnSuccessContainer,
91
+ elevated: false,
92
+ actionBorder: false,
93
+ };
94
+ case 'danger':
95
+ return {
96
+ bg: cr.error.sysErrorContainer,
97
+ text: cr.error.sysOnErrorContainer,
98
+ iconColor: cr.error.sysOnErrorContainer,
99
+ elevated: false,
100
+ actionBorder: false,
101
+ };
102
+ }
103
+ }
104
+
105
+ // ── Default icon per variant ─────────────────────────────────────────────────
106
+
107
+ function defaultIconName(
108
+ variant: AlertVariant,
109
+ ): React.ComponentProps<typeof Ionicons>['name'] {
110
+ switch (variant) {
111
+ case 'default':
112
+ case 'elevated':
113
+ return 'information-circle-outline';
114
+ case 'informative':
115
+ return 'information-circle';
116
+ case 'warning':
117
+ return 'warning';
118
+ case 'positive':
119
+ return 'checkmark-circle';
120
+ case 'danger':
121
+ return 'alert-circle';
122
+ }
123
+ }
124
+
125
+ // ── Component ────────────────────────────────────────────────────────────────
126
+
127
+ export function Alert({
128
+ variant = 'default',
129
+ size = 'lg',
130
+ title,
131
+ description,
132
+ icon,
133
+ hideIcon = false,
134
+ actionLabel,
135
+ onAction,
136
+ dismissible = false,
137
+ onDismiss,
138
+ }: AlertProps) {
139
+ const c = getVariantColors(variant);
140
+ const isLg = size === 'lg';
141
+
142
+ const resolvedIcon =
143
+ !hideIcon &&
144
+ (icon ?? (
145
+ <Ionicons
146
+ name={defaultIconName(variant)}
147
+ size={isLg ? 24 : 20}
148
+ color={c.iconColor}
149
+ />
150
+ ));
151
+
152
+ return (
153
+ <View
154
+ accessibilityRole="alert"
155
+ accessibilityLiveRegion="polite"
156
+ style={[
157
+ styles.root,
158
+ {
159
+ backgroundColor: c.bg,
160
+ borderRadius: isLg
161
+ ? dim.borderRadius.sysRadiusLg
162
+ : dim.borderRadius.sysRadiusMd,
163
+ },
164
+ c.elevated && styles.elevated,
165
+ ]}
166
+ >
167
+ {/* ── Content row ─────────────────────────────────────────────────── */}
168
+ <View
169
+ style={[
170
+ styles.content,
171
+ {
172
+ paddingHorizontal: isLg
173
+ ? dim.spacing.padding.sysPadding24
174
+ : dim.spacing.padding.sysPadding16,
175
+ paddingVertical: isLg
176
+ ? dim.spacing.padding.sysPadding16
177
+ : dim.spacing.padding.sysPadding16,
178
+ gap: isLg
179
+ ? dim.spacing.padding.sysPadding16
180
+ : dim.spacing.padding.sysPadding12,
181
+ alignItems: isLg ? 'flex-start' : 'center',
182
+ },
183
+ ]}
184
+ >
185
+ {/* Leading icon */}
186
+ {resolvedIcon && (
187
+ <View
188
+ style={{
189
+ paddingTop: isLg ? dim.spacing.padding.sysPadding2 : 0,
190
+ flexShrink: 0,
191
+ }}
192
+ >
193
+ {resolvedIcon}
194
+ </View>
195
+ )}
196
+
197
+ {/* Text + action */}
198
+ <View style={styles.textAction}>
199
+ {/* Text block */}
200
+ <View style={[styles.textBlock, { gap: 4 }]}>
201
+ {isLg && title && (
202
+ <Text
203
+ style={{
204
+ color: c.text,
205
+ fontSize: ts.titleSmall.sysFontSize,
206
+ lineHeight: ts.titleSmall.sysLineHeight,
207
+ fontWeight: '500',
208
+ includeFontPadding: false,
209
+ }}
210
+ >
211
+ {title}
212
+ </Text>
213
+ )}
214
+ <Text
215
+ style={{
216
+ color: c.text,
217
+ fontSize: isLg
218
+ ? ts.bodyMedium.sysFontSize
219
+ : ts.bodySmall.sysFontSize,
220
+ lineHeight: isLg
221
+ ? ts.bodyMedium.sysLineHeight
222
+ : ts.bodySmall.sysLineHeight,
223
+ fontWeight: '400',
224
+ includeFontPadding: false,
225
+ }}
226
+ >
227
+ {description}
228
+ </Text>
229
+ </View>
230
+
231
+ {/* Action button */}
232
+ {actionLabel && (
233
+ isLg ? (
234
+ // lg: pill button with white bg + shadow (or border for elevated)
235
+ <Pressable
236
+ onPress={onAction}
237
+ accessibilityRole="button"
238
+ accessibilityLabel={actionLabel}
239
+ style={({ pressed }) => [
240
+ styles.actionLg,
241
+ c.actionBorder && styles.actionLgBorder,
242
+ pressed && { opacity: 0.84 },
243
+ ]}
244
+ >
245
+ <Text
246
+ style={{
247
+ color: cr.surface.surface.sysOnSurface,
248
+ fontSize: ts.labelMedium.sysFontSize,
249
+ lineHeight: ts.labelMedium.sysLineHeight,
250
+ fontWeight: '400',
251
+ includeFontPadding: false,
252
+ }}
253
+ >
254
+ {actionLabel}
255
+ </Text>
256
+ </Pressable>
257
+ ) : (
258
+ // sm: text-only button
259
+ <Pressable
260
+ onPress={onAction}
261
+ accessibilityRole="button"
262
+ accessibilityLabel={actionLabel}
263
+ style={styles.actionSm}
264
+ >
265
+ <Text
266
+ style={{
267
+ color: c.text,
268
+ fontSize: ts.labelMedium.sysFontSize,
269
+ lineHeight: ts.labelMedium.sysLineHeight,
270
+ fontWeight: '500',
271
+ includeFontPadding: false,
272
+ }}
273
+ >
274
+ {actionLabel}
275
+ </Text>
276
+ </Pressable>
277
+ )
278
+ )}
279
+ </View>
280
+
281
+ {/* Dismiss button */}
282
+ {dismissible && (
283
+ <Pressable
284
+ onPress={onDismiss}
285
+ accessibilityRole="button"
286
+ accessibilityLabel="Dismiss"
287
+ style={({ pressed }) => [
288
+ styles.closeBtn,
289
+ isLg ? styles.closeBtnLg : styles.closeBtnSm,
290
+ pressed && { opacity: 0.6 },
291
+ ]}
292
+ >
293
+ <Ionicons
294
+ name="close"
295
+ size={16}
296
+ color={c.iconColor}
297
+ />
298
+ </Pressable>
299
+ )}
300
+ </View>
301
+ </View>
302
+ );
303
+ }
304
+
305
+ const styles = StyleSheet.create({
306
+ root: {
307
+ overflow: 'hidden',
308
+ },
309
+ elevated: {
310
+ shadowColor: '#000',
311
+ shadowOffset: { width: 0, height: 2 },
312
+ shadowOpacity: 0.06,
313
+ shadowRadius: 4,
314
+ elevation: 1,
315
+ overflow: 'visible',
316
+ },
317
+ content: {
318
+ flexDirection: 'row',
319
+ },
320
+ textAction: {
321
+ flex: 1,
322
+ flexDirection: 'row',
323
+ alignItems: 'center',
324
+ justifyContent: 'space-between',
325
+ minWidth: 0,
326
+ gap: 16,
327
+ },
328
+ textBlock: {
329
+ flex: 1,
330
+ minWidth: 0,
331
+ },
332
+ // lg action button — elevated pill
333
+ actionLg: {
334
+ height: 40,
335
+ paddingHorizontal: 24,
336
+ borderRadius: 9999,
337
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
338
+ alignItems: 'center',
339
+ justifyContent: 'center',
340
+ flexShrink: 0,
341
+ shadowColor: '#000',
342
+ shadowOffset: { width: 0, height: 2 },
343
+ shadowOpacity: 0.06,
344
+ shadowRadius: 4,
345
+ elevation: 1,
346
+ },
347
+ actionLgBorder: {
348
+ borderWidth: 1,
349
+ borderColor: cr.outline.sysOutline,
350
+ },
351
+ // sm action button — text only
352
+ actionSm: {
353
+ flexShrink: 0,
354
+ },
355
+ closeBtn: {
356
+ alignItems: 'center',
357
+ justifyContent: 'center',
358
+ borderRadius: 9999,
359
+ },
360
+ closeBtnLg: {
361
+ position: 'absolute',
362
+ top: 8,
363
+ right: 8,
364
+ width: 32,
365
+ height: 32,
366
+ },
367
+ closeBtnSm: {
368
+ width: 32,
369
+ height: 32,
370
+ flexShrink: 0,
371
+ },
372
+ });
package/src/Avatar.tsx ADDED
@@ -0,0 +1,255 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import React from 'react';
3
+ import {
4
+ Image,
5
+ ImageSourcePropType,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import { sys } from './tokens';
11
+
12
+ export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
13
+ export type AvatarVariant = 'text' | 'image' | 'icon';
14
+
15
+ export interface AvatarProps {
16
+ variant?: AvatarVariant;
17
+ size?: AvatarSize;
18
+ /** Text variant — up to 2 initials, e.g. "CP" */
19
+ initials?: string;
20
+ /** Image variant — any React Native image source */
21
+ source?: ImageSourcePropType;
22
+ /** Icon variant — custom node; omit to use the default person icon */
23
+ icon?: React.ReactNode;
24
+ /** Renders a coloured ring around the avatar */
25
+ activityRing?: boolean;
26
+ /** Renders a shield-check badge at the bottom-right corner */
27
+ presenceBadge?: boolean;
28
+ /** Accessible label announced by screen readers */
29
+ accessibilityLabel?: string;
30
+ }
31
+
32
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
33
+
34
+ const SIZE_TOKENS = {
35
+ xs: {
36
+ diameter: 24,
37
+ fontSize: ts.labelSmall.sysFontSize,
38
+ lineHeight: ts.labelSmall.sysLineHeight,
39
+ fontWeight: '400' as const,
40
+ iconSize: 12,
41
+ ringBorder: dim.borderWidth.sysStrokeMedium,
42
+ ringInset: -1,
43
+ badgeSize: 0, // xs has no presence badge
44
+ badgeIconSize: 0,
45
+ badgeOffset: 0,
46
+ },
47
+ sm: {
48
+ diameter: 32,
49
+ fontSize: ts.labelMedium.sysFontSize,
50
+ lineHeight: ts.labelMedium.sysLineHeight,
51
+ fontWeight: '400' as const,
52
+ iconSize: 16,
53
+ ringBorder: dim.borderWidth.sysStrokeMedium,
54
+ ringInset: -2,
55
+ badgeSize: 16,
56
+ badgeIconSize: 12,
57
+ badgeOffset: -4,
58
+ },
59
+ md: {
60
+ diameter: 40,
61
+ fontSize: ts.labelLarge.sysFontSize,
62
+ lineHeight: ts.labelLarge.sysLineHeight,
63
+ fontWeight: '500' as const,
64
+ iconSize: 20,
65
+ ringBorder: dim.borderWidth.sysStrokeThick,
66
+ ringInset: -2,
67
+ badgeSize: 20,
68
+ badgeIconSize: 16,
69
+ badgeOffset: -4,
70
+ },
71
+ lg: {
72
+ diameter: 48,
73
+ fontSize: ts.labelLarge.sysFontSize,
74
+ lineHeight: ts.labelLarge.sysLineHeight,
75
+ fontWeight: '500' as const,
76
+ iconSize: 24,
77
+ ringBorder: dim.borderWidth.sysStrokeThick,
78
+ ringInset: -2,
79
+ badgeSize: 20,
80
+ badgeIconSize: 16,
81
+ badgeOffset: -4,
82
+ },
83
+ xl: {
84
+ diameter: 64,
85
+ fontSize: ts.labelLarge.sysFontSize,
86
+ lineHeight: ts.labelLarge.sysLineHeight,
87
+ fontWeight: '500' as const,
88
+ iconSize: 32,
89
+ ringBorder: dim.borderWidth.sysStrokeThick,
90
+ ringInset: -2,
91
+ badgeSize: 24,
92
+ badgeIconSize: 20,
93
+ badgeOffset: -4,
94
+ },
95
+ '2xl': {
96
+ diameter: 88,
97
+ fontSize: ts.titleMedium.sysFontSize,
98
+ lineHeight: ts.titleMedium.sysLineHeight,
99
+ fontWeight: '400' as const,
100
+ iconSize: 44,
101
+ ringBorder: dim.borderWidth.sysStrokeBold,
102
+ ringInset: -2,
103
+ badgeSize: 32,
104
+ badgeIconSize: 24,
105
+ badgeOffset: 0,
106
+ },
107
+ '3xl': {
108
+ diameter: 120,
109
+ fontSize: ts.titleLarge.sysFontSize,
110
+ lineHeight: ts.titleLarge.sysLineHeight,
111
+ fontWeight: '500' as const,
112
+ iconSize: 60,
113
+ ringBorder: dim.borderWidth.sysStrokeBold,
114
+ ringInset: -2,
115
+ badgeSize: 32,
116
+ badgeIconSize: 24,
117
+ badgeOffset: 0,
118
+ },
119
+ };
120
+
121
+ export function Avatar({
122
+ variant = 'text',
123
+ size = 'md',
124
+ initials = 'CP',
125
+ source,
126
+ icon,
127
+ activityRing = false,
128
+ presenceBadge = false,
129
+ accessibilityLabel,
130
+ }: AvatarProps) {
131
+ const s = SIZE_TOKENS[size];
132
+ const r = s.diameter / 2;
133
+
134
+ const defaultLabel =
135
+ variant === 'text' ? `Avatar: ${initials}` : 'Avatar';
136
+
137
+ return (
138
+ // Outer wrapper — same footprint as avatar, does NOT clip so ring/badge
139
+ // can overflow its bounds freely.
140
+ <View
141
+ accessible
142
+ accessibilityRole="image"
143
+ accessibilityLabel={accessibilityLabel ?? defaultLabel}
144
+ style={{ width: s.diameter, height: s.diameter }}
145
+ >
146
+
147
+ {/* ── Avatar circle ─────────────────────────────────────────────── */}
148
+ <View
149
+ style={[
150
+ styles.circle,
151
+ {
152
+ width: s.diameter,
153
+ height: s.diameter,
154
+ borderRadius: r,
155
+ backgroundColor:
156
+ variant !== 'image'
157
+ ? cr.custom.info.sysInfoContainer
158
+ : undefined,
159
+ },
160
+ ]}
161
+ >
162
+ {variant === 'text' && (
163
+ <Text
164
+ style={{
165
+ color: cr.custom.info.sysOnInfoContainer,
166
+ fontSize: s.fontSize,
167
+ lineHeight: s.lineHeight,
168
+ fontWeight: s.fontWeight,
169
+ includeFontPadding: false,
170
+ textAlign: 'center',
171
+ }}
172
+ numberOfLines={1}
173
+ >
174
+ {initials}
175
+ </Text>
176
+ )}
177
+
178
+ {variant === 'image' && source && (
179
+ <Image
180
+ source={source}
181
+ style={{ width: s.diameter, height: s.diameter }}
182
+ resizeMode="cover"
183
+ />
184
+ )}
185
+
186
+ {variant === 'icon' && (
187
+ icon ?? (
188
+ <Ionicons
189
+ name="person"
190
+ size={s.iconSize}
191
+ color={cr.custom.info.sysOnInfoContainer}
192
+ />
193
+ )
194
+ )}
195
+ </View>
196
+
197
+ {/* ── Activity ring ─────────────────────────────────────────────── */}
198
+ {activityRing && (
199
+ <View
200
+ accessible={false}
201
+ style={{
202
+ position: 'absolute',
203
+ top: s.ringInset,
204
+ left: s.ringInset,
205
+ right: s.ringInset,
206
+ bottom: s.ringInset,
207
+ borderRadius: r + Math.abs(s.ringInset),
208
+ borderWidth: s.ringBorder,
209
+ borderColor: cr.accent.primary.sysPrimary,
210
+ }}
211
+ pointerEvents="none"
212
+ />
213
+ )}
214
+
215
+ {/* ── Presence badge ────────────────────────────────────────────── */}
216
+ {presenceBadge && s.badgeSize > 0 && (
217
+ <View
218
+ accessible={false}
219
+ style={[
220
+ styles.badge,
221
+ {
222
+ width: s.badgeSize,
223
+ height: s.badgeSize,
224
+ borderRadius: s.badgeSize / 2,
225
+ bottom: s.badgeOffset,
226
+ right: s.badgeOffset,
227
+ backgroundColor:
228
+ cr.surface.surfaceContainer.sysSurfaceContainerLowest,
229
+ },
230
+ ]}
231
+ >
232
+ <Ionicons
233
+ name="shield-checkmark"
234
+ size={s.badgeIconSize}
235
+ color={cr.custom.info.sysOnInfoContainer}
236
+ />
237
+ </View>
238
+ )}
239
+ </View>
240
+ );
241
+ }
242
+
243
+ const styles = StyleSheet.create({
244
+ circle: {
245
+ alignItems: 'center',
246
+ justifyContent: 'center',
247
+ overflow: 'hidden', // clips images to the circle
248
+ },
249
+ badge: {
250
+ position: 'absolute',
251
+ alignItems: 'center',
252
+ justifyContent: 'center',
253
+ overflow: 'hidden',
254
+ },
255
+ });