@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
@@ -1,31 +1,44 @@
1
1
  import React from 'react';
2
+
2
3
  import {
3
4
  Image,
4
5
  type ImageSourcePropType,
5
6
  Pressable,
7
+ StyleProp,
6
8
  StyleSheet,
7
9
  View,
10
+ ViewStyle,
8
11
  } from 'react-native';
12
+
9
13
  import { Ionicons } from '@expo/vector-icons';
10
- import { sys } from './tokens';
11
- import { BodyText } from './BodyText';
12
14
 
13
- export type CardVariant = 'outlined' | 'tonal' | 'filled' | 'doubled' | 'image';
14
- export type CardSize = 'sm' | 'md' | 'lg';
15
+ import { ICON_MAP, type IconName } from '../../icons';
16
+ import { sys } from '../../tokens';
17
+ import { BodyText } from '../BodyText';
15
18
 
16
- export interface CardProps {
17
- variant?: CardVariant;
18
- size?: CardSize;
19
- title?: string;
20
- description?: string;
21
- icon?: React.ReactNode;
19
+ export type ServiceCardVariant =
20
+ | 'outlined'
21
+ | 'tonal'
22
+ | 'filled'
23
+ | 'doubled'
24
+ | 'image';
25
+ export type ServiceCardSize = 'sm' | 'md' | 'lg';
26
+
27
+ export interface ServiceCardProps {
28
+ variant?: ServiceCardVariant;
29
+ size?: ServiceCardSize;
30
+ title?: React.ReactNode;
31
+ description?: React.ReactNode;
32
+ icon?: IconName;
22
33
  image?: ImageSourcePropType;
34
+ buttonIcon?: React.ReactNode;
23
35
  interactive?: boolean;
24
36
  disabled?: boolean;
25
37
  onPress?: () => void;
26
38
  children?: React.ReactNode;
27
39
  accessibilityLabel?: string;
28
40
  fullWidth?: boolean;
41
+ style?: StyleProp<ViewStyle>;
29
42
  }
30
43
 
31
44
  const { colorRoles: cr, dimensions: dim } = sys;
@@ -33,7 +46,7 @@ const { colorRoles: cr, dimensions: dim } = sys;
33
46
  // ── Variant tokens ────────────────────────────────────────────────────────────
34
47
 
35
48
  const VARIANT_TOKENS: Record<
36
- CardVariant,
49
+ ServiceCardVariant,
37
50
  {
38
51
  bg: string;
39
52
  borderColor: string;
@@ -67,12 +80,12 @@ const VARIANT_TOKENS: Record<
67
80
  chevronColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
68
81
  },
69
82
  filled: {
70
- bg: cr.accent.primary.sysPrimary,
83
+ bg: cr.accent.primary.sysPrimaryContainer,
71
84
  borderColor: 'transparent',
72
85
  borderWidth: 0,
73
- elevated: true,
86
+ elevated: false,
74
87
  titleColor: cr.accent.primary.sysOnPrimary,
75
- descColor: cr.accent.primary.sysOnPrimary,
88
+ descColor: cr.transparent.neutral.sysWhite80,
76
89
  chevronColor: cr.accent.primary.sysOnPrimary,
77
90
  },
78
91
  doubled: {
@@ -92,9 +105,9 @@ const VARIANT_TOKENS: Record<
92
105
  borderColor: 'transparent',
93
106
  borderWidth: 0,
94
107
  elevated: false,
95
- titleColor: '#ffffff',
108
+ titleColor: cr.surface.surface.sysInverseOnSurface,
96
109
  descColor: cr.transparent.neutral.sysWhite80,
97
- chevronColor: '#ffffff',
110
+ chevronColor: cr.surface.surface.sysInverseOnSurface,
98
111
  },
99
112
  };
100
113
 
@@ -108,6 +121,7 @@ const SIZE_TOKENS = {
108
121
  gap: dim.spacing.padding.sysPadding12,
109
122
  borderRadius: dim.borderRadius.sysRadiusMd,
110
123
  iconSize: 24,
124
+ iconSizeName: 'small' as const,
111
125
  titleVariant: 'medium' as const,
112
126
  showChevron: true,
113
127
  },
