@compsych/mobile-ui 1.0.4 → 1.0.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compsych/mobile-ui",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "ComPsych React Native Design System — 19 mobile UI components with self-contained design tokens",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/Card.tsx CHANGED
@@ -1,152 +1,335 @@
1
1
  import React from 'react';
2
- import { Pressable, StyleSheet, View, type PressableProps, type ViewProps } from 'react-native';
2
+ import {
3
+ Image,
4
+ type ImageSourcePropType,
5
+ Pressable,
6
+ StyleSheet,
7
+ View,
8
+ } from 'react-native';
9
+ import { Ionicons } from '@expo/vector-icons';
3
10
  import { sys } from './tokens';
11
+ import { BodyText } from './BodyText';
4
12
 
5
- export type CardVariant = 'outlined' | 'filled' | 'gradient';
6
- export type CardSize = 'sm' | 'md' | 'lg' | 'xl';
13
+ export type CardVariant = 'outlined' | 'tonal' | 'filled' | 'doubled' | 'image';
14
+ export type CardSize = 'sm' | 'md' | 'lg';
7
15
 
8
16
  export interface CardProps {
9
17
  variant?: CardVariant;
10
18
  size?: CardSize;
19
+ title?: string;
20
+ description?: string;
21
+ icon?: React.ReactNode;
22
+ image?: ImageSourcePropType;
11
23
  interactive?: boolean;
12
24
  disabled?: boolean;
13
- current?: boolean;
14
- fullWidth?: boolean;
15
25
  onPress?: () => void;
16
26
  children?: React.ReactNode;
17
27
  accessibilityLabel?: string;
28
+ fullWidth?: boolean;
18
29
  }
19
30
 
20
31
  const { colorRoles: cr, dimensions: dim } = sys;
21
32
 
