@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.
- 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} +42 -14
- 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} +92 -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,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
|
-
|
|
14
|
-
|
|
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
|
|
17
|
-
variant?:
|
|
18
|
-
size?:
|
|
19
|
-
title?:
|
|
20
|
-
description?:
|
|
21
|
-
icon?:
|
|
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
|
-
|
|
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.
|
|
83
|
+
bg: cr.accent.primary.sysPrimaryContainer,
|
|
72
84
|
borderColor: 'transparent',
|
|
73
85
|
borderWidth: 0,
|
|
74
|
-
elevated:
|
|
86
|
+
elevated: false,
|
|
75
87
|
titleColor: cr.accent.primary.sysOnPrimary,
|
|
76
|
-
descColor: cr.
|
|
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:
|
|
108
|
+
titleColor: cr.surface.surface.sysInverseOnSurface,
|
|
97
109
|
descColor: cr.transparent.neutral.sysWhite80,
|
|
98
|
-
chevronColor:
|
|
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
|
|
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
|
-
|
|
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:
|
|
172
|
-
|
|
173
|
-
|
|
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 =
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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:
|
|
238
|
+
gap: dim.spacing.padding.sysPadding24,
|
|
212
239
|
},
|
|
213
240
|
]}
|
|
214
241
|
>
|
|
215
|
-
{
|
|
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
|
-
{
|
|
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={[
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
|
|
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
|
+
});
|