@compsych/mobile-ui 1.0.9 → 1.0.11
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 +40 -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,12 +1,17 @@
|
|
|
1
|
-
import { Ionicons } from '@expo/vector-icons';
|
|
2
1
|
import React from 'react';
|
|
2
|
+
|
|
3
3
|
import {
|
|
4
4
|
Pressable,
|
|
5
|
+
StyleProp,
|
|
5
6
|
StyleSheet,
|
|
6
7
|
Text,
|
|
7
8
|
View,
|
|
9
|
+
ViewStyle,
|
|
8
10
|
} from 'react-native';
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
13
|
+
|
|
14
|
+
import { sys } from '../../tokens';
|
|
10
15
|
|
|
11
16
|
export type EmptyStateStyle = 'icon' | 'illustration';
|
|
12
17
|
export type EmptyStateViewport = 'desktop' | 'mobile';
|
|
@@ -29,6 +34,7 @@ export interface EmptyStateProps {
|
|
|
29
34
|
onAction?: () => void;
|
|
30
35
|
/** Whether the action button is shown (default: true when actionLabel is set) */
|
|
31
36
|
showAction?: boolean;
|
|
37
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
@@ -79,7 +85,7 @@ const ILLUS_H = 157;
|
|
|
79
85
|
const SCALE = ILLUS_W / 398; // ≈ 0.703
|
|
80
86
|
|
|
81
87
|
const CARD_W = Math.round(160 * SCALE); // 112
|
|
82
|
-
const CARD_H = Math.round(98 * SCALE);
|
|
88
|
+
const CARD_H = Math.round(98 * SCALE); // 69
|
|
83
89
|
|
|
84
90
|
function scaledPos(left: number, top: number) {
|
|
85
91
|
return {
|
|
@@ -120,7 +126,8 @@ function EmptyIllustration() {
|
|
|
120
126
|
width: CARD_W,
|
|
121
127
|
height: CARD_H,
|
|
122
128
|
borderColor: cr.outline.sysOutlineVariant,
|
|
123
|
-
backgroundColor:
|
|
129
|
+
backgroundColor:
|
|
130
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
124
131
|
},
|
|
125
132
|
styles.cardFaded,
|
|
126
133
|
]}
|
|
@@ -138,7 +145,8 @@ function EmptyIllustration() {
|
|
|
138
145
|
width: CARD_W,
|
|
139
146
|
height: CARD_H,
|
|
140
147
|
borderColor: cr.accent.primary.sysPrimary,
|
|
141
|
-
backgroundColor:
|
|
148
|
+
backgroundColor:
|
|
149
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
142
150
|
},
|
|
143
151
|
]}
|
|
144
152
|
>
|
|
@@ -190,12 +198,13 @@ export function EmptyState({
|
|
|
190
198
|
actionLabel,
|
|
191
199
|
onAction,
|
|
192
200
|
showAction,
|
|
201
|
+
containerStyle,
|
|
193
202
|
}: EmptyStateProps) {
|
|
194
203
|
const vp = VIEWPORT[viewport];
|
|
195
204
|
const hasAction = showAction !== undefined ? showAction : !!actionLabel;
|
|
196
205
|
|
|
197
206
|
return (
|
|
198
|
-
<View style={styles.root}>
|
|
207
|
+
<View style={[styles.root, containerStyle]}>
|
|
199
208
|
{/* ── Graphic ───────────────────────────────────────────────────────── */}
|
|
200
209
|
{style === 'illustration' ? (
|
|
201
210
|
<EmptyIllustration />
|
|
@@ -206,7 +215,8 @@ export function EmptyState({
|
|
|
206
215
|
{
|
|
207
216
|
width: vp.iconCircle,
|
|
208
217
|
height: vp.iconCircle,
|
|
209
|
-
backgroundColor:
|
|
218
|
+
backgroundColor:
|
|
219
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLow,
|
|
210
220
|
},
|
|
211
221
|
]}
|
|
212
222
|
>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { HeaderText } from './index';
|
|
6
|
+
|
|
7
|
+
describe('HeaderText', () => {
|
|
8
|
+
it('renders children', () => {
|
|
9
|
+
const { getByText } = render(<HeaderText>Page Title</HeaderText>);
|
|
10
|
+
expect(getByText('Page Title')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it.each([
|
|
14
|
+
'large',
|
|
15
|
+
'medium',
|
|
16
|
+
'small',
|
|
17
|
+
'headlineLarge',
|
|
18
|
+
'headlineMedium',
|
|
19
|
+
'headlineSmall',
|
|
20
|
+
'titleLarge',
|
|
21
|
+
'titleMedium',
|
|
22
|
+
'titleSmall',
|
|
23
|
+
] as const)('renders variant "%s" without crashing', (variant) => {
|
|
24
|
+
const { getByText } = render(
|
|
25
|
+
<HeaderText variant={variant}>Title</HeaderText>,
|
|
26
|
+
);
|
|
27
|
+
expect(getByText('Title')).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('applies custom color', () => {
|
|
31
|
+
const { getByText } = render(
|
|
32
|
+
<HeaderText color="#0000ff">Blue Title</HeaderText>,
|
|
33
|
+
);
|
|
34
|
+
expect(getByText('Blue Title')).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders emphasized text', () => {
|
|
38
|
+
const { getByText } = render(
|
|
39
|
+
<HeaderText emphasized>Emphasized</HeaderText>,
|
|
40
|
+
);
|
|
41
|
+
expect(getByText('Emphasized')).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
|
|
2
3
|
import { Text, type TextProps } from 'react-native';
|
|
4
|
+
|
|
3
5
|
import { useFonts } from 'expo-font';
|
|
4
6
|
|
|
5
|
-
import { sys } from '
|
|
7
|
+
import { sys } from '../../tokens';
|
|
6
8
|
|
|
7
9
|
export type HeaderVariant =
|
|
8
10
|
| 'large'
|
|
@@ -49,13 +51,15 @@ export function HeaderText({
|
|
|
49
51
|
...rest
|
|
50
52
|
}: HeaderTextProps) {
|
|
51
53
|
const [fontsLoaded] = useFonts({
|
|
52
|
-
GoogleSans_400Regular: require('
|
|
53
|
-
GoogleSans_500Medium: require('
|
|
54
|
-
GoogleSans_600SemiBold: require('
|
|
54
|
+
GoogleSans_400Regular: require('../../fonts/GoogleSans_400Regular.ttf'),
|
|
55
|
+
GoogleSans_500Medium: require('../../fonts/GoogleSans_500Medium.ttf'),
|
|
56
|
+
GoogleSans_600SemiBold: require('../../fonts/GoogleSans_600SemiBold.ttf'),
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
const token = ts[VARIANT_TOKEN_KEY[variant]];
|
|
58
|
-
const weight = emphasized
|
|
60
|
+
const weight = emphasized
|
|
61
|
+
? token.sysFontWeightEmphasized
|
|
62
|
+
: token.sysFontWeight;
|
|
59
63
|
|
|
60
64
|
return (
|
|
61
65
|
<Text
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { Input } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Input', () => {
|
|
8
|
+
it('renders label', () => {
|
|
9
|
+
const { getByText } = render(<Input label="Email" />);
|
|
10
|
+
expect(getByText('Email')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders helper text', () => {
|
|
14
|
+
const { getByText } = render(
|
|
15
|
+
<Input label="Email" helperText="Enter your work email" />,
|
|
16
|
+
);
|
|
17
|
+
expect(getByText('Enter your work email')).toBeTruthy();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders error text when invalid', () => {
|
|
21
|
+
const { getByText } = render(
|
|
22
|
+
<Input label="Email" invalid errorText="Invalid email address" />,
|
|
23
|
+
);
|
|
24
|
+
expect(getByText('Invalid email address')).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('calls onChangeText when text changes', () => {
|
|
28
|
+
const onChangeText = jest.fn();
|
|
29
|
+
const { getByDisplayValue } = render(
|
|
30
|
+
<Input label="Name" value="" onChangeText={onChangeText} />,
|
|
31
|
+
);
|
|
32
|
+
fireEvent.changeText(getByDisplayValue(''), 'John');
|
|
33
|
+
expect(onChangeText).toHaveBeenCalledWith('John');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(['sm', 'md', 'lg'] as const)(
|
|
37
|
+
'renders size "%s" without crashing',
|
|
38
|
+
(size) => {
|
|
39
|
+
const { getByText } = render(<Input label="Field" size={size} />);
|
|
40
|
+
expect(getByText('Field')).toBeTruthy();
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
+
|
|
2
3
|
import {
|
|
3
|
-
TextInput,
|
|
4
|
-
TextInputProps,
|
|
5
4
|
StyleSheet,
|
|
6
5
|
Text,
|
|
6
|
+
TextInput,
|
|
7
|
+
TextInputProps,
|
|
7
8
|
View,
|
|
8
9
|
} from 'react-native';
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
import { sys } from '../../tokens';
|
|
10
12
|
|
|
11
13
|
export type InputSize = 'sm' | 'md' | 'lg';
|
|
12
14
|
|
|
@@ -78,15 +80,16 @@ export function Input({
|
|
|
78
80
|
const hasError = invalid || !!errorText;
|
|
79
81
|
|
|
80
82
|
// ── Border ──────────────────────────────────────────────────────────────
|
|
81
|
-
const borderWidth =
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
const borderWidth =
|
|
84
|
+
isFocused || hasError
|
|
85
|
+
? dim.borderWidth.sysStrokeMedium
|
|
86
|
+
: dim.borderWidth.sysStrokeThin;
|
|
84
87
|
|
|
85
88
|
const borderColor = hasError
|
|
86
89
|
? cr.error.sysError
|
|
87
90
|
: isFocused
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
? cr.accent.primary.sysPrimary
|
|
92
|
+
: cr.outline.sysOutlineVariant;
|
|
90
93
|
|
|
91
94
|
// ── Text colours ─────────────────────────────────────────────────────────
|
|
92
95
|
const textColor = cr.surface.surface.sysOnSurface;
|
|
@@ -136,7 +139,8 @@ export function Input({
|
|
|
136
139
|
borderRadius: s.radius,
|
|
137
140
|
borderWidth,
|
|
138
141
|
borderColor,
|
|
139
|
-
backgroundColor:
|
|
142
|
+
backgroundColor:
|
|
143
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
140
144
|
flexDirection: 'row',
|
|
141
145
|
alignItems: 'center',
|
|
142
146
|
paddingHorizontal: s.paddingH,
|
|
@@ -147,7 +151,15 @@ export function Input({
|
|
|
147
151
|
>
|
|
148
152
|
{/* Leading icon */}
|
|
149
153
|
{leadingIcon && (
|
|
150
|
-
<View
|
|
154
|
+
<View
|
|
155
|
+
style={{
|
|
156
|
+
width: s.iconSize,
|
|
157
|
+
height: s.iconSize,
|
|
158
|
+
alignItems: 'center',
|
|
159
|
+
justifyContent: 'center',
|
|
160
|
+
flexShrink: 0,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
151
163
|
{leadingIcon}
|
|
152
164
|
</View>
|
|
153
165
|
)}
|
|
@@ -182,7 +194,15 @@ export function Input({
|
|
|
182
194
|
|
|
183
195
|
{/* Trailing icon */}
|
|
184
196
|
{trailingIcon && (
|
|
185
|
-
<View
|
|
197
|
+
<View
|
|
198
|
+
style={{
|
|
199
|
+
width: s.iconSize,
|
|
200
|
+
height: s.iconSize,
|
|
201
|
+
alignItems: 'center',
|
|
202
|
+
justifyContent: 'center',
|
|
203
|
+
flexShrink: 0,
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
186
206
|
{trailingIcon}
|
|
187
207
|
</View>
|
|
188
208
|
)}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { List, ListItem } from './index';
|
|
6
|
+
|
|
7
|
+
const items = [{ label: 'Item one' }, { label: 'Item two' }];
|
|
8
|
+
|
|
9
|
+
describe('List', () => {
|
|
10
|
+
it('renders list items from items prop', () => {
|
|
11
|
+
const { getByText } = render(<List items={items} />);
|
|
12
|
+
expect(getByText('Item one')).toBeTruthy();
|
|
13
|
+
expect(getByText('Item two')).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders items with onPress callbacks', () => {
|
|
17
|
+
const onPress = jest.fn();
|
|
18
|
+
const { getByText } = render(
|
|
19
|
+
<List items={[{ label: 'Tappable', onPress }]} />,
|
|
20
|
+
);
|
|
21
|
+
fireEvent.press(getByText('Tappable'));
|
|
22
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('ListItem', () => {
|
|
27
|
+
it('renders label', () => {
|
|
28
|
+
const { getByText } = render(<ListItem label="Settings" />);
|
|
29
|
+
expect(getByText('Settings')).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders subLabel when type is detailed', () => {
|
|
33
|
+
const { getByText } = render(
|
|
34
|
+
<ListItem
|
|
35
|
+
type="detailed"
|
|
36
|
+
label="Notifications"
|
|
37
|
+
subLabel="Manage your alerts"
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
expect(getByText('Manage your alerts')).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('calls onPress when tapped', () => {
|
|
44
|
+
const onPress = jest.fn();
|
|
45
|
+
const { getByRole } = render(
|
|
46
|
+
<ListItem label="Tappable" onPress={onPress} />,
|
|
47
|
+
);
|
|
48
|
+
fireEvent.press(getByRole('button'));
|
|
49
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it.each(['simple', 'detailed'] as const)(
|
|
53
|
+
'renders type "%s" without crashing',
|
|
54
|
+
(type) => {
|
|
55
|
+
const { getByText } = render(<ListItem label="Item" type={type} />);
|
|
56
|
+
expect(getByText('Item')).toBeTruthy();
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleProp,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
View,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
3
11
|
import { Ionicons } from '@expo/vector-icons';
|
|
4
|
-
|
|
5
|
-
import {
|
|
12
|
+
|
|
13
|
+
import { sys } from '../../tokens';
|
|
14
|
+
import { BodyText } from '../BodyText';
|
|
6
15
|
|
|
7
16
|
export type ListItemType = 'simple' | 'detailed';
|
|
8
17
|
|
|
@@ -19,6 +28,7 @@ export interface ListItemProps {
|
|
|
19
28
|
export interface ListProps {
|
|
20
29
|
items: Omit<ListItemProps, 'showDivider'>[];
|
|
21
30
|
type?: ListItemType;
|
|
31
|
+
style?: StyleProp<ViewStyle>;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
const { colorRoles: cr, dimensions: dim } = sys;
|
|
@@ -43,7 +53,10 @@ export function ListItem({
|
|
|
43
53
|
<View
|
|
44
54
|
style={[
|
|
45
55
|
styles.iconBadge,
|
|
46
|
-
{
|
|
56
|
+
{
|
|
57
|
+
backgroundColor:
|
|
58
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLow,
|
|
59
|
+
},
|
|
47
60
|
]}
|
|
48
61
|
>
|
|
49
62
|
{icon}
|
|
@@ -62,7 +75,10 @@ export function ListItem({
|
|
|
62
75
|
{label}
|
|
63
76
|
</BodyText>
|
|
64
77
|
{isDetailed && subLabel && (
|
|
65
|
-
<BodyText
|
|
78
|
+
<BodyText
|
|
79
|
+
variant="small"
|
|
80
|
+
color={cr.surface.surface.sysOnSurfaceVariant}
|
|
81
|
+
>
|
|
66
82
|
{subLabel}
|
|
67
83
|
</BodyText>
|
|
68
84
|
)}
|
|
@@ -132,9 +148,9 @@ export function ListItem({
|
|
|
132
148
|
|
|
133
149
|
// ── List ──────────────────────────────────────────────────────────────────────
|
|
134
150
|
|
|
135
|
-
export function List({ items, type = 'simple' }: ListProps) {
|
|
151
|
+
export function List({ items, type = 'simple', style }: ListProps) {
|
|
136
152
|
return (
|
|
137
|
-
<View>
|
|
153
|
+
<View style={style}>
|
|
138
154
|
{items.map((item, index) => (
|
|
139
155
|
<ListItem
|
|
140
156
|
key={index}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { Pagination } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Pagination', () => {
|
|
8
|
+
it('renders current and total page info', () => {
|
|
9
|
+
const { getByText } = render(
|
|
10
|
+
<Pagination currentPage={2} totalPages={10} onPageChange={jest.fn()} />,
|
|
11
|
+
);
|
|
12
|
+
expect(getByText('2')).toBeTruthy();
|
|
13
|
+
expect(getByText('10')).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('calls onPageChange with next page when next is pressed', () => {
|
|
17
|
+
const onPageChange = jest.fn();
|
|
18
|
+
const { getByLabelText } = render(
|
|
19
|
+
<Pagination
|
|
20
|
+
currentPage={3}
|
|
21
|
+
totalPages={10}
|
|
22
|
+
onPageChange={onPageChange}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
fireEvent.press(getByLabelText('Next page'));
|
|
26
|
+
expect(onPageChange).toHaveBeenCalledWith(4);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('calls onPageChange with previous page when prev is pressed', () => {
|
|
30
|
+
const onPageChange = jest.fn();
|
|
31
|
+
const { getByLabelText } = render(
|
|
32
|
+
<Pagination
|
|
33
|
+
currentPage={3}
|
|
34
|
+
totalPages={10}
|
|
35
|
+
onPageChange={onPageChange}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
fireEvent.press(getByLabelText('Previous page'));
|
|
39
|
+
expect(onPageChange).toHaveBeenCalledWith(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each(['sm', 'lg'] as const)(
|
|
43
|
+
'renders size "%s" without crashing',
|
|
44
|
+
(size) => {
|
|
45
|
+
const { toJSON } = render(
|
|
46
|
+
<Pagination
|
|
47
|
+
currentPage={1}
|
|
48
|
+
totalPages={5}
|
|
49
|
+
onPageChange={jest.fn()}
|
|
50
|
+
size={size}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
expect(toJSON()).toBeTruthy();
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
});
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { Ionicons } from '@expo/vector-icons';
|
|
2
1
|
import React from 'react';
|
|
3
|
-
|
|
4
|
-
import {
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleProp,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
13
|
+
|
|
14
|
+
import { sys } from '../../tokens';
|
|
5
15
|
|
|
6
16
|
export type PaginationSize = 'sm' | 'lg';
|
|
7
17
|
|
|
@@ -29,6 +39,7 @@ export interface PaginationProps {
|
|
|
29
39
|
siblingCount?: number;
|
|
30
40
|
/** On mobile/compact layouts — renders only prev + next arrows (no page numbers) */
|
|
31
41
|
compact?: boolean;
|
|
42
|
+
style?: StyleProp<ViewStyle>;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
@@ -149,9 +160,10 @@ function PageItem({ s, page, isActive, onPress }: PageItemProps) {
|
|
|
149
160
|
borderColor,
|
|
150
161
|
alignItems: 'center',
|
|
151
162
|
justifyContent: 'center',
|
|
152
|
-
backgroundColor:
|
|
153
|
-
|
|
154
|
-
|
|
163
|
+
backgroundColor:
|
|
164
|
+
pressed && !isActive
|
|
165
|
+
? cr.transparent.neutral.sysBlack10
|
|
166
|
+
: 'transparent',
|
|
155
167
|
}}
|
|
156
168
|
>
|
|
157
169
|
<Text
|
|
@@ -211,8 +223,7 @@ interface NavButtonProps {
|
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
function NavButton({ s, direction, disabled, onPress }: NavButtonProps) {
|
|
214
|
-
const iconName =
|
|
215
|
-
direction === 'prev' ? 'chevron-back' : 'chevron-forward';
|
|
226
|
+
const iconName = direction === 'prev' ? 'chevron-back' : 'chevron-forward';
|
|
216
227
|
|
|
217
228
|
return (
|
|
218
229
|
<Pressable
|
|
@@ -230,9 +241,10 @@ function NavButton({ s, direction, disabled, onPress }: NavButtonProps) {
|
|
|
230
241
|
borderRadius: s.itemSize / 2,
|
|
231
242
|
alignItems: 'center',
|
|
232
243
|
justifyContent: 'center',
|
|
233
|
-
backgroundColor:
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
backgroundColor:
|
|
245
|
+
pressed && !disabled
|
|
246
|
+
? cr.transparent.neutral.sysBlack10
|
|
247
|
+
: 'transparent',
|
|
236
248
|
}}
|
|
237
249
|
>
|
|
238
250
|
<Ionicons
|
|
@@ -255,6 +267,7 @@ export function Pagination({
|
|
|
255
267
|
onPageChange,
|
|
256
268
|
siblingCount = 1,
|
|
257
269
|
compact = false,
|
|
270
|
+
style,
|
|
258
271
|
}: PaginationProps) {
|
|
259
272
|
const s = SIZE_TOKENS[size];
|
|
260
273
|
const pages = buildPageRange(totalPages, currentPage, siblingCount);
|
|
@@ -322,10 +335,12 @@ export function Pagination({
|
|
|
322
335
|
{
|
|
323
336
|
paddingHorizontal: s.pillPaddingH,
|
|
324
337
|
paddingVertical: s.pillPaddingV,
|
|
325
|
-
backgroundColor:
|
|
338
|
+
backgroundColor:
|
|
339
|
+
cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
326
340
|
borderColor: cr.outline.sysOutline,
|
|
327
341
|
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
328
342
|
},
|
|
343
|
+
style,
|
|
329
344
|
]}
|
|
330
345
|
>
|
|
331
346
|
{content}
|
|
@@ -333,7 +348,8 @@ export function Pagination({
|
|
|
333
348
|
);
|
|
334
349
|
}
|
|
335
350
|
|
|
336
|
-
|
|
351
|
+
// sm — wrap content in a View so style can be applied
|
|
352
|
+
return <View style={style}>{content}</View>;
|
|
337
353
|
}
|
|
338
354
|
|
|
339
355
|
const styles = StyleSheet.create({
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { PlanCard } from './index';
|
|
6
|
+
|
|
7
|
+
const items = [
|
|
8
|
+
{ id: '1', title: 'Session 1', subtitle: 'Introduction', checked: false },
|
|
9
|
+
{ id: '2', title: 'Session 2', subtitle: 'Deep work', checked: true },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('PlanCard', () => {
|
|
13
|
+
it('renders title', () => {
|
|
14
|
+
const { getByText } = render(<PlanCard title="My Plan" />);
|
|
15
|
+
expect(getByText('My Plan')).toBeTruthy();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders tag when provided', () => {
|
|
19
|
+
const { getByText } = render(<PlanCard title="Plan" tag="Recommended" />);
|
|
20
|
+
expect(getByText('Recommended')).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders items when expanded', () => {
|
|
24
|
+
const { getByText } = render(
|
|
25
|
+
<PlanCard title="Plan" items={items} expanded />,
|
|
26
|
+
);
|
|
27
|
+
expect(getByText('Session 1')).toBeTruthy();
|
|
28
|
+
expect(getByText('Session 2')).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('calls onToggle when header is pressed', () => {
|
|
32
|
+
const onToggle = jest.fn();
|
|
33
|
+
const { getByLabelText } = render(
|
|
34
|
+
<PlanCard title="Plan" onToggle={onToggle} />,
|
|
35
|
+
);
|
|
36
|
+
fireEvent.press(getByLabelText('Expand'));
|
|
37
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders without items', () => {
|
|
41
|
+
const { toJSON } = render(<PlanCard title="Empty Plan" />);
|
|
42
|
+
expect(toJSON()).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|