@compsych/mobile-ui 1.0.26 → 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 +1 -1
- package/src/components/ActionSheet/index.tsx +2 -0
- package/src/components/Button/Button.test.tsx +5 -0
- package/src/components/Button/index.tsx +41 -39
- package/src/components/DateTimePicker/DateTimePicker.test.tsx +110 -0
- package/src/components/DateTimePicker/index.tsx +445 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { HeaderText } from '../HeaderText';
|
|
|
19
19
|
export interface ActionSheetAction {
|
|
20
20
|
label: string;
|
|
21
21
|
onPress: () => void;
|
|
22
|
+
loading?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface ActionSheetProps {
|
|
@@ -183,6 +184,7 @@ export function ActionSheet({
|
|
|
183
184
|
variant="filled"
|
|
184
185
|
size="xl"
|
|
185
186
|
fullWidth
|
|
187
|
+
loading={primaryAction.loading}
|
|
186
188
|
onPress={primaryAction.onPress}
|
|
187
189
|
/>
|
|
188
190
|
)}
|
|
@@ -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
|
-
) :
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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,
|