@compsych/mobile-ui 1.0.22 → 1.0.23
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 +4 -8
- package/src/components/ChatBubble/index.tsx +14 -16
- package/src/components/ChatInput/index.tsx +118 -109
- package/src/components/ServiceCard/index.tsx +37 -21
- package/src/components/StatusChip/StatusChip.test.tsx +47 -0
- package/src/components/StatusChip/index.tsx +90 -0
- package/src/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compsych/mobile-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.23",
|
|
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",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"test": "jest",
|
|
14
14
|
"test:watch": "jest --watch",
|
|
15
15
|
"test:coverage": "jest --coverage",
|
|
16
|
-
"prepare": "husky"
|
|
16
|
+
"prepare": "husky",
|
|
17
|
+
"postinstall": "rm -rf node_modules/react-native node_modules/react node_modules/react-native-linear-gradient node_modules/react-native-svg node_modules/lucide-react-native"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|
|
19
20
|
"react-native",
|
|
@@ -32,17 +33,12 @@
|
|
|
32
33
|
"peerDependencies": {
|
|
33
34
|
"@expo/vector-icons": ">=15.0.0",
|
|
34
35
|
"expo-font": ">=13.0.0",
|
|
35
|
-
"expo-linear-gradient": ">=13.0.0",
|
|
36
36
|
"lucide-react-native": ">=0.400.0",
|
|
37
37
|
"react": ">=18.0.0",
|
|
38
38
|
"react-native": ">=0.73.0",
|
|
39
|
+
"react-native-linear-gradient": ">=2.0.0",
|
|
39
40
|
"react-native-svg": ">=15.0.0"
|
|
40
41
|
},
|
|
41
|
-
"peerDependenciesMeta": {
|
|
42
|
-
"expo-linear-gradient": {
|
|
43
|
-
"optional": true
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
42
|
"devDependencies": {
|
|
47
43
|
"@babel/core": "^7.25.2",
|
|
48
44
|
"@babel/plugin-transform-flow-strip-types": "^7.27.1",
|
|
@@ -39,19 +39,21 @@ export function ChatBubble({
|
|
|
39
39
|
const tokenStyles = useMemo(
|
|
40
40
|
() => ({
|
|
41
41
|
bubble: {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
paddingHorizontal: dim.spacing.padding.sysPadding12,
|
|
45
|
-
borderRadius: dim.borderRadius.sysRadiusMd,
|
|
42
|
+
paddingVertical: dim.spacing.padding.sysPadding12,
|
|
43
|
+
paddingHorizontal: dim.spacing.padding.sysPadding16,
|
|
46
44
|
},
|
|
47
45
|
bubbleIncoming: {
|
|
48
|
-
backgroundColor: cr.surface.surfaceContainer.
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainer,
|
|
47
|
+
borderTopLeftRadius: dim.borderRadius.sysRadiusLg,
|
|
48
|
+
borderTopRightRadius: dim.borderRadius.sysRadiusLg,
|
|
51
49
|
borderBottomLeftRadius: dim.borderRadius.sysRadiusXs,
|
|
50
|
+
borderBottomRightRadius: dim.borderRadius.sysRadiusXs,
|
|
52
51
|
},
|
|
53
52
|
bubbleOutgoing: {
|
|
54
53
|
backgroundColor: cr.accent.primary.sysPrimary,
|
|
54
|
+
borderTopLeftRadius: dim.borderRadius.sysRadiusXl,
|
|
55
|
+
borderTopRightRadius: dim.borderRadius.sysRadiusXl,
|
|
56
|
+
borderBottomLeftRadius: dim.borderRadius.sysRadiusXl,
|
|
55
57
|
borderBottomRightRadius: dim.borderRadius.sysRadiusXs,
|
|
56
58
|
},
|
|
57
59
|
metaRow: {
|
|
@@ -84,7 +86,7 @@ export function ChatBubble({
|
|
|
84
86
|
style,
|
|
85
87
|
]}
|
|
86
88
|
>
|
|
87
|
-
<View>
|
|
89
|
+
<View style={styles.bubbleWrapper}>
|
|
88
90
|
{/* Bubble */}
|
|
89
91
|
<View
|
|
90
92
|
style={[
|
|
@@ -94,11 +96,7 @@ export function ChatBubble({
|
|
|
94
96
|
: tokenStyles.bubbleIncoming,
|
|
95
97
|
]}
|
|
96
98
|
>
|
|
97
|
-
<BodyText
|
|
98
|
-
variant="medium"
|
|
99
|
-
color={textColor}
|
|
100
|
-
style={styles.messageText}
|
|
101
|
-
>
|
|
99
|
+
<BodyText variant="medium" color={textColor}>
|
|
102
100
|
{message}
|
|
103
101
|
</BodyText>
|
|
104
102
|
</View>
|
|
@@ -168,9 +166,11 @@ const styles = StyleSheet.create({
|
|
|
168
166
|
},
|
|
169
167
|
rowIncoming: {
|
|
170
168
|
justifyContent: 'flex-start',
|
|
169
|
+
paddingRight: 64,
|
|
171
170
|
},
|
|
172
171
|
rowOutgoing: {
|
|
173
172
|
justifyContent: 'flex-end',
|
|
173
|
+
paddingLeft: 32,
|
|
174
174
|
},
|
|
175
175
|
metaIncoming: {
|
|
176
176
|
justifyContent: 'flex-start',
|
|
@@ -178,9 +178,7 @@ const styles = StyleSheet.create({
|
|
|
178
178
|
metaOutgoing: {
|
|
179
179
|
justifyContent: 'flex-end',
|
|
180
180
|
},
|
|
181
|
-
|
|
182
|
-
flexShrink: 1,
|
|
183
|
-
},
|
|
181
|
+
bubbleWrapper: {},
|
|
184
182
|
pressed: {
|
|
185
183
|
opacity: 0.6,
|
|
186
184
|
},
|
|
@@ -32,6 +32,8 @@ export interface ChatInputProps {
|
|
|
32
32
|
attachments?: ChatAttachment[];
|
|
33
33
|
onRemoveAttachment?: (index: number) => void;
|
|
34
34
|
disabled?: boolean;
|
|
35
|
+
/** Optional status banner rendered above the input (e.g. <StatusChip />) */
|
|
36
|
+
statusChip?: React.ReactNode;
|
|
35
37
|
style?: StyleProp<ViewStyle>;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -44,6 +46,7 @@ export function ChatInput({
|
|
|
44
46
|
attachments,
|
|
45
47
|
onRemoveAttachment,
|
|
46
48
|
disabled = false,
|
|
49
|
+
statusChip,
|
|
47
50
|
style,
|
|
48
51
|
}: ChatInputProps) {
|
|
49
52
|
const { colorRoles: cr, dimensions: dim, iconography: ico } = useTheme();
|
|
@@ -111,128 +114,134 @@ export function ChatInput({
|
|
|
111
114
|
);
|
|
112
115
|
|
|
113
116
|
return (
|
|
114
|
-
<View style={[styles.wrapper,
|
|
115
|
-
{/*
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
<View style={[styles.wrapper, style]}>
|
|
118
|
+
{/* Status chip */}
|
|
119
|
+
{statusChip}
|
|
120
|
+
|
|
121
|
+
{/* Input row */}
|
|
122
|
+
<View style={[disabled && styles.disabled]}>
|
|
123
|
+
{/* Attachment strip */}
|
|
124
|
+
{hasAttachments && (
|
|
125
|
+
<ScrollView
|
|
126
|
+
horizontal
|
|
127
|
+
showsHorizontalScrollIndicator={false}
|
|
128
|
+
style={styles.attachStrip}
|
|
129
|
+
contentContainerStyle={styles.attachStripContent}
|
|
130
|
+
>
|
|
131
|
+
{attachments!.map((att, i) => (
|
|
132
|
+
<View key={i} style={styles.attachItem}>
|
|
133
|
+
<Image
|
|
134
|
+
source={att.source ?? { uri: att.uri }}
|
|
135
|
+
style={tokenStyles.attachThumb}
|
|
136
|
+
resizeMode="cover"
|
|
137
|
+
accessibilityLabel={`Attachment ${i + 1}`}
|
|
138
|
+
/>
|
|
139
|
+
{onRemoveAttachment && (
|
|
140
|
+
<Pressable
|
|
141
|
+
onPress={() => onRemoveAttachment(i)}
|
|
142
|
+
style={styles.removeBtn}
|
|
143
|
+
accessibilityRole="button"
|
|
144
|
+
accessibilityLabel={`Remove attachment ${i + 1}`}
|
|
145
|
+
>
|
|
146
|
+
<Ionicons
|
|
147
|
+
name="close-circle"
|
|
148
|
+
size={16}
|
|
149
|
+
color={cr.surface.surface.sysOnSurfaceVariant}
|
|
150
|
+
/>
|
|
151
|
+
</Pressable>
|
|
152
|
+
)}
|
|
153
|
+
</View>
|
|
154
|
+
))}
|
|
155
|
+
</ScrollView>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Input row */}
|
|
159
|
+
<View
|
|
160
|
+
style={[
|
|
161
|
+
tokenStyles.container,
|
|
162
|
+
styles.inputRow,
|
|
163
|
+
isFocused && {
|
|
164
|
+
// focus ring via transparent primary halo
|
|
165
|
+
shadowColor: cr.accent.primary.sysPrimary,
|
|
166
|
+
shadowOffset: { width: 0, height: 0 },
|
|
167
|
+
shadowOpacity: 0.08,
|
|
168
|
+
shadowRadius: 4,
|
|
169
|
+
},
|
|
170
|
+
]}
|
|
122
171
|
>
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
{/* Attach button */}
|
|
173
|
+
{onAttach && (
|
|
174
|
+
<Pressable
|
|
175
|
+
onPress={onAttach}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
style={({ pressed }) => [
|
|
178
|
+
tokenStyles.attachBtn,
|
|
179
|
+
pressed && styles.pressed,
|
|
180
|
+
]}
|
|
181
|
+
accessibilityRole="button"
|
|
182
|
+
accessibilityLabel="Add attachment"
|
|
183
|
+
>
|
|
184
|
+
<Ionicons
|
|
185
|
+
name="add"
|
|
186
|
+
size={ico.sysSizeSm}
|
|
187
|
+
color={
|
|
188
|
+
disabled
|
|
189
|
+
? cr.surface.surface.sysOnSurfaceVariant
|
|
190
|
+
: cr.surface.surface.sysOnSurface
|
|
191
|
+
}
|
|
130
192
|
/>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
onPress={() => onRemoveAttachment(i)}
|
|
134
|
-
style={styles.removeBtn}
|
|
135
|
-
accessibilityRole="button"
|
|
136
|
-
accessibilityLabel={`Remove attachment ${i + 1}`}
|
|
137
|
-
>
|
|
138
|
-
<Ionicons
|
|
139
|
-
name="close-circle"
|
|
140
|
-
size={16}
|
|
141
|
-
color={cr.surface.surface.sysOnSurfaceVariant}
|
|
142
|
-
/>
|
|
143
|
-
</Pressable>
|
|
144
|
-
)}
|
|
145
|
-
</View>
|
|
146
|
-
))}
|
|
147
|
-
</ScrollView>
|
|
148
|
-
)}
|
|
193
|
+
</Pressable>
|
|
194
|
+
)}
|
|
149
195
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
196
|
+
{/* Text field */}
|
|
197
|
+
<TextInput
|
|
198
|
+
ref={inputRef}
|
|
199
|
+
value={value}
|
|
200
|
+
onChangeText={onChangeText}
|
|
201
|
+
placeholder={placeholder}
|
|
202
|
+
placeholderTextColor={cr.surface.surface.sysOnSurfaceVariant}
|
|
203
|
+
editable={!disabled}
|
|
204
|
+
multiline
|
|
205
|
+
onFocus={() => setIsFocused(true)}
|
|
206
|
+
onBlur={() => setIsFocused(false)}
|
|
207
|
+
onContentSizeChange={handleContentSizeChange}
|
|
208
|
+
scrollEnabled={inputHeight >= 120}
|
|
209
|
+
style={[
|
|
210
|
+
styles.textInput,
|
|
211
|
+
{
|
|
212
|
+
color: cr.surface.surface.sysOnSurface,
|
|
213
|
+
fontSize: 16,
|
|
214
|
+
lineHeight: 24,
|
|
215
|
+
height: inputHeight,
|
|
216
|
+
},
|
|
217
|
+
]}
|
|
218
|
+
accessibilityLabel={placeholder}
|
|
219
|
+
returnKeyType="default"
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
{/* Send button */}
|
|
166
223
|
<Pressable
|
|
167
|
-
onPress={
|
|
168
|
-
disabled={
|
|
224
|
+
onPress={canSend ? onSend : undefined}
|
|
225
|
+
disabled={!canSend}
|
|
169
226
|
style={({ pressed }) => [
|
|
170
|
-
tokenStyles.
|
|
171
|
-
pressed && styles.pressed,
|
|
227
|
+
tokenStyles.sendBtn,
|
|
228
|
+
pressed && canSend && styles.pressed,
|
|
172
229
|
]}
|
|
173
230
|
accessibilityRole="button"
|
|
174
|
-
accessibilityLabel="
|
|
231
|
+
accessibilityLabel="Send"
|
|
232
|
+
accessibilityState={{ disabled: !canSend }}
|
|
175
233
|
>
|
|
176
234
|
<Ionicons
|
|
177
|
-
name="
|
|
235
|
+
name="arrow-up"
|
|
178
236
|
size={ico.sysSizeSm}
|
|
179
237
|
color={
|
|
180
|
-
|
|
181
|
-
? cr.
|
|
182
|
-
: cr.surface.surface.
|
|
238
|
+
canSend
|
|
239
|
+
? cr.custom.warning.sysOnWarning
|
|
240
|
+
: cr.surface.surface.sysOnSurfaceVariant
|
|
183
241
|
}
|
|
184
242
|
/>
|
|
185
243
|
</Pressable>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{/* Text field */}
|
|
189
|
-
<TextInput
|
|
190
|
-
ref={inputRef}
|
|
191
|
-
value={value}
|
|
192
|
-
onChangeText={onChangeText}
|
|
193
|
-
placeholder={placeholder}
|
|
194
|
-
placeholderTextColor={cr.surface.surface.sysOnSurfaceVariant}
|
|
195
|
-
editable={!disabled}
|
|
196
|
-
multiline
|
|
197
|
-
onFocus={() => setIsFocused(true)}
|
|
198
|
-
onBlur={() => setIsFocused(false)}
|
|
199
|
-
onContentSizeChange={handleContentSizeChange}
|
|
200
|
-
scrollEnabled={inputHeight >= 120}
|
|
201
|
-
style={[
|
|
202
|
-
styles.textInput,
|
|
203
|
-
{
|
|
204
|
-
color: cr.surface.surface.sysOnSurface,
|
|
205
|
-
fontSize: 16,
|
|
206
|
-
lineHeight: 24,
|
|
207
|
-
height: inputHeight,
|
|
208
|
-
},
|
|
209
|
-
]}
|
|
210
|
-
accessibilityLabel={placeholder}
|
|
211
|
-
returnKeyType="default"
|
|
212
|
-
/>
|
|
213
|
-
|
|
214
|
-
{/* Send button */}
|
|
215
|
-
<Pressable
|
|
216
|
-
onPress={canSend ? onSend : undefined}
|
|
217
|
-
disabled={!canSend}
|
|
218
|
-
style={({ pressed }) => [
|
|
219
|
-
tokenStyles.sendBtn,
|
|
220
|
-
pressed && canSend && styles.pressed,
|
|
221
|
-
]}
|
|
222
|
-
accessibilityRole="button"
|
|
223
|
-
accessibilityLabel="Send"
|
|
224
|
-
accessibilityState={{ disabled: !canSend }}
|
|
225
|
-
>
|
|
226
|
-
<Ionicons
|
|
227
|
-
name="arrow-up"
|
|
228
|
-
size={ico.sysSizeSm}
|
|
229
|
-
color={
|
|
230
|
-
canSend
|
|
231
|
-
? cr.custom.warning.sysOnWarning
|
|
232
|
-
: cr.surface.surface.sysOnSurfaceVariant
|
|
233
|
-
}
|
|
234
|
-
/>
|
|
235
|
-
</Pressable>
|
|
244
|
+
</View>
|
|
236
245
|
</View>
|
|
237
246
|
</View>
|
|
238
247
|
);
|
|
@@ -240,7 +249,7 @@ export function ChatInput({
|
|
|
240
249
|
|
|
241
250
|
const styles = StyleSheet.create({
|
|
242
251
|
wrapper: {
|
|
243
|
-
gap:
|
|
252
|
+
gap: 12,
|
|
244
253
|
},
|
|
245
254
|
disabled: {
|
|
246
255
|
opacity: 0.48,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
ViewStyle,
|
|
11
11
|
} from 'react-native';
|
|
12
12
|
|
|
13
|
-
import
|
|
13
|
+
import LinearGradient from 'react-native-linear-gradient';
|
|
14
14
|
|
|
15
15
|
import { type IconName, SIZE_MAP, resolveIcon } from '../../icons';
|
|
16
16
|
import { useTheme } from '../../theme';
|
|
@@ -202,13 +202,14 @@ export function ServiceCard({
|
|
|
202
202
|
borderWidth: v.borderWidth,
|
|
203
203
|
borderRadius:
|
|
204
204
|
variant === 'doubled' ? dim.borderRadius.sysRadiusXl : s.borderRadius,
|
|
205
|
-
|
|
206
|
-
variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingH,
|
|
207
|
-
paddingVertical:
|
|
208
|
-
variant === 'doubled' ? dim.spacing.padding.sysPadding8 : s.paddingV,
|
|
205
|
+
|
|
209
206
|
opacity: disabled ? 0.48 : 1,
|
|
210
207
|
},
|
|
211
208
|
v.elevated && ELEVATION,
|
|
209
|
+
variant !== 'doubled' && {
|
|
210
|
+
paddingHorizontal: s.paddingH,
|
|
211
|
+
paddingVertical: s.paddingV,
|
|
212
|
+
},
|
|
212
213
|
fullWidth && { alignSelf: 'stretch' as const },
|
|
213
214
|
isRow ? styles.rowRoot : styles.colRoot,
|
|
214
215
|
!isRow && variant !== 'image' && { gap: s.gap },
|
|
@@ -240,10 +241,13 @@ export function ServiceCard({
|
|
|
240
241
|
const doubledInnerStyle: StyleProp<ViewStyle> = [
|
|
241
242
|
styles.colRoot,
|
|
242
243
|
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
alignSelf: 'stretch',
|
|
245
|
+
borderRadius: dim.borderRadius.sysRadiusXl,
|
|
246
|
+
borderColor: cr.accent.primary.sysOnPrimary,
|
|
247
|
+
borderWidth: dim.spacing.padding.sysPadding8,
|
|
248
|
+
paddingHorizontal: dim.spacing.padding.sysPadding8,
|
|
249
|
+
paddingVertical: dim.spacing.padding.sysPadding16,
|
|
250
|
+
gap: dim.spacing.padding.sysPadding12,
|
|
247
251
|
},
|
|
248
252
|
];
|
|
249
253
|
|
|
@@ -267,17 +271,21 @@ export function ServiceCard({
|
|
|
267
271
|
</>
|
|
268
272
|
);
|
|
269
273
|
|
|
270
|
-
inner =
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
start={gradientStart ?? { x: 0, y: 0 }}
|
|
274
|
-
end={gradientEnd ?? { x: 0, y: 1 }}
|
|
275
|
-
style={doubledInnerStyle}
|
|
274
|
+
inner = (
|
|
275
|
+
<View
|
|
276
|
+
style={[doubledInnerStyle, !gradient && { backgroundColor: v.innerBg }]}
|
|
276
277
|
>
|
|
277
|
-
{
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
278
|
+
{gradient && (
|
|
279
|
+
<LinearGradient
|
|
280
|
+
colors={gradient}
|
|
281
|
+
start={gradientStart ?? { x: 0, y: 0 }}
|
|
282
|
+
end={gradientEnd ?? { x: 0, y: 1 }}
|
|
283
|
+
style={[
|
|
284
|
+
StyleSheet.absoluteFillObject,
|
|
285
|
+
{ borderRadius: dim.borderRadius.sysRadiusLg },
|
|
286
|
+
]}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
281
289
|
{doubledInnerContent}
|
|
282
290
|
</View>
|
|
283
291
|
);
|
|
@@ -296,7 +304,11 @@ export function ServiceCard({
|
|
|
296
304
|
accessible={false}
|
|
297
305
|
/>
|
|
298
306
|
<LinearGradient
|
|
299
|
-
colors={[
|
|
307
|
+
colors={[
|
|
308
|
+
'rgba(0,0,0,0.4)',
|
|
309
|
+
'rgba(0,0,0,0.05)',
|
|
310
|
+
'rgba(0,0,0,0.5)',
|
|
311
|
+
]}
|
|
300
312
|
locations={[0.012, 0.268, 1]}
|
|
301
313
|
style={[
|
|
302
314
|
{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
|
|
@@ -313,7 +325,11 @@ export function ServiceCard({
|
|
|
313
325
|
<View style={{ flex: 1 }}>{textBlock}</View>
|
|
314
326
|
{onPress && ChevronRight && (
|
|
315
327
|
<View style={styles.imageArrowButton}>
|
|
316
|
-
<ChevronRight
|
|
328
|
+
<ChevronRight
|
|
329
|
+
size={16}
|
|
330
|
+
color={cr.surface.surface.sysOnSurface}
|
|
331
|
+
strokeWidth={1.5}
|
|
332
|
+
/>
|
|
317
333
|
</View>
|
|
318
334
|
)}
|
|
319
335
|
</View>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { ThemeProvider } from '../../theme';
|
|
6
|
+
import { StatusChip } from './index';
|
|
7
|
+
|
|
8
|
+
function renderWithTheme(ui: React.ReactElement) {
|
|
9
|
+
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('StatusChip', () => {
|
|
13
|
+
it('renders the label', () => {
|
|
14
|
+
renderWithTheme(<StatusChip label="10 minutes remaining" />);
|
|
15
|
+
expect(screen.getByText('10 minutes remaining')).toBeTruthy();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders warning variant by default', () => {
|
|
19
|
+
renderWithTheme(<StatusChip label="Warning" />);
|
|
20
|
+
expect(screen.getByText('Warning')).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders error variant', () => {
|
|
24
|
+
renderWithTheme(<StatusChip label="Session ended" variant="error" />);
|
|
25
|
+
expect(screen.getByText('Session ended')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('accepts a custom icon', () => {
|
|
29
|
+
renderWithTheme(<StatusChip label="Custom" icon="TimerIcon" />);
|
|
30
|
+
expect(screen.getByText('Custom')).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders with left alignment', () => {
|
|
34
|
+
renderWithTheme(<StatusChip label="Left" align="left" />);
|
|
35
|
+
expect(screen.getByText('Left')).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders with right alignment', () => {
|
|
39
|
+
renderWithTheme(<StatusChip label="Right" align="right" />);
|
|
40
|
+
expect(screen.getByText('Right')).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders fullWidth', () => {
|
|
44
|
+
renderWithTheme(<StatusChip label="Full" fullWidth />);
|
|
45
|
+
expect(screen.getByText('Full')).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { type IconName, SIZE_MAP, resolveIcon } from '../../icons';
|
|
6
|
+
import { useTheme } from '../../theme';
|
|
7
|
+
import { BodyText } from '../BodyText';
|
|
8
|
+
|
|
9
|
+
export type StatusChipVariant = 'warning' | 'error';
|
|
10
|
+
export type StatusChipAlign = 'left' | 'center' | 'right';
|
|
11
|
+
|
|
12
|
+
export interface StatusChipProps {
|
|
13
|
+
label: string;
|
|
14
|
+
variant?: StatusChipVariant;
|
|
15
|
+
/** Icon name — defaults to ClockFadingIcon (warning) or ClockAlertIcon (error) */
|
|
16
|
+
icon?: IconName;
|
|
17
|
+
align?: StatusChipAlign;
|
|
18
|
+
fullWidth?: boolean;
|
|
19
|
+
style?: StyleProp<ViewStyle>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ICONS: Record<StatusChipVariant, IconName> = {
|
|
23
|
+
warning: 'ClockFadingIcon',
|
|
24
|
+
error: 'ClockAlertIcon',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const JUSTIFY: Record<StatusChipAlign, 'flex-start' | 'center' | 'flex-end'> = {
|
|
28
|
+
left: 'flex-start',
|
|
29
|
+
center: 'center',
|
|
30
|
+
right: 'flex-end',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function StatusChip({
|
|
34
|
+
label,
|
|
35
|
+
variant = 'warning',
|
|
36
|
+
icon,
|
|
37
|
+
align = 'center',
|
|
38
|
+
fullWidth = false,
|
|
39
|
+
style,
|
|
40
|
+
}: StatusChipProps) {
|
|
41
|
+
const { colorRoles: cr } = useTheme();
|
|
42
|
+
|
|
43
|
+
const colors =
|
|
44
|
+
variant === 'warning'
|
|
45
|
+
? {
|
|
46
|
+
bg: cr.custom.warning.sysWarningContainer,
|
|
47
|
+
text: cr.custom.warning.sysOnWarningContainer,
|
|
48
|
+
}
|
|
49
|
+
: {
|
|
50
|
+
bg: cr.error.sysErrorContainer,
|
|
51
|
+
text: cr.error.sysOnErrorContainer,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const iconName = icon ?? DEFAULT_ICONS[variant];
|
|
55
|
+
const IconComponent = resolveIcon(iconName);
|
|
56
|
+
const { size: iconPx, strokeWidth: iconSW } = SIZE_MAP['small'];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View
|
|
60
|
+
style={[
|
|
61
|
+
styles.chip,
|
|
62
|
+
{
|
|
63
|
+
backgroundColor: colors.bg,
|
|
64
|
+
justifyContent: JUSTIFY[align],
|
|
65
|
+
alignSelf: fullWidth ? ('stretch' as const) : ('flex-start' as const),
|
|
66
|
+
},
|
|
67
|
+
style,
|
|
68
|
+
]}
|
|
69
|
+
>
|
|
70
|
+
{IconComponent && (
|
|
71
|
+
<IconComponent size={iconPx} strokeWidth={iconSW} color={colors.text} />
|
|
72
|
+
)}
|
|
73
|
+
<BodyText variant="labelMedium" color={colors.text} numberOfLines={1}>
|
|
74
|
+
{label}
|
|
75
|
+
</BodyText>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const styles = StyleSheet.create({
|
|
81
|
+
chip: {
|
|
82
|
+
flexDirection: 'row',
|
|
83
|
+
alignItems: 'center',
|
|
84
|
+
borderRadius: 9999,
|
|
85
|
+
paddingHorizontal: 16,
|
|
86
|
+
paddingVertical: 4,
|
|
87
|
+
gap: 8,
|
|
88
|
+
height: 32,
|
|
89
|
+
},
|
|
90
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,13 @@ export type {
|
|
|
132
132
|
export { ChatInput } from './components/ChatInput';
|
|
133
133
|
export type { ChatInputProps, ChatAttachment } from './components/ChatInput';
|
|
134
134
|
|
|
135
|
+
export { StatusChip } from './components/StatusChip';
|
|
136
|
+
export type {
|
|
137
|
+
StatusChipProps,
|
|
138
|
+
StatusChipVariant,
|
|
139
|
+
StatusChipAlign,
|
|
140
|
+
} from './components/StatusChip';
|
|
141
|
+
|
|
135
142
|
export { BodyText } from './components/BodyText';
|
|
136
143
|
export type { BodyTextProps, BodyVariant } from './components/BodyText';
|
|
137
144
|
|