@@ -118,6 +132,7 @@ const SIZE_TOKENS = {
118
132
  gap: dim.spacing.padding.sysPadding24,
119
133
  borderRadius: dim.borderRadius.sysRadiusLg,
120
134
  iconSize: 32,
135
+ iconSizeName: 'medium' as const,
121
136
  titleVariant: 'medium' as const,
122
137
  showChevron: false,
123
138
  },
@@ -128,6 +143,7 @@ const SIZE_TOKENS = {
128
143
  gap: dim.spacing.padding.sysPadding32,
129
144
  borderRadius: dim.borderRadius.sysRadiusLg,
130
145
  iconSize: 32,
146
+ iconSizeName: 'large' as const,
131
147
  titleVariant: 'large' as const,
132
148
  showChevron: false,
133
149
  },
@@ -143,55 +159,68 @@ const ELEVATION = {
143
159
 
144
160
  // ── Component ─────────────────────────────────────────────────────────────────
145
161
 
146
- export function Card({
162
+ export function ServiceCard({
147
163
  variant = 'outlined',
148
164
  size = 'lg',
149
165
  title,
150
166
  description,
151
167
  icon,
152
168
  image,
169
+ buttonIcon,
153
170
  interactive = false,
154
171
  disabled = false,
155
172
  onPress,
156
173
  children,
157
174
  accessibilityLabel,
158
175
  fullWidth = false,
159
- }: CardProps) {
176
+ style,
177
+ }: ServiceCardProps) {
160
178
  const v = VARIANT_TOKENS[variant];
161
179
  const s = SIZE_TOKENS[size];
162
180
  const isRow = s.layout === 'row';
163
181
 
182
+ const IconComponent = icon ? ICON_MAP[icon] : null;
183
+ const iconColor = v.iconBadgeColor ?? v.titleColor;
184
+ const renderedIcon = IconComponent ? (
185
+ <IconComponent size={s.iconSizeName} color={iconColor} />
186
+ ) : null;
187
+
164
188
  const outerStyle = [
165
189
  {
166
190
  backgroundColor: variant === 'doubled' ? v.bg : v.bg,
167
191
  borderColor: v.borderColor,
168
192
  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,
193
+ borderRadius:
194
+ variant === 'doubled' ? dim.borderRadius.sysRadiusXl : s.borderRadius,
195
+ paddingHorizontal:
196
+ variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingH,
197
+ paddingVertical:
198
+ variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingV,
172
199
  opacity: disabled ? 0.48 : 1,
173
200
  },
174
201
  v.elevated && ELEVATION,
175
202
  fullWidth && { alignSelf: 'stretch' as const },
176
203
  isRow ? styles.rowRoot : styles.colRoot,
177
204
  !isRow && { gap: s.gap },
205
+ style,
178
206
  ];
179
207
 
180
208
  // ── 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;
209
+ const textBlock =
210
+ title || description ? (
211
+ <View style={isRow ? styles.rowTextBlock : undefined}>
212
+ {title && (
213
+ <BodyText variant={s.titleVariant} color={v.titleColor}>
214
+ {title}
215
+ </BodyText>
216
+ )}
217
+ {!isRow && description && (
218
+ <BodyText variant="small" color={v.descColor}>
219
+ {description}
220
+ </BodyText>
221
+ )}
222
+ </View>
223
+ ) : null;
195
224
 
196
225
  // ── Inner content by variant ────────────────────────────────────────────────
197
226
  let inner: React.ReactNode;
@@ -206,11 +235,11 @@ export function Card({
206
235
  borderRadius: s.borderRadius,
207
236
  paddingHorizontal: s.paddingH,
208
237
  paddingVertical: s.paddingV,
209
- gap: s.gap,
238
+ gap: dim.spacing.padding.sysPadding24,
210
239
  },
211
240
  ]}
212
241
  >
213
- {icon && (
242
+ {renderedIcon && (
214
243
  <View
215
244
  style={[
216
245
  styles.doubledIconBadge,
@@ -220,7 +249,7 @@ export function Card({
220
249
  },
221
250
  ]}
222
251
  >
223
- {icon}
252
+ {renderedIcon}
224
253
  </View>
225
254
  )}
226
255
  {textBlock}
@@ -234,20 +263,30 @@ export function Card({
234
263
  <>
235
264
  <Image
236
265
  source={image}
237
- style={[StyleSheet.absoluteFillObject, { borderRadius: s.borderRadius }]}
266
+ style={[
267
+ StyleSheet.absoluteFillObject,
268
+ { borderRadius: s.borderRadius },
269
+ ]}
238
270
  resizeMode="cover"
239
271
  accessible={false}
240
272
  />
241
273
  <View
242
274
  style={[
243
275
  StyleSheet.absoluteFillObject,
244
- { borderRadius: s.borderRadius, backgroundColor: 'rgba(0,0,0,0.30)' },
276
+ {
277
+ borderRadius: s.borderRadius,
278
+ backgroundColor: 'rgba(0,0,0,0.30)',
279
+ },
245
280
  ]}
246
281
  pointerEvents="none"
247
282
  />
248
283
  </>
249
284
  )}
250
- {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
285
+ {renderedIcon && (
286
+ <View style={{ width: s.iconSize, height: s.iconSize }}>
287
+ {renderedIcon}
288
+ </View>
289
+ )}
251
290
  {textBlock}
252
291
  {children}
253
292
  </>
@@ -256,7 +295,11 @@ export function Card({
256
295
  // sm — horizontal row
257
296
  inner = (
258
297
  <>
259
- {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
298
+ {renderedIcon && (
299
+ <View style={{ width: s.iconSize, height: s.iconSize }}>
300
+ {renderedIcon}
301
+ </View>
302
+ )}
260
303
  {textBlock}
261
304
  {s.showChevron && (
262
305
  <Ionicons
@@ -273,9 +316,25 @@ export function Card({
273
316
  // md / lg — vertical column
274
317
  inner = (
275
318
  <>
276
- {icon && <View style={{ width: s.iconSize, height: s.iconSize }}>{icon}</View>}
319
+ {renderedIcon && (
320
+ <View style={{ width: s.iconSize, height: s.iconSize }}>
321
+ {renderedIcon}
322
+ </View>
323
+ )}
277
324
  {textBlock}
278
325
  {children}
326
+ {buttonIcon && (
327
+ <View
328
+ style={{
329
+ position: 'absolute',
330
+ bottom: s.paddingV,
331
+ right: s.paddingH,
332
+ }}
333
+ pointerEvents="none"
334
+ >
335
+ {buttonIcon}
336
+ </View>
337
+ )}
279
338
  </>
280
339
  );
281
340
  }
@@ -286,7 +345,9 @@ export function Card({
286
345
  <Pressable
287
346
  onPress={disabled ? undefined : onPress}
288
347
  accessibilityRole="button"
289
- accessibilityLabel={accessibilityLabel ?? title}
348
+ accessibilityLabel={
349
+ accessibilityLabel ?? (typeof title === 'string' ? title : undefined)
350
+ }
290
351
  accessibilityState={{ disabled }}
291
352
  style={({ pressed }) => [
292
353
  styles.overflow,
@@ -299,11 +360,7 @@ export function Card({
299
360
  );
300
361
  }
301
362
 
302
- return (
303
- <View style={[styles.overflow, ...outerStyle]}>
304
- {inner}
305
- </View>
306
- );
363
+ return <View style={[styles.overflow, ...outerStyle]}>{inner}</View>;
307
364
  }
308
365
 
309
366
  const styles = StyleSheet.create({
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import { Slider } from './index';
6
+
7
+ describe('Slider', () => {
8
+ it('renders without crashing', () => {
9
+ const { toJSON } = render(<Slider />);
10
+ expect(toJSON()).toBeTruthy();
11
+ });
12
+
13
+ it('renders label', () => {
14
+ const { getByText } = render(<Slider label="Volume" />);
15
+ expect(getByText('Volume')).toBeTruthy();
16
+ });
17
+
18
+ it('renders min/max labels when showMinMax is true', () => {
19
+ const { getByText } = render(<Slider min={0} max={100} showMinMax />);
20
+ expect(getByText('0')).toBeTruthy();
21
+ expect(getByText('100')).toBeTruthy();
22
+ });
23
+
24
+ it('renders disabled state', () => {
25
+ const { toJSON } = render(<Slider label="Disabled slider" disabled />);
26
+ expect(toJSON()).toBeTruthy();
27
+ });
28
+
29
+ it('respects default value', () => {
30
+ const { toJSON } = render(<Slider defaultValue={50} min={0} max={100} />);
31
+ expect(toJSON()).toBeTruthy();
32
+ });
33
+ });
@@ -1,13 +1,16 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+
2
3
  import {
3
- AccessibilityInfo,
4
4
  LayoutChangeEvent,
5
5
  PanResponder,
6
+ StyleProp,
6
7
  StyleSheet,
7
8
  Text,
8
9
  View,
10
+ ViewStyle,
9
11
  } from 'react-native';
10
- import { sys } from './tokens';
12
+
13
+ import { sys } from '../../tokens';
11
14
 
12
15
  export interface SliderProps {
13
16
  /** Controlled value */
@@ -28,6 +31,7 @@ export interface SliderProps {
28
31
  showMinMax?: boolean;
29
32
  disabled?: boolean;
30
33
  accessibilityLabel?: string;
34
+ style?: StyleProp<ViewStyle>;
31
35
  }
32
36
 
33
37
  const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
@@ -39,7 +43,7 @@ const THUMB_HALF = THUMB_SIZE / 2; // 12
39
43
  const HIT_HEIGHT = 48; // symbol height from Figma — contains track + thumb
40
44
  // Vertical offsets within the 48px container
41
45
  const TRACK_TOP = (HIT_HEIGHT - TRACK_HEIGHT) / 2; // 20
42
- const THUMB_TOP = (HIT_HEIGHT - THUMB_SIZE) / 2; // 12
46
+ const THUMB_TOP = (HIT_HEIGHT - THUMB_SIZE) / 2; // 12
43
47
 
44
48
  // ── Helpers ───────────────────────────────────────────────────────────────────
45
49
 
@@ -61,6 +65,7 @@ export function Slider({
61
65
  showMinMax = true,
62
66
  disabled = false,
63
67
  accessibilityLabel,
68
+ style,
64
69
  }: SliderProps) {
65
70
  const isControlled = valueProp !== undefined;
66
71
  const [internalValue, setInternalValue] = useState<number>(
@@ -110,7 +115,8 @@ export function Slider({
110
115
 
111
116
  const panResponder = useRef(
112
117
  PanResponder.create({
113
- onStartShouldSetPanResponder: () => !disabled && trackWidthRef.current > 0,
118
+ onStartShouldSetPanResponder: () =>
119
+ !disabled && trackWidthRef.current > 0,
114
120
  onMoveShouldSetPanResponder: () => !disabled && trackWidthRef.current > 0,
115
121
  // Don't let parent ScrollView steal the gesture once we've started
116
122
  onPanResponderTerminationRequest: () => false,
@@ -118,7 +124,11 @@ export function Slider({
118
124
  onPanResponderGrant: (evt) => {
119
125
  setPressed(true);
120
126
  // Jump thumb to the tapped position on the track
121
- const tapped = clamp(evt.nativeEvent.locationX, 0, trackWidthRef.current);
127
+ const tapped = clamp(
128
+ evt.nativeEvent.locationX,
129
+ 0,
130
+ trackWidthRef.current,
131
+ );
122
132
  panStartFraction.current = tapped / trackWidthRef.current;
123
133
  const v = fractionToValue(panStartFraction.current);
124
134
  commitValue(v, false);
@@ -127,7 +137,8 @@ export function Slider({
127
137
  onPanResponderMove: (_, gs) => {
128
138
  const newFrac = clamp(
129
139
  panStartFraction.current + gs.dx / trackWidthRef.current,
130
- 0, 1,
140
+ 0,
141
+ 1,
131
142
  );
132
143
  const v = fractionToValue(newFrac);
133
144
  commitValue(v, false);
@@ -137,7 +148,8 @@ export function Slider({
137
148
  setPressed(false);
138
149
  const newFrac = clamp(
139
150
  panStartFraction.current + gs.dx / trackWidthRef.current,
140
- 0, 1,
151
+ 0,
152
+ 1,
141
153
  );
142
154
  commitValue(fractionToValue(newFrac), true);
143
155
  },
@@ -172,7 +184,7 @@ export function Slider({
172
184
  // ── Render ────────────────────────────────────────────────────────────────────
173
185
  return (
174
186
  <View
175
- style={styles.root}
187
+ style={[styles.root, style]}
176
188
  accessible
177
189
  accessibilityRole="adjustable"
178
190
  accessibilityLabel={accessibilityLabel ?? label}
@@ -190,21 +202,12 @@ export function Slider({
190
202
  onBlur={() => setFocused(false)}
191
203
  >
192
204
  {/* ── Optional label ────────────────────────────────────────────────── */}
193
- {label && (
194
- <Text style={styles.label}>{label}</Text>
195
- )}
205
+ {label && <Text style={styles.label}>{label}</Text>}
196
206
 
197
207
  {/* ── Slider row ────────────────────────────────────────────────────── */}
198
- <View
199
- style={[
200
- styles.row,
201
- disabled && styles.rowDisabled,
202
- ]}
203
- >
208
+ <View style={[styles.row, disabled && styles.rowDisabled]}>
204
209
  {/* Min label */}
205
- {showMinMax && (
206
- <Text style={styles.rangeLabel}>{min}</Text>
207
- )}
210
+ {showMinMax && <Text style={styles.rangeLabel}>{min}</Text>}
208
211
 
209
212
  {/* ── Track container — the gesture zone ──────────────────────────── */}
210
213
  <View
@@ -216,21 +219,10 @@ export function Slider({
216
219
  <View style={styles.trackBg} />
217
220
 
218
221
  {/* Track fill (filled — up to thumb center) */}
219
- <View
220
- style={[
221
- styles.trackFill,
222
- { width: fillWidth },
223
- ]}
224
- />
222
+ <View style={[styles.trackFill, { width: fillWidth }]} />
225
223
 
226
224
  {/* ── Thumb ─────────────────────────────────────────────────────── */}
227
- <View
228
- style={[
229
- styles.thumb,
230
- { left: thumbLeft },
231
- thumbShadow,
232
- ]}
233
- >
225
+ <View style={[styles.thumb, { left: thumbLeft }, thumbShadow]}>
234
226
  {/* Focus ring — 1px outside thumb (26×26 centered over 24×24) */}
235
227
  {focused && (
236
228
  <View
@@ -248,9 +240,7 @@ export function Slider({
248
240
  </View>
249
241
 
250
242
  {/* Max label */}
251
- {showMinMax && (
252
- <Text style={styles.rangeLabel}>{max}</Text>
253
- )}
243
+ {showMinMax && <Text style={styles.rangeLabel}>{max}</Text>}
254
244
  </View>
255
245
  </View>
256
246
  );
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { Snackbar } from './index';
6
+
7
+ describe('Snackbar', () => {
8
+ it('renders message when visible', () => {
9
+ const { getByText } = render(
10
+ <Snackbar visible message="Changes saved" onClose={jest.fn()} />,
11
+ );
12
+ expect(getByText('Changes saved')).toBeTruthy();
13
+ });
14
+
15
+ it('hides interaction when not visible via pointerEvents', () => {
16
+ const { toJSON } = render(
17
+ <Snackbar visible={false} message="Changes saved" onClose={jest.fn()} />,
18
+ );
19
+ // Snackbar uses animated opacity — the container gets pointerEvents="none" when hidden
20
+ expect(toJSON()).toBeTruthy();
21
+ });
22
+
23
+ it('calls onAction when action button is pressed', () => {
24
+ const onAction = jest.fn();
25
+ const { getByText } = render(
26
+ <Snackbar
27
+ visible
28
+ message="Item deleted"
29
+ actionLabel="Undo"
30
+ onAction={onAction}
31
+ onClose={jest.fn()}
32
+ />,
33
+ );
34
+ fireEvent.press(getByText('Undo'));
35
+ expect(onAction).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('calls onClose when dismiss button is pressed', () => {
39
+ const onClose = jest.fn();
40
+ const { getByLabelText } = render(
41
+ <Snackbar visible message="Done" onClose={onClose} />,
42
+ );
43
+ fireEvent.press(getByLabelText('Dismiss'));
44
+ expect(onClose).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it.each(['filled', 'outlined'] as const)(
48
+ 'renders variant "%s" without crashing',
49
+ (variant) => {
50
+ const { getByText } = render(
51
+ <Snackbar
52
+ visible
53
+ message="Hello"
54
+ variant={variant}
55
+ onClose={jest.fn()}
56
+ />,
57
+ );
58
+ expect(getByText('Hello')).toBeTruthy();
59
+ },
60
+ );
61
+ });
@@ -1,13 +1,18 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
+
2
3
  import {
3
4
  Animated,
4
5
  Pressable,
6
+ StyleProp,
5
7
  StyleSheet,
6
8
  View,
9
+ ViewStyle,
7
10
  } from 'react-native';
11
+
8
12
  import { Ionicons } from '@expo/vector-icons';
9
- import { sys } from './tokens';
10
- import { BodyText } from './BodyText';
13
+
14
+ import { sys } from '../../tokens';
15
+ import { BodyText } from '../BodyText';
11
16
 
12
17
  export type SnackbarVariant = 'filled' | 'outlined';
13
18
 
@@ -18,6 +23,7 @@ export interface SnackbarProps {
18
23
  actionLabel?: string;
19
24
  onAction?: () => void;
20
25
  onClose?: () => void;
26
+ style?: StyleProp<ViewStyle>;
21
27
  }
22
28
 
23
29
  const { colorRoles: cr, dimensions: dim } = sys;
@@ -29,6 +35,7 @@ export function Snackbar({
29
35
  actionLabel,
30
36
  onAction,
31
37
  onClose,
38
+ style,
32
39
  }: SnackbarProps) {
33
40
  const translateY = useRef(new Animated.Value(100)).current;
34
41
  const opacity = useRef(new Animated.Value(0)).current;
@@ -66,7 +73,9 @@ export function Snackbar({
66
73
 
67
74
  const isFilled = variant === 'filled';
68
75
 
69
- const containerStyle = isFilled ? styles.containerFilled : styles.containerOutlined;
76
+ const containerStyle = isFilled
77
+ ? styles.containerFilled
78
+ : styles.containerOutlined;
70
79
  const labelColor = isFilled
71
80
  ? cr.accent.primary.sysOnPrimary
72
81
  : cr.surface.surface.sysOnSurface;
@@ -82,10 +91,7 @@ export function Snackbar({
82
91
 
83
92
  return (
84
93
  <Animated.View
85
- style={[
86
- styles.wrapper,
87
- { opacity, transform: [{ translateY }] },
88
- ]}
94
+ style={[styles.wrapper, { opacity, transform: [{ translateY }] }, style]}
89
95
  pointerEvents={visible ? 'box-none' : 'none'}
90
96
  >
91
97
  <View style={[styles.container, containerStyle]}>
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+
3
+ import { Animated } from 'react-native';
4
+
5
+ import { fireEvent, render } from '@testing-library/react-native';
6
+
7
+ import { Switch } from './index';
8
+
9
+ beforeEach(() => {
10
+ jest.spyOn(Animated, 'parallel').mockReturnValue({
11
+ start: jest.fn(),
12
+ stop: jest.fn(),
13
+ reset: jest.fn(),
14
+ } as any);
15
+ });
16
+
17
+ afterEach(() => {
18
+ jest.restoreAllMocks();
19
+ });
20
+
21
+ describe('Switch', () => {
22
+ it('renders without crashing', () => {
23
+ const { toJSON } = render(<Switch />);
24
+ expect(toJSON()).toBeTruthy();
25
+ });
26
+
27
+ it('calls onValueChange with true when toggled on', () => {
28
+ const onValueChange = jest.fn();
29
+ const { getByRole } = render(
30
+ <Switch value={false} onValueChange={onValueChange} />,
31
+ );
32
+ fireEvent.press(getByRole('switch'));
33
+ expect(onValueChange).toHaveBeenCalledWith(true);
34
+ });
35
+
36
+ it('calls onValueChange with false when toggled off', () => {
37
+ const onValueChange = jest.fn();
38
+ const { getByRole } = render(
39
+ <Switch value={true} onValueChange={onValueChange} />,
40
+ );
41
+ fireEvent.press(getByRole('switch'));
42
+ expect(onValueChange).toHaveBeenCalledWith(false);
43
+ });
44
+
45
+ it('is accessible as a switch', () => {
46
+ const { getByRole } = render(<Switch value={false} />);
47
+ expect(getByRole('switch')).toBeTruthy();
48
+ });
49
+
50
+ it('reflects disabled state in accessibility', () => {
51
+ const { getByRole } = render(<Switch value={false} disabled />);
52
+ expect(getByRole('switch').props.accessibilityState.disabled).toBe(true);
53
+ });
54
+ });