@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.
- package/package.json +40 -2
- package/src/components/ActionSheet/ActionSheet.test.tsx +89 -0
- package/src/{ActionSheet.tsx → components/ActionSheet/index.tsx} +16 -8
- package/src/components/Alert/Alert.test.tsx +61 -0
- package/src/{Alert.tsx → components/Alert/index.tsx} +14 -11
- package/src/components/Avatar/Avatar.test.tsx +52 -0
- package/src/{Avatar.tsx → components/Avatar/index.tsx} +15 -13
- package/src/components/Badge/Badge.test.tsx +30 -0
- package/src/{Badge.tsx → components/Badge/index.tsx} +17 -9
- package/src/components/BodyText/BodyText.test.tsx +30 -0
- package/src/{BodyText.tsx → components/BodyText/index.tsx} +9 -5
- package/src/components/Breadcrumb/Breadcrumb.test.tsx +37 -0
- package/src/{Breadcrumb.tsx → components/Breadcrumb/index.tsx} +13 -4
- package/src/components/Button/Button.test.tsx +45 -0
- package/src/{Button.tsx → components/Button/index.tsx} +36 -17
- package/src/components/Checkbox/Checkbox.test.tsx +57 -0
- package/src/{Checkbox.tsx → components/Checkbox/index.tsx} +37 -15
- package/src/components/Chip/Chip.test.tsx +40 -0
- package/src/{Chip.tsx → components/Chip/index.tsx} +18 -9
- package/src/components/Divider/Divider.test.tsx +28 -0
- package/src/{Divider.tsx → components/Divider/index.tsx} +30 -20
- package/src/components/EmptyState/EmptyState.test.tsx +60 -0
- package/src/{EmptyState.tsx → components/EmptyState/index.tsx} +17 -7
- package/src/components/HeaderText/HeaderText.test.tsx +43 -0
- package/src/{HeaderText.tsx → components/HeaderText/index.tsx} +9 -5
- package/src/components/Input/Input.test.tsx +43 -0
- package/src/{Input.tsx → components/Input/index.tsx} +31 -11
- package/src/components/List/List.test.tsx +59 -0
- package/src/{List.tsx → components/List/index.tsx} +23 -7
- package/src/components/Pagination/Pagination.test.tsx +56 -0
- package/src/{Pagination.tsx → components/Pagination/index.tsx} +29 -13
- package/src/components/PlanCard/PlanCard.test.tsx +44 -0
- package/src/{PlanCard.tsx → components/PlanCard/index.tsx} +46 -17
- package/src/components/ProgressTracker/ProgressTracker.test.tsx +47 -0
- package/src/{ProgressTracker.tsx → components/ProgressTracker/index.tsx} +16 -25
- package/src/components/PromotionCard/PromotionCard.test.tsx +73 -0
- package/src/components/PromotionCard/index.tsx +538 -0
- package/src/components/RadioButton/RadioButton.test.tsx +50 -0
- package/src/{RadioButton.tsx → components/RadioButton/index.tsx} +21 -10
- package/src/components/ScreenContainer/ScreenContainer.test.tsx +29 -0
- package/src/components/ScreenContainer/index.tsx +41 -0
- package/src/components/SegmentedControl/SegmentedControl.test.tsx +60 -0
- package/src/{SegmentedControl.tsx → components/SegmentedControl/index.tsx} +15 -7
- package/src/components/SelectionCard/SelectionCard.test.tsx +48 -0
- package/src/components/SelectionCard/index.tsx +213 -0
- package/src/components/ServiceCard/ServiceCard.test.tsx +61 -0
- package/src/{Card.tsx → components/ServiceCard/index.tsx} +106 -49
- package/src/components/Slider/Slider.test.tsx +33 -0
- package/src/{Slider.tsx → components/Slider/index.tsx} +26 -36
- package/src/components/Snackbar/Snackbar.test.tsx +61 -0
- package/src/{Snackbar.tsx → components/Snackbar/index.tsx} +13 -7
- package/src/components/Switch/Switch.test.tsx +54 -0
- package/src/{Switch.tsx → components/Switch/index.tsx} +16 -5
- package/src/components/Tooltip/Tooltip.test.tsx +30 -0
- package/src/{Tooltip.tsx → components/Tooltip/index.tsx} +11 -7
- package/src/icons/AtomIcon.tsx +32 -0
- package/src/icons/BinocularsIcon.tsx +45 -0
- package/src/icons/FileChartColumnIncreasingIcon.tsx +44 -0
- package/src/icons/FlagIcon.tsx +20 -0
- package/src/icons/GlobeIcon.tsx +20 -0
- package/src/icons/GraduationCapIcon.tsx +32 -0
- package/src/icons/HandHeartIcon.tsx +38 -0
- package/src/icons/HandshakeIcon.tsx +39 -0
- package/src/icons/HazeIcon.tsx +42 -0
- package/src/icons/HeartHandshakeIcon.tsx +20 -0
- package/src/icons/HourglassIcon.tsx +33 -0
- package/src/icons/IdCardIcon.tsx +44 -0
- package/src/icons/MessageCirclePlusIcon.tsx +32 -0
- package/src/icons/MountainSnowIcon.tsx +26 -0
- package/src/icons/SnowflakeIcon.tsx +86 -0
- package/src/icons/StethoscopeIcon.tsx +34 -0
- package/src/icons/UserRoundIcon.tsx +26 -0
- package/src/icons/WheatIcon.tsx +62 -0
- package/src/icons/index.ts +63 -0
- package/src/icons/types.ts +45 -0
- package/src/index.ts +159 -71
- 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
|
-
|
|
14
|
-
|
|
15
|
+
import { ICON_MAP, type IconName } from '../../icons';
|
|
16
|
+
import { sys } from '../../tokens';
|
|
17
|
+
import { BodyText } from '../BodyText';
|
|
15
18
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
83
|
+
bg: cr.accent.primary.sysPrimaryContainer,
|
|
71
84
|
borderColor: 'transparent',
|
|
72
85
|
borderWidth: 0,
|
|
73
|
-
elevated:
|
|
86
|
+
elevated: false,
|
|
74
87
|
titleColor: cr.accent.primary.sysOnPrimary,
|
|
75
|
-
descColor: cr.
|
|
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:
|
|
108
|
+
titleColor: cr.surface.surface.sysInverseOnSurface,
|
|
96
109
|
descColor: cr.transparent.neutral.sysWhite80,
|
|
97
|
-
chevronColor:
|
|
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
|
|
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
|
-
|
|
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:
|
|
170
|
-
|
|
171
|
-
|
|
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 =
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
{
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
238
|
+
gap: dim.spacing.padding.sysPadding24,
|
|
210
239
|
},
|
|
211
240
|
]}
|
|
212
241
|
>
|
|
213
|
-
{
|
|
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
|
-
{
|
|
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={[
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
|
|
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;
|
|
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: () =>
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
10
|
-
import {
|
|
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
|
|
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
|
+
});
|