@compsych/mobile-ui 1.0.9 → 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} +42 -14
  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} +92 -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,24 +1,35 @@
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';
18
+
19
+ export type ServiceCardVariant =
20
+ | 'outlined'
21
+ | 'tonal'
22
+ | 'filled'
23
+ | 'doubled'
24
+ | 'image';
25
+ export type ServiceCardSize = 'sm' | 'md' | 'lg';
15
26
 
16
- export interface CardProps {
17
- variant?: CardVariant;
18
- size?: CardSize;
19
- title?: string;
20
- description?: string;
21
- icon?: React.ReactNode;
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;
23
34
  buttonIcon?: React.ReactNode;
24
35
  interactive?: boolean;
@@ -27,6 +38,7 @@ export interface CardProps {
27
38
  children?: React.ReactNode;
28
39
  accessibilityLabel?: string;
29
40
  fullWidth?: boolean;
41
+ style?: StyleProp<ViewStyle>;
30
42
  }
31
43
 
32
44
  const { colorRoles: cr, dimensions: dim } = sys;
@@ -34,7 +46,7 @@ const { colorRoles: cr, dimensions: dim } = sys;
34
46
  // ── Variant tokens ────────────────────────────────────────────────────────────
35
47
 
36
48
  const VARIANT_TOKENS: Record<
37
- CardVariant,
49
+ ServiceCardVariant,
38
50
  {
39
51
  bg: string;
40
52
  borderColor: string;
@@ -68,12 +80,12 @@ const VARIANT_TOKENS: Record<
68
80
  chevronColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
69
81
  },
70
82
  filled: {
71
- bg: cr.accent.primary.sysPrimary,
83
+ bg: cr.accent.primary.sysPrimaryContainer,
72
84
  borderColor: 'transparent',
73
85
  borderWidth: 0,
74
- elevated: true,
86
+ elevated: false,
75
87
  titleColor: cr.accent.primary.sysOnPrimary,
76
- descColor: cr.accent.primary.sysOnPrimary,
88
+ descColor: cr.transparent.neutral.sysWhite80,
77
89
  chevronColor: cr.accent.primary.sysOnPrimary,
78
90
  },
79
91
  doubled: {
@@ -93,9 +105,9 @@ const VARIANT_TOKENS: Record<
93
105
  borderColor: 'transparent',
94
106
  borderWidth: 0,
95
107
  elevated: false,
96
- titleColor: '#ffffff',
108
+ titleColor: cr.surface.surface.sysInverseOnSurface,
97
109
  descColor: cr.transparent.neutral.sysWhite80,
98
- chevronColor: '#ffffff',
110
+ chevronColor: cr.surface.surface.sysInverseOnSurface,
99
111
  },
100
112
  };
101
113
 
@@ -109,6 +121,7 @@ const SIZE_TOKENS = {
109
121
  gap: dim.spacing.padding.sysPadding12,
110
122
  borderRadius: dim.borderRadius.sysRadiusMd,
111
123
  iconSize: 24,
124
+ iconSizeName: 'small' as const,
112
125
  titleVariant: 'medium' as const,
113
126
  showChevron: true,
114
127
  },
@@ -119,6 +132,7 @@ const SIZE_TOKENS = {
119
132
  gap: dim.spacing.padding.sysPadding24,
120
133
  borderRadius: dim.borderRadius.sysRadiusLg,
121
134
  iconSize: 32,
135
+ iconSizeName: 'medium' as const,
122
136
  titleVariant: 'medium' as const,
123
137
  showChevron: false,
124
138
  },
@@ -129,6 +143,7 @@ const SIZE_TOKENS = {
129
143
  gap: dim.spacing.padding.sysPadding32,
130
144
  borderRadius: dim.borderRadius.sysRadiusLg,
131
145
  iconSize: 32,
146
+ iconSizeName: 'large' as const,
132
147
  titleVariant: 'large' as const,
133
148
  showChevron: false,
134
149
  },
