@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compsych/mobile-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "ComPsych React Native Design System — 19 mobile UI components with self-contained design tokens",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src"
|
|
11
11
|
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "jest",
|
|
14
|
+
"test:watch": "jest --watch",
|
|
15
|
+
"test:coverage": "jest --coverage",
|
|
16
|
+
"prepare": "husky"
|
|
17
|
+
},
|
|
12
18
|
"keywords": [
|
|
13
19
|
"react-native",
|
|
14
20
|
"compsych",
|
|
@@ -16,11 +22,43 @@
|
|
|
16
22
|
"components",
|
|
17
23
|
"expo"
|
|
18
24
|
],
|
|
25
|
+
"lint-staged": {
|
|
26
|
+
"src/**/*.{ts,tsx}": [
|
|
27
|
+
"eslint --fix",
|
|
28
|
+
"prettier --write"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
19
31
|
"license": "UNLICENSED",
|
|
20
32
|
"peerDependencies": {
|
|
21
33
|
"@expo/vector-icons": ">=15.0.0",
|
|
22
34
|
"expo-font": ">=13.0.0",
|
|
23
35
|
"react": ">=18.0.0",
|
|
24
|
-
"react-native": ">=0.73.0"
|
|
36
|
+
"react-native": ">=0.73.0",
|
|
37
|
+
"react-native-svg": ">=15.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@babel/core": "^7.25.2",
|
|
41
|
+
"@babel/plugin-transform-flow-strip-types": "^7.27.1",
|
|
42
|
+
"@babel/preset-env": "^7.25.2",
|
|
43
|
+
"@babel/preset-react": "^7.25.2",
|
|
44
|
+
"@babel/preset-typescript": "^7.25.2",
|
|
45
|
+
"@react-native/jest-preset": "^0.85.3",
|
|
46
|
+
"@testing-library/react-native": "^13.0.0",
|
|
47
|
+
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
|
48
|
+
"@types/jest": "^29.5.0",
|
|
49
|
+
"@types/react": "~19.1.0",
|
|
50
|
+
"@types/react-test-renderer": "^19.0.0",
|
|
51
|
+
"babel-jest": "^29.7.0",
|
|
52
|
+
"eslint": "^9.39.4",
|
|
53
|
+
"eslint-plugin-unused-imports": "^4.4.1",
|
|
54
|
+
"husky": "^9.1.7",
|
|
55
|
+
"jest": "^29.7.0",
|
|
56
|
+
"jest-expo": "^55.0.17",
|
|
57
|
+
"lint-staged": "^15.5.2",
|
|
58
|
+
"prettier": "^3.8.3",
|
|
59
|
+
"react": "^19.2.3",
|
|
60
|
+
"react-test-renderer": "^19.2.3",
|
|
61
|
+
"typescript": "~5.9.2",
|
|
62
|
+
"typescript-eslint": "^8.59.3"
|
|
25
63
|
}
|
|
26
64
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Text } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
6
|
+
|
|
7
|
+
import { ActionSheet } from './index';
|
|
8
|
+
|
|
9
|
+
jest.mock('react-native/Libraries/Modal/Modal', () => {
|
|
10
|
+
const mockModal = ({
|
|
11
|
+
children,
|
|
12
|
+
visible,
|
|
13
|
+
}: {
|
|
14
|
+
children: any;
|
|
15
|
+
visible: boolean;
|
|
16
|
+
}) => {
|
|
17
|
+
const { View } = jest.requireActual('react-native');
|
|
18
|
+
const { createElement } = require('react');
|
|
19
|
+
return visible ? createElement(View, null, children) : null;
|
|
20
|
+
};
|
|
21
|
+
mockModal.displayName = 'Modal';
|
|
22
|
+
return { default: mockModal };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('ActionSheet', () => {
|
|
26
|
+
it('renders title when visible', () => {
|
|
27
|
+
const { getByText } = render(
|
|
28
|
+
<ActionSheet visible title="Choose an option" onClose={jest.fn()} />,
|
|
29
|
+
);
|
|
30
|
+
expect(getByText('Choose an option')).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not render content when not visible', () => {
|
|
34
|
+
const { queryByText } = render(
|
|
35
|
+
<ActionSheet
|
|
36
|
+
visible={false}
|
|
37
|
+
title="Choose an option"
|
|
38
|
+
onClose={jest.fn()}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
expect(queryByText('Choose an option')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('calls onClose when close button is pressed', () => {
|
|
45
|
+
const onClose = jest.fn();
|
|
46
|
+
const { getByLabelText } = render(
|
|
47
|
+
<ActionSheet visible title="Options" onClose={onClose} />,
|
|
48
|
+
);
|
|
49
|
+
fireEvent.press(getByLabelText('Close'));
|
|
50
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders primary action and handles press', () => {
|
|
54
|
+
const onPress = jest.fn();
|
|
55
|
+
const { getByText } = render(
|
|
56
|
+
<ActionSheet
|
|
57
|
+
visible
|
|
58
|
+
title="Options"
|
|
59
|
+
onClose={jest.fn()}
|
|
60
|
+
primaryAction={{ label: 'Confirm', onPress }}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
fireEvent.press(getByText('Confirm'));
|
|
64
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders secondary action and handles press', () => {
|
|
68
|
+
const onPress = jest.fn();
|
|
69
|
+
const { getByText } = render(
|
|
70
|
+
<ActionSheet
|
|
71
|
+
visible
|
|
72
|
+
title="Options"
|
|
73
|
+
onClose={jest.fn()}
|
|
74
|
+
secondaryAction={{ label: 'Cancel', onPress }}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
fireEvent.press(getByText('Cancel'));
|
|
78
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders children content', () => {
|
|
82
|
+
const { getByText } = render(
|
|
83
|
+
<ActionSheet visible title="Options" onClose={jest.fn()}>
|
|
84
|
+
<Text>Custom content</Text>
|
|
85
|
+
</ActionSheet>,
|
|
86
|
+
);
|
|
87
|
+
expect(getByText('Custom content')).toBeTruthy();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
|
|
2
3
|
import {
|
|
3
4
|
Modal,
|
|
4
5
|
Pressable,
|
|
5
6
|
ScrollView,
|
|
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
|
-
|
|
11
|
-
import {
|
|
12
|
-
import { Button } from '
|
|
14
|
+
|
|
15
|
+
import { sys } from '../../tokens';
|
|
16
|
+
import { Button } from '../Button';
|
|
17
|
+
import { HeaderText } from '../HeaderText';
|
|
13
18
|
|
|
14
19
|
export interface ActionSheetAction {
|
|
15
20
|
label: string;
|
|
@@ -23,9 +28,10 @@ export interface ActionSheetProps {
|
|
|
23
28
|
children?: React.ReactNode;
|
|
24
29
|
primaryAction?: ActionSheetAction;
|
|
25
30
|
secondaryAction?: ActionSheetAction;
|
|
31
|
+
style?: StyleProp<ViewStyle>;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
const { colorRoles: cr
|
|
34
|
+
const { colorRoles: cr } = sys;
|
|
29
35
|
|
|
30
36
|
export function ActionSheet({
|
|
31
37
|
visible,
|
|
@@ -34,6 +40,7 @@ export function ActionSheet({
|
|
|
34
40
|
children,
|
|
35
41
|
primaryAction,
|
|
36
42
|
secondaryAction,
|
|
43
|
+
style,
|
|
37
44
|
}: ActionSheetProps) {
|
|
38
45
|
return (
|
|
39
46
|
<Modal
|
|
@@ -47,9 +54,8 @@ export function ActionSheet({
|
|
|
47
54
|
<Pressable style={styles.backdrop} onPress={onClose} accessible={false} />
|
|
48
55
|
|
|
49
56
|
{/* Sheet */}
|
|
50
|
-
<View style={styles.sheetWrapper} pointerEvents="box-none">
|
|
57
|
+
<View style={[styles.sheetWrapper, style]} pointerEvents="box-none">
|
|
51
58
|
<View style={styles.sheet}>
|
|
52
|
-
|
|
53
59
|
{/* Toolbar */}
|
|
54
60
|
<View style={styles.toolbar}>
|
|
55
61
|
{/* Grabber */}
|
|
@@ -78,7 +84,10 @@ export function ActionSheet({
|
|
|
78
84
|
accessibilityLabel="Close"
|
|
79
85
|
style={({ pressed }) => [
|
|
80
86
|
styles.closeButton,
|
|
81
|
-
{
|
|
87
|
+
{
|
|
88
|
+
backgroundColor:
|
|
89
|
+
cr.surface.surfaceContainer.sysSurfaceContainer,
|
|
90
|
+
},
|
|
82
91
|
pressed && styles.closePresseed,
|
|
83
92
|
]}
|
|
84
93
|
>
|
|
@@ -125,7 +134,6 @@ export function ActionSheet({
|
|
|
125
134
|
)}
|
|
126
135
|
</View>
|
|
127
136
|
)}
|
|
128
|
-
|
|
129
137
|
</View>
|
|
130
138
|
</View>
|
|
131
139
|
</Modal>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { Alert } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Alert', () => {
|
|
8
|
+
it('renders description text', () => {
|
|
9
|
+
const { getByText } = render(<Alert description="Something went wrong" />);
|
|
10
|
+
expect(getByText('Something went wrong')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders title when size is lg', () => {
|
|
14
|
+
const { getByText } = render(
|
|
15
|
+
<Alert size="lg" title="Heads up" description="Details here" />,
|
|
16
|
+
);
|
|
17
|
+
expect(getByText('Heads up')).toBeTruthy();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not render title when size is sm', () => {
|
|
21
|
+
const { queryByText } = render(
|
|
22
|
+
<Alert size="sm" title="Heads up" description="Details here" />,
|
|
23
|
+
);
|
|
24
|
+
expect(queryByText('Heads up')).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('calls onAction when action button is pressed', () => {
|
|
28
|
+
const onAction = jest.fn();
|
|
29
|
+
const { getByRole } = render(
|
|
30
|
+
<Alert description="Try again" actionLabel="Retry" onAction={onAction} />,
|
|
31
|
+
);
|
|
32
|
+
fireEvent.press(getByRole('button', { name: 'Retry' }));
|
|
33
|
+
expect(onAction).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('calls onDismiss when dismiss button is pressed', () => {
|
|
37
|
+
const onDismiss = jest.fn();
|
|
38
|
+
const { getByRole } = render(
|
|
39
|
+
<Alert description="Dismissible" dismissible onDismiss={onDismiss} />,
|
|
40
|
+
);
|
|
41
|
+
fireEvent.press(getByRole('button', { name: 'Dismiss' }));
|
|
42
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
'default',
|
|
47
|
+
'elevated',
|
|
48
|
+
'informative',
|
|
49
|
+
'warning',
|
|
50
|
+
'positive',
|
|
51
|
+
'danger',
|
|
52
|
+
] as const)('renders variant "%s" without crashing', (variant) => {
|
|
53
|
+
const { toJSON } = render(<Alert variant={variant} description="Test" />);
|
|
54
|
+
expect(toJSON()).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hides icon when hideIcon is true', () => {
|
|
58
|
+
const { toJSON } = render(<Alert description="No icon" hideIcon />);
|
|
59
|
+
expect(toJSON()).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -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 AlertVariant =
|
|
12
17
|
| 'default'
|
|
@@ -35,6 +40,7 @@ export interface AlertProps {
|
|
|
35
40
|
/** Show dismiss (×) button */
|
|
36
41
|
dismissible?: boolean;
|
|
37
42
|
onDismiss?: () => void;
|
|
43
|
+
style?: StyleProp<ViewStyle>;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
@@ -135,6 +141,7 @@ export function Alert({
|
|
|
135
141
|
onAction,
|
|
136
142
|
dismissible = false,
|
|
137
143
|
onDismiss,
|
|
144
|
+
style,
|
|
138
145
|
}: AlertProps) {
|
|
139
146
|
const c = getVariantColors(variant);
|
|
140
147
|
const isLg = size === 'lg';
|
|
@@ -162,6 +169,7 @@ export function Alert({
|
|
|
162
169
|
: dim.borderRadius.sysRadiusMd,
|
|
163
170
|
},
|
|
164
171
|
c.elevated && styles.elevated,
|
|
172
|
+
style,
|
|
165
173
|
]}
|
|
166
174
|
>
|
|
167
175
|
{/* ── Content row ─────────────────────────────────────────────────── */}
|
|
@@ -229,8 +237,8 @@ export function Alert({
|
|
|
229
237
|
</View>
|
|
230
238
|
|
|
231
239
|
{/* Action button */}
|
|
232
|
-
{actionLabel &&
|
|
233
|
-
isLg ? (
|
|
240
|
+
{actionLabel &&
|
|
241
|
+
(isLg ? (
|
|
234
242
|
// lg: pill button with white bg + shadow (or border for elevated)
|
|
235
243
|
<Pressable
|
|
236
244
|
onPress={onAction}
|
|
@@ -274,8 +282,7 @@ export function Alert({
|
|
|
274
282
|
{actionLabel}
|
|
275
283
|
</Text>
|
|
276
284
|
</Pressable>
|
|
277
|
-
)
|
|
278
|
-
)}
|
|
285
|
+
))}
|
|
279
286
|
</View>
|
|
280
287
|
|
|
281
288
|
{/* Dismiss button */}
|
|
@@ -290,11 +297,7 @@ export function Alert({
|
|
|
290
297
|
pressed && { opacity: 0.6 },
|
|
291
298
|
]}
|
|
292
299
|
>
|
|
293
|
-
<Ionicons
|
|
294
|
-
name="close"
|
|
295
|
-
size={16}
|
|
296
|
-
color={c.iconColor}
|
|
297
|
-
/>
|
|
300
|
+
<Ionicons name="close" size={16} color={c.iconColor} />
|
|
298
301
|
</Pressable>
|
|
299
302
|
)}
|
|
300
303
|
</View>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { Avatar } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Avatar', () => {
|
|
8
|
+
it('renders initials in text variant', () => {
|
|
9
|
+
const { getByText } = render(<Avatar variant="text" initials="CP" />);
|
|
10
|
+
expect(getByText('CP')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders image variant without crashing', () => {
|
|
14
|
+
const { getByLabelText } = render(
|
|
15
|
+
<Avatar
|
|
16
|
+
variant="image"
|
|
17
|
+
source={{ uri: 'https://example.com/avatar.jpg' }}
|
|
18
|
+
accessibilityLabel="User avatar"
|
|
19
|
+
/>,
|
|
20
|
+
);
|
|
21
|
+
expect(getByLabelText('User avatar')).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders icon variant without crashing', () => {
|
|
25
|
+
const { getByLabelText } = render(
|
|
26
|
+
<Avatar variant="icon" accessibilityLabel="Icon avatar" />,
|
|
27
|
+
);
|
|
28
|
+
expect(getByLabelText('Icon avatar')).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each(['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const)(
|
|
32
|
+
'renders size "%s" without crashing',
|
|
33
|
+
(size) => {
|
|
34
|
+
const { getByText } = render(
|
|
35
|
+
<Avatar variant="text" initials="AB" size={size} />,
|
|
36
|
+
);
|
|
37
|
+
expect(getByText('AB')).toBeTruthy();
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
it('renders activity ring when activityRing is true', () => {
|
|
42
|
+
const { getByLabelText } = render(
|
|
43
|
+
<Avatar
|
|
44
|
+
variant="text"
|
|
45
|
+
initials="CP"
|
|
46
|
+
activityRing
|
|
47
|
+
accessibilityLabel="Active user"
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
expect(getByLabelText('Active user')).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { Ionicons } from '@expo/vector-icons';
|
|
2
1
|
import React from 'react';
|
|
2
|
+
|
|
3
3
|
import {
|
|
4
4
|
Image,
|
|
5
5
|
ImageSourcePropType,
|
|
6
|
+
StyleProp,
|
|
6
7
|
StyleSheet,
|
|
7
8
|
Text,
|
|
8
9
|
View,
|
|
10
|
+
ViewStyle,
|
|
9
11
|
} from 'react-native';
|
|
10
|
-
|
|
12
|
+
|
|
13
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
14
|
+
|
|
15
|
+
import { sys } from '../../tokens';
|
|
11
16
|
|
|
12
17
|
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
|
13
18
|
export type AvatarVariant = 'text' | 'image' | 'icon';
|
|
@@ -27,6 +32,7 @@ export interface AvatarProps {
|
|
|
27
32
|
presenceBadge?: boolean;
|
|
28
33
|
/** Accessible label announced by screen readers */
|
|
29
34
|
accessibilityLabel?: string;
|
|
35
|
+
style?: StyleProp<ViewStyle>;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
@@ -127,12 +133,12 @@ export function Avatar({
|
|
|
127
133
|
activityRing = false,
|
|
128
134
|
presenceBadge = false,
|
|
129
135
|
accessibilityLabel,
|
|
136
|
+
style,
|
|
130
137
|
}: AvatarProps) {
|
|
131
138
|
const s = SIZE_TOKENS[size];
|
|
132
139
|
const r = s.diameter / 2;
|
|
133
140
|
|
|
134
|
-
const defaultLabel =
|
|
135
|
-
variant === 'text' ? `Avatar: ${initials}` : 'Avatar';
|
|
141
|
+
const defaultLabel = variant === 'text' ? `Avatar: ${initials}` : 'Avatar';
|
|
136
142
|
|
|
137
143
|
return (
|
|
138
144
|
// Outer wrapper — same footprint as avatar, does NOT clip so ring/badge
|
|
@@ -141,9 +147,8 @@ export function Avatar({
|
|
|
141
147
|
accessible
|
|
142
148
|
accessibilityRole="image"
|
|
143
149
|
accessibilityLabel={accessibilityLabel ?? defaultLabel}
|
|
144
|
-
style={{ width: s.diameter, height: s.diameter }}
|
|
150
|
+
style={[{ width: s.diameter, height: s.diameter }, style]}
|
|
145
151
|
>
|
|
146
|
-
|
|
147
152
|
{/* ── Avatar circle ─────────────────────────────────────────────── */}
|
|
148
153
|
<View
|
|
149
154
|
style={[
|
|
@@ -153,9 +158,7 @@ export function Avatar({
|
|
|
153
158
|
height: s.diameter,
|
|
154
159
|
borderRadius: r,
|
|
155
160
|
backgroundColor:
|
|
156
|
-
variant !== 'image'
|
|
157
|
-
? cr.custom.info.sysInfoContainer
|
|
158
|
-
: undefined,
|
|
161
|
+
variant !== 'image' ? cr.custom.info.sysInfoContainer : undefined,
|
|
159
162
|
},
|
|
160
163
|
]}
|
|
161
164
|
>
|
|
@@ -183,15 +186,14 @@ export function Avatar({
|
|
|
183
186
|
/>
|
|
184
187
|
)}
|
|
185
188
|
|
|
186
|
-
{variant === 'icon' &&
|
|
187
|
-
icon ?? (
|
|
189
|
+
{variant === 'icon' &&
|
|
190
|
+
(icon ?? (
|
|
188
191
|
<Ionicons
|
|
189
192
|
name="person"
|
|
190
193
|
size={s.iconSize}
|
|
191
194
|
color={cr.custom.info.sysOnInfoContainer}
|
|
192
195
|
/>
|
|
193
|
-
)
|
|
194
|
-
)}
|
|
196
|
+
))}
|
|
195
197
|
</View>
|
|
196
198
|
|
|
197
199
|
{/* ── Activity ring ─────────────────────────────────────────────── */}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { Badge } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Badge', () => {
|
|
8
|
+
it('renders label text', () => {
|
|
9
|
+
const { getByText } = render(<Badge label="New" />);
|
|
10
|
+
expect(getByText('New')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it.each(['sm', 'md', 'lg'] as const)(
|
|
14
|
+
'renders size "%s" without crashing',
|
|
15
|
+
(size) => {
|
|
16
|
+
const { getByText } = render(<Badge label="Badge" size={size} />);
|
|
17
|
+
expect(getByText('Badge')).toBeTruthy();
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
it.each(['filled', 'positive', 'danger', 'elevated', 'tonal'] as const)(
|
|
22
|
+
'renders style "%s" without crashing',
|
|
23
|
+
(badgeStyle) => {
|
|
24
|
+
const { getByText } = render(
|
|
25
|
+
<Badge label="Badge" badgeStyle={badgeStyle} />,
|
|
26
|
+
);
|
|
27
|
+
expect(getByText('Badge')).toBeTruthy();
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
|
|
3
|
-
import {
|
|
2
|
+
|
|
3
|
+
import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { sys } from '../../tokens';
|
|
4
6
|
|
|
5
7
|
export type BadgeSize = 'sm' | 'md' | 'lg';
|
|
6
8
|
export type BadgeStyle =
|
|
@@ -16,6 +18,7 @@ export interface BadgeProps {
|
|
|
16
18
|
label?: number | string;
|
|
17
19
|
size?: BadgeSize;
|
|
18
20
|
badgeStyle?: BadgeStyle;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
@@ -105,6 +108,7 @@ export function Badge({
|
|
|
105
108
|
label,
|
|
106
109
|
size = 'md',
|
|
107
110
|
badgeStyle = 'filled',
|
|
111
|
+
style,
|
|
108
112
|
}: BadgeProps) {
|
|
109
113
|
const s = SIZE_TOKENS[size];
|
|
110
114
|
const c = getStyleColors(badgeStyle);
|
|
@@ -114,13 +118,16 @@ export function Badge({
|
|
|
114
118
|
return (
|
|
115
119
|
<View
|
|
116
120
|
accessible={false}
|
|
117
|
-
style={
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
style={[
|
|
122
|
+
{
|
|
123
|
+
width: s.outerSize,
|
|
124
|
+
height: s.outerSize,
|
|
125
|
+
borderRadius: s.outerSize / 2,
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
justifyContent: 'center',
|
|
128
|
+
},
|
|
129
|
+
style,
|
|
130
|
+
]}
|
|
124
131
|
>
|
|
125
132
|
<View
|
|
126
133
|
style={{
|
|
@@ -152,6 +159,7 @@ export function Badge({
|
|
|
152
159
|
backgroundColor: c.bg,
|
|
153
160
|
},
|
|
154
161
|
c.elevated && styles.elevated,
|
|
162
|
+
style,
|
|
155
163
|
]}
|
|
156
164
|
>
|
|
157
165
|
<Text
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { BodyText } from './index';
|
|
6
|
+
|
|
7
|
+
describe('BodyText', () => {
|
|
8
|
+
it('renders children', () => {
|
|
9
|
+
const { getByText } = render(<BodyText>Hello world</BodyText>);
|
|
10
|
+
expect(getByText('Hello world')).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it.each(['small', 'medium', 'large', 'labelMedium', 'labelLarge'] as const)(
|
|
14
|
+
'renders variant "%s" without crashing',
|
|
15
|
+
(variant) => {
|
|
16
|
+
const { getByText } = render(<BodyText variant={variant}>Text</BodyText>);
|
|
17
|
+
expect(getByText('Text')).toBeTruthy();
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
it('applies custom color', () => {
|
|
22
|
+
const { getByText } = render(<BodyText color="#ff0000">Colored</BodyText>);
|
|
23
|
+
expect(getByText('Colored')).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders emphasized text', () => {
|
|
27
|
+
const { getByText } = render(<BodyText emphasized>Bold</BodyText>);
|
|
28
|
+
expect(getByText('Bold')).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -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 BodyVariant =
|
|
8
10
|
| 'large'
|
|
@@ -43,13 +45,15 @@ export function BodyText({
|
|
|
43
45
|
...rest
|
|
44
46
|
}: BodyTextProps) {
|
|
45
47
|
const [fontsLoaded] = useFonts({
|
|
46
|
-
GoogleSans_400Regular: require('
|
|
47
|
-
GoogleSans_500Medium: require('
|
|
48
|
-
GoogleSans_600SemiBold: require('
|
|
48
|
+
GoogleSans_400Regular: require('../../fonts/GoogleSans_400Regular.ttf'),
|
|
49
|
+
GoogleSans_500Medium: require('../../fonts/GoogleSans_500Medium.ttf'),
|
|
50
|
+
GoogleSans_600SemiBold: require('../../fonts/GoogleSans_600SemiBold.ttf'),
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
const token = ts[VARIANT_TOKEN_KEY[variant]];
|
|
52
|
-
const weight = emphasized
|
|
54
|
+
const weight = emphasized
|
|
55
|
+
? token.sysFontWeightEmphasized
|
|
56
|
+
: token.sysFontWeight;
|
|
53
57
|
|
|
54
58
|
return (
|
|
55
59
|
<Text
|