@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/src/Card.tsx ADDED
@@ -0,0 +1,152 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View, type PressableProps, type ViewProps } from 'react-native';
3
+ import { sys } from './tokens';
4
+
5
+ export type CardVariant = 'outlined' | 'filled' | 'gradient';
6
+ export type CardSize = 'sm' | 'md' | 'lg' | 'xl';
7
+
8
+ export interface CardProps {
9
+ variant?: CardVariant;
10
+ size?: CardSize;
11
+ interactive?: boolean;
12
+ disabled?: boolean;
13
+ current?: boolean;
14
+ fullWidth?: boolean;
15
+ onPress?: () => void;
16
+ children?: React.ReactNode;
17
+ accessibilityLabel?: string;
18
+ }
19
+
20
+ const { colorRoles: cr, dimensions: dim } = sys;
21
+
22
+ const VARIANT_TOKENS = {
23
+ outlined: {
24
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
25
+ borderColor: cr.outline.sysOutline,
26
+ borderWidth: dim.borderWidth.sysStrokeThin,
27
+ elevated: false,
28
+ },
29
+ filled: {
30
+ bg: cr.accent.primary.sysPrimaryContainer,
31
+ borderColor: 'transparent',
32
+ borderWidth: 0,
33
+ elevated: true,
34
+ },
35
+ gradient: {
36
+ // Approximates the gradient variant with a tinted surface; expo-linear-gradient not required.
37
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
38
+ borderColor: cr.outline.sysOutline,
39
+ borderWidth: dim.borderWidth.sysStrokeThin,
40
+ elevated: false,
41
+ },
42
+ };
43
+
44
+ const SIZE_TOKENS = {
45
+ sm: {
46
+ padding: dim.spacing.padding.sysPadding24,
47
+ borderRadius: dim.borderRadius.sysRadiusLg,
48
+ gap: dim.spacing.padding.sysPadding12,
49
+ },
50
+ md: {
51
+ padding: dim.spacing.padding.sysPadding32,
52
+ borderRadius: dim.borderRadius.sysRadiusLg,
53
+ gap: dim.spacing.padding.sysPadding16,
54
+ },
55
+ lg: {
56
+ padding: dim.spacing.padding.sysPadding32,
57
+ 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,
64
+ },
65
+ };
66
+
67
+ export function Card({
68
+ variant = 'outlined',
69
+ size = 'md',
70
+ interactive = false,
71
+ disabled = false,
72
+ current = false,
73
+ fullWidth = false,
74
+ onPress,
75
+ children,
76
+ accessibilityLabel,
77
+ }: CardProps) {
78
+ const v = VARIANT_TOKENS[variant];
79
+ const s = SIZE_TOKENS[size];
80
+
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' ? (
107
+ <View
108
+ style={[
109
+ StyleSheet.absoluteFillObject,
110
+ {
111
+ borderRadius: s.borderRadius,
112
+ backgroundColor: cr.transparent.primary.sysPrimary08,
113
+ },
114
+ ]}
115
+ pointerEvents="none"
116
+ />
117
+ ) : null;
118
+
119
+ if (interactive || onPress) {
120
+ return (
121
+ <Pressable
122
+ onPress={disabled ? undefined : onPress}
123
+ accessibilityRole="button"
124
+ accessibilityLabel={accessibilityLabel}
125
+ accessibilityState={{ disabled, selected: current }}
126
+ style={({ pressed }) => [
127
+ styles.root,
128
+ baseStyle,
129
+ pressed && !disabled
130
+ ? { opacity: 0.84 }
131
+ : {},
132
+ ]}
133
+ >
134
+ {gradientOverlay}
135
+ {children}
136
+ </Pressable>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <View style={[styles.root, baseStyle]}>
142
+ {gradientOverlay}
143
+ {children}
144
+ </View>
145
+ );
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ root: {
150
+ overflow: 'hidden',
151
+ },
152
+ });
@@ -0,0 +1,182 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import React, { useState } from 'react';
3
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
4
+ import { sys } from './tokens';
5
+
6
+ export type CheckboxSize = 'sm' | 'md';
7
+ export type CheckboxCheckedState = boolean | 'indeterminate';
8
+
9
+ export interface CheckboxProps {
10
+ checked?: CheckboxCheckedState;
11
+ defaultChecked?: boolean;
12
+ onChange?: (checked: boolean) => void;
13
+ size?: CheckboxSize;
14
+ label?: string;
15
+ description?: string;
16
+ disabled?: boolean;
17
+ invalid?: boolean;
18
+ }
19
+
20
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
21
+
22
+ const SIZE_TOKENS = {
23
+ sm: { box: 20, hitArea: 36, fontSize: ts.bodySmall.sysFontSize, lineHeight: ts.bodySmall.sysLineHeight, iconSize: 13 },
24
+ md: { box: 24, hitArea: 40, fontSize: ts.bodyMedium.sysFontSize, lineHeight: ts.bodyMedium.sysLineHeight, iconSize: 16 },
25
+ };
26
+
27
+ export function Checkbox({
28
+ checked: checkedProp,
29
+ defaultChecked = false,
30
+ onChange,
31
+ size = 'md',
32
+ label,
33
+ description,
34
+ disabled = false,
35
+ invalid = false,
36
+ }: CheckboxProps) {
37
+ const [internalChecked, setInternalChecked] = useState<boolean>(defaultChecked);
38
+ const isControlled = checkedProp !== undefined;
39
+ const checkedState: CheckboxCheckedState = isControlled ? checkedProp : internalChecked;
40
+
41
+ const isChecked = checkedState === true;
42
+ const isIndeterminate = checkedState === 'indeterminate';
43
+ const isSelected = isChecked || isIndeterminate;
44
+
45
+ const s = SIZE_TOKENS[size];
46
+ const hitPad = (s.hitArea - s.box) / 2;
47
+
48
+ function handlePress() {
49
+ if (disabled) return;
50
+ // Indeterminate → true on activation (matches HTML spec)
51
+ const next = checkedState === true ? false : true;
52
+ if (!isControlled) setInternalChecked(next);
53
+ onChange?.(next);
54
+ }
55
+
56
+ const borderColor = invalid
57
+ ? cr.error.sysError
58
+ : isSelected
59
+ ? 'transparent'
60
+ : cr.outline.sysOutlineFixed;
61
+
62
+ const boxBg = isSelected
63
+ ? invalid
64
+ ? cr.error.sysError
65
+ : cr.accent.primary.sysPrimary
66
+ : 'transparent';
67
+
68
+ return (
69
+ <Pressable
70
+ onPress={handlePress}
71
+ accessibilityRole="checkbox"
72
+ accessibilityLabel={label}
73
+ accessibilityState={{
74
+ checked: isIndeterminate ? 'mixed' : isChecked,
75
+ disabled,
76
+ }}
77
+ style={({ pressed }) => [
78
+ styles.root,
79
+ disabled && styles.disabled,
80
+ ]}
81
+ >
82
+ {({ pressed }) => (
83
+ <>
84
+ {/* Hit-area / state-layer circle */}
85
+ <View
86
+ style={[
87
+ styles.hitArea,
88
+ {
89
+ width: s.hitArea,
90
+ height: s.hitArea,
91
+ borderRadius: s.hitArea / 2,
92
+ backgroundColor:
93
+ pressed && !disabled
94
+ ? cr.transparent.neutral.sysBlack10
95
+ : 'transparent',
96
+ },
97
+ ]}
98
+ >
99
+ {/* Checkbox box */}
100
+ <View
101
+ style={{
102
+ width: s.box,
103
+ height: s.box,
104
+ borderRadius: dim.borderRadius.sysRadiusXs,
105
+ borderWidth: isSelected ? 0 : dim.borderWidth.sysStrokeMedium,
106
+ borderColor,
107
+ backgroundColor: boxBg,
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ }}
111
+ >
112
+ {isChecked && !isIndeterminate && (
113
+ <Ionicons
114
+ name="checkmark"
115
+ size={s.iconSize}
116
+ color={cr.accent.primary.sysOnPrimary}
117
+ />
118
+ )}
119
+ {isIndeterminate && (
120
+ <View
121
+ style={{
122
+ width: s.box * 0.5,
123
+ height: 2,
124
+ borderRadius: 1,
125
+ backgroundColor: cr.accent.primary.sysOnPrimary,
126
+ }}
127
+ />
128
+ )}
129
+ </View>
130
+ </View>
131
+
132
+ {(label || description) && (
133
+ <View style={[styles.textBlock, { gap: 2 }]}>
134
+ {label && (
135
+ <Text
136
+ style={{
137
+ color: cr.surface.surface.sysOnSurface,
138
+ fontSize: s.fontSize,
139
+ lineHeight: s.lineHeight,
140
+ includeFontPadding: false,
141
+ }}
142
+ >
143
+ {label}
144
+ </Text>
145
+ )}
146
+ {description && (
147
+ <Text
148
+ style={{
149
+ color: cr.surface.surface.sysOnSurfaceVariant,
150
+ fontSize: s.fontSize - 2,
151
+ lineHeight: s.lineHeight - 2,
152
+ includeFontPadding: false,
153
+ }}
154
+ >
155
+ {description}
156
+ </Text>
157
+ )}
158
+ </View>
159
+ )}
160
+ </>
161
+ )}
162
+ </Pressable>
163
+ );
164
+ }
165
+
166
+ const styles = StyleSheet.create({
167
+ root: {
168
+ flexDirection: 'row',
169
+ alignItems: 'center',
170
+ },
171
+ hitArea: {
172
+ alignItems: 'center',
173
+ justifyContent: 'center',
174
+ },
175
+ textBlock: {
176
+ flex: 1,
177
+ flexDirection: 'column',
178
+ },
179
+ disabled: {
180
+ opacity: 0.48,
181
+ },
182
+ });
package/src/Chip.tsx ADDED
@@ -0,0 +1,291 @@
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 ChipSize = 'sm' | 'md' | 'lg' | 'xl';
12
+ export type ChipUsage = 'neutral' | 'informative' | 'positive' | 'danger' | 'warning';
13
+
14
+ export interface ChipProps {
15
+ label: string;
16
+ size?: ChipSize;
17
+ usage?: ChipUsage;
18
+ /** Optional icon rendered before the label */
19
+ leadingIcon?: React.ReactNode;
20
+ /** Renders a close button — fires onDismiss when tapped */
21
+ dismissible?: boolean;
22
+ onDismiss?: () => void;
23
+ /** Renders a small count badge after the label */
24
+ badge?: number | string;
25
+ }
26
+
27
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
28
+
29
+ // ── Size tokens ───────────────────────────────────────────────────────────────
30
+
31
+ const SIZE_TOKENS = {
32
+ sm: {
33
+ height: 20,
34
+ paddingH: dim.spacing.padding.sysPadding8,
35
+ paddingV: dim.spacing.padding.sysPadding4,
36
+ gap: dim.spacing.padding.sysPadding4,
37
+ fontSize: ts.labelSmall.sysFontSize,
38
+ lineHeight: ts.labelSmall.sysLineHeight,
39
+ letterSpacing: ts.labelSmall.sysTracking,
40
+ iconSize: 16,
41
+ closeBtnSize: 16,
42
+ closeIconSize: 12,
43
+ badgeSize: 16,
44
+ badgeFontSize: ts.labelSmall.sysFontSize,
45
+ badgeLineHeight: ts.labelSmall.sysLineHeight,
46
+ },
47
+ md: {
48
+ height: 24,
49
+ paddingH: dim.spacing.padding.sysPadding12,
50
+ paddingV: dim.spacing.padding.sysPadding4,
51
+ gap: dim.spacing.padding.sysPadding4,
52
+ fontSize: ts.labelMedium.sysFontSize,
53
+ lineHeight: ts.labelMedium.sysLineHeight,
54
+ letterSpacing: ts.labelMedium.sysTracking,
55
+ iconSize: 16,
56
+ closeBtnSize: 16,
57
+ closeIconSize: 12,
58
+ badgeSize: 16,
59
+ badgeFontSize: ts.labelSmall.sysFontSize,
60
+ badgeLineHeight: ts.labelSmall.sysLineHeight,
61
+ },
62
+ lg: {
63
+ height: 32,
64
+ paddingH: dim.spacing.padding.sysPadding16,
65
+ paddingV: dim.spacing.padding.sysPadding4,
66
+ gap: dim.spacing.padding.sysPadding8,
67
+ fontSize: ts.labelMedium.sysFontSize,
68
+ lineHeight: ts.labelMedium.sysLineHeight,
69
+ letterSpacing: ts.labelMedium.sysTracking,
70
+ iconSize: 20,
71
+ closeBtnSize: 20,
72
+ closeIconSize: 16,
73
+ badgeSize: 20,
74
+ badgeFontSize: ts.labelSmall.sysFontSize,
75
+ badgeLineHeight: ts.labelSmall.sysLineHeight,
76
+ },
77
+ xl: {
78
+ height: 40,
79
+ paddingH: dim.spacing.padding.sysPadding16,
80
+ paddingV: dim.spacing.padding.sysPadding8,
81
+ gap: dim.spacing.padding.sysPadding8,
82
+ fontSize: ts.bodyMedium.sysFontSize,
83
+ lineHeight: ts.bodyMedium.sysLineHeight,
84
+ letterSpacing: ts.bodyMedium.sysTracking,
85
+ iconSize: 20,
86
+ closeBtnSize: 24,
87
+ closeIconSize: 16,
88
+ badgeSize: 24,
89
+ badgeFontSize: ts.labelMedium.sysFontSize,
90
+ badgeLineHeight: ts.labelMedium.sysLineHeight,
91
+ },
92
+ };
93
+
94
+ // ── Usage/color tokens ────────────────────────────────────────────────────────
95
+
96
+ type UsageColors = {
97
+ bg: string;
98
+ text: string;
99
+ closeBg: string;
100
+ badgeBg: string;
101
+ badgeText: string;
102
+ /** true → render elevation shadow */
103
+ elevated: boolean;
104
+ };
105
+
106
+ function getUsageColors(usage: ChipUsage): UsageColors {
107
+ switch (usage) {
108
+ case 'neutral':
109
+ return {
110
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
111
+ text: cr.surface.surface.sysOnSurface,
112
+ closeBg: cr.surface.surfaceContainer.sysSurfaceContainerLow,
113
+ badgeBg: cr.surface.surfaceContainer.sysSurfaceContainer,
114
+ badgeText: cr.surface.surface.sysOnSurface,
115
+ elevated: true,
116
+ };
117
+ case 'informative':
118
+ return {
119
+ bg: cr.custom.info.sysInfo,
120
+ text: cr.accent.primary.sysOnPrimary,
121
+ closeBg: cr.transparent.neutral.sysWhite20,
122
+ badgeBg: cr.transparent.neutral.sysWhite20,
123
+ badgeText: cr.custom.info.sysOnInfo,
124
+ elevated: false,
125
+ };
126
+ case 'positive':
127
+ return {
128
+ bg: cr.custom.success.sysSuccess,
129
+ text: cr.custom.success.sysOnSuccess,
130
+ closeBg: cr.transparent.neutral.sysWhite20,
131
+ badgeBg: cr.transparent.neutral.sysWhite20,
132
+ badgeText: cr.custom.success.sysOnSuccess,
133
+ elevated: false,
134
+ };
135
+ case 'danger':
136
+ return {
137
+ bg: cr.error.sysError,
138
+ text: cr.error.sysOnError,
139
+ closeBg: cr.transparent.neutral.sysWhite20,
140
+ badgeBg: cr.transparent.neutral.sysWhite20,
141
+ badgeText: cr.error.sysOnError,
142
+ elevated: false,
143
+ };
144
+ case 'warning':
145
+ return {
146
+ bg: cr.custom.warning.sysWarning,
147
+ text: cr.custom.warning.sysOnWarning,
148
+ closeBg: cr.transparent.neutral.sysWhite20,
149
+ badgeBg: cr.transparent.neutral.sysWhite20,
150
+ badgeText: cr.custom.warning.sysOnWarning,
151
+ elevated: false,
152
+ };
153
+ }
154
+ }
155
+
156
+ // ── Component ─────────────────────────────────────────────────────────────────
157
+
158
+ export function Chip({
159
+ label,
160
+ size = 'md',
161
+ usage = 'neutral',
162
+ leadingIcon,
163
+ dismissible = false,
164
+ onDismiss,
165
+ badge,
166
+ }: ChipProps) {
167
+ const s = SIZE_TOKENS[size];
168
+ const c = getUsageColors(usage);
169
+
170
+ return (
171
+ <View
172
+ accessible
173
+ accessibilityRole="none"
174
+ accessibilityLabel={badge !== undefined ? `${label}, ${badge}` : label}
175
+ style={[
176
+ styles.root,
177
+ {
178
+ height: s.height,
179
+ paddingHorizontal: s.paddingH,
180
+ paddingVertical: s.paddingV,
181
+ gap: s.gap,
182
+ backgroundColor: c.bg,
183
+ },
184
+ c.elevated && styles.elevated,
185
+ ]}
186
+ >
187
+ {/* Leading icon */}
188
+ {leadingIcon && (
189
+ <View
190
+ style={{
191
+ width: s.iconSize,
192
+ height: s.iconSize,
193
+ alignItems: 'center',
194
+ justifyContent: 'center',
195
+ flexShrink: 0,
196
+ }}
197
+ >
198
+ {leadingIcon}
199
+ </View>
200
+ )}
201
+
202
+ {/* Label */}
203
+ <Text
204
+ style={{
205
+ color: c.text,
206
+ fontSize: s.fontSize,
207
+ lineHeight: s.lineHeight,
208
+ letterSpacing: s.letterSpacing,
209
+ includeFontPadding: false,
210
+ }}
211
+ numberOfLines={1}
212
+ >
213
+ {label}
214
+ </Text>
215
+
216
+ {/* Badge */}
217
+ {badge !== undefined && (
218
+ <View
219
+ style={{
220
+ width: s.badgeSize,
221
+ height: s.badgeSize,
222
+ borderRadius: s.badgeSize / 2,
223
+ backgroundColor: c.badgeBg,
224
+ alignItems: 'center',
225
+ justifyContent: 'center',
226
+ flexShrink: 0,
227
+ }}
228
+ >
229
+ <Text
230
+ style={{
231
+ color: c.badgeText,
232
+ fontSize: s.badgeFontSize,
233
+ lineHeight: s.badgeLineHeight,
234
+ includeFontPadding: false,
235
+ fontWeight: '500',
236
+ }}
237
+ >
238
+ {badge}
239
+ </Text>
240
+ </View>
241
+ )}
242
+
243
+ {/* Dismiss button */}
244
+ {dismissible && (
245
+ <Pressable
246
+ onPress={onDismiss}
247
+ accessibilityRole="button"
248
+ accessibilityLabel="Dismiss"
249
+ hitSlop={4}
250
+ style={({ pressed }) => ({
251
+ width: s.closeBtnSize,
252
+ height: s.closeBtnSize,
253
+ borderRadius: s.closeBtnSize / 2,
254
+ backgroundColor: pressed
255
+ ? cr.transparent.neutral.sysBlack10
256
+ : c.closeBg,
257
+ alignItems: 'center',
258
+ justifyContent: 'center',
259
+ flexShrink: 0,
260
+ overflow: 'hidden',
261
+ })}
262
+ >
263
+ <Ionicons
264
+ name="close"
265
+ size={s.closeIconSize}
266
+ color={c.text}
267
+ />
268
+ </Pressable>
269
+ )}
270
+ </View>
271
+ );
272
+ }
273
+
274
+ const styles = StyleSheet.create({
275
+ root: {
276
+ flexDirection: 'row',
277
+ alignItems: 'center',
278
+ alignSelf: 'flex-start',
279
+ borderRadius: 9999,
280
+ overflow: 'hidden',
281
+ },
282
+ elevated: {
283
+ shadowColor: '#000',
284
+ shadowOffset: { width: 0, height: 2 },
285
+ shadowOpacity: 0.10,
286
+ shadowRadius: 8,
287
+ elevation: 2,
288
+ // overflow must be visible for shadow on iOS — override here
289
+ overflow: 'visible',
290
+ },
291
+ });
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { sys } from './tokens';
4
+
5
+ export type DividerVariant = 'horizontal' | 'vertical';
6
+ export type DividerWeight = 'thin' | 'thick';
7
+
8
+ export interface DividerProps {
9
+ variant?: DividerVariant;
10
+ weight?: DividerWeight;
11
+ dashed?: boolean;
12
+ }
13
+
14
+ const { colorRoles: cr, dimensions: dim } = sys;
15
+
16
+ export function Divider({
17
+ variant = 'horizontal',
18
+ weight = 'thin',
19
+ dashed = false,
20
+ }: DividerProps) {
21
+ const thickness =
22
+ weight === 'thin'
23
+ ? dim.borderWidth.sysStrokeThin
24
+ : dim.borderWidth.sysStrokeThick;
25
+
26
+ const color = cr.outline.sysOutlineVariant;
27
+
28
+ if (variant === 'horizontal') {
29
+ return (
30
+ <View
31
+ style={{
32
+ // Zero height — the line is drawn as a top border
33
+ height: 0,
34
+ // Stretch to fill parent width
35
+ alignSelf: 'stretch',
36
+ borderTopWidth: thickness,
37
+ borderTopColor: color,
38
+ borderStyle: dashed ? 'dashed' : 'solid',
39
+ }}
40
+ accessible={false}
41
+ />
42
+ );
43
+ }
44
+
45
+ // Vertical
46
+ return (
47
+ <View
48
+ style={{
49
+ // Zero width — the line is drawn as a left border
50
+ width: 0,
51
+ // Stretch to fill parent height
52
+ alignSelf: 'stretch',
53
+ borderLeftWidth: thickness,
54
+ borderLeftColor: color,
55
+ borderStyle: dashed ? 'dashed' : 'solid',
56
+ }}
57
+ accessible={false}
58
+ />
59
+ );
60
+ }