@compsych/mobile-ui 1.0.8 → 1.0.10

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.
Files changed (77) hide show
  1. package/package.json +40 -2
  2. package/src/components/ActionSheet/ActionSheet.test.tsx +89 -0
  3. package/src/{ActionSheet.tsx → components/ActionSheet/index.tsx} +16 -8
  4. package/src/components/Alert/Alert.test.tsx +61 -0
  5. package/src/{Alert.tsx → components/Alert/index.tsx} +14 -11
  6. package/src/components/Avatar/Avatar.test.tsx +52 -0
  7. package/src/{Avatar.tsx → components/Avatar/index.tsx} +15 -13
  8. package/src/components/Badge/Badge.test.tsx +30 -0
  9. package/src/{Badge.tsx → components/Badge/index.tsx} +17 -9
  10. package/src/components/BodyText/BodyText.test.tsx +30 -0
  11. package/src/{BodyText.tsx → components/BodyText/index.tsx} +9 -5
  12. package/src/components/Breadcrumb/Breadcrumb.test.tsx +37 -0
  13. package/src/{Breadcrumb.tsx → components/Breadcrumb/index.tsx} +13 -4
  14. package/src/components/Button/Button.test.tsx +45 -0
  15. package/src/{Button.tsx → components/Button/index.tsx} +36 -17
  16. package/src/components/Checkbox/Checkbox.test.tsx +57 -0
  17. package/src/{Checkbox.tsx → components/Checkbox/index.tsx} +37 -15
  18. package/src/components/Chip/Chip.test.tsx +40 -0
  19. package/src/{Chip.tsx → components/Chip/index.tsx} +18 -9
  20. package/src/components/Divider/Divider.test.tsx +28 -0
  21. package/src/{Divider.tsx → components/Divider/index.tsx} +30 -20
  22. package/src/components/EmptyState/EmptyState.test.tsx +60 -0
  23. package/src/{EmptyState.tsx → components/EmptyState/index.tsx} +17 -7
  24. package/src/components/HeaderText/HeaderText.test.tsx +43 -0
  25. package/src/{HeaderText.tsx → components/HeaderText/index.tsx} +9 -5
  26. package/src/components/Input/Input.test.tsx +43 -0
  27. package/src/{Input.tsx → components/Input/index.tsx} +31 -11
  28. package/src/components/List/List.test.tsx +59 -0
  29. package/src/{List.tsx → components/List/index.tsx} +23 -7
  30. package/src/components/Pagination/Pagination.test.tsx +56 -0
  31. package/src/{Pagination.tsx → components/Pagination/index.tsx} +29 -13
  32. package/src/components/PlanCard/PlanCard.test.tsx +44 -0
  33. package/src/{PlanCard.tsx → components/PlanCard/index.tsx} +46 -17
  34. package/src/components/ProgressTracker/ProgressTracker.test.tsx +47 -0
  35. package/src/{ProgressTracker.tsx → components/ProgressTracker/index.tsx} +16 -25
  36. package/src/components/PromotionCard/PromotionCard.test.tsx +73 -0
  37. package/src/components/PromotionCard/index.tsx +538 -0
  38. package/src/components/RadioButton/RadioButton.test.tsx +50 -0
  39. package/src/{RadioButton.tsx → components/RadioButton/index.tsx} +21 -10
  40. package/src/components/ScreenContainer/ScreenContainer.test.tsx +29 -0
  41. package/src/components/ScreenContainer/index.tsx +41 -0
  42. package/src/components/SegmentedControl/SegmentedControl.test.tsx +60 -0
  43. package/src/{SegmentedControl.tsx → components/SegmentedControl/index.tsx} +15 -7
  44. package/src/components/SelectionCard/SelectionCard.test.tsx +48 -0
  45. package/src/components/SelectionCard/index.tsx +213 -0
  46. package/src/components/ServiceCard/ServiceCard.test.tsx +61 -0
  47. package/src/{Card.tsx → components/ServiceCard/index.tsx} +106 -49
  48. package/src/components/Slider/Slider.test.tsx +33 -0
  49. package/src/{Slider.tsx → components/Slider/index.tsx} +26 -36
  50. package/src/components/Snackbar/Snackbar.test.tsx +61 -0
  51. package/src/{Snackbar.tsx → components/Snackbar/index.tsx} +13 -7
  52. package/src/components/Switch/Switch.test.tsx +54 -0
  53. package/src/{Switch.tsx → components/Switch/index.tsx} +16 -5
  54. package/src/components/Tooltip/Tooltip.test.tsx +30 -0
  55. package/src/{Tooltip.tsx → components/Tooltip/index.tsx} +11 -7
  56. package/src/icons/AtomIcon.tsx +32 -0
  57. package/src/icons/BinocularsIcon.tsx +45 -0
  58. package/src/icons/FileChartColumnIncreasingIcon.tsx +44 -0
  59. package/src/icons/FlagIcon.tsx +20 -0
  60. package/src/icons/GlobeIcon.tsx +20 -0
  61. package/src/icons/GraduationCapIcon.tsx +32 -0
  62. package/src/icons/HandHeartIcon.tsx +38 -0
  63. package/src/icons/HandshakeIcon.tsx +39 -0
  64. package/src/icons/HazeIcon.tsx +42 -0
  65. package/src/icons/HeartHandshakeIcon.tsx +20 -0
  66. package/src/icons/HourglassIcon.tsx +33 -0
  67. package/src/icons/IdCardIcon.tsx +44 -0
  68. package/src/icons/MessageCirclePlusIcon.tsx +32 -0
  69. package/src/icons/MountainSnowIcon.tsx +26 -0
  70. package/src/icons/SnowflakeIcon.tsx +86 -0
  71. package/src/icons/StethoscopeIcon.tsx +34 -0
  72. package/src/icons/UserRoundIcon.tsx +26 -0
  73. package/src/icons/WheatIcon.tsx +62 -0
  74. package/src/icons/index.ts +63 -0
  75. package/src/icons/types.ts +45 -0
  76. package/src/index.ts +159 -71
  77. package/src/tokens.ts +135 -15
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+
3
+ import { ScrollView, type ScrollViewProps } from 'react-native';
4
+
5
+ import { sys } from '../../tokens';
6
+
7
+ const { dimensions: dim, colorRoles: cr } = sys;
8
+
9
+ export interface ScreenContainerProps extends ScrollViewProps {
10
+ children?: React.ReactNode;
11
+ }
12
+
13
+ export function ScreenContainer({
14
+ children,
15
+ style,
16
+ contentContainerStyle,
17
+ ...rest
18
+ }: ScreenContainerProps) {
19
+ return (
20
+ <ScrollView
21
+ style={[
22
+ {
23
+ flex: 1,
24
+ backgroundColor:
25
+ cr.surface.surfaceContainer.sysSurfaceContainerLowest,
26
+ },
27
+ style,
28
+ ]}
29
+ contentContainerStyle={[
30
+ {
31
+ paddingHorizontal: dim.spacing.padding.sysPadding16,
32
+ paddingVertical: dim.spacing.padding.sysPadding8,
33
+ },
34
+ contentContainerStyle,
35
+ ]}
36
+ {...rest}
37
+ >
38
+ {children}
39
+ </ScrollView>
40
+ );
41
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { SegmentedControl } from './index';
6
+
7
+ const options = [
8
+ { value: 'day', label: 'Day' },
9
+ { value: 'week', label: 'Week' },
10
+ { value: 'month', label: 'Month' },
11
+ ];
12
+
13
+ describe('SegmentedControl', () => {
14
+ it('renders all option labels', () => {
15
+ const { getByText } = render(
16
+ <SegmentedControl options={options} value="day" onChange={jest.fn()} />,
17
+ );
18
+ expect(getByText('Day')).toBeTruthy();
19
+ expect(getByText('Week')).toBeTruthy();
20
+ expect(getByText('Month')).toBeTruthy();
21
+ });
22
+
23
+ it('calls onChange with the tapped option value', () => {
24
+ const onChange = jest.fn();
25
+ const { getByText } = render(
26
+ <SegmentedControl options={options} value="day" onChange={onChange} />,
27
+ );
28
+ fireEvent.press(getByText('Week'));
29
+ expect(onChange).toHaveBeenCalledWith('week');
30
+ });
31
+
32
+ it('does not call onChange for a disabled option', () => {
33
+ const onChange = jest.fn();
34
+ const disabledOptions = [
35
+ { value: 'a', label: 'A' },
36
+ { value: 'b', label: 'B', disabled: true },
37
+ ];
38
+ const { getByText } = render(
39
+ <SegmentedControl
40
+ options={disabledOptions}
41
+ value="a"
42
+ onChange={onChange}
43
+ />,
44
+ );
45
+ fireEvent.press(getByText('B'));
46
+ expect(onChange).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it('renders full width when fullWidth is true', () => {
50
+ const { toJSON } = render(
51
+ <SegmentedControl
52
+ options={options}
53
+ value="day"
54
+ onChange={jest.fn()}
55
+ fullWidth
56
+ />,
57
+ );
58
+ expect(toJSON()).toBeTruthy();
59
+ });
60
+ });
@@ -1,7 +1,15 @@
1
1
  import React from 'react';
2
- import { Pressable, StyleSheet, View } from 'react-native';
3
- import { sys } from './tokens';
4
- import { BodyText } from './BodyText';
2
+
3
+ import {
4
+ Pressable,
5
+ StyleProp,
6
+ StyleSheet,
7
+ View,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import { sys } from '../../tokens';
12
+ import { BodyText } from '../BodyText';
5
13
 
6
14
  export interface SegmentedControlOption {
7
15
  value: string;
@@ -15,6 +23,7 @@ export interface SegmentedControlProps {
15
23
  value: string;
16
24
  onChange: (value: string) => void;
17
25
  fullWidth?: boolean;
26
+ style?: StyleProp<ViewStyle>;
18
27
  }
19
28
 
20
29
  const { colorRoles: cr, dimensions: dim } = sys;
@@ -24,9 +33,10 @@ export function SegmentedControl({
24
33
  value,
25
34
  onChange,
26
35
  fullWidth = false,
36
+ style,
27
37
  }: SegmentedControlProps) {
28
38
  return (
29
- <View style={[styles.container, fullWidth && styles.fullWidth]}>
39
+ <View style={[styles.container, fullWidth && styles.fullWidth, style]}>
30
40
  {options.map((option) => {
31
41
  const isActive = option.value === value;
32
42
  const isDisabled = option.disabled ?? false;
@@ -51,9 +61,7 @@ export function SegmentedControl({
51
61
  isDisabled && styles.disabled,
52
62
  ]}
53
63
  >
54
- {option.icon && (
55
- <View style={styles.iconSlot}>{option.icon}</View>
56
- )}
64
+ {option.icon && <View style={styles.iconSlot}>{option.icon}</View>}
57
65
  <BodyText
58
66
  variant="small"
59
67
  emphasized={isActive}
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { SelectionCard } from './index';
6
+
7
+ describe('SelectionCard', () => {
8
+ it('renders title', () => {
9
+ const { getByText } = render(<SelectionCard title="Option A" />);
10
+ expect(getByText('Option A')).toBeTruthy();
11
+ });
12
+
13
+ it('calls onPress when tapped', () => {
14
+ const onPress = jest.fn();
15
+ const { getByText } = render(
16
+ <SelectionCard title="Option A" onPress={onPress} />,
17
+ );
18
+ fireEvent.press(getByText('Option A'));
19
+ expect(onPress).toHaveBeenCalledTimes(1);
20
+ });
21
+
22
+ it('renders disabled state', () => {
23
+ const { toJSON } = render(<SelectionCard title="Option A" disabled />);
24
+ expect(toJSON()).toBeTruthy();
25
+ });
26
+
27
+ it.each(['sm', 'md'] as const)(
28
+ 'renders size "%s" without crashing',
29
+ (size) => {
30
+ const { getByText } = render(
31
+ <SelectionCard title="Option" size={size} />,
32
+ );
33
+ expect(getByText('Option')).toBeTruthy();
34
+ },
35
+ );
36
+
37
+ it('renders selected state', () => {
38
+ const { toJSON } = render(<SelectionCard title="Selected" selected />);
39
+ expect(toJSON()).toBeTruthy();
40
+ });
41
+
42
+ it('renders multi-select variant', () => {
43
+ const { toJSON } = render(
44
+ <SelectionCard title="Multi" multiSelect selected />,
45
+ );
46
+ expect(toJSON()).toBeTruthy();
47
+ });
48
+ });
@@ -0,0 +1,213 @@
1
+ import React from 'react';
2
+
3
+ import {
4
+ Pressable,
5
+ StyleProp,
6
+ StyleSheet,
7
+ View,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import { Ionicons } from '@expo/vector-icons';
12
+
13
+ import { ICON_MAP, type IconName } from '../../icons';
14
+ import { sys } from '../../tokens';
15
+ import { BodyText } from '../BodyText';
16
+ import { HeaderText } from '../HeaderText';
17
+
18
+ export type SelectionCardSize = 'sm' | 'md';
19
+
20
+ export interface SelectionCardProps {
21
+ title: string;
22
+ icon?: IconName;
23
+ size?: SelectionCardSize;
24
+ multiSelect?: boolean;
25
+ selected?: boolean;
26
+ disabled?: boolean;
27
+ onPress?: () => void;
28
+ accessibilityLabel?: string;
29
+ style?: StyleProp<ViewStyle>;
30
+ }
31
+
32
+ const { colorRoles: cr, dimensions: dim } = sys;
33
+
34
+ const SELECTED_RING = {
35
+ shadowColor: cr.accent.primary.sysPrimary,
36
+ shadowOffset: { width: 1, height: 2 },
37
+ shadowOpacity: 1,
38
+ shadowRadius: 0,
39
+ elevation: 2,
40
+ };
41
+
42
+ export function SelectionCard({
43
+ title,
44
+ icon,
45
+ size = 'md',
46
+ multiSelect = false,
47
+ selected = false,
48
+ disabled = false,
49
+ onPress,
50
+ accessibilityLabel,
51
+ style,
52
+ }: SelectionCardProps) {
53
+ const IconComponent = icon ? ICON_MAP[icon] : null;
54
+
55
+ const a11yState = {
56
+ disabled,
57
+ ...(multiSelect ? { checked: selected } : { selected }),
58
+ };
59
+
60
+ const checkboxNode = (
61
+ <Ionicons
62
+ name={selected ? 'checkbox' : 'square-outline'}
63
+ size={20}
64
+ color={selected ? cr.accent.primary.sysPrimary : cr.outline.sysOutline}
65
+ accessible={false}
66
+ />
67
+ );
68
+
69
+ if (size === 'sm') {
70
+ const iconColor = selected
71
+ ? cr.accent.primary.sysPrimary
72
+ : cr.surface.surface.sysOnSurface;
73
+
74
+ return (
75
+ <Pressable
76
+ onPress={disabled ? undefined : onPress}
77
+ accessibilityRole={multiSelect ? 'checkbox' : 'radio'}
78
+ accessibilityLabel={accessibilityLabel ?? title}
79
+ accessibilityState={a11yState}
80
+ style={({ pressed }) => [
81
+ styles.smRoot,
82
+ {
83
+ borderColor: selected
84
+ ? cr.accent.primary.sysPrimary
85
+ : cr.outline.sysOutline,
86
+ borderWidth: selected
87
+ ? dim.borderWidth.sysStrokeMedium
88
+ : dim.borderWidth.sysStrokeThin,
89
+ opacity: disabled ? 0.38 : 1,
90
+ },
91
+ selected && SELECTED_RING,
92
+ pressed && !disabled && styles.pressed,
93
+ style,
94
+ ]}
95
+ >
96
+ {IconComponent && (
97
+ <View style={styles.smIconWrap}>
98
+ <IconComponent size="small" color={iconColor} />
99
+ </View>
100
+ )}
101
+ <BodyText
102
+ variant="labelLarge"
103
+ color={cr.surface.surface.sysOnSurface}
104
+ emphasized={selected}
105
+ style={styles.smTitle}
106
+ >
107
+ {title}
108
+ </BodyText>
109
+ {multiSelect && checkboxNode}
110
+ </Pressable>
111
+ );
112
+ }
113
+
114
+ // ── Medium ──────────────────────────────────────────────────────────────────
115
+ const iconBgColor = selected
116
+ ? cr.accent.primary.sysPrimary
117
+ : cr.surface.surfaceContainer.sysSurfaceContainer;
118
+ const iconColor = selected
119
+ ? cr.accent.primary.sysOnPrimary
120
+ : cr.surface.surface.sysOnSurface;
121
+ const titleColor = selected
122
+ ? cr.accent.primary.sysPrimary
123
+ : cr.surface.surface.sysOnSurface;
124
+
125
+ const iconCircle = IconComponent ? (
126
+ <View style={[styles.mdIconCircle, { backgroundColor: iconBgColor }]}>
127
+ <IconComponent size="small" color={iconColor} />
128
+ </View>
129
+ ) : null;
130
+
131
+ return (
132
+ <Pressable
133
+ onPress={disabled ? undefined : onPress}
134
+ accessibilityRole={multiSelect ? 'checkbox' : 'radio'}
135
+ accessibilityLabel={accessibilityLabel ?? title}
136
+ accessibilityState={a11yState}
137
+ style={({ pressed }) => [
138
+ styles.mdRoot,
139
+ {
140
+ borderColor: selected
141
+ ? cr.accent.primary.sysPrimary
142
+ : cr.outline.sysOutline,
143
+ borderWidth: selected
144
+ ? dim.borderWidth.sysStrokeThick
145
+ : dim.borderWidth.sysStrokeMedium,
146
+ opacity: disabled ? 0.38 : 1,
147
+ },
148
+ selected && SELECTED_RING,
149
+ pressed && !disabled && styles.pressed,
150
+ style,
151
+ ]}
152
+ >
153
+ {multiSelect ? (
154
+ <View style={styles.mdIconCheckboxRow}>
155
+ {iconCircle}
156
+ {checkboxNode}
157
+ </View>
158
+ ) : (
159
+ iconCircle
160
+ )}
161
+ <HeaderText variant="titleSmall" color={titleColor} emphasized={selected}>
162
+ {title}
163
+ </HeaderText>
164
+ </Pressable>
165
+ );
166
+ }
167
+
168
+ const styles = StyleSheet.create({
169
+ mdRoot: {
170
+ flexDirection: 'column',
171
+ alignItems: 'flex-start',
172
+ padding: dim.spacing.padding.sysPadding24,
173
+ gap: dim.spacing.padding.sysPadding24,
174
+ borderRadius: dim.borderRadius.sysRadiusLg,
175
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
176
+ overflow: 'hidden',
177
+ },
178
+ mdIconCircle: {
179
+ alignItems: 'center',
180
+ justifyContent: 'center',
181
+ padding: dim.spacing.padding.sysPadding12,
182
+ borderRadius: dim.borderRadius.sysRadiusFull,
183
+ overflow: 'hidden',
184
+ },
185
+ mdIconCheckboxRow: {
186
+ flexDirection: 'row',
187
+ alignItems: 'flex-start',
188
+ justifyContent: 'space-between',
189
+ width: '100%',
190
+ },
191
+ smRoot: {
192
+ flexDirection: 'row',
193
+ alignItems: 'center',
194
+ gap: dim.spacing.padding.sysPadding12,
195
+ paddingHorizontal: dim.spacing.padding.sysPadding20,
196
+ paddingVertical: dim.spacing.padding.sysPadding16,
197
+ borderRadius: dim.borderRadius.sysRadiusMd,
198
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
199
+ overflow: 'hidden',
200
+ },
201
+ smIconWrap: {
202
+ width: 24,
203
+ height: 24,
204
+ alignItems: 'center',
205
+ justifyContent: 'center',
206
+ },
207
+ smTitle: {
208
+ flex: 1,
209
+ },
210
+ pressed: {
211
+ opacity: 0.84,
212
+ },
213
+ });
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { ServiceCard } from './index';
6
+
7
+ describe('ServiceCard', () => {
8
+ it('renders title', () => {
9
+ const { getByText } = render(<ServiceCard title="Therapy" />);
10
+ expect(getByText('Therapy')).toBeTruthy();
11
+ });
12
+
13
+ it('renders description', () => {
14
+ const { getByText } = render(
15
+ <ServiceCard title="Therapy" description="One-on-one sessions" />,
16
+ );
17
+ expect(getByText('One-on-one sessions')).toBeTruthy();
18
+ });
19
+
20
+ it('calls onPress when interactive and tapped', () => {
21
+ const onPress = jest.fn();
22
+ const { getByLabelText } = render(
23
+ <ServiceCard
24
+ title="Therapy"
25
+ interactive
26
+ onPress={onPress}
27
+ accessibilityLabel="Therapy card"
28
+ />,
29
+ );
30
+ fireEvent.press(getByLabelText('Therapy card'));
31
+ expect(onPress).toHaveBeenCalledTimes(1);
32
+ });
33
+
34
+ it('renders in disabled state', () => {
35
+ const { toJSON } = render(
36
+ <ServiceCard
37
+ title="Therapy"
38
+ interactive
39
+ disabled
40
+ accessibilityLabel="Therapy card"
41
+ />,
42
+ );
43
+ expect(toJSON()).toBeTruthy();
44
+ });
45
+
46
+ it.each(['outlined', 'tonal', 'filled', 'doubled'] as const)(
47
+ 'renders variant "%s" without crashing',
48
+ (variant) => {
49
+ const { toJSON } = render(<ServiceCard title="Card" variant={variant} />);
50
+ expect(toJSON()).toBeTruthy();
51
+ },
52
+ );
53
+
54
+ it.each(['sm', 'md', 'lg'] as const)(
55
+ 'renders size "%s" without crashing',
56
+ (size) => {
57
+ const { toJSON } = render(<ServiceCard title="Card" size={size} />);
58
+ expect(toJSON()).toBeTruthy();
59
+ },
60
+ );
61
+ });