@compsych/mobile-ui 1.0.14 → 1.0.16

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.14",
3
+ "version": "1.0.16",
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",
@@ -32,11 +32,17 @@
32
32
  "peerDependencies": {
33
33
  "@expo/vector-icons": ">=15.0.0",
34
34
  "expo-font": ">=13.0.0",
35
+ "expo-linear-gradient": ">=13.0.0",
35
36
  "lucide-react-native": ">=0.400.0",
36
37
  "react": ">=18.0.0",
37
38
  "react-native": ">=0.73.0",
38
39
  "react-native-svg": ">=15.0.0"
39
40
  },
41
+ "peerDependenciesMeta": {
42
+ "expo-linear-gradient": {
43
+ "optional": true
44
+ }
45
+ },
40
46
  "devDependencies": {
41
47
  "@babel/core": "^7.25.2",
42
48
  "@babel/plugin-transform-flow-strip-types": "^7.27.1",
@@ -52,6 +58,7 @@
52
58
  "babel-jest": "^29.7.0",
53
59
  "eslint": "^9.39.4",
54
60
  "eslint-plugin-unused-imports": "^4.4.1",
61
+ "expo-linear-gradient": "^56.0.4",
55
62
  "husky": "^9.1.7",
56
63
  "jest": "^29.7.0",
57
64
  "jest-expo": "^55.0.17",
@@ -1,13 +1,15 @@
1
- import React, { useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
2
 
3
3
  import {
4
4
  Image,
5
5
  ImageSourcePropType,
6
+ NativeSyntheticEvent,
6
7
  Pressable,
7
8
  ScrollView,
8
9
  StyleProp,
9
10
  StyleSheet,
10
11
  TextInput,
12
+ TextInputContentSizeChangeEventData,
11
13
  View,
12
14
  ViewStyle,
13
15
  } from 'react-native';
@@ -47,6 +49,18 @@ export function ChatInput({
47
49
  const { colorRoles: cr, dimensions: dim, iconography: ico } = useTheme();
48
50
  const [isFocused, setIsFocused] = useState(false);
49
51
  const inputRef = useRef<TextInput>(null);
52
+ const [inputHeight, setInputHeight] = useState(24);
53
+
54
+ const handleContentSizeChange = useCallback(
55
+ (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
56
+ const newHeight = Math.max(
57
+ 24,
58
+ Math.min(120, e.nativeEvent.contentSize.height),
59
+ );
60
+ setInputHeight(newHeight);
61
+ },
62
+ [],
63
+ );
50
64
 
51
65
  const hasText = value.trim().length > 0;
52
66
  const hasAttachments = !!attachments && attachments.length > 0;
@@ -182,12 +196,15 @@ export function ChatInput({
182
196
  multiline
183
197
  onFocus={() => setIsFocused(true)}
184
198
  onBlur={() => setIsFocused(false)}
199
+ onContentSizeChange={handleContentSizeChange}
200
+ scrollEnabled={inputHeight >= 120}
185
201
  style={[
186
202
  styles.textInput,
187
203
  {
188
204
  color: cr.surface.surface.sysOnSurface,
189
205
  fontSize: 16,
190
206
  lineHeight: 24,
207
+ height: inputHeight,
191
208
  },
192
209
  ]}
193
210
  accessibilityLabel={placeholder}
@@ -237,7 +254,6 @@ const styles = StyleSheet.create({
237
254
  includeFontPadding: false,
238
255
  padding: 0,
239
256
  margin: 0,
240
- maxHeight: 120,
241
257
  },
242
258
  attachStrip: {
243
259
  flexShrink: 0,
@@ -10,6 +10,8 @@ import {
10
10
  ViewStyle,
11
11
  } from 'react-native';
12
12
 
13
+ import { LinearGradient } from 'expo-linear-gradient';
14
+
13
15
  import { type IconName, SIZE_MAP, resolveIcon } from '../../icons';
14
16
  import { useTheme } from '../../theme';
15
17
  import { BodyText } from '../BodyText';
@@ -37,6 +39,12 @@ export interface ServiceCardProps {
37
39
  accessibilityLabel?: string;
38
40
  fullWidth?: boolean;
39
41
  style?: StyleProp<ViewStyle>;
42
+ /** Gradient colors for the "doubled" variant inner card (e.g. ['#a855f7', '#6366f1']). Falls back to the solid token color when omitted. */
43
+ gradient?: readonly [string, string, ...string[]];
44
+ /** Start point of the gradient in 0–1 coordinate space. Defaults to { x: 0, y: 0 } (top). */
45
+ gradientStart?: { x: number; y: number };
46
+ /** End point of the gradient in 0–1 coordinate space. Defaults to { x: 0, y: 1 } (bottom). */
47
+ gradientEnd?: { x: number; y: number };
40
48
  }
41
49
 
42
50
  const ELEVATION = {
@@ -64,6 +72,9 @@ export function ServiceCard({
64
72
  accessibilityLabel,
65
73
  fullWidth = false,
66
74
  style,
75
+ gradient,
76
+ gradientStart,
77
+ gradientEnd,
67
78
  }: ServiceCardProps) {
68
79
  const { colorRoles: cr, dimensions: dim } = useTheme();
69
80
 
@@ -225,19 +236,18 @@ export function ServiceCard({
225
236
  let inner: React.ReactNode;
226
237
 
227
238
  if (variant === 'doubled') {
228
- inner = (
229
- <View
230
- style={[
231
- styles.colRoot,
232
- {
233
- backgroundColor: v.innerBg,
234
- borderRadius: s.borderRadius,
235
- paddingHorizontal: s.paddingH,
236
- paddingVertical: s.paddingV,
237
- gap: dim.spacing.padding.sysPadding24,
238
- },
239
- ]}
240
- >
239
+ const doubledInnerStyle: StyleProp<ViewStyle> = [
240
+ styles.colRoot,
241
+ {
242
+ borderRadius: s.borderRadius,
243
+ paddingHorizontal: s.paddingH,
244
+ paddingVertical: s.paddingV,
245
+ gap: dim.spacing.padding.sysPadding24,
246
+ },
247
+ ];
248
+
249
+ const doubledInnerContent = (
250
+ <>
241
251
  {renderedIcon && (
242
252
  <View
243
253
  style={[
@@ -253,6 +263,21 @@ export function ServiceCard({
253
263
  )}
254
264
  {textBlock}
255
265
  {children}
266
+ </>
267
+ );
268
+
269
+ inner = gradient ? (
270
+ <LinearGradient
271
+ colors={gradient}
272
+ start={gradientStart ?? { x: 0, y: 0 }}
273
+ end={gradientEnd ?? { x: 0, y: 1 }}
274
+ style={doubledInnerStyle}
275
+ >
276
+ {doubledInnerContent}
277
+ </LinearGradient>
278
+ ) : (
279
+ <View style={[doubledInnerStyle, { backgroundColor: v.innerBg }]}>
280
+ {doubledInnerContent}
256
281
  </View>
257
282
  );
258
283
  } else if (variant === 'image') {
package/src/index.ts CHANGED
@@ -35,14 +35,6 @@ export type {
35
35
  ServiceCardSize,
36
36
  } from './components/ServiceCard';
37
37
 
38
- export { PromotionCard } from './components/PromotionCard';
39
- export type {
40
- PromotionCardProps,
41
- PromotionCardVariant,
42
- PromotionCardUsage,
43
- PromotionCardSize,
44
- } from './components/PromotionCard';
45
-
46
38
  export { Checkbox } from './components/Checkbox';
47
39
  export type {
48
40
  CheckboxProps,
@@ -1,73 +0,0 @@
1
- import React from 'react';
2
-
3
- import { fireEvent, render } from '@testing-library/react-native';
4
-
5
- import { PromotionCard } from './index';
6
-
7
- describe('PromotionCard', () => {
8
- it('renders title and description', () => {
9
- const { getByText } = render(
10
- <PromotionCard title="New feature" description="Check it out" />,
11
- );
12
- expect(getByText('New feature')).toBeTruthy();
13
- expect(getByText('Check it out')).toBeTruthy();
14
- });
15
-
16
- it('renders chip label when provided', () => {
17
- const { getByText } = render(
18
- <PromotionCard title="Card" chipLabel="NEW" />,
19
- );
20
- expect(getByText('NEW')).toBeTruthy();
21
- });
22
-
23
- it('calls onPress when tapped', () => {
24
- const onPress = jest.fn();
25
- const { getByRole } = render(
26
- <PromotionCard
27
- title="Promo"
28
- accessibilityLabel="Promo card"
29
- onPress={onPress}
30
- />,
31
- );
32
- fireEvent.press(getByRole('button', { name: 'Promo card' }));
33
- expect(onPress).toHaveBeenCalledTimes(1);
34
- });
35
-
36
- it('renders disabled state without crashing', () => {
37
- const { toJSON } = render(
38
- <PromotionCard
39
- title="Promo"
40
- accessibilityLabel="Promo card"
41
- onPress={jest.fn()}
42
- disabled
43
- />,
44
- );
45
- expect(toJSON()).toBeTruthy();
46
- });
47
-
48
- it.each(['filled', 'tonal'] as const)(
49
- 'renders variant "%s" without crashing',
50
- (variant) => {
51
- const { toJSON } = render(
52
- <PromotionCard title="Card" variant={variant} />,
53
- );
54
- expect(toJSON()).toBeTruthy();
55
- },
56
- );
57
-
58
- it.each(['md', 'lg'] as const)(
59
- 'renders size "%s" without crashing',
60
- (size) => {
61
- const { toJSON } = render(<PromotionCard title="Card" size={size} />);
62
- expect(toJSON()).toBeTruthy();
63
- },
64
- );
65
-
66
- it.each(['neutral', 'informative', 'positive', 'danger', 'warning'] as const)(
67
- 'renders usage "%s" without crashing',
68
- (usage) => {
69
- const { toJSON } = render(<PromotionCard title="Card" usage={usage} />);
70
- expect(toJSON()).toBeTruthy();
71
- },
72
- );
73
- });
@@ -1,543 +0,0 @@
1
- import React, { useMemo } from 'react';
2
-
3
- import {
4
- Image,
5
- type ImageSourcePropType,
6
- Pressable,
7
- StyleProp,
8
- StyleSheet,
9
- View,
10
- ViewStyle,
11
- } from 'react-native';
12
-
13
- import { Ionicons } from '@expo/vector-icons';
14
- import Svg, { Circle as SvgCircle } from 'react-native-svg';
15
-
16
- import { useTheme } from '../../theme';
17
- import { BodyText } from '../BodyText';
18
- import { HeaderText } from '../HeaderText';
19
-
20
- export type PromotionCardVariant = 'filled' | 'tonal';
21
- export type PromotionCardUsage =
22
- | 'neutral'
23
- | 'informative'
24
- | 'positive'
25
- | 'danger'
26
- | 'warning'
27
- | 'image';
28
- export type PromotionCardSize = 'md' | 'lg';
29
-
30
- export interface PromotionCardProps {
31
- size?: PromotionCardSize;
32
- variant?: PromotionCardVariant;
33
- usage?: PromotionCardUsage;
34
- title?: string;
35
- description?: string;
36
- chipLabel?: string;
37
- chipIcon?: React.ReactNode;
38
- image?: ImageSourcePropType;
39
- progress?: number;
40
- showProgressBar?: boolean;
41
- showRingTimer?: boolean;
42
- onPress?: () => void;
43
- disabled?: boolean;
44
- accessibilityLabel?: string;
45
- style?: StyleProp<ViewStyle>;
46
- }
47
-
48
- type VariantTokens = {
49
- cardBg: string;
50
- chipBg: string;
51
- chipTextColor: string;
52
- titleColor: string;
53
- descColor: string;
54
- descOpacity: number;
55
- buttonBg: string;
56
- buttonIconColor: string;
57
- progressColor: string;
58
- };
59
-
60
- const MD_HEIGHT = 197;
61
- const LG_HEIGHT = 363;
62
- const MD_BUTTON = 32;
63
- const LG_BUTTON = 48;
64
- const MD_RING_R = 14;
65
- const LG_RING_R = 21;
66
- const MD_RING_C = 2 * Math.PI * MD_RING_R;
67
- const LG_RING_C = 2 * Math.PI * LG_RING_R;
68
- const LG_DOTS_BOTTOM = 53;
69
- const LG_DOTS_LEFT = 32;
70
- const LG_DOT_SIZE = 6;
71
- const LG_DOT_GAP = 4;
72
- const LG_DOT_COUNT = 5;
73
-
74
- export function PromotionCard({
75
- size = 'md',
76
- variant = 'filled',
77
- usage = 'neutral',
78
- title,
79
- description,
80
- chipLabel,
81
- chipIcon,
82
- image,
83
- progress = 0.25,
84
- showProgressBar = true,
85
- showRingTimer = true,
86
- onPress,
87
- disabled = false,
88
- accessibilityLabel,
89
- style,
90
- }: PromotionCardProps) {
91
- const { colorRoles: cr, dimensions: dim } = useTheme();
92
-
93
- const tokens = useMemo(
94
- (): Record<
95
- PromotionCardVariant,
96
- Record<PromotionCardUsage, VariantTokens>
97
- > => ({
98
- filled: {
99
- neutral: {
100
- cardBg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
101
- chipBg: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
102
- chipTextColor: cr.surface.surface.sysOnSurface,
103
- titleColor: '#0f0f0f',
104
- descColor: cr.surface.surface.sysOnSurfaceVariant,
105
- descOpacity: 1,
106
- buttonBg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
107
- buttonIconColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
108
- progressColor: cr.accent.primary.sysPrimary,
109
- },
110
- informative: {
111
- cardBg: cr.custom.info.sysOnInfoContainer,
112
- chipBg: cr.custom.info.sysInfoContainer,
113
- chipTextColor: cr.custom.info.sysOnInfoContainer,
114
- titleColor: cr.custom.info.sysOnInfo,
115
- descColor: cr.transparent.neutral.sysWhite80,
116
- descOpacity: 1,
117
- buttonBg: cr.custom.info.sysOnInfo,
118
- buttonIconColor: cr.custom.info.sysOnInfoContainer,
119
- progressColor: cr.custom.info.sysOnInfo,
120
- },
121
- positive: {
122
- cardBg: cr.custom.success.sysOnSuccessContainer,
123
- chipBg: cr.custom.success.sysSuccessContainer,
124
- chipTextColor: cr.custom.success.sysOnSuccessContainer,
125
- titleColor: cr.custom.success.sysOnSuccess,
126
- descColor: cr.transparent.neutral.sysWhite80,
127
- descOpacity: 1,
128
- buttonBg: cr.custom.success.sysOnSuccess,
129
- buttonIconColor: cr.custom.success.sysOnSuccessContainer,
130
- progressColor: cr.custom.success.sysOnSuccess,
131
- },
132
- danger: {
133
- cardBg: cr.error.sysOnErrorContainer,
134
- chipBg: cr.error.sysErrorContainer,
135
- chipTextColor: cr.error.sysOnErrorContainer,
136
- titleColor: cr.error.sysOnError,
137
- descColor: cr.transparent.neutral.sysWhite80,
138
- descOpacity: 1,
139
- buttonBg: cr.error.sysOnError,
140
- buttonIconColor: cr.error.sysOnErrorContainer,
141
- progressColor: cr.error.sysOnError,
142
- },
143
- warning: {
144
- cardBg: cr.custom.warning.sysOnWarningContainer,
145
- chipBg: cr.custom.warning.sysWarningContainer,
146
- chipTextColor: cr.custom.warning.sysOnWarningContainer,
147
- titleColor: cr.custom.warning.sysOnWarning,
148
- descColor: cr.transparent.neutral.sysWhite80,
149
- descOpacity: 1,
150
- buttonBg: cr.custom.warning.sysOnWarning,
151
- buttonIconColor: cr.custom.warning.sysOnWarningContainer,
152
- progressColor: cr.custom.warning.sysOnWarning,
153
- },
154
- image: {
155
- cardBg: 'transparent',
156
- chipBg: 'rgba(255,255,255,0.2)',
157
- chipTextColor: cr.surface.inverse.sysInverseOnSurface,
158
- titleColor: cr.accent.primary.sysOnPrimary,
159
- descColor: cr.transparent.neutral.sysWhite80,
160
- descOpacity: 1,
161
- buttonBg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
162
- buttonIconColor: cr.surface.surface.sysOnSurface,
163
- progressColor: cr.accent.primary.sysOnPrimary,
164
- },
165
- },
166
- tonal: {
167
- neutral: {
168
- cardBg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
169
- chipBg: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
170
- chipTextColor: cr.surface.surface.sysOnSurface,
171
- titleColor: '#0f0f0f',
172
- descColor: cr.surface.surface.sysOnSurfaceVariant,
173
- descOpacity: 1,
174
- buttonBg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
175
- buttonIconColor: cr.addOn.primaryFixed.sysOnPrimaryFixed,
176
- progressColor: cr.accent.primary.sysPrimary,
177
- },
178
- informative: {
179
- cardBg: cr.custom.info.sysInfoContainer,
180
- chipBg: cr.custom.info.sysOnInfo,
181
- chipTextColor: cr.custom.info.sysOnInfoContainer,
182
- titleColor: cr.custom.info.sysOnInfoContainer,
183
- descColor: cr.custom.info.sysOnInfoContainer,
184
- descOpacity: 0.64,
185
- buttonBg: cr.custom.info.sysInfo,
186
- buttonIconColor: cr.custom.info.sysOnInfo,
187
- progressColor: cr.custom.info.sysInfo,
188
- },
189
- positive: {
190
- cardBg: cr.custom.success.sysSuccessContainer,
191
- chipBg: cr.custom.success.sysOnSuccess,
192
- chipTextColor: cr.custom.success.sysOnSuccessContainer,
193
- titleColor: cr.custom.success.sysOnSuccessContainer,
194
- descColor: cr.custom.success.sysOnSuccessContainer,
195
- descOpacity: 0.64,
196
- buttonBg: cr.custom.success.sysSuccess,
197
- buttonIconColor: cr.custom.success.sysOnSuccess,
198
- progressColor: cr.custom.success.sysSuccess,
199
- },
200
- danger: {
201
- cardBg: cr.error.sysErrorContainer,
202
- chipBg: cr.error.sysOnError,
203
- chipTextColor: cr.error.sysOnErrorContainer,
204
- titleColor: cr.error.sysOnErrorContainer,
205
- descColor: cr.error.sysOnErrorContainer,
206
- descOpacity: 0.64,
207
- buttonBg: cr.error.sysError,
208
- buttonIconColor: cr.error.sysOnError,
209
- progressColor: cr.error.sysError,
210
- },
211
- warning: {
212
- cardBg: cr.custom.warning.sysWarningContainer,
213
- chipBg: cr.custom.warning.sysOnWarning,
214
- chipTextColor: cr.custom.warning.sysOnWarningContainer,
215
- titleColor: cr.custom.warning.sysOnWarningContainer,
216
- descColor: cr.custom.warning.sysOnWarningContainer,
217
- descOpacity: 0.64,
218
- buttonBg: cr.custom.warning.sysWarning,
219
- buttonIconColor: cr.custom.warning.sysOnWarning,
220
- progressColor: cr.custom.warning.sysWarning,
221
- },
222
- image: {
223
- cardBg: 'transparent',
224
- chipBg: 'rgba(255,255,255,0.2)',
225
- chipTextColor: cr.surface.inverse.sysInverseOnSurface,
226
- titleColor: cr.accent.primary.sysOnPrimary,
227
- descColor: cr.transparent.neutral.sysWhite80,
228
- descOpacity: 1,
229
- buttonBg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
230
- buttonIconColor: cr.surface.surface.sysOnSurface,
231
- progressColor: cr.accent.primary.sysOnPrimary,
232
- },
233
- },
234
- }),
235
- [cr],
236
- );
237
-
238
- // lg tokens: neutral uses filled, others use tonal
239
- function getLgTokens(u: PromotionCardUsage): VariantTokens {
240
- if (u === 'neutral') return tokens.filled.neutral;
241
- return tokens.tonal[u];
242
- }
243
-
244
- const isLg = size === 'lg';
245
- const t = isLg ? getLgTokens(usage) : tokens[variant][usage];
246
- const isImage = usage === 'image';
247
- const p = Math.max(0, Math.min(1, progress));
248
-
249
- const buttonSize = isLg ? LG_BUTTON : MD_BUTTON;
250
- const ringR = isLg ? LG_RING_R : MD_RING_R;
251
- const ringC = isLg ? LG_RING_C : MD_RING_C;
252
- const ringArcLength = ringC * p;
253
-
254
- const containerStyle = [
255
- styles.card,
256
- isLg
257
- ? {
258
- height: LG_HEIGHT,
259
- borderRadius: dim.borderRadius.sysRadiusXl,
260
- padding: dim.spacing.padding.sysPadding32,
261
- flexDirection: 'column' as const,
262
- gap: dim.spacing.padding.sysPadding24,
263
- }
264
- : {
265
- height: MD_HEIGHT,
266
- borderRadius: dim.borderRadius.sysRadiusLg,
267
- paddingHorizontal: dim.spacing.padding.sysPadding16,
268
- paddingVertical: dim.spacing.padding.sysPadding16,
269
- justifyContent: 'space-between' as const,
270
- },
271
- { backgroundColor: t.cardBg, opacity: disabled ? 0.48 : 1 },
272
- style,
273
- ];
274
-
275
- const inner = (
276
- <>
277
- {isImage && image && (
278
- <>
279
- <Image
280
- source={image}
281
- style={[
282
- StyleSheet.absoluteFillObject,
283
- {
284
- borderRadius: isLg
285
- ? dim.borderRadius.sysRadiusXl
286
- : dim.borderRadius.sysRadiusLg,
287
- },
288
- ]}
289
- resizeMode="cover"
290
- accessible={false}
291
- />
292
- <View
293
- style={[
294
- StyleSheet.absoluteFillObject,
295
- {
296
- borderRadius: isLg
297
- ? dim.borderRadius.sysRadiusXl
298
- : dim.borderRadius.sysRadiusLg,
299
- backgroundColor: 'rgba(0,0,0,0.35)',
300
- },
301
- ]}
302
- pointerEvents="none"
303
- />
304
- </>
305
- )}
306
-
307
- {/* Chip */}
308
- {chipLabel && (
309
- <View
310
- style={[
311
- isLg
312
- ? {
313
- flexDirection: 'row' as const,
314
- alignItems: 'center' as const,
315
- alignSelf: 'flex-start' as const,
316
- borderRadius: dim.borderRadius.sysRadiusFull,
317
- paddingHorizontal: dim.spacing.padding.sysPadding16,
318
- paddingVertical: dim.spacing.padding.sysPadding4,
319
- gap: dim.spacing.padding.sysPadding8,
320
- height: 32,
321
- }
322
- : {
323
- flexDirection: 'row' as const,
324
- alignItems: 'center' as const,
325
- alignSelf: 'flex-start' as const,
326
- borderRadius: dim.borderRadius.sysRadiusFull,
327
- paddingHorizontal: dim.spacing.padding.sysPadding12,
328
- paddingVertical: dim.spacing.padding.sysPadding4,
329
- gap: dim.spacing.padding.sysPadding4,
330
- height: 24,
331
- },
332
- { backgroundColor: t.chipBg },
333
- ]}
334
- >
335
- {chipIcon && (
336
- <View
337
- style={
338
- isLg
339
- ? {
340
- width: 20,
341
- height: 20,
342
- alignItems: 'center',
343
- justifyContent: 'center',
344
- }
345
- : {
346
- width: 16,
347
- height: 16,
348
- alignItems: 'center',
349
- justifyContent: 'center',
350
- }
351
- }
352
- >
353
- {chipIcon}
354
- </View>
355
- )}
356
- <BodyText variant="labelMedium" color={t.chipTextColor}>
357
- {chipLabel}
358
- </BodyText>
359
- </View>
360
- )}
361
-
362
- {/* Content (title + description) */}
363
- <View
364
- style={
365
- isLg
366
- ? { gap: dim.spacing.padding.sysPadding8, flex: 1 }
367
- : { gap: dim.spacing.padding.sysPadding4, maxWidth: '78%' }
368
- }
369
- >
370
- {title &&
371
- (isLg ? (
372
- <HeaderText variant="titleLarge" color={t.titleColor}>
373
- {title}
374
- </HeaderText>
375
- ) : (
376
- <HeaderText variant="titleSmall" color={t.titleColor}>
377
- {title}
378
- </HeaderText>
379
- ))}
380
- {description && (
381
- <View style={{ opacity: t.descOpacity }}>
382
- <BodyText variant={isLg ? 'medium' : 'small'} color={t.descColor}>
383
- {description}
384
- </BodyText>
385
- </View>
386
- )}
387
- </View>
388
-
389
- {/* Arrow button (absolute, bottom-right) */}
390
- <View
391
- style={[
392
- styles.buttonWrap,
393
- {
394
- bottom: isLg
395
- ? dim.spacing.padding.sysPadding32
396
- : dim.spacing.padding.sysPadding16,
397
- right: isLg
398
- ? dim.spacing.padding.sysPadding32
399
- : dim.spacing.padding.sysPadding16,
400
- width: buttonSize,
401
- height: buttonSize,
402
- },
403
- ]}
404
- >
405
- <View
406
- style={[
407
- styles.button,
408
- {
409
- width: buttonSize,
410
- height: buttonSize,
411
- borderRadius: dim.borderRadius.sysRadiusFull,
412
- backgroundColor: t.buttonBg,
413
- },
414
- ]}
415
- >
416
- <Ionicons
417
- name="arrow-forward"
418
- size={isLg ? 24 : 16}
419
- color={t.buttonIconColor}
420
- accessible={false}
421
- />
422
- </View>
423
- {showRingTimer && (
424
- <Svg
425
- width={buttonSize}
426
- height={buttonSize}
427
- style={StyleSheet.absoluteFillObject}
428
- >
429
- <SvgCircle
430
- cx={buttonSize / 2}
431
- cy={buttonSize / 2}
432
- r={ringR}
433
- stroke={t.progressColor}
434
- strokeWidth={2}
435
- fill="transparent"
436
- strokeDasharray={`${ringArcLength} ${ringC - ringArcLength}`}
437
- strokeLinecap="round"
438
- rotation={-90}
439
- origin={`${buttonSize / 2}, ${buttonSize / 2}`}
440
- />
441
- </Svg>
442
- )}
443
- </View>
444
-
445
- {/* Pagination dots (lg only) */}
446
- {isLg && (
447
- <View style={styles.dotsRow}>
448
- {Array.from({ length: LG_DOT_COUNT }).map((_, i) => (
449
- <View
450
- key={i}
451
- style={[
452
- styles.dot,
453
- {
454
- backgroundColor: i === 0 ? t.progressColor : 'transparent',
455
- borderColor: t.progressColor,
456
- borderWidth: i === 0 ? 0 : 1.5,
457
- opacity: i === 0 ? 1 : 0.4,
458
- },
459
- ]}
460
- />
461
- ))}
462
- </View>
463
- )}
464
-
465
- {/* Progress bar */}
466
- {showProgressBar && (
467
- <View
468
- style={[styles.progressTrack, { height: isLg ? 4 : 2 }]}
469
- pointerEvents="none"
470
- >
471
- <View
472
- style={[
473
- styles.progressFill,
474
- {
475
- width: `${p * 100}%` as any,
476
- height: isLg ? 4 : 2,
477
- backgroundColor: t.progressColor,
478
- },
479
- ]}
480
- />
481
- </View>
482
- )}
483
- </>
484
- );
485
-
486
- if (onPress) {
487
- return (
488
- <Pressable
489
- onPress={disabled ? undefined : onPress}
490
- accessibilityRole="button"
491
- accessibilityLabel={accessibilityLabel ?? title}
492
- accessibilityState={{ disabled }}
493
- style={({ pressed }: { pressed: boolean }) => [
494
- ...containerStyle,
495
- pressed && !disabled && styles.pressed,
496
- ]}
497
- >
498
- {inner}
499
- </Pressable>
500
- );
501
- }
502
-
503
- return <View style={containerStyle}>{inner}</View>;
504
- }
505
-
506
- const styles = StyleSheet.create({
507
- card: {
508
- overflow: 'hidden',
509
- alignSelf: 'stretch',
510
- },
511
- buttonWrap: {
512
- position: 'absolute',
513
- },
514
- button: {
515
- alignItems: 'center',
516
- justifyContent: 'center',
517
- overflow: 'hidden',
518
- },
519
- dotsRow: {
520
- position: 'absolute',
521
- bottom: LG_DOTS_BOTTOM,
522
- left: LG_DOTS_LEFT,
523
- flexDirection: 'row',
524
- alignItems: 'center',
525
- gap: LG_DOT_GAP,
526
- },
527
- dot: {
528
- width: LG_DOT_SIZE,
529
- height: LG_DOT_SIZE,
530
- borderRadius: LG_DOT_SIZE / 2,
531
- },
532
- progressTrack: {
533
- position: 'absolute',
534
- bottom: 0,
535
- left: 0,
536
- right: 0,
537
- backgroundColor: 'rgba(0,0,0,0.08)',
538
- },
539
- progressFill: {},
540
- pressed: {
541
- opacity: 0.84,
542
- },
543
- });