@compsych/mobile-ui 1.0.27 → 1.0.28

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.27",
3
+ "version": "1.0.28",
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",
@@ -48,4 +48,9 @@ describe('Button', () => {
48
48
  const { getByText } = render(<Button label="Full" fullWidth />);
49
49
  expect(getByText('Full')).toBeTruthy();
50
50
  });
51
+
52
+ it('does not render label text when loading is true', () => {
53
+ const { queryByText } = render(<Button label="Submit" loading />);
54
+ expect(queryByText('Submit')).toBeNull();
55
+ });
51
56
  });
@@ -209,45 +209,47 @@ export function Button({
209
209
  <View style={[styles.content, { gap: dim.spacing.padding.sysPadding8 }]}>
210
210
  {loading ? (
211
211
  <ActivityIndicator size={s.iconSize} color={v.label} />
212
- ) : leadingIcon ? (
213
- <View
214
- style={{
215
- width: s.iconSize,
216
- height: s.iconSize,
217
- alignItems: 'center',
218
- justifyContent: 'center',
219
- }}
220
- >
221
- {leadingIcon}
222
- </View>
223
- ) : null}
224
-
225
- {!iconOnly && (
226
- <Text
227
- style={{
228
- color: v.label,
229
- fontSize: s.fontSize,
230
- lineHeight: s.lineHeight,
231
- fontWeight: '600',
232
- includeFontPadding: false,
233
- }}
234
- numberOfLines={1}
235
- >
236
- {label}
237
- </Text>
238
- )}
239
-
240
- {trailingIcon && (
241
- <View
242
- style={{
243
- width: s.iconSize,
244
- height: s.iconSize,
245
- alignItems: 'center',
246
- justifyContent: 'center',
247
- }}
248
- >
249
- {trailingIcon}
250
- </View>
212
+ ) : (
213
+ <>
214
+ {!iconOnly && leadingIcon && (
215
+ <View
216
+ style={{
217
+ width: s.iconSize,
218
+ height: s.iconSize,
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ }}
222
+ >
223
+ {leadingIcon}
224
+ </View>
225
+ )}
226
+ {!iconOnly && (
227
+ <Text
228
+ style={{
229
+ color: v.label,
230
+ fontSize: s.fontSize,
231
+ lineHeight: s.lineHeight,
232
+ fontWeight: '600',
233
+ includeFontPadding: false,
234
+ }}
235
+ numberOfLines={1}
236
+ >
237
+ {label}
238
+ </Text>
239
+ )}
240
+ {trailingIcon && (
241
+ <View
242
+ style={{
243
+ width: s.iconSize,
244
+ height: s.iconSize,
245
+ alignItems: 'center',
246
+ justifyContent: 'center',
247
+ }}
248
+ >
249
+ {trailingIcon}
250
+ </View>
251
+ )}
252
+ </>
251
253
  )}
252
254
  </View>
253
255
  </Pressable>
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { DateTimePicker } from './index';
6
+
7
+ jest.mock('../../theme', () => ({
8
+ useTheme: () => ({
9
+ colorRoles: {
10
+ accent: {
11
+ primary: {
12
+ sysPrimary: '#0057B8',
13
+ sysOnPrimary: '#FFFFFF',
14
+ },
15
+ },
16
+ outline: {
17
+ sysOutline: '#C4C4C4',
18
+ },
19
+ surface: {
20
+ surface: {
21
+ sysOnSurface: '#1A1A1A',
22
+ sysOnSurfaceVariant: '#6B6B6B',
23
+ sysSurface: '#FFFFFF',
24
+ },
25
+ surfaceContainer: {
26
+ sysSurfaceContainerLowest: '#FFFFFF',
27
+ sysSurfaceContainer: '#F5F5F5',
28
+ },
29
+ },
30
+ },
31
+ dimensions: {
32
+ spacing: { padding: { sysPadding8: 8, sysPadding16: 16 } },
33
+ borderRadius: { sysRadiusFull: 999 },
34
+ borderWidth: { sysStrokeThin: 1, sysStrokeMedium: 1.5 },
35
+ },
36
+ typeScale: {
37
+ labelSmall: { sysFontSize: 12, sysLineHeight: 16 },
38
+ labelMedium: { sysFontSize: 14, sysLineHeight: 20 },
39
+ },
40
+ iconography: { sysSizeXs: 16, sysSizeSm: 20, sysSizeMd: 24 },
41
+ }),
42
+ }));
43
+
44
+ const BASE_PROPS = {
45
+ selectedDate: null,
46
+ onSelectDate: jest.fn(),
47
+ selectedTime: null,
48
+ onSelectTime: jest.fn(),
49
+ availableTimes: ['10:00 AM', '10:30 AM', '11:00 AM'],
50
+ availableTimeLabel: 'Available time',
51
+ maxMonthsAhead: 1,
52
+ };
53
+
54
+ describe('DateTimePicker', () => {
55
+ beforeEach(() => {
56
+ jest.clearAllMocks();
57
+ // Fix current date to avoid flakiness: 2026-07-02
58
+ jest.useFakeTimers();
59
+ jest.setSystemTime(new Date(2026, 6, 2));
60
+ });
61
+
62
+ afterEach(() => {
63
+ jest.useRealTimers();
64
+ });
65
+
66
+ it('renders without crashing', () => {
67
+ const { getByTestId } = render(<DateTimePicker {...BASE_PROPS} />);
68
+ expect(getByTestId('calendar-picker')).toBeTruthy();
69
+ expect(getByTestId('calendar-month-label')).toBeTruthy();
70
+ expect(getByTestId('calendar-prev-button')).toBeTruthy();
71
+ expect(getByTestId('calendar-next-button')).toBeTruthy();
72
+ });
73
+
74
+ it('does not show time slots when no date is selected', () => {
75
+ const { queryByTestId } = render(<DateTimePicker {...BASE_PROPS} />);
76
+ expect(queryByTestId('time-slot-list')).toBeNull();
77
+ expect(queryByTestId('available-time-label')).toBeNull();
78
+ });
79
+
80
+ it('shows time slots after a date is selected', () => {
81
+ const { getByTestId } = render(
82
+ <DateTimePicker {...BASE_PROPS} selectedDate="2026-07-10" />,
83
+ );
84
+ expect(getByTestId('time-slot-list')).toBeTruthy();
85
+ expect(getByTestId('available-time-label')).toBeTruthy();
86
+ });
87
+
88
+ it('calls onSelectDate when a day is tapped', () => {
89
+ const onSelectDate = jest.fn();
90
+ const { getByTestId } = render(
91
+ <DateTimePicker {...BASE_PROPS} onSelectDate={onSelectDate} />,
92
+ );
93
+ // Day 10 should be enabled (in the future)
94
+ fireEvent.press(getByTestId('calendar-day-10'));
95
+ expect(onSelectDate).toHaveBeenCalledWith('2026-07-10');
96
+ });
97
+
98
+ it('calls onSelectTime when a time slot is tapped', () => {
99
+ const onSelectTime = jest.fn();
100
+ const { getByTestId } = render(
101
+ <DateTimePicker
102
+ {...BASE_PROPS}
103
+ selectedDate="2026-07-10"
104
+ onSelectTime={onSelectTime}
105
+ />,
106
+ );
107
+ fireEvent.press(getByTestId('time-slot-10-00-AM'));
108
+ expect(onSelectTime).toHaveBeenCalledWith('10:00 AM');
109
+ });
110
+ });
@@ -0,0 +1,445 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+
3
+ import {
4
+ I18nManager,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from 'react-native';
11
+
12
+ import { useTheme } from '../../theme';
13
+
14
+ export interface DateTimePickerProps {
15
+ selectedDate: string | null;
16
+ onSelectDate: (date: string) => void;
17
+ selectedTime: string | null;
18
+ onSelectTime: (time: string) => void;
19
+ availableTimes: string[];
20
+ availableTimeLabel: string;
21
+ maxMonthsAhead?: number;
22
+ }
23
+
24
+ const DAY_HEADERS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
25
+
26
+ function toLocalDateString(date: Date): string {
27
+ const y = date.getFullYear();
28
+ const m = String(date.getMonth() + 1).padStart(2, '0');
29
+ const d = String(date.getDate()).padStart(2, '0');
30
+ return `${y}-${m}-${d}`;
31
+ }
32
+
33
+ function addMonths(date: Date, count: number): Date {
34
+ const result = new Date(date.getFullYear(), date.getMonth() + count, 1);
35
+ return result;
36
+ }
37
+
38
+ export function DateTimePicker({
39
+ selectedDate,
40
+ onSelectDate,
41
+ selectedTime,
42
+ onSelectTime,
43
+ availableTimes,
44
+ availableTimeLabel,
45
+ maxMonthsAhead = 1,
46
+ }: DateTimePickerProps) {
47
+ const { colorRoles: cr } = useTheme();
48
+
49
+ const today = useMemo(() => {
50
+ const d = new Date();
51
+ d.setHours(0, 0, 0, 0);
52
+ return d;
53
+ }, []);
54
+
55
+ const maxDate = useMemo(() => {
56
+ const d = addMonths(today, maxMonthsAhead);
57
+ // Last day of the maxMonthsAhead month
58
+ return new Date(d.getFullYear(), d.getMonth() + 1, 0);
59
+ }, [today, maxMonthsAhead]);
60
+
61
+ const [viewYear, setViewYear] = useState(today.getFullYear());
62
+ const [viewMonth, setViewMonth] = useState(today.getMonth());
63
+
64
+ const canGoPrev = useMemo(() => {
65
+ return viewYear > today.getFullYear() || viewMonth > today.getMonth();
66
+ }, [viewYear, viewMonth, today]);
67
+
68
+ const canGoNext = useMemo(() => {
69
+ const maxMonth = addMonths(today, maxMonthsAhead);
70
+ return (
71
+ viewYear < maxMonth.getFullYear() ||
72
+ (viewYear === maxMonth.getFullYear() && viewMonth < maxMonth.getMonth())
73
+ );
74
+ }, [viewYear, viewMonth, today, maxMonthsAhead]);
75
+
76
+ const handlePrevMonth = useCallback(() => {
77
+ if (!canGoPrev) return;
78
+ if (viewMonth === 0) {
79
+ setViewMonth(11);
80
+ setViewYear((y) => y - 1);
81
+ } else {
82
+ setViewMonth((m) => m - 1);
83
+ }
84
+ }, [canGoPrev, viewMonth]);
85
+
86
+ const handleNextMonth = useCallback(() => {
87
+ if (!canGoNext) return;
88
+ if (viewMonth === 11) {
89
+ setViewMonth(0);
90
+ setViewYear((y) => y + 1);
91
+ } else {
92
+ setViewMonth((m) => m + 1);
93
+ }
94
+ }, [canGoNext, viewMonth]);
95
+
96
+ const monthLabel = useMemo(() => {
97
+ const d = new Date(viewYear, viewMonth, 1);
98
+ return d.toLocaleString('default', { month: 'long', year: 'numeric' });
99
+ }, [viewYear, viewMonth]);
100
+
101
+ // Build calendar grid
102
+ const calendarDays = useMemo(() => {
103
+ const firstDay = new Date(viewYear, viewMonth, 1).getDay();
104
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
105
+ const cells: (number | null)[] = [];
106
+ for (let i = 0; i < firstDay; i++) cells.push(null);
107
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d);
108
+ // Pad to complete last row
109
+ while (cells.length % 7 !== 0) cells.push(null);
110
+ return cells;
111
+ }, [viewYear, viewMonth]);
112
+
113
+ const isDisabled = useCallback(
114
+ (day: number) => {
115
+ const d = new Date(viewYear, viewMonth, day);
116
+ d.setHours(0, 0, 0, 0);
117
+ return d < today || d > maxDate;
118
+ },
119
+ [viewYear, viewMonth, today, maxDate],
120
+ );
121
+
122
+ const isToday = useCallback(
123
+ (day: number) => {
124
+ const d = new Date(viewYear, viewMonth, day);
125
+ return toLocalDateString(d) === toLocalDateString(today);
126
+ },
127
+ [viewYear, viewMonth, today],
128
+ );
129
+
130
+ const isSelected = useCallback(
131
+ (day: number) => {
132
+ const d = new Date(viewYear, viewMonth, day);
133
+ return toLocalDateString(d) === selectedDate;
134
+ },
135
+ [viewYear, viewMonth, selectedDate],
136
+ );
137
+
138
+ const handleDayPress = useCallback(
139
+ (day: number) => {
140
+ if (isDisabled(day)) return;
141
+ const d = new Date(viewYear, viewMonth, day);
142
+ onSelectDate(toLocalDateString(d));
143
+ },
144
+ [viewYear, viewMonth, isDisabled, onSelectDate],
145
+ );
146
+
147
+ const sysPrimary = cr.accent.primary.sysPrimary;
148
+ const sysOutline = cr.outline.sysOutline;
149
+ const sysOnSurface = cr.surface.surface.sysOnSurface;
150
+ const sysOnSurfaceVariant = cr.surface.surface.sysOnSurfaceVariant;
151
+ const sysSurface = cr.surface.surface.sysSurface;
152
+
153
+ const prevChevron = I18nManager.isRTL ? '>' : '<';
154
+ const nextChevron = I18nManager.isRTL ? '<' : '>';
155
+
156
+ const weeks: (number | null)[][] = [];
157
+ for (let i = 0; i < calendarDays.length; i += 7) {
158
+ weeks.push(calendarDays.slice(i, i + 7));
159
+ }
160
+
161
+ return (
162
+ <View
163
+ testID="calendar-picker"
164
+ style={[
165
+ styles.card,
166
+ { borderColor: sysOutline, backgroundColor: sysSurface },
167
+ ]}
168
+ >
169
+ {/* Calendar section */}
170
+ <View style={styles.calendarSection}>
171
+ {/* Month nav row */}
172
+ <View style={styles.monthNavRow}>
173
+ <Pressable
174
+ testID="calendar-prev-button"
175
+ onPress={handlePrevMonth}
176
+ style={[styles.navButton, !canGoPrev && styles.navButtonDisabled]}
177
+ disabled={!canGoPrev}
178
+ accessibilityRole="button"
179
+ accessibilityLabel="Previous month"
180
+ >
181
+ <Text
182
+ style={[
183
+ styles.navChevron,
184
+ { color: canGoPrev ? sysOnSurface : sysOnSurfaceVariant },
185
+ ]}
186
+ >
187
+ {prevChevron}
188
+ </Text>
189
+ </Pressable>
190
+
191
+ <Text
192
+ testID="calendar-month-label"
193
+ style={[styles.monthLabel, { color: sysOnSurface }]}
194
+ >
195
+ {monthLabel}
196
+ </Text>
197
+
198
+ <Pressable
199
+ testID="calendar-next-button"
200
+ onPress={handleNextMonth}
201
+ style={[styles.navButton, !canGoNext && styles.navButtonDisabled]}
202
+ disabled={!canGoNext}
203
+ accessibilityRole="button"
204
+ accessibilityLabel="Next month"
205
+ >
206
+ <Text
207
+ style={[
208
+ styles.navChevron,
209
+ { color: canGoNext ? sysOnSurface : sysOnSurfaceVariant },
210
+ ]}
211
+ >
212
+ {nextChevron}
213
+ </Text>
214
+ </Pressable>
215
+ </View>
216
+
217
+ {/* Day headers */}
218
+ <View style={styles.weekRow}>
219
+ {DAY_HEADERS.map((h) => (
220
+ <View key={h} style={styles.dayCell}>
221
+ <Text
222
+ testID={`day-header-${h}`}
223
+ style={[styles.dayHeader, { color: sysOnSurfaceVariant }]}
224
+ >
225
+ {h}
226
+ </Text>
227
+ </View>
228
+ ))}
229
+ </View>
230
+
231
+ {/* Week rows */}
232
+ {weeks.map((week, wi) => (
233
+ <View key={wi} style={styles.weekRow}>
234
+ {week.map((day, di) => {
235
+ if (day === null) {
236
+ return <View key={`empty-${di}`} style={styles.dayCell} />;
237
+ }
238
+ const disabled = isDisabled(day);
239
+ const selected = isSelected(day);
240
+ const todayDay = isToday(day);
241
+
242
+ const cellStyle = [
243
+ styles.dayCellInner,
244
+ selected && {
245
+ borderColor: sysPrimary,
246
+ borderWidth: 2,
247
+ shadowColor: sysPrimary,
248
+ shadowOffset: { width: 1, height: 2 },
249
+ shadowOpacity: 1,
250
+ shadowRadius: 0,
251
+ elevation: 2,
252
+ },
253
+ !selected &&
254
+ todayDay && {
255
+ borderColor: sysPrimary,
256
+ borderWidth: 1.5,
257
+ },
258
+ disabled && styles.dayCellDisabled,
259
+ ];
260
+
261
+ const textStyle = [
262
+ styles.dayText,
263
+ { color: selected ? sysPrimary : sysOnSurface },
264
+ ];
265
+
266
+ return (
267
+ <Pressable
268
+ key={day}
269
+ testID={`calendar-day-${day}`}
270
+ style={styles.dayCell}
271
+ onPress={() => handleDayPress(day)}
272
+ disabled={disabled}
273
+ accessibilityRole="button"
274
+ accessibilityLabel={`Day ${day}`}
275
+ accessibilityState={{ disabled, selected }}
276
+ >
277
+ <View style={cellStyle}>
278
+ <Text style={textStyle}>{day}</Text>
279
+ </View>
280
+ </Pressable>
281
+ );
282
+ })}
283
+ </View>
284
+ ))}
285
+ </View>
286
+
287
+ {/* Divider + time section */}
288
+ {selectedDate != null && (
289
+ <>
290
+ <View style={[styles.divider, { backgroundColor: sysOutline }]} />
291
+ <View style={styles.timeSection}>
292
+ <Text
293
+ testID="available-time-label"
294
+ style={[styles.availableTimeLabel, { color: sysOnSurface }]}
295
+ >
296
+ {availableTimeLabel}
297
+ </Text>
298
+ <ScrollView
299
+ testID="time-slot-list"
300
+ style={styles.timeList}
301
+ contentContainerStyle={styles.timeListContent}
302
+ showsVerticalScrollIndicator={false}
303
+ >
304
+ {availableTimes.map((slot) => {
305
+ const slotTestId = `time-slot-${slot.replace(/[: ]/g, '-')}`;
306
+ const slotSelected = slot === selectedTime;
307
+ return (
308
+ <Pressable
309
+ key={slot}
310
+ testID={slotTestId}
311
+ onPress={() => onSelectTime(slot)}
312
+ accessibilityRole="button"
313
+ accessibilityState={{ selected: slotSelected }}
314
+ style={[
315
+ styles.timeSlot,
316
+ {
317
+ borderColor: slotSelected ? sysPrimary : sysOutline,
318
+ borderWidth: slotSelected ? 2 : 1.5,
319
+ backgroundColor: sysSurface,
320
+ },
321
+ slotSelected && {
322
+ shadowColor: sysPrimary,
323
+ shadowOffset: { width: 1, height: 2 },
324
+ shadowOpacity: 1,
325
+ shadowRadius: 0,
326
+ elevation: 2,
327
+ },
328
+ ]}
329
+ >
330
+ <Text
331
+ style={[
332
+ styles.timeSlotText,
333
+ { color: slotSelected ? sysPrimary : sysOnSurface },
334
+ ]}
335
+ >
336
+ {slot}
337
+ </Text>
338
+ </Pressable>
339
+ );
340
+ })}
341
+ </ScrollView>
342
+ </View>
343
+ </>
344
+ )}
345
+ </View>
346
+ );
347
+ }
348
+
349
+ const styles = StyleSheet.create({
350
+ card: {
351
+ borderRadius: 16,
352
+ borderWidth: 1,
353
+ overflow: 'hidden',
354
+ },
355
+ calendarSection: {
356
+ paddingHorizontal: 8,
357
+ paddingVertical: 16,
358
+ },
359
+ monthNavRow: {
360
+ flexDirection: 'row',
361
+ alignItems: 'center',
362
+ justifyContent: 'space-between',
363
+ marginBottom: 8,
364
+ },
365
+ navButton: {
366
+ padding: 8,
367
+ alignItems: 'center',
368
+ justifyContent: 'center',
369
+ },
370
+ navButtonDisabled: {
371
+ opacity: 0.3,
372
+ },
373
+ navChevron: {
374
+ fontSize: 18,
375
+ fontWeight: '600',
376
+ },
377
+ monthLabel: {
378
+ fontSize: 16,
379
+ fontWeight: '600',
380
+ textAlign: 'center',
381
+ flex: 1,
382
+ },
383
+ weekRow: {
384
+ flexDirection: 'row',
385
+ },
386
+ dayCell: {
387
+ flex: 1,
388
+ aspectRatio: 1,
389
+ alignItems: 'center',
390
+ justifyContent: 'center',
391
+ },
392
+ dayHeader: {
393
+ fontSize: 10,
394
+ fontWeight: '500',
395
+ textAlign: 'center',
396
+ },
397
+ dayCellInner: {
398
+ width: '80%',
399
+ aspectRatio: 1,
400
+ borderRadius: 100,
401
+ alignItems: 'center',
402
+ justifyContent: 'center',
403
+ backgroundColor: 'transparent',
404
+ borderWidth: 0,
405
+ borderColor: 'transparent',
406
+ },
407
+ dayCellDisabled: {
408
+ opacity: 0.3,
409
+ },
410
+ dayText: {
411
+ fontSize: 13,
412
+ fontWeight: '500',
413
+ textAlign: 'center',
414
+ },
415
+ divider: {
416
+ height: 1,
417
+ width: '100%',
418
+ },
419
+ timeSection: {
420
+ padding: 16,
421
+ },
422
+ availableTimeLabel: {
423
+ fontSize: 14,
424
+ fontWeight: '500',
425
+ textAlign: 'center',
426
+ marginBottom: 12,
427
+ },
428
+ timeList: {
429
+ maxHeight: 200,
430
+ },
431
+ timeListContent: {
432
+ gap: 8,
433
+ },
434
+ timeSlot: {
435
+ borderRadius: 12,
436
+ paddingVertical: 12,
437
+ paddingHorizontal: 16,
438
+ alignItems: 'center',
439
+ justifyContent: 'center',
440
+ },
441
+ timeSlotText: {
442
+ fontSize: 14,
443
+ textAlign: 'center',
444
+ },
445
+ });
package/src/index.ts CHANGED
@@ -117,6 +117,9 @@ export type {
117
117
  TooltipDirection,
118
118
  } from './components/Tooltip';
119
119
 
120
+ export { DateTimePicker } from './components/DateTimePicker';
121
+ export type { DateTimePickerProps } from './components/DateTimePicker';
122
+
120
123
  export { ActionSheet } from './components/ActionSheet';
121
124
  export type {
122
125
  ActionSheetProps,