@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/Badge.tsx ADDED
@@ -0,0 +1,189 @@
1
+ import React from 'react';
2
+ import { StyleSheet, Text, View } from 'react-native';
3
+ import { sys } from './tokens';
4
+
5
+ export type BadgeSize = 'sm' | 'md' | 'lg';
6
+ export type BadgeStyle =
7
+ | 'filled'
8
+ | 'positive'
9
+ | 'danger'
10
+ | 'elevated'
11
+ | 'tonal'
12
+ | 'dot';
13
+
14
+ export interface BadgeProps {
15
+ /** Number or short string to display — ignored for `dot` style */
16
+ label?: number | string;
17
+ size?: BadgeSize;
18
+ badgeStyle?: BadgeStyle;
19
+ }
20
+
21
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
22
+
23
+ // ── Size tokens ───────────────────────────────────────────────────────────────
24
+
25
+ const SIZE_TOKENS = {
26
+ sm: {
27
+ outerSize: 16,
28
+ paddingH: dim.spacing.padding.sysPadding4,
29
+ fontSize: ts.labelSmall.sysFontSize,
30
+ lineHeight: ts.labelSmall.sysLineHeight,
31
+ letterSpacing: ts.labelSmall.sysTracking,
32
+ dotInner: 6,
33
+ },
34
+ md: {
35
+ outerSize: 20,
36
+ paddingH: dim.spacing.padding.sysPadding8,
37
+ fontSize: ts.labelMedium.sysFontSize,
38
+ lineHeight: ts.labelMedium.sysLineHeight,
39
+ letterSpacing: ts.labelMedium.sysTracking,
40
+ dotInner: 8,
41
+ },
42
+ lg: {
43
+ outerSize: 24,
44
+ paddingH: dim.spacing.padding.sysPadding8,
45
+ fontSize: ts.labelMedium.sysFontSize,
46
+ lineHeight: ts.labelMedium.sysLineHeight,
47
+ letterSpacing: ts.labelMedium.sysTracking,
48
+ dotInner: 12,
49
+ },
50
+ };
51
+
52
+ // ── Style/color tokens ────────────────────────────────────────────────────────
53
+
54
+ type StyleColors = {
55
+ bg: string;
56
+ text: string;
57
+ elevated: boolean;
58
+ };
59
+
60
+ function getStyleColors(style: BadgeStyle): StyleColors {
61
+ switch (style) {
62
+ case 'filled':
63
+ return {
64
+ bg: cr.accent.primary.sysPrimary,
65
+ text: cr.accent.primary.sysOnPrimary,
66
+ elevated: false,
67
+ };
68
+ case 'positive':
69
+ return {
70
+ bg: cr.custom.success.sysSuccess,
71
+ text: cr.custom.success.sysOnSuccess,
72
+ elevated: false,
73
+ };
74
+ case 'danger':
75
+ return {
76
+ bg: cr.error.sysError,
77
+ text: cr.error.sysOnError,
78
+ elevated: false,
79
+ };
80
+ case 'elevated':
81
+ return {
82
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
83
+ text: cr.surface.surface.sysOnSurface,
84
+ elevated: true,
85
+ };
86
+ case 'tonal':
87
+ return {
88
+ bg: cr.surface.surfaceContainer.sysSurfaceContainer,
89
+ text: cr.surface.surface.sysOnSurface,
90
+ elevated: false,
91
+ };
92
+ case 'dot':
93
+ // Container is transparent; the dot itself is sysPrimary
94
+ return {
95
+ bg: 'transparent',
96
+ text: 'transparent',
97
+ elevated: false,
98
+ };
99
+ }
100
+ }
101
+
102
+ // ── Component ─────────────────────────────────────────────────────────────────
103
+
104
+ export function Badge({
105
+ label,
106
+ size = 'md',
107
+ badgeStyle = 'filled',
108
+ }: BadgeProps) {
109
+ const s = SIZE_TOKENS[size];
110
+ const c = getStyleColors(badgeStyle);
111
+
112
+ // Dot variant — render a solid filled circle inside a transparent wrapper
113
+ if (badgeStyle === 'dot') {
114
+ return (
115
+ <View
116
+ accessible={false}
117
+ style={{
118
+ width: s.outerSize,
119
+ height: s.outerSize,
120
+ borderRadius: s.outerSize / 2,
121
+ alignItems: 'center',
122
+ justifyContent: 'center',
123
+ }}
124
+ >
125
+ <View
126
+ style={{
127
+ width: s.dotInner,
128
+ height: s.dotInner,
129
+ borderRadius: s.dotInner / 2,
130
+ backgroundColor: cr.accent.primary.sysPrimary,
131
+ }}
132
+ />
133
+ </View>
134
+ );
135
+ }
136
+
137
+ // Number/text badge — pill that is at minimum a circle, grows wider for
138
+ // longer labels (e.g. "99+")
139
+ const displayLabel = label !== undefined ? String(label) : '';
140
+
141
+ return (
142
+ <View
143
+ accessible
144
+ accessibilityLabel={displayLabel ? `${displayLabel} badge` : 'Badge'}
145
+ style={[
146
+ styles.root,
147
+ {
148
+ minWidth: s.outerSize,
149
+ height: s.outerSize,
150
+ borderRadius: s.outerSize / 2,
151
+ paddingHorizontal: s.paddingH,
152
+ backgroundColor: c.bg,
153
+ },
154
+ c.elevated && styles.elevated,
155
+ ]}
156
+ >
157
+ <Text
158
+ style={{
159
+ color: c.text,
160
+ fontSize: s.fontSize,
161
+ lineHeight: s.lineHeight,
162
+ letterSpacing: s.letterSpacing,
163
+ fontWeight: '500',
164
+ includeFontPadding: false,
165
+ textAlign: 'center',
166
+ }}
167
+ numberOfLines={1}
168
+ >
169
+ {displayLabel}
170
+ </Text>
171
+ </View>
172
+ );
173
+ }
174
+
175
+ const styles = StyleSheet.create({
176
+ root: {
177
+ alignItems: 'center',
178
+ justifyContent: 'center',
179
+ // overflow visible so elevated shadow renders outside the pill
180
+ overflow: 'visible',
181
+ },
182
+ elevated: {
183
+ shadowColor: '#000',
184
+ shadowOffset: { width: 0, height: 2 },
185
+ shadowOpacity: 0.06,
186
+ shadowRadius: 4,
187
+ elevation: 1,
188
+ },
189
+ });
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import { Text, type TextProps } from 'react-native';
3
+
4
+ import { sys } from './tokens';
5
+
6
+ export type BodyVariant =
7
+ | 'large'
8
+ | 'medium'
9
+ | 'small'
10
+ | 'labelLarge'
11
+ | 'labelMedium'
12
+ | 'labelSmall';
13
+
14
+ export interface BodyTextProps extends TextProps {
15
+ variant?: BodyVariant;
16
+ emphasized?: boolean;
17
+ color?: string;
18
+ }
19
+
20
+ const FONT_FAMILY: Record<string, string> = {
21
+ regular: 'GoogleSans_400Regular',
22
+ medium: 'GoogleSans_500Medium',
23
+ semibold: 'GoogleSans_600SemiBold',
24
+ };
25
+
26
+ const VARIANT_TOKEN_KEY: Record<BodyVariant, string> = {
27
+ large: 'bodyLarge',
28
+ medium: 'bodyMedium',
29
+ small: 'bodySmall',
30
+ labelLarge: 'labelLarge',
31
+ labelMedium: 'labelMedium',
32
+ labelSmall: 'labelSmall',
33
+ };
34
+
35
+ const { typeScale: ts } = sys;
36
+
37
+ export function BodyText({
38
+ variant = 'medium',
39
+ emphasized = false,
40
+ color,
41
+ style,
42
+ ...rest
43
+ }: BodyTextProps) {
44
+ const token = ts[VARIANT_TOKEN_KEY[variant]];
45
+ const weight = emphasized ? token.sysFontWeightEmphasized : token.sysFontWeight;
46
+
47
+ return (
48
+ <Text
49
+ style={[
50
+ {
51
+ fontFamily: FONT_FAMILY[weight],
52
+ fontSize: token.sysFontSize,
53
+ lineHeight: token.sysLineHeight,
54
+ letterSpacing: token.sysTracking,
55
+ color,
56
+ },
57
+ style,
58
+ ]}
59
+ {...rest}
60
+ />
61
+ );
62
+ }
@@ -0,0 +1,192 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import React from 'react';
3
+ import {
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import { sys } from './tokens';
11
+
12
+ export type BreadcrumbSize = 'sm' | 'lg';
13
+
14
+ export interface BreadcrumbItem {
15
+ /** Display text. Omit when `isHome` is true. */
16
+ label?: string;
17
+ /** Renders the home icon instead of text */
18
+ isHome?: boolean;
19
+ /** Renders "…" — tap to expand hidden crumbs */
20
+ isOverflow?: boolean;
21
+ /** Reduces opacity; non-interactive */
22
+ disabled?: boolean;
23
+ /**
24
+ * Press handler. Omit (or leave undefined) for the current/last item —
25
+ * it will automatically render as non-interactive with `sysOnSurface` text.
26
+ */
27
+ onPress?: () => void;
28
+ }
29
+
30
+ export interface BreadcrumbProps {
31
+ items: BreadcrumbItem[];
32
+ size?: BreadcrumbSize;
33
+ }
34
+
35
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
36
+
37
+ const SIZE_TOKENS = {
38
+ sm: {
39
+ fontSize: ts.labelSmall.sysFontSize,
40
+ lineHeight: ts.labelSmall.sysLineHeight,
41
+ letterSpacing: ts.labelSmall.sysTracking,
42
+ paddingH: dim.spacing.padding.sysPadding8,
43
+ paddingV: dim.spacing.padding.sysPadding2,
44
+ iconSize: 16,
45
+ dividerWidth: 8,
46
+ },
47
+ lg: {
48
+ fontSize: ts.labelMedium.sysFontSize,
49
+ lineHeight: ts.labelMedium.sysLineHeight,
50
+ letterSpacing: ts.labelMedium.sysTracking,
51
+ paddingH: dim.spacing.padding.sysPadding8,
52
+ paddingV: dim.spacing.padding.sysPadding4,
53
+ iconSize: 16,
54
+ dividerWidth: 8,
55
+ },
56
+ };
57
+
58
+ export function Breadcrumb({ items, size = 'lg' }: BreadcrumbProps) {
59
+ const s = SIZE_TOKENS[size];
60
+
61
+ return (
62
+ // ScrollView lets a long breadcrumb scroll horizontally without clipping
63
+ <ScrollView
64
+ horizontal
65
+ showsHorizontalScrollIndicator={false}
66
+ contentContainerStyle={styles.row}
67
+ >
68
+ {items.map((item, index) => {
69
+ const isLast = index === items.length - 1;
70
+ // Last item is always "current" regardless of whether onPress is set
71
+ const isCurrent = isLast;
72
+ const isInteractive = !isCurrent && !item.disabled;
73
+
74
+ const textColor = isCurrent
75
+ ? cr.surface.surface.sysOnSurface
76
+ : cr.surface.surface.sysOnSurfaceVariant;
77
+
78
+ const content = (
79
+ <View
80
+ style={[
81
+ styles.content,
82
+ {
83
+ paddingHorizontal: s.paddingH,
84
+ paddingVertical: item.isHome ? dim.spacing.padding.sysPadding2 : s.paddingV,
85
+ gap: item.isHome ? 0 : dim.spacing.padding.sysPadding4,
86
+ },
87
+ item.disabled && styles.disabled,
88
+ ]}
89
+ >
90
+ {item.isHome ? (
91
+ <Ionicons
92
+ name="home"
93
+ size={s.iconSize}
94
+ color={cr.surface.surface.sysOnSurfaceVariant}
95
+ />
96
+ ) : (
97
+ <Text
98
+ style={{
99
+ color: textColor,
100
+ fontSize: s.fontSize,
101
+ lineHeight: s.lineHeight,
102
+ letterSpacing: s.letterSpacing,
103
+ includeFontPadding: false,
104
+ fontWeight: isCurrent ? '500' : '400',
105
+ }}
106
+ numberOfLines={1}
107
+ >
108
+ {item.isOverflow ? '…' : item.label}
109
+ </Text>
110
+ )}
111
+ </View>
112
+ );
113
+
114
+ return (
115
+ <View key={index} style={styles.item}>
116
+ {/* Item — pressable if navigable, plain View if current/disabled */}
117
+ {isInteractive ? (
118
+ <Pressable
119
+ onPress={item.onPress}
120
+ accessibilityRole="link"
121
+ accessibilityLabel={item.isHome ? 'Home' : item.label}
122
+ style={({ pressed }) => [
123
+ styles.pressable,
124
+ pressed && styles.pressed,
125
+ ]}
126
+ >
127
+ {content}
128
+ </Pressable>
129
+ ) : (
130
+ <View
131
+ accessibilityRole={isCurrent ? 'text' : undefined}
132
+ accessibilityState={isCurrent ? { selected: true } : undefined}
133
+ >
134
+ {content}
135
+ </View>
136
+ )}
137
+
138
+ {/* Divider — shown after every item except the last */}
139
+ {!isLast && (
140
+ <View
141
+ accessible={false}
142
+ style={[styles.divider, { width: s.dividerWidth }]}
143
+ >
144
+ <Text
145
+ style={{
146
+ color: cr.surface.surface.sysOnSurfaceVariant,
147
+ fontSize: s.fontSize,
148
+ lineHeight: s.lineHeight,
149
+ includeFontPadding: false,
150
+ textAlign: 'center',
151
+ }}
152
+ >
153
+ /
154
+ </Text>
155
+ </View>
156
+ )}
157
+ </View>
158
+ );
159
+ })}
160
+ </ScrollView>
161
+ );
162
+ }
163
+
164
+ const styles = StyleSheet.create({
165
+ row: {
166
+ flexDirection: 'row',
167
+ alignItems: 'center',
168
+ },
169
+ item: {
170
+ flexDirection: 'row',
171
+ alignItems: 'center',
172
+ flexShrink: 0,
173
+ },
174
+ pressable: {
175
+ borderRadius: 4,
176
+ },
177
+ pressed: {
178
+ backgroundColor: cr.transparent.neutral.sysBlack10,
179
+ borderRadius: 4,
180
+ },
181
+ content: {
182
+ flexDirection: 'row',
183
+ alignItems: 'center',
184
+ },
185
+ disabled: {
186
+ opacity: 0.64,
187
+ },
188
+ divider: {
189
+ alignItems: 'center',
190
+ justifyContent: 'center',
191
+ },
192
+ });
package/src/Button.tsx ADDED
@@ -0,0 +1,228 @@
1
+ import React from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type PressableProps,
9
+ } from 'react-native';
10
+ import { sys } from './tokens';
11
+
12
+ export type ButtonVariant =
13
+ | 'filled'
14
+ | 'tonal'
15
+ | 'outlined'
16
+ | 'elevated'
17
+ | 'text'
18
+ | 'danger'
19
+ | 'danger-outlined';
20
+
21
+ export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
22
+
23
+ export interface ButtonProps extends Omit<PressableProps, 'children' | 'style'> {
24
+ variant?: ButtonVariant;
25
+ size?: ButtonSize;
26
+ label: string;
27
+ disabled?: boolean;
28
+ loading?: boolean;
29
+ fullWidth?: boolean;
30
+ iconOnly?: boolean;
31
+ leadingIcon?: React.ReactNode;
32
+ trailingIcon?: React.ReactNode;
33
+ }
34
+
35
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
36
+
37
+ const VARIANT_TOKENS: Record<
38
+ ButtonVariant,
39
+ { bg: string; label: string; borderColor: string; borderWidth: number }
40
+ > = {
41
+ filled: {
42
+ bg: cr.accent.primary.sysPrimary,
43
+ label: cr.accent.primary.sysOnPrimary,
44
+ borderColor: 'transparent',
45
+ borderWidth: 0,
46
+ },
47
+ tonal: {
48
+ bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
49
+ label: cr.addOn.primaryFixed.sysOnPrimaryFixed,
50
+ borderColor: 'transparent',
51
+ borderWidth: 0,
52
+ },
53
+ outlined: {
54
+ bg: 'transparent',
55
+ label: cr.surface.surface.sysOnSurface,
56
+ borderColor: cr.outline.sysOutline,
57
+ borderWidth: dim.borderWidth.sysStrokeThin,
58
+ },
59
+ elevated: {
60
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
61
+ label: cr.surface.surface.sysOnSurface,
62
+ borderColor: 'transparent',
63
+ borderWidth: 0,
64
+ },
65
+ text: {
66
+ bg: 'transparent',
67
+ label: cr.surface.surface.sysOnSurface,
68
+ borderColor: 'transparent',
69
+ borderWidth: 0,
70
+ },
71
+ danger: {
72
+ bg: cr.error.sysError,
73
+ label: cr.error.sysOnError,
74
+ borderColor: 'transparent',
75
+ borderWidth: 0,
76
+ },
77
+ 'danger-outlined': {
78
+ bg: 'transparent',
79
+ label: cr.error.sysError,
80
+ borderColor: cr.error.sysErrorContainer,
81
+ borderWidth: dim.borderWidth.sysStrokeThin,
82
+ },
83
+ };
84
+
85
+ const SIZE_TOKENS = {
86
+ sm: {
87
+ height: 32,
88
+ paddingH: dim.spacing.padding.sysPadding12,
89
+ paddingV: dim.spacing.padding.sysPadding4,
90
+ fontSize: ts.labelSmall.sysFontSize,
91
+ lineHeight: ts.labelSmall.sysLineHeight,
92
+ iconSize: sys.iconography.sysSizeXs,
93
+ },
94
+ md: {
95
+ height: 40,
96
+ paddingH: dim.spacing.padding.sysPadding16,
97
+ paddingV: dim.spacing.padding.sysPadding8,
98
+ fontSize: ts.labelMedium.sysFontSize,
99
+ lineHeight: ts.labelMedium.sysLineHeight,
100
+ iconSize: sys.iconography.sysSizeXs,
101
+ },
102
+ lg: {
103
+ height: 48,
104
+ paddingH: dim.spacing.padding.sysPadding24,
105
+ paddingV: dim.spacing.padding.sysPadding12,
106
+ fontSize: ts.labelLarge.sysFontSize,
107
+ lineHeight: ts.labelLarge.sysLineHeight,
108
+ iconSize: sys.iconography.sysSizeSm,
109
+ },
110
+ xl: {
111
+ height: 56,
112
+ paddingH: dim.spacing.padding.sysPadding32,
113
+ paddingV: dim.spacing.padding.sysPadding16,
114
+ fontSize: ts.titleSmall.sysFontSize,
115
+ lineHeight: ts.titleSmall.sysLineHeight,
116
+ iconSize: sys.iconography.sysSizeMd,
117
+ },
118
+ };
119
+
120
+ export function Button({
121
+ variant = 'filled',
122
+ size = 'md',
123
+ label,
124
+ disabled = false,
125
+ loading = false,
126
+ fullWidth = false,
127
+ iconOnly = false,
128
+ leadingIcon,
129
+ trailingIcon,
130
+ onPress,
131
+ accessibilityLabel,
132
+ ...rest
133
+ }: ButtonProps) {
134
+ const v = VARIANT_TOKENS[variant];
135
+ const s = SIZE_TOKENS[size];
136
+ const isDisabled = disabled || loading;
137
+ const paddingH = variant === 'text' ? 0 : s.paddingH;
138
+
139
+ return (
140
+ <Pressable
141
+ {...rest}
142
+ onPress={isDisabled ? undefined : onPress}
143
+ accessibilityRole="button"
144
+ accessibilityLabel={accessibilityLabel ?? label}
145
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
146
+ style={({ pressed }) => [
147
+ styles.root,
148
+ {
149
+ backgroundColor: v.bg,
150
+ borderColor: v.borderColor,
151
+ borderWidth: v.borderWidth,
152
+ borderRadius: dim.borderRadius.sysRadiusFull,
153
+ ...(iconOnly
154
+ ? { width: s.height, height: s.height }
155
+ : variant === 'text'
156
+ ? {
157
+ // Text variant is content-height (no fixed container). Figma:
158
+ // sm=26px, md=28px, lg=31px, xl=32px — each resolves to
159
+ // lineHeight + 2×paddingV, not the standard fixed height.
160
+ paddingHorizontal: 0,
161
+ paddingVertical: s.paddingV,
162
+ }
163
+ : {
164
+ height: s.height,
165
+ paddingHorizontal: paddingH,
166
+ paddingVertical: s.paddingV,
167
+ }),
168
+ ...(fullWidth && !iconOnly ? { alignSelf: 'stretch' } : {}),
169
+ opacity: isDisabled ? 0.48 : pressed ? 0.82 : 1,
170
+ ...(variant === 'elevated'
171
+ ? {
172
+ shadowColor: '#000',
173
+ shadowOffset: { width: 0, height: 2 },
174
+ shadowOpacity: 0.06,
175
+ shadowRadius: 4,
176
+ elevation: 1,
177
+ }
178
+ : {}),
179
+ },
180
+ ]}
181
+ >
182
+ <View style={[styles.content, { gap: dim.spacing.padding.sysPadding8 }]}>
183
+ {loading ? (
184
+ <ActivityIndicator size={s.iconSize} color={v.label} />
185
+ ) : leadingIcon ? (
186
+ <View style={{ width: s.iconSize, height: s.iconSize, alignItems: 'center', justifyContent: 'center' }}>
187
+ {leadingIcon}
188
+ </View>
189
+ ) : null}
190
+
191
+ {!iconOnly && (
192
+ <Text
193
+ style={{
194
+ color: v.label,
195
+ fontSize: s.fontSize,
196
+ lineHeight: s.lineHeight,
197
+ fontWeight: '600',
198
+ includeFontPadding: false,
199
+ }}
200
+ numberOfLines={1}
201
+ >
202
+ {label}
203
+ </Text>
204
+ )}
205
+
206
+ {trailingIcon && (
207
+ <View style={{ width: s.iconSize, height: s.iconSize, alignItems: 'center', justifyContent: 'center' }}>
208
+ {trailingIcon}
209
+ </View>
210
+ )}
211
+ </View>
212
+ </Pressable>
213
+ );
214
+ }
215
+
216
+ const styles = StyleSheet.create({
217
+ root: {
218
+ flexDirection: 'row',
219
+ justifyContent: 'center',
220
+ alignItems: 'center',
221
+ overflow: 'hidden',
222
+ },
223
+ content: {
224
+ flexDirection: 'row',
225
+ alignItems: 'center',
226
+ justifyContent: 'center',
227
+ },
228
+ });