22
- const VARIANT_TOKENS = {
33
+ // ── Variant tokens ────────────────────────────────────────────────────────────
34
+
35
+ const VARIANT_TOKENS: Record<
36
+ CardVariant,
37
+ {
38
+ bg: string;
39
+ borderColor: string;
40
+ borderWidth: number;
41
+ elevated: boolean;
42
+ titleColor: string;
43
+ descColor: string;
44
+ chevronColor: string;
45
+ // doubled inner gradient overlay color
46
+ innerBg?: string;
47
+ iconBadgeBg?: string;
48
+ iconBadgeColor?: string;
49
+ }
50
+ > = {
23
51
  outlined: {
24
52
  bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
25
53
  borderColor: cr.outline.sysOutline,
26
54
  borderWidth: dim.borderWidth.sysStrokeThin,
55
+ elevated: true,
56
+ titleColor: cr.surface.surface.sysOnSurface,
57
+ descColor: cr.surface.surface.sysOnSurfaceVariant,
58
+ chevronColor: cr.outline.sysOutlineFixed,
59
+ },
60
+ tonal: {
61
+ bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
62
+ borderColor: 'transparent',
63
+ borderWidth: 0,
27
64
  elevated: false,
65
+ titleColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
66
+ descColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
67
+ chevronColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
28
68
  },
29
69
  filled: {
30
- bg: cr.accent.primary.sysPrimaryContainer,
70
+ bg: cr.accent.primary.sysPrimary,
31
71
  borderColor: 'transparent',
32
72
  borderWidth: 0,
33
73
  elevated: true,
74
+ titleColor: cr.accent.primary.sysOnPrimary,
75
+ descColor: cr.accent.primary.sysOnPrimary,
76
+ chevronColor: cr.accent.primary.sysOnPrimary,
34
77
  },
35
- gradient: {
36
- // Approximates the gradient variant with a tinted surface; expo-linear-gradient not required.
78
+ doubled: {
37
79
  bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
38
80
  borderColor: cr.outline.sysOutline,
39
81
  borderWidth: dim.borderWidth.sysStrokeThin,
40
82
  elevated: false,
83
+ titleColor: cr.surface.surface.sysOnSurface,
84
+ descColor: cr.surface.surface.sysOnSurfaceVariant,
85
+ chevronColor: cr.outline.sysOutlineFixed,
86
+ innerBg: cr.transparent.primary.sysPrimary08,
87
+ iconBadgeBg: cr.accent.primary.sysPrimary,
88
+ iconBadgeColor: cr.accent.primary.sysOnPrimary,
89
+ },
90
+ image: {
91
+ bg: 'transparent',
92
+ borderColor: 'transparent',
93
+ borderWidth: 0,
94
+ elevated: false,
95
+ titleColor: '#ffffff',
96
+ descColor: cr.transparent.neutral.sysWhite80,
97
+ chevronColor: '#ffffff',
41
98
  },
42
99
  };
43
100
 
101
+ // ── Size tokens ───────────────────────────────────────────────────────────────
102
+
44
103
  const SIZE_TOKENS = {
45
104
  sm: {
46
- padding: dim.spacing.padding.sysPadding24,
47
- borderRadius: dim.borderRadius.sysRadiusLg,
105
+ layout: 'row' as const,
106
+ paddingH: dim.spacing.padding.sysPadding16,
107
+ paddingV: dim.spacing.padding.sysPadding12,
48
108
  gap: dim.spacing.padding.sysPadding12,
109
+ borderRadius: dim.borderRadius.sysRadiusMd,
110
+ iconSize: 24,
111
+ titleVariant: 'medium' as const,
112
+ showChevron: true,
49
113
  },
50
114
  md: {
51
- padding: dim.spacing.padding.sysPadding32,
115
+ layout: 'column' as const,
116
+ paddingH: dim.spacing.padding.sysPadding16,
117
+ paddingV: dim.spacing.padding.sysPadding16,
118
+ gap: dim.spacing.padding.sysPadding24,
52
119
  borderRadius: dim.borderRadius.sysRadiusLg,
53
- gap: dim.spacing.padding.sysPadding16,
120
+ iconSize: 32,
121
+ titleVariant: 'medium' as const,
122
+ showChevron: false,
54
123
  },
55
124
  lg: {
56
- padding: dim.spacing.padding.sysPadding32,
125
+ layout: 'column' as const,
126
+ paddingH: dim.spacing.padding.sysPadding16,
127
+ paddingV: dim.spacing.padding.sysPadding16,
128
+ gap: dim.spacing.padding.sysPadding32,
57
129
  borderRadius: dim.borderRadius.sysRadiusLg,
58
- gap: dim.spacing.padding.sysPadding24,
59
- },
60
- xl: {
61
- padding: dim.spacing.padding.sysPadding48,
62
- borderRadius: dim.borderRadius.sysRadiusXl,
63
- gap: dim.spacing.padding.sysPadding24,
130
+ iconSize: 32,
131
+ titleVariant: 'large' as const,
132
+ showChevron: false,
64
133
  },
65
134
  };
66
135
 
136
+ const ELEVATION = {
137
+ shadowColor: '#000',
138
+ shadowOffset: { width: 0, height: 2 },
139
+ shadowOpacity: 0.08,
140
+ shadowRadius: 8,
141
+ elevation: 2,
142
+ };
143
+
144
+ // ── Component ─────────────────────────────────────────────────────────────────
145
+
67
146
  export function Card({
68
147
  variant = 'outlined',
69
- size = 'md',
148
+ size = 'lg',
149
+ title,
150
+ description,
151
+ icon,
152
+ image,
70
153
  interactive = false,
71
154
  disabled = false,
72
- current = false,
73
- fullWidth = false,
74
155
  onPress,
75
156
  children,
76
157
  accessibilityLabel,
158
+ fullWidth = false,
77
159
  }: CardProps) {
78
160
  const v = VARIANT_TOKENS[variant];
79
161
  const s = SIZE_TOKENS[size];
162
+ const isRow = s.layout === 'row';
163
+
164
+ const outerStyle = [
165
+ {
166
+ backgroundColor: variant === 'doubled' ? v.bg : v.bg,
167
+ borderColor: v.borderColor,
168
+ borderWidth: v.borderWidth,
169
+ borderRadius: variant === 'doubled' ? dim.borderRadius.sysRadiusXl : s.borderRadius,
170
+ paddingHorizontal: variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingH,
171
+ paddingVertical: variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingV,
172
+ opacity: disabled ? 0.48 : 1,
173
+ },
174
+ v.elevated && ELEVATION,
175
+ fullWidth && { alignSelf: 'stretch' as const },
176
+ isRow ? styles.rowRoot : styles.colRoot,
177
+ !isRow && { gap: s.gap },
178
+ ];
179
+
180
+ // ── Text block ──────────────────────────────────────────────────────────────
181
+ const textBlock = (title || description) ? (
182
+ <View style={isRow ? styles.rowTextBlock : undefined}>
183
+ {title && (
184
+ <BodyText variant={s.titleVariant} color={v.titleColor}>
185
+ {title}
186
+ </BodyText>
187
+ )}
188
+ {!isRow && description && (
189
+ <BodyText variant="small" color={v.descColor}>
190
+ {description}
191
+ </BodyText>
192
+ )}
193
+ </View>
194
+ ) : null;
195
+
196
+ // ── Inner content by variant ────────────────────────────────────────────────
197
+ let inner: React.ReactNode;
80
198
 
81
- const borderColor = current ? cr.accent.primary.sysPrimary : v.borderColor;
82
- const borderWidth = current ? dim.borderWidth.sysStrokeThick : v.borderWidth;
83
-
84
- const baseStyle = {
85
- backgroundColor: v.bg,
86
- borderColor,
87
- borderWidth,
88
- borderRadius: s.borderRadius,
89
- padding: s.padding,
90
- gap: s.gap,
91
- ...(fullWidth ? { alignSelf: 'stretch' as const } : {}),
92
- ...(v.elevated
93
- ? {
94
- shadowColor: '#000',
95
- shadowOffset: { width: 0, height: 2 },
96
- shadowOpacity: 0.06,
97
- shadowRadius: 4,
98
- elevation: 1,
99
- }
100
- : {}),
101
- opacity: disabled ? 0.48 : 1,
102
- };
103
-
104
- // Gradient variant: overlay a subtle primary tint over the content area
105
- const gradientOverlay =
106
- variant === 'gradient' ? (
199
+ if (variant === 'doubled') {
200
+ inner = (
107
201
  <View
108
202
  style={[
109
- StyleSheet.absoluteFillObject,
203
+ styles.colRoot,
110
204
  {
205
+ backgroundColor: v.innerBg,
111
206
  borderRadius: s.borderRadius,
112
- backgroundColor: cr.transparent.primary.sysPrimary08,
207
+ paddingHorizontal: s.paddingH,
208
+ paddingVertical: s.paddingV,
209
+ gap: s.gap,
113
210
  },
114
211
  ]}
115
- pointerEvents="none"
116
- />
117
- ) : null;
212
+ >
213
+ {icon && (
214
+ <View
215
+ style={[
216
+ styles.doubledIconBadge,
217
+ {
218
+ backgroundColor: v.iconBadgeBg,
219
+ borderRadius: dim.borderRadius.sysRadiusFull,
220
+ },
221
+ ]}
222
+ >
223
+ {icon}
224
+ </View>
225
+ )}
226
+ {textBlock}
227
+ {children}
228
+ </View>
229
+ );
230
+ } else if (variant === 'image') {
231
+ inner = (
232
+ <>
233
+ {image && (
234
+ <>
235
+ <Image
236
+ source={image}
237
+ style={[StyleSheet.absoluteFillObject, { borderRadius: s.borderRadius }]}
238
+ resizeMode="cover"
239
+ accessible={false}
240
+ />
241
+ <View
242
+ style={[
243
+ StyleSheet.absoluteFillObject,
244
+ { borderRadius: s.borderRadius, backgroundColor: 'rgba(0,0,0,0.30)' },
245
+ ]}
246
+ pointerEvents="none"
247
+ />
248
+ </>
249
+ )}
250
+ {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
251
+ {textBlock}
252
+ {children}
253
+ </>
254
+ );
255
+ } else if (isRow) {
256
+ // sm — horizontal row
257
+ inner = (
258
+ <>
259
+ {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
260
+ {textBlock}
261
+ {s.showChevron && (
262
+ <Ionicons
263
+ name="chevron-forward"
264
+ size={20}
265
+ color={v.chevronColor}
266
+ accessible={false}
267
+ />
268
+ )}
269
+ {children}
270
+ </>
271
+ );
272
+ } else {
273
+ // md / lg — vertical column
274
+ inner = (
275
+ <>
276
+ {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
277
+ {textBlock}
278
+ {children}
279
+ </>
280
+ );
281
+ }
118
282
 
283
+ // ── Wrapper ─────────────────────────────────────────────────────────────────
119
284
  if (interactive || onPress) {
120
285
  return (
121
286
  <Pressable
122
287
  onPress={disabled ? undefined : onPress}
123
288
  accessibilityRole="button"
124
- accessibilityLabel={accessibilityLabel}
125
- accessibilityState={{ disabled, selected: current }}
289
+ accessibilityLabel={accessibilityLabel ?? title}
290
+ accessibilityState={{ disabled }}
126
291
  style={({ pressed }) => [
127
- styles.root,
128
- baseStyle,
129
- pressed && !disabled
130
- ? { opacity: 0.84 }
131
- : {},
292
+ styles.overflow,
293
+ ...outerStyle,
294
+ pressed && !disabled && styles.pressed,
132
295
  ]}
133
296
  >
134
- {gradientOverlay}
135
- {children}
297
+ {inner}
136
298
  </Pressable>
137
299
  );
138
300
  }
139
301
 
140
302
  return (
141
- <View style={[styles.root, baseStyle]}>
142
- {gradientOverlay}
143
- {children}
303
+ <View style={[styles.overflow, ...outerStyle]}>
304
+ {inner}
144
305
  </View>
145
306
  );
146
307
  }
147
308
 
148
309
  const styles = StyleSheet.create({
149
- root: {
310
+ overflow: {
150
311
  overflow: 'hidden',
151
312
  },
313
+ colRoot: {
314
+ flexDirection: 'column',
315
+ alignItems: 'flex-start',
316
+ },
317
+ rowRoot: {
318
+ flexDirection: 'row',
319
+ alignItems: 'center',
320
+ gap: 12,
321
+ },
322
+ rowTextBlock: {
323
+ flex: 1,
324
+ minWidth: 0,
325
+ },
326
+ doubledIconBadge: {
327
+ width: 48,
328
+ height: 48,
329
+ alignItems: 'center',
330
+ justifyContent: 'center',
331
+ },
332
+ pressed: {
333
+ opacity: 0.84,
334
+ },
152
335
  });
package/src/tokens.ts CHANGED
@@ -65,7 +65,9 @@ export const sys = {
65
65
  transparent: {
66
66
  neutral: {
67
67
  sysBlack10: '#0000001a',
68
+ sysWhite10: '#ffffff1a',
68
69
  sysWhite20: '#ffffff33',
70
+ sysWhite80: 'rgba(255,255,255,0.8)',
69
71
  },
70
72
  primary: {
71
73
  sysPrimary08: '#075cba14',