@@ -144,7 +159,7 @@ const ELEVATION = {
144
159
 
145
160
  // ── Component ─────────────────────────────────────────────────────────────────
146
161
 
147
- export function Card({
162
+ export function ServiceCard({
148
163
  variant = 'outlined',
149
164
  size = 'lg',
150
165
  title,
@@ -158,42 +173,54 @@ export function Card({
158
173
  children,
159
174
  accessibilityLabel,
160
175
  fullWidth = false,
161
- }: CardProps) {
176
+ style,
177
+ }: ServiceCardProps) {
162
178
  const v = VARIANT_TOKENS[variant];
163
179
  const s = SIZE_TOKENS[size];
164
180
  const isRow = s.layout === 'row';
165
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
+
166
188
  const outerStyle = [
167
189
  {
168
190
  backgroundColor: variant === 'doubled' ? v.bg : v.bg,
169
191
  borderColor: v.borderColor,
170
192
  borderWidth: v.borderWidth,
171
- borderRadius: variant === 'doubled' ? dim.borderRadius.sysRadiusXl : s.borderRadius,
172
- paddingHorizontal: variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingH,
173
- 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,
174
199
  opacity: disabled ? 0.48 : 1,
175
200
  },
176
201
  v.elevated && ELEVATION,
177
202
  fullWidth && { alignSelf: 'stretch' as const },
178
203
  isRow ? styles.rowRoot : styles.colRoot,
179
204
  !isRow && { gap: s.gap },
205
+ style,
180
206
  ];
181
207
 
182
208
  // ── Text block ──────────────────────────────────────────────────────────────
183
- const textBlock = (title || description) ? (
184
- <View style={isRow ? styles.rowTextBlock : undefined}>
185
- {title && (
186
- <BodyText variant={s.titleVariant} color={v.titleColor}>
187
- {title}
188
- </BodyText>
189
- )}
190
- {!isRow && description && (
191
- <BodyText variant="small" color={v.descColor}>
192
- {description}
193
- </BodyText>
194
- )}
195
- </View>
196
- ) : 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;
197
224
 
198
225
  // ── Inner content by variant ────────────────────────────────────────────────
199
226
  let inner: React.ReactNode;
@@ -208,11 +235,11 @@ export function Card({
208
235
  borderRadius: s.borderRadius,
209
236
  paddingHorizontal: s.paddingH,
210
237
  paddingVertical: s.paddingV,
211
- gap: s.gap,
238
+ gap: dim.spacing.padding.sysPadding24,
212
239
  },
213
240
  ]}
214
241
  >
215
- {icon && (
242
+ {renderedIcon && (
216
243
  <View
217
244
  style={[
218
245
  styles.doubledIconBadge,
@@ -222,7 +249,7 @@ export function Card({
222
249
  },
223
250
  ]}
224
251
  >
225
- {icon}
252
+ {renderedIcon}
226
253
  </View>
227
254
  )}
228
255
  {textBlock}
@@ -236,20 +263,30 @@ export function Card({
236
263
  <>
237
264
  <Image
238
265
  source={image}
239
- style={[StyleSheet.absoluteFillObject, { borderRadius: s.borderRadius }]}
266
+ style={[
267
+ StyleSheet.absoluteFillObject,
268
+ { borderRadius: s.borderRadius },
269
+ ]}
240
270
  resizeMode="cover"
241
271
  accessible={false}
242
272
  />
243
273
  <View
244
274
  style={[
245
275
  StyleSheet.absoluteFillObject,
246
- { borderRadius: s.borderRadius, backgroundColor: 'rgba(0,0,0,0.30)' },
276
+ {
277
+ borderRadius: s.borderRadius,
278
+ backgroundColor: 'rgba(0,0,0,0.30)',
279
+ },
247
280
  ]}
248
281
  pointerEvents="none"
249
282
  />
250
283
  </>
251
284
  )}
252
- {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
+ )}
253
290
  {textBlock}
254
291
  {children}
255
292
  </>
@@ -258,7 +295,11 @@ export function Card({
258
295
  // sm — horizontal row
259
296
  inner = (
260
297
  <>
261
- {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
+ )}
262
303
  {textBlock}
263
304
  {s.showChevron && (
264
305
  <Ionicons
@@ -275,7 +316,11 @@ export function Card({
275
316
  // md / lg — vertical column
276
317
  inner = (
277
318
  <>
278
- {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
+ )}
279
324
  {textBlock}
280
325
  {children}
281
326
  {buttonIcon && (
@@ -300,7 +345,9 @@ export function Card({
300
345
  <Pressable
301
346
  onPress={disabled ? undefined : onPress}
302
347
  accessibilityRole="button"
303
- accessibilityLabel={accessibilityLabel ?? title}
348
+ accessibilityLabel={
349
+ accessibilityLabel ?? (typeof title === 'string' ? title : undefined)
350
+ }
304
351
  accessibilityState={{ disabled }}
305
352
  style={({ pressed }) => [
306
353
  styles.overflow,
@@ -313,11 +360,7 @@ export function Card({
313
360
  );
314
361
  }
315
362
 
316
- return (
317
- <View style={[styles.overflow, ...outerStyle]}>
318
- {inner}
319
- </View>
320
- );
363
+ return <View style={[styles.overflow, ...outerStyle]}>{inner}</View>;
321
364
  }
322
365
 
323
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
+ });