@compsych/mobile-ui 1.0.11 → 1.0.13
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 +1 -1
- package/src/components/ActionSheet/index.tsx +124 -57
- package/src/components/Alert/index.tsx +19 -10
- package/src/components/Avatar/index.tsx +95 -92
- package/src/components/Badge/index.tsx +39 -36
- package/src/components/BodyText/index.tsx +5 -5
- package/src/components/Breadcrumb/index.tsx +32 -30
- package/src/components/Button/Button.test.tsx +13 -7
- package/src/components/Button/index.tsx +114 -92
- package/src/components/ChatBubble/ChatBubble.test.tsx +77 -0
- package/src/components/ChatBubble/index.tsx +187 -0
- package/src/components/ChatInput/ChatInput.test.tsx +90 -0
- package/src/components/ChatInput/index.tsx +263 -0
- package/src/components/Checkbox/index.tsx +24 -21
- package/src/components/Chip/index.tsx +75 -72
- package/src/components/Divider/index.tsx +3 -3
- package/src/components/EmptyState/index.tsx +72 -55
- package/src/components/HeaderText/index.tsx +5 -5
- package/src/components/Input/index.tsx +40 -37
- package/src/components/List/index.tsx +2 -3
- package/src/components/Pagination/index.tsx +56 -43
- package/src/components/PlanCard/index.tsx +118 -50
- package/src/components/ProgressTracker/index.tsx +44 -26
- package/src/components/PromotionCard/index.tsx +217 -212
- package/src/components/RadioButton/index.tsx +27 -25
- package/src/components/ScreenContainer/ScreenContainer.test.tsx +12 -2
- package/src/components/ScreenContainer/index.tsx +21 -10
- package/src/components/SegmentedControl/index.tsx +37 -30
- package/src/components/SelectionCard/index.tsx +38 -26
- package/src/components/ServiceCard/index.tsx +162 -130
- package/src/components/Slider/index.tsx +77 -77
- package/src/components/Snackbar/index.tsx +69 -57
- package/src/components/Switch/index.tsx +13 -12
- package/src/components/Tooltip/index.tsx +3 -3
- package/src/index.ts +13 -0
- package/src/theme/ThemeContext.ts +11 -0
- package/src/theme/ThemeProvider.tsx +40 -0
- package/src/theme/__tests__/ThemeProvider.test.tsx +101 -0
- package/src/theme/__tests__/defaultTheme.test.ts +36 -0
- package/src/theme/__tests__/mergeTheme.test.ts +94 -0
- package/src/theme/defaultTheme.ts +15 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/mergeTheme.ts +47 -0
- package/src/theme/types.ts +39 -0
- package/src/theme/useTheme.ts +16 -0
|
@@ -4,7 +4,7 @@ import { Text, type TextProps } from 'react-native';
|
|
|
4
4
|
|
|
5
5
|
import { useFonts } from 'expo-font';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { useTheme } from '../../theme';
|
|
8
8
|
|
|
9
9
|
export type BodyVariant =
|
|
10
10
|
| 'large'
|
|
@@ -26,7 +26,7 @@ const FONT_FAMILY: Record<string, string> = {
|
|
|
26
26
|
semibold: 'GoogleSans_600SemiBold',
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const VARIANT_TOKEN_KEY: Record<BodyVariant,
|
|
29
|
+
const VARIANT_TOKEN_KEY: Record<BodyVariant, string> = {
|
|
30
30
|
large: 'bodyLarge',
|
|
31
31
|
medium: 'bodyMedium',
|
|
32
32
|
small: 'bodySmall',
|
|
@@ -35,8 +35,6 @@ const VARIANT_TOKEN_KEY: Record<BodyVariant, keyof typeof sys.typeScale> = {
|
|
|
35
35
|
labelSmall: 'labelSmall',
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
const { typeScale: ts } = sys;
|
|
39
|
-
|
|
40
38
|
export function BodyText({
|
|
41
39
|
variant = 'medium',
|
|
42
40
|
emphasized = false,
|
|
@@ -44,13 +42,15 @@ export function BodyText({
|
|
|
44
42
|
style,
|
|
45
43
|
...rest
|
|
46
44
|
}: BodyTextProps) {
|
|
45
|
+
const { typeScale: ts } = useTheme();
|
|
46
|
+
|
|
47
47
|
const [fontsLoaded] = useFonts({
|
|
48
48
|
GoogleSans_400Regular: require('../../fonts/GoogleSans_400Regular.ttf'),
|
|
49
49
|
GoogleSans_500Medium: require('../../fonts/GoogleSans_500Medium.ttf'),
|
|
50
50
|
GoogleSans_600SemiBold: require('../../fonts/GoogleSans_600SemiBold.ttf'),
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
const token = ts[VARIANT_TOKEN_KEY[variant]];
|
|
53
|
+
const token = ts[VARIANT_TOKEN_KEY[variant] as keyof typeof ts];
|
|
54
54
|
const weight = emphasized
|
|
55
55
|
? token.sysFontWeightEmphasized
|
|
56
56
|
: token.sysFontWeight;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
Pressable,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { Ionicons } from '@expo/vector-icons';
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { useTheme } from '../../theme';
|
|
16
16
|
|
|
17
17
|
export type BreadcrumbSize = 'sm' | 'lg';
|
|
18
18
|
|
|
@@ -38,31 +38,34 @@ export interface BreadcrumbProps {
|
|
|
38
38
|
style?: StyleProp<ViewStyle>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
export function Breadcrumb({ items, size = 'lg', style }: BreadcrumbProps) {
|
|
42
|
+
const { colorRoles: cr, dimensions: dim, typeScale: ts } = useTheme();
|
|
42
43
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
44
|
+
const sizeTokens = useMemo(
|
|
45
|
+
() => ({
|
|
46
|
+
sm: {
|
|
47
|
+
fontSize: ts.labelSmall.sysFontSize,
|
|
48
|
+
lineHeight: ts.labelSmall.sysLineHeight,
|
|
49
|
+
letterSpacing: ts.labelSmall.sysTracking,
|
|
50
|
+
paddingH: dim.spacing.padding.sysPadding8,
|
|
51
|
+
paddingV: dim.spacing.padding.sysPadding2,
|
|
52
|
+
iconSize: 16,
|
|
53
|
+
dividerWidth: 8,
|
|
54
|
+
},
|
|
55
|
+
lg: {
|
|
56
|
+
fontSize: ts.labelMedium.sysFontSize,
|
|
57
|
+
lineHeight: ts.labelMedium.sysLineHeight,
|
|
58
|
+
letterSpacing: ts.labelMedium.sysTracking,
|
|
59
|
+
paddingH: dim.spacing.padding.sysPadding8,
|
|
60
|
+
paddingV: dim.spacing.padding.sysPadding4,
|
|
61
|
+
iconSize: 16,
|
|
62
|
+
dividerWidth: 8,
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
[dim, ts],
|
|
66
|
+
);
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
const s = SIZE_TOKENS[size];
|
|
68
|
+
const s = sizeTokens[size];
|
|
66
69
|
|
|
67
70
|
return (
|
|
68
71
|
// ScrollView lets a long breadcrumb scroll horizontally without clipping
|
|
@@ -130,7 +133,10 @@ export function Breadcrumb({ items, size = 'lg', style }: BreadcrumbProps) {
|
|
|
130
133
|
accessibilityLabel={item.isHome ? 'Home' : item.label}
|
|
131
134
|
style={({ pressed }) => [
|
|
132
135
|
styles.pressable,
|
|
133
|
-
pressed &&
|
|
136
|
+
pressed && {
|
|
137
|
+
backgroundColor: cr.transparent.neutral.sysBlack10,
|
|
138
|
+
borderRadius: 4,
|
|
139
|
+
},
|
|
134
140
|
]}
|
|
135
141
|
>
|
|
136
142
|
{content}
|
|
@@ -183,10 +189,6 @@ const styles = StyleSheet.create({
|
|
|
183
189
|
pressable: {
|
|
184
190
|
borderRadius: 4,
|
|
185
191
|
},
|
|
186
|
-
pressed: {
|
|
187
|
-
backgroundColor: cr.transparent.neutral.sysBlack10,
|
|
188
|
-
borderRadius: 4,
|
|
189
|
-
},
|
|
190
192
|
content: {
|
|
191
193
|
flexDirection: 'row',
|
|
192
194
|
alignItems: 'center',
|
|
@@ -22,13 +22,19 @@ describe('Button', () => {
|
|
|
22
22
|
expect(getByRole('button').props.accessibilityState.disabled).toBe(true);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it.each([
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
it.each([
|
|
26
|
+
'filled',
|
|
27
|
+
'tonal',
|
|
28
|
+
'outlined',
|
|
29
|
+
'elevated',
|
|
30
|
+
'text',
|
|
31
|
+
'danger',
|
|
32
|
+
'danger-outlined',
|
|
33
|
+
'warning',
|
|
34
|
+
] as const)('renders variant "%s" without crashing', (variant) => {
|
|
35
|
+
const { getByText } = render(<Button label="Button" variant={variant} />);
|
|
36
|
+
expect(getByText('Button')).toBeTruthy();
|
|
37
|
+
});
|
|
32
38
|
|
|
33
39
|
it.each(['sm', 'md', 'lg', 'xl'] as const)(
|
|
34
40
|
'renders size "%s" without crashing',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ActivityIndicator,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
View,
|
|
10
10
|
} from 'react-native';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { useTheme } from '../../theme';
|
|
13
13
|
|
|
14
14
|
export type ButtonVariant =
|
|
15
15
|
| 'filled'
|
|
@@ -18,7 +18,8 @@ export type ButtonVariant =
|
|
|
18
18
|
| 'elevated'
|
|
19
19
|
| 'text'
|
|
20
20
|
| 'danger'
|
|
21
|
-
| 'danger-outlined'
|
|
21
|
+
| 'danger-outlined'
|
|
22
|
+
| 'warning';
|
|
22
23
|
|
|
23
24
|
export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
24
25
|
|
|
@@ -37,91 +38,6 @@ export interface ButtonProps extends Omit<
|
|
|
37
38
|
trailingIcon?: React.ReactNode;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
41
|
-
|
|
42
|
-
const VARIANT_TOKENS: Record<
|
|
43
|
-
ButtonVariant,
|
|
44
|
-
{ bg: string; label: string; borderColor: string; borderWidth: number }
|
|
45
|
-
> = {
|
|
46
|
-
filled: {
|
|
47
|
-
bg: cr.accent.primary.sysPrimary,
|
|
48
|
-
label: cr.accent.primary.sysOnPrimary,
|
|
49
|
-
borderColor: 'transparent',
|
|
50
|
-
borderWidth: 0,
|
|
51
|
-
},
|
|
52
|
-
tonal: {
|
|
53
|
-
bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
|
|
54
|
-
label: cr.addOn.primaryFixed.sysOnPrimaryFixed,
|
|
55
|
-
borderColor: 'transparent',
|
|
56
|
-
borderWidth: 0,
|
|
57
|
-
},
|
|
58
|
-
outlined: {
|
|
59
|
-
bg: 'transparent',
|
|
60
|
-
label: cr.surface.surface.sysOnSurface,
|
|
61
|
-
borderColor: cr.outline.sysOutline,
|
|
62
|
-
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
63
|
-
},
|
|
64
|
-
elevated: {
|
|
65
|
-
bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
66
|
-
label: cr.surface.surface.sysOnSurface,
|
|
67
|
-
borderColor: 'transparent',
|
|
68
|
-
borderWidth: 0,
|
|
69
|
-
},
|
|
70
|
-
text: {
|
|
71
|
-
bg: 'transparent',
|
|
72
|
-
label: cr.surface.surface.sysOnSurface,
|
|
73
|
-
borderColor: 'transparent',
|
|
74
|
-
borderWidth: 0,
|
|
75
|
-
},
|
|
76
|
-
danger: {
|
|
77
|
-
bg: cr.error.sysError,
|
|
78
|
-
label: cr.error.sysOnError,
|
|
79
|
-
borderColor: 'transparent',
|
|
80
|
-
borderWidth: 0,
|
|
81
|
-
},
|
|
82
|
-
'danger-outlined': {
|
|
83
|
-
bg: 'transparent',
|
|
84
|
-
label: cr.error.sysError,
|
|
85
|
-
borderColor: cr.error.sysErrorContainer,
|
|
86
|
-
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const SIZE_TOKENS = {
|
|
91
|
-
sm: {
|
|
92
|
-
height: 32,
|
|
93
|
-
paddingH: dim.spacing.padding.sysPadding12,
|
|
94
|
-
paddingV: dim.spacing.padding.sysPadding4,
|
|
95
|
-
fontSize: ts.labelSmall.sysFontSize,
|
|
96
|
-
lineHeight: ts.labelSmall.sysLineHeight,
|
|
97
|
-
iconSize: sys.iconography.sysSizeXs,
|
|
98
|
-
},
|
|
99
|
-
md: {
|
|
100
|
-
height: 40,
|
|
101
|
-
paddingH: dim.spacing.padding.sysPadding16,
|
|
102
|
-
paddingV: dim.spacing.padding.sysPadding8,
|
|
103
|
-
fontSize: ts.labelMedium.sysFontSize,
|
|
104
|
-
lineHeight: ts.labelMedium.sysLineHeight,
|
|
105
|
-
iconSize: sys.iconography.sysSizeXs,
|
|
106
|
-
},
|
|
107
|
-
lg: {
|
|
108
|
-
height: 48,
|
|
109
|
-
paddingH: dim.spacing.padding.sysPadding24,
|
|
110
|
-
paddingV: dim.spacing.padding.sysPadding12,
|
|
111
|
-
fontSize: ts.labelLarge.sysFontSize,
|
|
112
|
-
lineHeight: ts.labelLarge.sysLineHeight,
|
|
113
|
-
iconSize: sys.iconography.sysSizeSm,
|
|
114
|
-
},
|
|
115
|
-
xl: {
|
|
116
|
-
height: 56,
|
|
117
|
-
paddingH: dim.spacing.padding.sysPadding32,
|
|
118
|
-
paddingV: dim.spacing.padding.sysPadding16,
|
|
119
|
-
fontSize: ts.titleSmall.sysFontSize,
|
|
120
|
-
lineHeight: ts.titleSmall.sysLineHeight,
|
|
121
|
-
iconSize: sys.iconography.sysSizeMd,
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
|
|
125
41
|
export function Button({
|
|
126
42
|
variant = 'filled',
|
|
127
43
|
size = 'md',
|
|
@@ -136,6 +52,109 @@ export function Button({
|
|
|
136
52
|
accessibilityLabel,
|
|
137
53
|
...rest
|
|
138
54
|
}: ButtonProps) {
|
|
55
|
+
const {
|
|
56
|
+
colorRoles: cr,
|
|
57
|
+
dimensions: dim,
|
|
58
|
+
typeScale: ts,
|
|
59
|
+
iconography: ico,
|
|
60
|
+
} = useTheme();
|
|
61
|
+
|
|
62
|
+
const VARIANT_TOKENS = useMemo(
|
|
63
|
+
() =>
|
|
64
|
+
({
|
|
65
|
+
filled: {
|
|
66
|
+
bg: cr.accent.primary.sysPrimary,
|
|
67
|
+
label: cr.accent.primary.sysOnPrimary,
|
|
68
|
+
borderColor: 'transparent',
|
|
69
|
+
borderWidth: 0,
|
|
70
|
+
},
|
|
71
|
+
tonal: {
|
|
72
|
+
bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
|
|
73
|
+
label: cr.addOn.primaryFixed.sysOnPrimaryFixed,
|
|
74
|
+
borderColor: 'transparent',
|
|
75
|
+
borderWidth: 0,
|
|
76
|
+
},
|
|
77
|
+
outlined: {
|
|
78
|
+
bg: 'transparent',
|
|
79
|
+
label: cr.surface.surface.sysOnSurface,
|
|
80
|
+
borderColor: cr.outline.sysOutline,
|
|
81
|
+
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
82
|
+
},
|
|
83
|
+
elevated: {
|
|
84
|
+
bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
85
|
+
label: cr.surface.surface.sysOnSurface,
|
|
86
|
+
borderColor: 'transparent',
|
|
87
|
+
borderWidth: 0,
|
|
88
|
+
},
|
|
89
|
+
text: {
|
|
90
|
+
bg: 'transparent',
|
|
91
|
+
label: cr.surface.surface.sysOnSurface,
|
|
92
|
+
borderColor: 'transparent',
|
|
93
|
+
borderWidth: 0,
|
|
94
|
+
},
|
|
95
|
+
danger: {
|
|
96
|
+
bg: cr.error.sysError,
|
|
97
|
+
label: cr.error.sysOnError,
|
|
98
|
+
borderColor: 'transparent',
|
|
99
|
+
borderWidth: 0,
|
|
100
|
+
},
|
|
101
|
+
'danger-outlined': {
|
|
102
|
+
bg: 'transparent',
|
|
103
|
+
label: cr.error.sysError,
|
|
104
|
+
borderColor: cr.error.sysErrorContainer,
|
|
105
|
+
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
106
|
+
},
|
|
107
|
+
warning: {
|
|
108
|
+
bg: cr.custom.warning.sysWarning,
|
|
109
|
+
label: cr.custom.warning.sysOnWarning,
|
|
110
|
+
borderColor: 'transparent',
|
|
111
|
+
borderWidth: 0,
|
|
112
|
+
},
|
|
113
|
+
}) as Record<
|
|
114
|
+
ButtonVariant,
|
|
115
|
+
{ bg: string; label: string; borderColor: string; borderWidth: number }
|
|
116
|
+
>,
|
|
117
|
+
[cr, dim],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const SIZE_TOKENS = useMemo(
|
|
121
|
+
() => ({
|
|
122
|
+
sm: {
|
|
123
|
+
height: 32,
|
|
124
|
+
paddingH: dim.spacing.padding.sysPadding12,
|
|
125
|
+
paddingV: dim.spacing.padding.sysPadding4,
|
|
126
|
+
fontSize: ts.labelSmall.sysFontSize,
|
|
127
|
+
lineHeight: ts.labelSmall.sysLineHeight,
|
|
128
|
+
iconSize: ico.sysSizeXs,
|
|
129
|
+
},
|
|
130
|
+
md: {
|
|
131
|
+
height: 40,
|
|
132
|
+
paddingH: dim.spacing.padding.sysPadding16,
|
|
133
|
+
paddingV: dim.spacing.padding.sysPadding8,
|
|
134
|
+
fontSize: ts.labelMedium.sysFontSize,
|
|
135
|
+
lineHeight: ts.labelMedium.sysLineHeight,
|
|
136
|
+
iconSize: ico.sysSizeXs,
|
|
137
|
+
},
|
|
138
|
+
lg: {
|
|
139
|
+
height: 48,
|
|
140
|
+
paddingH: dim.spacing.padding.sysPadding24,
|
|
141
|
+
paddingV: dim.spacing.padding.sysPadding12,
|
|
142
|
+
fontSize: ts.labelLarge.sysFontSize,
|
|
143
|
+
lineHeight: ts.labelLarge.sysLineHeight,
|
|
144
|
+
iconSize: ico.sysSizeSm,
|
|
145
|
+
},
|
|
146
|
+
xl: {
|
|
147
|
+
height: 56,
|
|
148
|
+
paddingH: dim.spacing.padding.sysPadding32,
|
|
149
|
+
paddingV: dim.spacing.padding.sysPadding16,
|
|
150
|
+
fontSize: ts.titleSmall.sysFontSize,
|
|
151
|
+
lineHeight: ts.titleSmall.sysLineHeight,
|
|
152
|
+
iconSize: ico.sysSizeMd,
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
[dim, ts, ico],
|
|
156
|
+
);
|
|
157
|
+
|
|
139
158
|
const v = VARIANT_TOKENS[variant];
|
|
140
159
|
const s = SIZE_TOKENS[size];
|
|
141
160
|
const isDisabled = disabled || loading;
|
|
@@ -172,13 +191,16 @@ export function Button({
|
|
|
172
191
|
}),
|
|
173
192
|
...(fullWidth && !iconOnly ? { alignSelf: 'stretch' } : {}),
|
|
174
193
|
opacity: isDisabled ? 0.48 : pressed ? 0.82 : 1,
|
|
194
|
+
// Elevated: Figma specifies two drop shadows — RN only supports one
|
|
195
|
+
// on iOS, so we use the larger/more visible one (lv2: 0px 4px 16px
|
|
196
|
+
// rgba(0,0,0,0.10)) and fall back to elevation: 2 on Android.
|
|
175
197
|
...(variant === 'elevated'
|
|
176
198
|
? {
|
|
177
199
|
shadowColor: '#000',
|
|
178
|
-
shadowOffset: { width: 0, height:
|
|
179
|
-
shadowOpacity: 0.
|
|
180
|
-
shadowRadius:
|
|
181
|
-
elevation:
|
|
200
|
+
shadowOffset: { width: 0, height: 4 },
|
|
201
|
+
shadowOpacity: 0.1,
|
|
202
|
+
shadowRadius: 16,
|
|
203
|
+
elevation: 2,
|
|
182
204
|
}
|
|
183
205
|
: {}),
|
|
184
206
|
},
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { ChatBubble } from './index';
|
|
6
|
+
|
|
7
|
+
jest.mock('@expo/vector-icons', () => ({
|
|
8
|
+
Ionicons: 'Ionicons',
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('ChatBubble', () => {
|
|
12
|
+
it('renders message for incoming variant', () => {
|
|
13
|
+
const { getByText } = render(
|
|
14
|
+
<ChatBubble variant="incoming" message="Hello, how can I help?" />,
|
|
15
|
+
);
|
|
16
|
+
expect(getByText('Hello, how can I help?')).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders message for outgoing variant', () => {
|
|
20
|
+
const { getByText } = render(
|
|
21
|
+
<ChatBubble variant="outgoing" message="I need some help" />,
|
|
22
|
+
);
|
|
23
|
+
expect(getByText('I need some help')).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders timestamp when provided', () => {
|
|
27
|
+
const { getByText } = render(
|
|
28
|
+
<ChatBubble variant="incoming" message="Hi" timestamp="11:30 AM" />,
|
|
29
|
+
);
|
|
30
|
+
expect(getByText('11:30 AM')).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not render timestamp when omitted', () => {
|
|
34
|
+
const { queryByText } = render(
|
|
35
|
+
<ChatBubble variant="incoming" message="Hi" />,
|
|
36
|
+
);
|
|
37
|
+
expect(queryByText('11:30 AM')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders thumbs-up button and calls onThumbsUp when pressed', () => {
|
|
41
|
+
const onThumbsUp = jest.fn();
|
|
42
|
+
const { getByLabelText } = render(
|
|
43
|
+
<ChatBubble variant="incoming" message="Hi" onThumbsUp={onThumbsUp} />,
|
|
44
|
+
);
|
|
45
|
+
fireEvent.press(getByLabelText('Thumbs up'));
|
|
46
|
+
expect(onThumbsUp).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders thumbs-down button and calls onThumbsDown when pressed', () => {
|
|
50
|
+
const onThumbsDown = jest.fn();
|
|
51
|
+
const { getByLabelText } = render(
|
|
52
|
+
<ChatBubble
|
|
53
|
+
variant="incoming"
|
|
54
|
+
message="Hi"
|
|
55
|
+
onThumbsDown={onThumbsDown}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
fireEvent.press(getByLabelText('Thumbs down'));
|
|
59
|
+
expect(onThumbsDown).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not render reaction buttons for outgoing variant', () => {
|
|
63
|
+
const onThumbsUp = jest.fn();
|
|
64
|
+
const { queryByLabelText } = render(
|
|
65
|
+
<ChatBubble variant="outgoing" message="Hey" onThumbsUp={onThumbsUp} />,
|
|
66
|
+
);
|
|
67
|
+
expect(queryByLabelText('Thumbs up')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not render reaction buttons when handlers are not provided', () => {
|
|
71
|
+
const { queryByLabelText } = render(
|
|
72
|
+
<ChatBubble variant="incoming" message="Hi" />,
|
|
73
|
+
);
|
|
74
|
+
expect(queryByLabelText('Thumbs up')).toBeNull();
|
|
75
|
+
expect(queryByLabelText('Thumbs down')).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleProp,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
View,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
11
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
12
|
+
|
|
13
|
+
import { useTheme } from '../../theme';
|
|
14
|
+
import { BodyText } from '../BodyText';
|
|
15
|
+
|
|
16
|
+
export type ChatBubbleVariant = 'incoming' | 'outgoing';
|
|
17
|
+
|
|
18
|
+
export interface ChatBubbleProps {
|
|
19
|
+
variant: ChatBubbleVariant;
|
|
20
|
+
message: string;
|
|
21
|
+
timestamp?: string;
|
|
22
|
+
onThumbsUp?: () => void;
|
|
23
|
+
onThumbsDown?: () => void;
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ChatBubble({
|
|
28
|
+
variant,
|
|
29
|
+
message,
|
|
30
|
+
timestamp,
|
|
31
|
+
onThumbsUp,
|
|
32
|
+
onThumbsDown,
|
|
33
|
+
style,
|
|
34
|
+
}: ChatBubbleProps) {
|
|
35
|
+
const { colorRoles: cr, dimensions: dim, iconography: ico } = useTheme();
|
|
36
|
+
|
|
37
|
+
const isOutgoing = variant === 'outgoing';
|
|
38
|
+
|
|
39
|
+
const tokenStyles = useMemo(
|
|
40
|
+
() => ({
|
|
41
|
+
bubble: {
|
|
42
|
+
maxWidth: '80%' as const,
|
|
43
|
+
paddingVertical: dim.spacing.padding.sysPadding8,
|
|
44
|
+
paddingHorizontal: dim.spacing.padding.sysPadding12,
|
|
45
|
+
borderRadius: dim.borderRadius.sysRadiusMd,
|
|
46
|
+
},
|
|
47
|
+
bubbleIncoming: {
|
|
48
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
|
|
49
|
+
borderWidth: dim.borderWidth.sysStrokeThin,
|
|
50
|
+
borderColor: cr.outline.sysOutlineVariant,
|
|
51
|
+
borderBottomLeftRadius: dim.borderRadius.sysRadiusXs,
|
|
52
|
+
},
|
|
53
|
+
bubbleOutgoing: {
|
|
54
|
+
backgroundColor: cr.accent.primary.sysPrimary,
|
|
55
|
+
borderBottomRightRadius: dim.borderRadius.sysRadiusXs,
|
|
56
|
+
},
|
|
57
|
+
metaRow: {
|
|
58
|
+
flexDirection: 'row' as const,
|
|
59
|
+
alignItems: 'center' as const,
|
|
60
|
+
gap: dim.spacing.padding.sysPadding8,
|
|
61
|
+
marginTop: dim.spacing.padding.sysPadding4,
|
|
62
|
+
},
|
|
63
|
+
reactionBtn: {
|
|
64
|
+
padding: dim.spacing.padding.sysPadding2,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
[cr, dim],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const textColor = isOutgoing
|
|
71
|
+
? cr.accent.primary.sysOnPrimary
|
|
72
|
+
: cr.surface.surface.sysOnSurface;
|
|
73
|
+
const timestampColor = isOutgoing
|
|
74
|
+
? 'rgba(255,255,255,0.64)'
|
|
75
|
+
: cr.surface.surface.sysOnSurfaceVariant;
|
|
76
|
+
const iconColor = cr.surface.surface.sysOnSurfaceVariant;
|
|
77
|
+
const iconSize = ico.sysSizeXs;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<View
|
|
81
|
+
style={[
|
|
82
|
+
styles.row,
|
|
83
|
+
isOutgoing ? styles.rowOutgoing : styles.rowIncoming,
|
|
84
|
+
style,
|
|
85
|
+
]}
|
|
86
|
+
>
|
|
87
|
+
<View>
|
|
88
|
+
{/* Bubble */}
|
|
89
|
+
<View
|
|
90
|
+
style={[
|
|
91
|
+
tokenStyles.bubble,
|
|
92
|
+
isOutgoing
|
|
93
|
+
? tokenStyles.bubbleOutgoing
|
|
94
|
+
: tokenStyles.bubbleIncoming,
|
|
95
|
+
]}
|
|
96
|
+
>
|
|
97
|
+
<BodyText
|
|
98
|
+
variant="medium"
|
|
99
|
+
color={textColor}
|
|
100
|
+
style={styles.messageText}
|
|
101
|
+
>
|
|
102
|
+
{message}
|
|
103
|
+
</BodyText>
|
|
104
|
+
</View>
|
|
105
|
+
|
|
106
|
+
{/* Meta row: timestamp + reactions */}
|
|
107
|
+
<View
|
|
108
|
+
style={[
|
|
109
|
+
tokenStyles.metaRow,
|
|
110
|
+
isOutgoing ? styles.metaOutgoing : styles.metaIncoming,
|
|
111
|
+
]}
|
|
112
|
+
>
|
|
113
|
+
{timestamp && (
|
|
114
|
+
<BodyText variant="labelSmall" color={timestampColor}>
|
|
115
|
+
{timestamp}
|
|
116
|
+
</BodyText>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Reaction buttons — incoming only */}
|
|
120
|
+
{!isOutgoing && (onThumbsUp || onThumbsDown) && (
|
|
121
|
+
<>
|
|
122
|
+
{onThumbsUp && (
|
|
123
|
+
<Pressable
|
|
124
|
+
onPress={onThumbsUp}
|
|
125
|
+
style={({ pressed }) => [
|
|
126
|
+
tokenStyles.reactionBtn,
|
|
127
|
+
pressed && styles.pressed,
|
|
128
|
+
]}
|
|
129
|
+
accessibilityRole="button"
|
|
130
|
+
accessibilityLabel="Thumbs up"
|
|
131
|
+
>
|
|
132
|
+
<Ionicons
|
|
133
|
+
name="thumbs-up-outline"
|
|
134
|
+
size={iconSize}
|
|
135
|
+
color={iconColor}
|
|
136
|
+
/>
|
|
137
|
+
</Pressable>
|
|
138
|
+
)}
|
|
139
|
+
{onThumbsDown && (
|
|
140
|
+
<Pressable
|
|
141
|
+
onPress={onThumbsDown}
|
|
142
|
+
style={({ pressed }) => [
|
|
143
|
+
tokenStyles.reactionBtn,
|
|
144
|
+
pressed && styles.pressed,
|
|
145
|
+
]}
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel="Thumbs down"
|
|
148
|
+
>
|
|
149
|
+
<Ionicons
|
|
150
|
+
name="thumbs-down-outline"
|
|
151
|
+
size={iconSize}
|
|
152
|
+
color={iconColor}
|
|
153
|
+
/>
|
|
154
|
+
</Pressable>
|
|
155
|
+
)}
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</View>
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const styles = StyleSheet.create({
|
|
165
|
+
row: {
|
|
166
|
+
flexDirection: 'row',
|
|
167
|
+
width: '100%',
|
|
168
|
+
},
|
|
169
|
+
rowIncoming: {
|
|
170
|
+
justifyContent: 'flex-start',
|
|
171
|
+
},
|
|
172
|
+
rowOutgoing: {
|
|
173
|
+
justifyContent: 'flex-end',
|
|
174
|
+
},
|
|
175
|
+
metaIncoming: {
|
|
176
|
+
justifyContent: 'flex-start',
|
|
177
|
+
},
|
|
178
|
+
metaOutgoing: {
|
|
179
|
+
justifyContent: 'flex-end',
|
|
180
|
+
},
|
|
181
|
+
messageText: {
|
|
182
|
+
flexShrink: 1,
|
|
183
|
+
},
|
|
184
|
+
pressed: {
|
|
185
|
+
opacity: 0.6,
|
|
186
|
+
},
|
|
187
|
+
});
|