@compsych/mobile-ui 1.0.6 → 1.0.9

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.6",
3
+ "version": "1.0.9",
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",
@@ -0,0 +1,209 @@
1
+ import React from 'react';
2
+ import {
3
+ Modal,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ View,
8
+ } from 'react-native';
9
+ import { Ionicons } from '@expo/vector-icons';
10
+ import { sys } from './tokens';
11
+ import { HeaderText } from './HeaderText';
12
+ import { Button } from './Button';
13
+
14
+ export interface ActionSheetAction {
15
+ label: string;
16
+ onPress: () => void;
17
+ }
18
+
19
+ export interface ActionSheetProps {
20
+ visible: boolean;
21
+ onClose: () => void;
22
+ title?: string;
23
+ children?: React.ReactNode;
24
+ primaryAction?: ActionSheetAction;
25
+ secondaryAction?: ActionSheetAction;
26
+ }
27
+
28
+ const { colorRoles: cr, dimensions: dim } = sys;
29
+
30
+ export function ActionSheet({
31
+ visible,
32
+ onClose,
33
+ title,
34
+ children,
35
+ primaryAction,
36
+ secondaryAction,
37
+ }: ActionSheetProps) {
38
+ return (
39
+ <Modal
40
+ visible={visible}
41
+ transparent
42
+ animationType="slide"
43
+ onRequestClose={onClose}
44
+ statusBarTranslucent
45
+ >
46
+ {/* Backdrop */}
47
+ <Pressable style={styles.backdrop} onPress={onClose} accessible={false} />
48
+
49
+ {/* Sheet */}
50
+ <View style={styles.sheetWrapper} pointerEvents="box-none">
51
+ <View style={styles.sheet}>
52
+
53
+ {/* Toolbar */}
54
+ <View style={styles.toolbar}>
55
+ {/* Grabber */}
56
+ <View style={styles.grabberRow}>
57
+ <View style={styles.grabber} />
58
+ </View>
59
+
60
+ {/* Title row */}
61
+ <View style={styles.titleRow}>
62
+ {/* Invisible spacer to balance the close button */}
63
+ <View style={styles.closeButton} />
64
+
65
+ <HeaderText
66
+ variant="titleSmall"
67
+ emphasized
68
+ color={cr.surface.surface.sysOnSurface}
69
+ style={styles.titleText}
70
+ >
71
+ {title ?? ''}
72
+ </HeaderText>
73
+
74
+ {/* Close button */}
75
+ <Pressable
76
+ onPress={onClose}
77
+ accessibilityRole="button"
78
+ accessibilityLabel="Close"
79
+ style={({ pressed }) => [
80
+ styles.closeButton,
81
+ { backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainer },
82
+ pressed && styles.closePresseed,
83
+ ]}
84
+ >
85
+ <Ionicons
86
+ name="close"
87
+ size={20}
88
+ color={cr.surface.surface.sysOnSurface}
89
+ />
90
+ </Pressable>
91
+ </View>
92
+ </View>
93
+
94
+ {/* Content */}
95
+ {children && (
96
+ <ScrollView
97
+ style={styles.content}
98
+ contentContainerStyle={styles.contentInner}
99
+ showsVerticalScrollIndicator={false}
100
+ >
101
+ {children}
102
+ </ScrollView>
103
+ )}
104
+
105
+ {/* Actions */}
106
+ {(primaryAction || secondaryAction) && (
107
+ <View style={styles.actions}>
108
+ {primaryAction && (
109
+ <Button
110
+ label={primaryAction.label}
111
+ variant="filled"
112
+ size="xl"
113
+ fullWidth
114
+ onPress={primaryAction.onPress}
115
+ />
116
+ )}
117
+ {secondaryAction && (
118
+ <Button
119
+ label={secondaryAction.label}
120
+ variant="outlined"
121
+ size="xl"
122
+ fullWidth
123
+ onPress={secondaryAction.onPress}
124
+ />
125
+ )}
126
+ </View>
127
+ )}
128
+
129
+ </View>
130
+ </View>
131
+ </Modal>
132
+ );
133
+ }
134
+
135
+ const { colorRoles: cr2, dimensions: dim2 } = sys;
136
+
137
+ const styles = StyleSheet.create({
138
+ backdrop: {
139
+ ...StyleSheet.absoluteFillObject,
140
+ backgroundColor: 'rgba(0,0,0,0.4)',
141
+ },
142
+ sheetWrapper: {
143
+ flex: 1,
144
+ justifyContent: 'flex-end',
145
+ paddingHorizontal: dim2.spacing.padding.sysPadding6,
146
+ paddingBottom: dim2.spacing.padding.sysPadding6,
147
+ },
148
+ sheet: {
149
+ backgroundColor: cr2.surface.surfaceContainer.sysSurfaceContainerLowest,
150
+ borderRadius: dim2.borderRadius.sysRadiusXxl,
151
+ borderWidth: 1,
152
+ borderColor: 'rgba(255,255,255,0.4)',
153
+ overflow: 'hidden',
154
+ shadowColor: '#000',
155
+ shadowOffset: { width: 0, height: 4 },
156
+ shadowOpacity: 0.1,
157
+ shadowRadius: 16,
158
+ elevation: 8,
159
+ },
160
+ toolbar: {
161
+ alignItems: 'center',
162
+ width: '100%',
163
+ },
164
+ grabberRow: {
165
+ height: 16,
166
+ justifyContent: 'flex-end',
167
+ alignItems: 'center',
168
+ paddingBottom: 0,
169
+ paddingTop: 5,
170
+ },
171
+ grabber: {
172
+ width: 36,
173
+ height: 5,
174
+ borderRadius: 100,
175
+ backgroundColor: '#ccc',
176
+ },
177
+ titleRow: {
178
+ flexDirection: 'row',
179
+ alignItems: 'center',
180
+ justifyContent: 'space-between',
181
+ paddingHorizontal: dim2.spacing.padding.sysPadding16,
182
+ paddingVertical: dim2.spacing.padding.sysPadding8,
183
+ width: '100%',
184
+ },
185
+ titleText: {
186
+ flex: 1,
187
+ textAlign: 'center',
188
+ },
189
+ closeButton: {
190
+ width: 32,
191
+ height: 32,
192
+ borderRadius: dim2.borderRadius.sysRadiusFull,
193
+ alignItems: 'center',
194
+ justifyContent: 'center',
195
+ },
196
+ closePresseed: {
197
+ opacity: 0.7,
198
+ },
199
+ content: {
200
+ maxHeight: 400,
201
+ },
202
+ contentInner: {
203
+ padding: dim2.spacing.padding.sysPadding16,
204
+ },
205
+ actions: {
206
+ padding: dim2.spacing.padding.sysPadding24,
207
+ gap: dim2.spacing.padding.sysPadding12,
208
+ },
209
+ });
package/src/Card.tsx CHANGED
@@ -20,6 +20,7 @@ export interface CardProps {
20
20
  description?: string;
21
21
  icon?: React.ReactNode;
22
22
  image?: ImageSourcePropType;
23
+ buttonIcon?: React.ReactNode;
23
24
  interactive?: boolean;
24
25
  disabled?: boolean;
25
26
  onPress?: () => void;
@@ -150,6 +151,7 @@ export function Card({
150
151
  description,
151
152
  icon,
152
153
  image,
154
+ buttonIcon,
153
155
  interactive = false,
154
156
  disabled = false,
155
157
  onPress,
@@ -276,6 +278,18 @@ export function Card({
276
278
  {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
277
279
  {textBlock}
278
280
  {children}
281
+ {buttonIcon && (
282
+ <View
283
+ style={{
284
+ position: 'absolute',
285
+ bottom: s.paddingV,
286
+ right: s.paddingH,
287
+ }}
288
+ pointerEvents="none"
289
+ >
290
+ {buttonIcon}
291
+ </View>
292
+ )}
279
293
  </>
280
294
  );
281
295
  }
package/src/List.tsx ADDED
@@ -0,0 +1,179 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+ import { Ionicons } from '@expo/vector-icons';
4
+ import { sys } from './tokens';
5
+ import { BodyText } from './BodyText';
6
+
7
+ export type ListItemType = 'simple' | 'detailed';
8
+
9
+ export interface ListItemProps {
10
+ type?: ListItemType;
11
+ label: string;
12
+ subLabel?: string;
13
+ icon?: React.ReactNode;
14
+ onPress?: () => void;
15
+ disabled?: boolean;
16
+ showDivider?: boolean;
17
+ }
18
+
19
+ export interface ListProps {
20
+ items: Omit<ListItemProps, 'showDivider'>[];
21
+ type?: ListItemType;
22
+ }
23
+
24
+ const { colorRoles: cr, dimensions: dim } = sys;
25
+
26
+ // ── ListItem ──────────────────────────────────────────────────────────────────
27
+
28
+ export function ListItem({
29
+ type = 'simple',
30
+ label,
31
+ subLabel,
32
+ icon,
33
+ onPress,
34
+ disabled = false,
35
+ showDivider = true,
36
+ }: ListItemProps) {
37
+ const isDetailed = type === 'detailed';
38
+
39
+ const content = (
40
+ <View style={[styles.itemRow, { gap: dim.spacing.padding.sysPadding12 }]}>
41
+ {isDetailed ? (
42
+ // Detailed: icon inside circular badge
43
+ <View
44
+ style={[
45
+ styles.iconBadge,
46
+ { backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLow },
47
+ ]}
48
+ >
49
+ {icon}
50
+ </View>
51
+ ) : (
52
+ // Simple: bare icon
53
+ icon && <View style={styles.simpleIcon}>{icon}</View>
54
+ )}
55
+
56
+ <View style={styles.textBlock}>
57
+ <BodyText
58
+ variant="medium"
59
+ emphasized={isDetailed}
60
+ color={cr.surface.surface.sysOnSurface}
61
+ >
62
+ {label}
63
+ </BodyText>
64
+ {isDetailed && subLabel && (
65
+ <BodyText variant="small" color={cr.surface.surface.sysOnSurfaceVariant}>
66
+ {subLabel}
67
+ </BodyText>
68
+ )}
69
+ </View>
70
+
71
+ <Ionicons
72
+ name="chevron-forward"
73
+ size={20}
74
+ color={cr.outline.sysOutlineFixed}
75
+ accessible={false}
76
+ />
77
+ </View>
78
+ );
79
+
80
+ const divider = showDivider ? (
81
+ <View
82
+ style={[
83
+ styles.divider,
84
+ {
85
+ // Indent past icon + gap: 24px (simple) or 48px+16px (detailed)
86
+ marginLeft: isDetailed ? 64 : 36,
87
+ borderBottomColor: cr.outline.sysOutline,
88
+ },
89
+ ]}
90
+ />
91
+ ) : null;
92
+
93
+ if (onPress) {
94
+ return (
95
+ <View style={disabled && styles.disabled}>
96
+ <Pressable
97
+ onPress={disabled ? undefined : onPress}
98
+ accessibilityRole="button"
99
+ accessibilityLabel={label}
100
+ accessibilityState={{ disabled }}
101
+ style={({ pressed }) => [
102
+ isDetailed
103
+ ? { paddingTop: dim.spacing.padding.sysPadding12 }
104
+ : {
105
+ paddingVertical: dim.spacing.padding.sysPadding16,
106
+ },
107
+ pressed && !disabled && styles.pressed,
108
+ ]}
109
+ >
110
+ {content}
111
+ </Pressable>
112
+ {divider}
113
+ </View>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <View style={disabled && styles.disabled}>
119
+ <View
120
+ style={
121
+ isDetailed
122
+ ? { paddingTop: dim.spacing.padding.sysPadding12 }
123
+ : { paddingVertical: dim.spacing.padding.sysPadding16 }
124
+ }
125
+ >
126
+ {content}
127
+ </View>
128
+ {divider}
129
+ </View>
130
+ );
131
+ }
132
+
133
+ // ── List ──────────────────────────────────────────────────────────────────────
134
+
135
+ export function List({ items, type = 'simple' }: ListProps) {
136
+ return (
137
+ <View>
138
+ {items.map((item, index) => (
139
+ <ListItem
140
+ key={index}
141
+ {...item}
142
+ type={item.type ?? type}
143
+ showDivider={index < items.length - 1}
144
+ />
145
+ ))}
146
+ </View>
147
+ );
148
+ }
149
+
150
+ const styles = StyleSheet.create({
151
+ itemRow: {
152
+ flexDirection: 'row',
153
+ alignItems: 'center',
154
+ },
155
+ simpleIcon: {
156
+ width: 24,
157
+ height: 24,
158
+ },
159
+ iconBadge: {
160
+ width: 48,
161
+ height: 48,
162
+ borderRadius: 999,
163
+ alignItems: 'center',
164
+ justifyContent: 'center',
165
+ },
166
+ textBlock: {
167
+ flex: 1,
168
+ minWidth: 0,
169
+ },
170
+ divider: {
171
+ borderBottomWidth: StyleSheet.hairlineWidth,
172
+ },
173
+ pressed: {
174
+ backgroundColor: 'rgba(0,0,0,0.04)',
175
+ },
176
+ disabled: {
177
+ opacity: 0.48,
178
+ },
179
+ });
@@ -0,0 +1,313 @@
1
+ import React from 'react';
2
+ import {
3
+ LayoutAnimation,
4
+ Platform,
5
+ Pressable,
6
+ StyleSheet,
7
+ UIManager,
8
+ View,
9
+ } from 'react-native';
10
+ import { Ionicons } from '@expo/vector-icons';
11
+ import { sys } from './tokens';
12
+ import { BodyText } from './BodyText';
13
+
14
+ if (Platform.OS === 'android') {
15
+ UIManager.setLayoutAnimationEnabledExperimental?.(true);
16
+ }
17
+
18
+ // ─── Dropdown Item ───────────────────────────────────────────────────────────
19
+
20
+ export interface PlanCardItemData {
21
+ id: string;
22
+ title: string;
23
+ subtitle?: string;
24
+ checked?: boolean;
25
+ onToggle?: () => void;
26
+ onAudio?: () => void;
27
+ onOpen?: () => void;
28
+ }
29
+
30
+ interface DropdownItemProps extends PlanCardItemData {
31
+ showDivider?: boolean;
32
+ }
33
+
34
+ const { colorRoles: cr, dimensions: dim } = sys;
35
+
36
+ export function PlanCardDropdownItem({
37
+ title,
38
+ subtitle,
39
+ checked = false,
40
+ onToggle,
41
+ onAudio,
42
+ onOpen,
43
+ showDivider = true,
44
+ }: DropdownItemProps) {
45
+ return (
46
+ <View>
47
+ <View style={itemStyles.row}>
48
+ {/* Check indicator */}
49
+ <Pressable
50
+ onPress={onToggle}
51
+ accessibilityRole="checkbox"
52
+ accessibilityState={{ checked }}
53
+ style={itemStyles.checkWrap}
54
+ >
55
+ {checked ? (
56
+ <View style={itemStyles.checkedCircle}>
57
+ <Ionicons name="checkmark" size={12} color="#fff" />
58
+ </View>
59
+ ) : (
60
+ <View style={itemStyles.uncheckedCircle} />
61
+ )}
62
+ </Pressable>
63
+
64
+ {/* Text */}
65
+ <View style={itemStyles.textCol}>
66
+ <BodyText variant="small" color={cr.surface.surface.sysOnSurface}>
67
+ {title}
68
+ </BodyText>
69
+ {subtitle && (
70
+ <BodyText variant="small" color={cr.surface.surface.sysOnSurfaceVariant}>
71
+ {subtitle}
72
+ </BodyText>
73
+ )}
74
+ </View>
75
+
76
+ {/* Action buttons */}
77
+ <View style={itemStyles.actions}>
78
+ {onAudio && (
79
+ <Pressable
80
+ onPress={onAudio}
81
+ accessibilityRole="button"
82
+ accessibilityLabel="Listen"
83
+ style={({ pressed }) => [itemStyles.actionBtn, pressed && itemStyles.pressed]}
84
+ >
85
+ <Ionicons name="headset-outline" size={16} color={cr.surface.surface.sysOnSurface} />
86
+ </Pressable>
87
+ )}
88
+ {onOpen && (
89
+ <Pressable
90
+ onPress={onOpen}
91
+ accessibilityRole="button"
92
+ accessibilityLabel="Open"
93
+ style={({ pressed }) => [itemStyles.actionBtn, pressed && itemStyles.pressed]}
94
+ >
95
+ <Ionicons name="arrow-up-circle-outline" size={16} color={cr.surface.surface.sysOnSurface} />
96
+ </Pressable>
97
+ )}
98
+ </View>
99
+ </View>
100
+
101
+ {showDivider && <View style={itemStyles.divider} />}
102
+ </View>
103
+ );
104
+ }
105
+
106
+ const itemStyles = StyleSheet.create({
107
+ row: {
108
+ flexDirection: 'row',
109
+ alignItems: 'center',
110
+ paddingVertical: dim.spacing.padding.sysPadding16,
111
+ gap: dim.spacing.padding.sysPadding12,
112
+ },
113
+ checkWrap: {
114
+ width: 20,
115
+ height: 20,
116
+ alignItems: 'center',
117
+ justifyContent: 'center',
118
+ },
119
+ checkedCircle: {
120
+ width: 20,
121
+ height: 20,
122
+ borderRadius: dim.borderRadius.sysRadiusFull,
123
+ backgroundColor: cr.custom.success.sysSuccess,
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ },
127
+ uncheckedCircle: {
128
+ width: 20,
129
+ height: 20,
130
+ borderRadius: dim.borderRadius.sysRadiusFull,
131
+ borderWidth: 1.5,
132
+ borderColor: cr.outline.sysOutlineFixed,
133
+ },
134
+ textCol: {
135
+ flex: 1,
136
+ gap: 2,
137
+ },
138
+ actions: {
139
+ flexDirection: 'row',
140
+ gap: dim.spacing.padding.sysPadding8,
141
+ },
142
+ actionBtn: {
143
+ width: 32,
144
+ height: 32,
145
+ borderRadius: dim.borderRadius.sysRadiusFull,
146
+ borderWidth: dim.borderWidth.sysStrokeThin,
147
+ borderColor: cr.outline.sysOutline,
148
+ alignItems: 'center',
149
+ justifyContent: 'center',
150
+ },
151
+ pressed: {
152
+ opacity: 0.7,
153
+ },
154
+ divider: {
155
+ height: StyleSheet.hairlineWidth,
156
+ backgroundColor: cr.outline.sysOutline,
157
+ },
158
+ });
159
+
160
+ // ─── Plan Card ────────────────────────────────────────────────────────────────
161
+
162
+ export interface PlanCardProps {
163
+ title: string;
164
+ icon?: React.ReactNode;
165
+ tag?: string;
166
+ items?: PlanCardItemData[];
167
+ expanded?: boolean;
168
+ onToggle?: () => void;
169
+ children?: React.ReactNode;
170
+ }
171
+
172
+ export function PlanCard({
173
+ title,
174
+ icon,
175
+ tag,
176
+ items,
177
+ expanded = false,
178
+ onToggle,
179
+ children,
180
+ }: PlanCardProps) {
181
+ function handleToggle() {
182
+ if (Platform.OS !== 'web') {
183
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
184
+ }
185
+ onToggle?.();
186
+ }
187
+
188
+ const hasContent = expanded && (children || (items && items.length > 0));
189
+
190
+ return (
191
+ <View style={cardStyles.card}>
192
+ {/* Header */}
193
+ <View style={cardStyles.header}>
194
+ {/* Left: icon + title stacked */}
195
+ <View style={cardStyles.left}>
196
+ <View style={cardStyles.iconCircle}>
197
+ {icon ?? (
198
+ <Ionicons name="document-text-outline" size={16} color={cr.accent.primary.sysOnPrimary} />
199
+ )}
200
+ </View>
201
+ <BodyText
202
+ variant="medium"
203
+ color={cr.surface.surface.sysOnSurface}
204
+ >
205
+ {title}
206
+ </BodyText>
207
+ </View>
208
+
209
+ {/* Right: tag chip + toggle */}
210
+ <View style={cardStyles.right}>
211
+ {tag && (
212
+ <View style={cardStyles.chip}>
213
+ <BodyText variant="small" color={cr.custom.info.sysOnInfoContainer}>
214
+ {tag}
215
+ </BodyText>
216
+ </View>
217
+ )}
218
+ <Pressable
219
+ onPress={handleToggle}
220
+ accessibilityRole="button"
221
+ accessibilityLabel={expanded ? 'Collapse' : 'Expand'}
222
+ style={({ pressed }) => [
223
+ cardStyles.toggleBtn,
224
+ pressed && cardStyles.pressed,
225
+ ]}
226
+ >
227
+ <Ionicons
228
+ name={expanded ? 'remove' : 'add'}
229
+ size={16}
230
+ color={cr.surface.surface.sysOnSurfaceVariant}
231
+ />
232
+ </Pressable>
233
+ </View>
234
+ </View>
235
+
236
+ {/* Expanded content */}
237
+ {hasContent && (
238
+ <View style={cardStyles.content}>
239
+ <View style={cardStyles.contentDivider} />
240
+ {children
241
+ ? children
242
+ : items!.map((item, index) => (
243
+ <PlanCardDropdownItem
244
+ key={item.id}
245
+ {...item}
246
+ showDivider={index < items!.length - 1}
247
+ />
248
+ ))}
249
+ </View>
250
+ )}
251
+ </View>
252
+ );
253
+ }
254
+
255
+ const cardStyles = StyleSheet.create({
256
+ card: {
257
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
258
+ borderRadius: dim.borderRadius.sysRadiusMd,
259
+ borderWidth: dim.borderWidth.sysStrokeThin,
260
+ borderColor: cr.outline.sysOutline,
261
+ padding: dim.spacing.padding.sysPadding16,
262
+ overflow: 'hidden',
263
+ },
264
+ header: {
265
+ flexDirection: 'row',
266
+ alignItems: 'center',
267
+ justifyContent: 'space-between',
268
+ gap: dim.spacing.padding.sysPadding12,
269
+ },
270
+ left: {
271
+ flex: 1,
272
+ gap: dim.spacing.padding.sysPadding8,
273
+ },
274
+ iconCircle: {
275
+ width: 28,
276
+ height: 28,
277
+ borderRadius: dim.borderRadius.sysRadiusFull,
278
+ backgroundColor: cr.accent.primary.sysPrimary,
279
+ alignItems: 'center',
280
+ justifyContent: 'center',
281
+ },
282
+ right: {
283
+ flexDirection: 'row',
284
+ alignItems: 'center',
285
+ gap: dim.spacing.padding.sysPadding8,
286
+ alignSelf: 'flex-start',
287
+ },
288
+ chip: {
289
+ backgroundColor: cr.custom.info.sysInfoContainer,
290
+ borderRadius: dim.borderRadius.sysRadiusFull,
291
+ paddingHorizontal: dim.spacing.padding.sysPadding8,
292
+ paddingVertical: dim.spacing.padding.sysPadding4,
293
+ },
294
+ toggleBtn: {
295
+ width: 20,
296
+ height: 20,
297
+ borderRadius: dim.borderRadius.sysRadiusFull,
298
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainer,
299
+ alignItems: 'center',
300
+ justifyContent: 'center',
301
+ },
302
+ pressed: {
303
+ opacity: 0.7,
304
+ },
305
+ content: {
306
+ marginTop: dim.spacing.padding.sysPadding8,
307
+ },
308
+ contentDivider: {
309
+ height: StyleSheet.hairlineWidth,
310
+ backgroundColor: cr.outline.sysOutline,
311
+ marginBottom: dim.spacing.padding.sysPadding4,
312
+ },
313
+ });
@@ -0,0 +1,114 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+ import { sys } from './tokens';
4
+ import { BodyText } from './BodyText';
5
+
6
+ export interface SegmentedControlOption {
7
+ value: string;
8
+ label: string;
9
+ icon?: React.ReactNode;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export interface SegmentedControlProps {
14
+ options: SegmentedControlOption[];
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ fullWidth?: boolean;
18
+ }
19
+
20
+ const { colorRoles: cr, dimensions: dim } = sys;
21
+
22
+ export function SegmentedControl({
23
+ options,
24
+ value,
25
+ onChange,
26
+ fullWidth = false,
27
+ }: SegmentedControlProps) {
28
+ return (
29
+ <View style={[styles.container, fullWidth && styles.fullWidth]}>
30
+ {options.map((option) => {
31
+ const isActive = option.value === value;
32
+ const isDisabled = option.disabled ?? false;
33
+
34
+ return (
35
+ <Pressable
36
+ key={option.value}
37
+ onPress={isDisabled ? undefined : () => onChange(option.value)}
38
+ accessibilityRole="tab"
39
+ accessibilityLabel={option.label}
40
+ accessibilityState={{ selected: isActive, disabled: isDisabled }}
41
+ style={({ pressed }) => [
42
+ styles.item,
43
+ {
44
+ paddingHorizontal: dim.spacing.padding.sysPadding12,
45
+ paddingVertical: dim.spacing.padding.sysPadding6,
46
+ gap: dim.spacing.padding.sysPadding4,
47
+ borderRadius: dim.borderRadius.sysRadiusFull,
48
+ },
49
+ isActive && { backgroundColor: cr.accent.primary.sysPrimary },
50
+ !isActive && pressed && !isDisabled && styles.pressed,
51
+ isDisabled && styles.disabled,
52
+ ]}
53
+ >
54
+ {option.icon && (
55
+ <View style={styles.iconSlot}>{option.icon}</View>
56
+ )}
57
+ <BodyText
58
+ variant="small"
59
+ emphasized={isActive}
60
+ color={
61
+ isActive
62
+ ? cr.accent.primary.sysOnPrimary
63
+ : cr.surface.surface.sysOnSurfaceVariant
64
+ }
65
+ numberOfLines={1}
66
+ >
67
+ {option.label}
68
+ </BodyText>
69
+ </Pressable>
70
+ );
71
+ })}
72
+ </View>
73
+ );
74
+ }
75
+
76
+ const styles = StyleSheet.create({
77
+ container: {
78
+ flexDirection: 'row',
79
+ alignItems: 'center',
80
+ alignSelf: 'flex-start',
81
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
82
+ borderWidth: dim.borderWidth.sysStrokeThin,
83
+ borderColor: cr.outline.sysOutline,
84
+ borderRadius: dim.borderRadius.sysRadiusFull,
85
+ padding: dim.spacing.padding.sysPadding4,
86
+ gap: dim.spacing.padding.sysPadding4,
87
+ shadowColor: '#000',
88
+ shadowOffset: { width: 0, height: 2 },
89
+ shadowOpacity: 0.06,
90
+ shadowRadius: 4,
91
+ elevation: 1,
92
+ },
93
+ fullWidth: {
94
+ alignSelf: 'stretch',
95
+ },
96
+ item: {
97
+ flex: 1,
98
+ flexDirection: 'row',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ },
102
+ iconSlot: {
103
+ width: 16,
104
+ height: 16,
105
+ alignItems: 'center',
106
+ justifyContent: 'center',
107
+ },
108
+ pressed: {
109
+ backgroundColor: cr.transparent.neutral.sysBlack10,
110
+ },
111
+ disabled: {
112
+ opacity: 0.48,
113
+ },
114
+ });
@@ -0,0 +1,189 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ Pressable,
5
+ StyleSheet,
6
+ View,
7
+ } from 'react-native';
8
+ import { Ionicons } from '@expo/vector-icons';
9
+ import { sys } from './tokens';
10
+ import { BodyText } from './BodyText';
11
+
12
+ export type SnackbarVariant = 'filled' | 'outlined';
13
+
14
+ export interface SnackbarProps {
15
+ visible: boolean;
16
+ message: string;
17
+ variant?: SnackbarVariant;
18
+ actionLabel?: string;
19
+ onAction?: () => void;
20
+ onClose?: () => void;
21
+ }
22
+
23
+ const { colorRoles: cr, dimensions: dim } = sys;
24
+
25
+ export function Snackbar({
26
+ visible,
27
+ message,
28
+ variant = 'filled',
29
+ actionLabel,
30
+ onAction,
31
+ onClose,
32
+ }: SnackbarProps) {
33
+ const translateY = useRef(new Animated.Value(100)).current;
34
+ const opacity = useRef(new Animated.Value(0)).current;
35
+
36
+ useEffect(() => {
37
+ if (visible) {
38
+ Animated.parallel([
39
+ Animated.spring(translateY, {
40
+ toValue: 0,
41
+ useNativeDriver: true,
42
+ damping: 20,
43
+ stiffness: 300,
44
+ }),
45
+ Animated.timing(opacity, {
46
+ toValue: 1,
47
+ duration: 200,
48
+ useNativeDriver: true,
49
+ }),
50
+ ]).start();
51
+ } else {
52
+ Animated.parallel([
53
+ Animated.timing(translateY, {
54
+ toValue: 100,
55
+ duration: 200,
56
+ useNativeDriver: true,
57
+ }),
58
+ Animated.timing(opacity, {
59
+ toValue: 0,
60
+ duration: 150,
61
+ useNativeDriver: true,
62
+ }),
63
+ ]).start();
64
+ }
65
+ }, [visible]);
66
+
67
+ const isFilled = variant === 'filled';
68
+
69
+ const containerStyle = isFilled ? styles.containerFilled : styles.containerOutlined;
70
+ const labelColor = isFilled
71
+ ? cr.accent.primary.sysOnPrimary
72
+ : cr.surface.surface.sysOnSurface;
73
+ const actionColor = isFilled
74
+ ? cr.accent.primary.sysOnPrimary
75
+ : cr.accent.primary.sysPrimary;
76
+ const closeBg = isFilled
77
+ ? cr.transparent.neutral.sysWhite10
78
+ : cr.surface.surfaceContainer.sysSurfaceContainer;
79
+ const closeIconColor = isFilled
80
+ ? cr.accent.primary.sysOnPrimary
81
+ : cr.surface.surface.sysOnSurfaceVariant;
82
+
83
+ return (
84
+ <Animated.View
85
+ style={[
86
+ styles.wrapper,
87
+ { opacity, transform: [{ translateY }] },
88
+ ]}
89
+ pointerEvents={visible ? 'box-none' : 'none'}
90
+ >
91
+ <View style={[styles.container, containerStyle]}>
92
+ <BodyText
93
+ variant="medium"
94
+ color={labelColor}
95
+ style={styles.label}
96
+ numberOfLines={2}
97
+ >
98
+ {message}
99
+ </BodyText>
100
+
101
+ <View style={styles.actionSlot}>
102
+ {actionLabel && onAction && (
103
+ <Pressable
104
+ onPress={onAction}
105
+ accessibilityRole="button"
106
+ accessibilityLabel={actionLabel}
107
+ style={({ pressed }) => [
108
+ styles.actionButton,
109
+ pressed && styles.pressed,
110
+ ]}
111
+ >
112
+ <BodyText variant="small" emphasized color={actionColor}>
113
+ {actionLabel}
114
+ </BodyText>
115
+ </Pressable>
116
+ )}
117
+
118
+ {onClose && (
119
+ <Pressable
120
+ onPress={onClose}
121
+ accessibilityRole="button"
122
+ accessibilityLabel="Dismiss"
123
+ style={({ pressed }) => [
124
+ styles.closeButton,
125
+ { backgroundColor: closeBg },
126
+ pressed && styles.pressed,
127
+ ]}
128
+ >
129
+ <Ionicons name="close" size={16} color={closeIconColor} />
130
+ </Pressable>
131
+ )}
132
+ </View>
133
+ </View>
134
+ </Animated.View>
135
+ );
136
+ }
137
+
138
+ const styles = StyleSheet.create({
139
+ wrapper: {
140
+ position: 'absolute',
141
+ bottom: dim.spacing.padding.sysPadding24,
142
+ left: dim.spacing.padding.sysPadding16,
143
+ right: dim.spacing.padding.sysPadding16,
144
+ alignItems: 'center',
145
+ },
146
+ container: {
147
+ flexDirection: 'row',
148
+ alignItems: 'center',
149
+ paddingVertical: dim.spacing.padding.sysPadding12,
150
+ paddingHorizontal: dim.spacing.padding.sysPadding16,
151
+ borderRadius: dim.borderRadius.sysRadiusMd,
152
+ gap: dim.spacing.padding.sysPadding16,
153
+ width: '100%',
154
+ shadowColor: '#000',
155
+ shadowOffset: { width: 0, height: 4 },
156
+ shadowOpacity: 0.12,
157
+ shadowRadius: 12,
158
+ elevation: 6,
159
+ },
160
+ containerFilled: {
161
+ backgroundColor: cr.accent.primary.sysPrimaryContainer,
162
+ },
163
+ containerOutlined: {
164
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
165
+ borderWidth: dim.borderWidth.sysStrokeThin,
166
+ borderColor: cr.outline.sysOutline,
167
+ },
168
+ label: {
169
+ flex: 1,
170
+ },
171
+ actionSlot: {
172
+ flexDirection: 'row',
173
+ alignItems: 'center',
174
+ gap: dim.spacing.padding.sysPadding12,
175
+ },
176
+ actionButton: {
177
+ paddingVertical: dim.spacing.padding.sysPadding4,
178
+ },
179
+ closeButton: {
180
+ width: 24,
181
+ height: 24,
182
+ borderRadius: dim.borderRadius.sysRadiusFull,
183
+ alignItems: 'center',
184
+ justifyContent: 'center',
185
+ },
186
+ pressed: {
187
+ opacity: 0.7,
188
+ },
189
+ });
package/src/index.ts CHANGED
@@ -31,9 +31,15 @@ export type { DividerProps, DividerVariant, DividerWeight } from './Divider';
31
31
  export { EmptyState } from './EmptyState';
32
32
  export type { EmptyStateProps, EmptyStateStyle, EmptyStateViewport } from './EmptyState';
33
33
 
34
+ export { List, ListItem } from './List';
35
+ export type { ListProps, ListItemProps, ListItemType } from './List';
36
+
34
37
  export { Input } from './Input';
35
38
  export type { InputProps, InputSize } from './Input';
36
39
 
40
+ export { PlanCard, PlanCardDropdownItem } from './PlanCard';
41
+ export type { PlanCardProps, PlanCardItemData } from './PlanCard';
42
+
37
43
  export { Pagination } from './Pagination';
38
44
  export type { PaginationProps, PaginationSize } from './Pagination';
39
45
 
@@ -43,15 +49,24 @@ export type { ProgressTrackerProps, ProgressTrackerSize, ProgressBarProps, Track
43
49
  export { RadioButton } from './RadioButton';
44
50
  export type { RadioButtonProps, RadioButtonSize } from './RadioButton';
45
51
 
52
+ export { SegmentedControl } from './SegmentedControl';
53
+ export type { SegmentedControlProps, SegmentedControlOption } from './SegmentedControl';
54
+
46
55
  export { Slider } from './Slider';
47
56
  export type { SliderProps } from './Slider';
48
57
 
58
+ export { Snackbar } from './Snackbar';
59
+ export type { SnackbarProps, SnackbarVariant } from './Snackbar';
60
+
49
61
  export { Switch } from './Switch';
50
62
  export type { SwitchProps } from './Switch';
51
63
 
52
64
  export { Tooltip } from './Tooltip';
53
65
  export type { TooltipProps, TooltipVariant, TooltipDirection } from './Tooltip';
54
66
 
67
+ export { ActionSheet } from './ActionSheet';
68
+ export type { ActionSheetProps, ActionSheetAction } from './ActionSheet';
69
+
55
70
  export { BodyText } from './BodyText';
56
71
  export type { BodyTextProps, BodyVariant } from './BodyText';
57
72
 
package/src/tokens.ts CHANGED
@@ -88,6 +88,7 @@ export const sys = {
88
88
  sysRadiusMd: 12,
89
89
  sysRadiusLg: 16,
90
90
  sysRadiusXl: 24,
91
+ sysRadiusXxl: 32,
91
92
  sysRadiusFull: 999,
92
93
  },
93
94
  borderWidth: {
@@ -100,6 +101,7 @@ export const sys = {
100
101
  padding: {
101
102
  sysPadding2: 2,
102
103
  sysPadding4: 4,
104
+ sysPadding6: 6,
103
105
  sysPadding8: 8,
104
106
  sysPadding12: 12,
105
107
  sysPadding16: 16,