@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compsych/mobile-ui",
3
- "version": "1.0.22",
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
- maxWidth: '80%' as const,
43
- paddingVertical: dim.spacing.padding.sysPadding8,
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.sysSurfaceContainerLowest,
49
- borderWidth: dim.borderWidth.sysStrokeThin,
50
- borderColor: cr.outline.sysOutlineVariant,
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
- messageText: {
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, disabled && styles.disabled, style]}>
115
- {/* Attachment strip */}
116
- {hasAttachments && (
117
- <ScrollView
118
- horizontal
119
- showsHorizontalScrollIndicator={false}
120
- style={styles.attachStrip}
121
- contentContainerStyle={styles.attachStripContent}
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
- {attachments!.map((att, i) => (
124
- <View key={i} style={styles.attachItem}>
125
- <Image
126
- source={att.source ?? { uri: att.uri }}
127
- style={tokenStyles.attachThumb}
128
- resizeMode="cover"
129
- accessibilityLabel={`Attachment ${i + 1}`}
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
- {onRemoveAttachment && (
132
- <Pressable
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
- {/* Input row */}
151
- <View
152
- style={[
153
- tokenStyles.container,
154
- styles.inputRow,
155
- isFocused && {
156
- // focus ring via transparent primary halo
157
- shadowColor: cr.accent.primary.sysPrimary,
158
- shadowOffset: { width: 0, height: 0 },
159
- shadowOpacity: 0.08,
160
- shadowRadius: 4,
161
- },
162
- ]}
163
- >
164
- {/* Attach button */}
165
- {onAttach && (
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={onAttach}
168
- disabled={disabled}
224
+ onPress={canSend ? onSend : undefined}
225
+ disabled={!canSend}
169
226
  style={({ pressed }) => [
170
- tokenStyles.attachBtn,
171
- pressed && styles.pressed,
227
+ tokenStyles.sendBtn,
228
+ pressed && canSend && styles.pressed,
172
229
  ]}
173
230
  accessibilityRole="button"
174
- accessibilityLabel="Add attachment"
231
+ accessibilityLabel="Send"
232
+ accessibilityState={{ disabled: !canSend }}
175
233
  >
176
234
  <Ionicons
177
- name="add"
235
+ name="arrow-up"
178
236
  size={ico.sysSizeSm}
179
237
  color={
180
- disabled
181
- ? cr.surface.surface.sysOnSurfaceVariant
182
- : cr.surface.surface.sysOnSurface
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: 4,
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 { LinearGradient } from 'expo-linear-gradient';
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
- paddingHorizontal:
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
- borderRadius: s.borderRadius,
244
- paddingHorizontal: s.paddingH,
245
- paddingVertical: s.paddingV,
246
- gap: dim.spacing.padding.sysPadding24,
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 = gradient ? (
271
- <LinearGradient
272
- colors={gradient}
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
- {doubledInnerContent}
278
- </LinearGradient>
279
- ) : (
280
- <View style={[doubledInnerStyle, { backgroundColor: v.innerBg }]}>
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={['rgba(0,0,0,0.4)', 'rgba(0,0,0,0.05)', 'rgba(0,0,0,0.5)']}
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 size={16} color={cr.surface.surface.sysOnSurface} strokeWidth={1.5} />
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