@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.
Files changed (77) hide show
  1. package/package.json +40 -2
  2. package/src/components/ActionSheet/ActionSheet.test.tsx +89 -0
  3. package/src/{ActionSheet.tsx → components/ActionSheet/index.tsx} +16 -8
  4. package/src/components/Alert/Alert.test.tsx +61 -0
  5. package/src/{Alert.tsx → components/Alert/index.tsx} +14 -11
  6. package/src/components/Avatar/Avatar.test.tsx +52 -0
  7. package/src/{Avatar.tsx → components/Avatar/index.tsx} +15 -13
  8. package/src/components/Badge/Badge.test.tsx +30 -0
  9. package/src/{Badge.tsx → components/Badge/index.tsx} +17 -9
  10. package/src/components/BodyText/BodyText.test.tsx +30 -0
  11. package/src/{BodyText.tsx → components/BodyText/index.tsx} +9 -5
  12. package/src/components/Breadcrumb/Breadcrumb.test.tsx +37 -0
  13. package/src/{Breadcrumb.tsx → components/Breadcrumb/index.tsx} +13 -4
  14. package/src/components/Button/Button.test.tsx +45 -0
  15. package/src/{Button.tsx → components/Button/index.tsx} +36 -17
  16. package/src/components/Checkbox/Checkbox.test.tsx +57 -0
  17. package/src/{Checkbox.tsx → components/Checkbox/index.tsx} +37 -15
  18. package/src/components/Chip/Chip.test.tsx +40 -0
  19. package/src/{Chip.tsx → components/Chip/index.tsx} +18 -9
  20. package/src/components/Divider/Divider.test.tsx +28 -0
  21. package/src/{Divider.tsx → components/Divider/index.tsx} +30 -20
  22. package/src/components/EmptyState/EmptyState.test.tsx +60 -0
  23. package/src/{EmptyState.tsx → components/EmptyState/index.tsx} +17 -7
  24. package/src/components/HeaderText/HeaderText.test.tsx +43 -0
  25. package/src/{HeaderText.tsx → components/HeaderText/index.tsx} +9 -5
  26. package/src/components/Input/Input.test.tsx +43 -0
  27. package/src/{Input.tsx → components/Input/index.tsx} +31 -11
  28. package/src/components/List/List.test.tsx +59 -0
  29. package/src/{List.tsx → components/List/index.tsx} +23 -7
  30. package/src/components/Pagination/Pagination.test.tsx +56 -0
  31. package/src/{Pagination.tsx → components/Pagination/index.tsx} +29 -13
  32. package/src/components/PlanCard/PlanCard.test.tsx +44 -0
  33. package/src/{PlanCard.tsx → components/PlanCard/index.tsx} +42 -14
  34. package/src/components/ProgressTracker/ProgressTracker.test.tsx +47 -0
  35. package/src/{ProgressTracker.tsx → components/ProgressTracker/index.tsx} +16 -25
  36. package/src/components/PromotionCard/PromotionCard.test.tsx +73 -0
  37. package/src/components/PromotionCard/index.tsx +538 -0
  38. package/src/components/RadioButton/RadioButton.test.tsx +50 -0
  39. package/src/{RadioButton.tsx → components/RadioButton/index.tsx} +21 -10
  40. package/src/components/ScreenContainer/ScreenContainer.test.tsx +29 -0
  41. package/src/components/ScreenContainer/index.tsx +40 -0
  42. package/src/components/SegmentedControl/SegmentedControl.test.tsx +60 -0
  43. package/src/{SegmentedControl.tsx → components/SegmentedControl/index.tsx} +15 -7
  44. package/src/components/SelectionCard/SelectionCard.test.tsx +48 -0
  45. package/src/components/SelectionCard/index.tsx +213 -0
  46. package/src/components/ServiceCard/ServiceCard.test.tsx +61 -0
  47. package/src/{Card.tsx → components/ServiceCard/index.tsx} +92 -49
  48. package/src/components/Slider/Slider.test.tsx +33 -0
  49. package/src/{Slider.tsx → components/Slider/index.tsx} +26 -36
  50. package/src/components/Snackbar/Snackbar.test.tsx +61 -0
  51. package/src/{Snackbar.tsx → components/Snackbar/index.tsx} +13 -7
  52. package/src/components/Switch/Switch.test.tsx +54 -0
  53. package/src/{Switch.tsx → components/Switch/index.tsx} +16 -5
  54. package/src/components/Tooltip/Tooltip.test.tsx +30 -0
  55. package/src/{Tooltip.tsx → components/Tooltip/index.tsx} +11 -7
  56. package/src/icons/AtomIcon.tsx +32 -0
  57. package/src/icons/BinocularsIcon.tsx +45 -0
  58. package/src/icons/FileChartColumnIncreasingIcon.tsx +44 -0
  59. package/src/icons/FlagIcon.tsx +20 -0
  60. package/src/icons/GlobeIcon.tsx +20 -0
  61. package/src/icons/GraduationCapIcon.tsx +32 -0
  62. package/src/icons/HandHeartIcon.tsx +38 -0
  63. package/src/icons/HandshakeIcon.tsx +39 -0
  64. package/src/icons/HazeIcon.tsx +42 -0
  65. package/src/icons/HeartHandshakeIcon.tsx +20 -0
  66. package/src/icons/HourglassIcon.tsx +33 -0
  67. package/src/icons/IdCardIcon.tsx +44 -0
  68. package/src/icons/MessageCirclePlusIcon.tsx +32 -0
  69. package/src/icons/MountainSnowIcon.tsx +26 -0
  70. package/src/icons/SnowflakeIcon.tsx +86 -0
  71. package/src/icons/StethoscopeIcon.tsx +34 -0
  72. package/src/icons/UserRoundIcon.tsx +26 -0
  73. package/src/icons/WheatIcon.tsx +62 -0
  74. package/src/icons/index.ts +63 -0
  75. package/src/icons/types.ts +45 -0
  76. package/src/index.ts +159 -71
  77. package/src/tokens.ts +135 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compsych/mobile-ui",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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
- import { sys } from './tokens';
11
- import { HeaderText } from './HeaderText';
12
- import { Button } from './Button';
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, dimensions: dim } = sys;
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
- { backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainer },
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
- import { sys } from './tokens';
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
- import { sys } from './tokens';
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
- import { StyleSheet, Text, View } from 'react-native';
3
- import { sys } from './tokens';
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
- width: s.outerSize,
119
- height: s.outerSize,
120
- borderRadius: s.outerSize / 2,
121
- alignItems: 'center',
122
- justifyContent: 'center',
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 './tokens';
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('./fonts/GoogleSans_400Regular.ttf'),
47
- GoogleSans_500Medium: require('./fonts/GoogleSans_500Medium.ttf'),
48
- GoogleSans_600SemiBold: require('./fonts/GoogleSans_600SemiBold.ttf'),
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 ? token.sysFontWeightEmphasized : token.sysFontWeight;
54
+ const weight = emphasized
55
+ ? token.sysFontWeightEmphasized
56
+ : token.sysFontWeight;
53
57
 
54
58
  return (
55
59
  <Text