@compsych/mobile-ui 1.0.14 → 1.0.15

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.15",
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",
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
- });