@lotics/ui 1.11.1 → 1.13.0
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 +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/calendar/calendar_view.tsx +5 -1
- package/src/calendar/month_view.tsx +40 -4
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/css_modules.d.ts +2 -0
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/gantt/gantt_view.tsx +31 -5
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/popover.tsx +28 -2
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/use_pointer_drag.ts +99 -0
- package/src/datetime_picker.tsx +0 -44
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import React, { useCallback, useImperativeHandle, useMemo, useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { useScreenSize } from "./use_screen_size";
|
|
6
|
+
import { Picker, PickerOption } from "./picker";
|
|
7
|
+
import { IconButton } from "./icon_button";
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Types
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
export type CalendarSingleValue = Date | null;
|
|
14
|
+
export type CalendarRangeValue = { start: Date | null; end: Date | null };
|
|
15
|
+
|
|
16
|
+
/** Day of week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday */
|
|
17
|
+
export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
18
|
+
|
|
19
|
+
export type CalendarValue<TMode extends "single" | "range"> = TMode extends "single"
|
|
20
|
+
? CalendarSingleValue
|
|
21
|
+
: CalendarRangeValue;
|
|
22
|
+
|
|
23
|
+
export interface CalendarRef {
|
|
24
|
+
/** Navigate calendar view to a specific month */
|
|
25
|
+
navigateToMonth: (year: number, month: number) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CalendarBaseProps {
|
|
29
|
+
/** First day of the week (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Default: 1 (Monday) */
|
|
30
|
+
firstDayOfWeek?: DayOfWeek;
|
|
31
|
+
/** BCP-47 locale for weekday/month names. Default: "en-US". */
|
|
32
|
+
locale?: string;
|
|
33
|
+
/** Ref to access calendar methods like navigateToMonth */
|
|
34
|
+
ref?: React.Ref<CalendarRef>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CalendarProps<TMode extends "single" | "range" = "single" | "range"> =
|
|
38
|
+
CalendarBaseProps &
|
|
39
|
+
(TMode extends "single"
|
|
40
|
+
? {
|
|
41
|
+
mode: "single";
|
|
42
|
+
value: CalendarSingleValue;
|
|
43
|
+
onValueChange: (value: CalendarSingleValue) => void;
|
|
44
|
+
}
|
|
45
|
+
: TMode extends "range"
|
|
46
|
+
? {
|
|
47
|
+
mode: "range";
|
|
48
|
+
value: CalendarRangeValue;
|
|
49
|
+
onValueChange: (value: CalendarRangeValue) => void;
|
|
50
|
+
}
|
|
51
|
+
: {
|
|
52
|
+
mode: "single" | "range";
|
|
53
|
+
value: CalendarSingleValue | CalendarRangeValue;
|
|
54
|
+
onValueChange: (value: CalendarSingleValue | CalendarRangeValue) => void;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
interface CalendarMonthProps {
|
|
58
|
+
year: number;
|
|
59
|
+
month: number;
|
|
60
|
+
selectedDate: Date | null;
|
|
61
|
+
rangeStart: Date | null;
|
|
62
|
+
rangeEnd: Date | null;
|
|
63
|
+
isRange: boolean;
|
|
64
|
+
onDateSelect: (date: Date) => void;
|
|
65
|
+
onMonthChange: (year: number, month: number) => void;
|
|
66
|
+
showNavigation?: boolean;
|
|
67
|
+
/** Show only left arrow (for range mode left calendar) */
|
|
68
|
+
showLeftArrow?: boolean;
|
|
69
|
+
/** Show only right arrow (for range mode right calendar) */
|
|
70
|
+
showRightArrow?: boolean;
|
|
71
|
+
/** Callback for left arrow click (for range mode) */
|
|
72
|
+
onPrevMonth?: () => void;
|
|
73
|
+
/** Callback for right arrow click (for range mode) */
|
|
74
|
+
onNextMonth?: () => void;
|
|
75
|
+
/** First day of the week (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Default: 1 (Monday) */
|
|
76
|
+
firstDayOfWeek?: DayOfWeek;
|
|
77
|
+
/** BCP-47 locale for weekday/month names. */
|
|
78
|
+
locale: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Helper Functions
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
/** BCP-47 locale used when a caller does not supply one. */
|
|
86
|
+
const DEFAULT_LOCALE = "en-US";
|
|
87
|
+
|
|
88
|
+
interface LocalizedNames {
|
|
89
|
+
/** Weekday short names, index 0 = Sunday. */
|
|
90
|
+
weekdays: string[];
|
|
91
|
+
/** Month long names, index 0 = January. */
|
|
92
|
+
months: string[];
|
|
93
|
+
/** Month names as Picker options keyed by month index. */
|
|
94
|
+
monthOptions: PickerOption<string>[];
|
|
95
|
+
/** Full date string for a day cell's accessible name. */
|
|
96
|
+
formatDayLabel: (date: Date) => string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Jan 1 2017 was a Sunday — formatting Jan 1..7 yields Sun..Sat. Sample dates are
|
|
100
|
+
// built in local time so the runtime timezone can't shift the rendered name.
|
|
101
|
+
function buildLocalizedNames(locale: string): LocalizedNames {
|
|
102
|
+
const weekdayFormatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
103
|
+
const weekdays = [1, 2, 3, 4, 5, 6, 7].map((day) =>
|
|
104
|
+
weekdayFormatter.format(new Date(2017, 0, day)),
|
|
105
|
+
);
|
|
106
|
+
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "long" });
|
|
107
|
+
const months = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((month) =>
|
|
108
|
+
monthFormatter.format(new Date(2017, month, 1)),
|
|
109
|
+
);
|
|
110
|
+
const dayLabelFormatter = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
|
|
111
|
+
return {
|
|
112
|
+
weekdays,
|
|
113
|
+
months,
|
|
114
|
+
monthOptions: months.map((month, index) => ({ label: month, value: String(index) })),
|
|
115
|
+
formatDayLabel: (date) => dayLabelFormatter.format(date),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Intl formatters are costly to construct; derive each locale's names once.
|
|
120
|
+
const localizedNamesCache = new Map<string, LocalizedNames>();
|
|
121
|
+
|
|
122
|
+
function getLocalizedNames(locale: string): LocalizedNames {
|
|
123
|
+
let names = localizedNamesCache.get(locale);
|
|
124
|
+
if (!names) {
|
|
125
|
+
names = buildLocalizedNames(locale);
|
|
126
|
+
localizedNamesCache.set(locale, names);
|
|
127
|
+
}
|
|
128
|
+
return names;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getDaysOfWeekLabels(weekdays: string[], firstDayOfWeek: DayOfWeek): string[] {
|
|
132
|
+
const result: string[] = [];
|
|
133
|
+
for (let i = 0; i < 7; i++) {
|
|
134
|
+
result.push(weekdays[(firstDayOfWeek + i) % 7]);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getYearOptions(currentYear: number): PickerOption<string>[] {
|
|
140
|
+
const options: PickerOption<string>[] = [];
|
|
141
|
+
for (let year = currentYear - 10; year <= currentYear + 10; year++) {
|
|
142
|
+
options.push({ label: String(year), value: String(year) });
|
|
143
|
+
}
|
|
144
|
+
return options;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getDaysInMonth(year: number, month: number): number {
|
|
148
|
+
return new Date(year, month + 1, 0).getDate();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getFirstDayOfMonth(year: number, month: number, firstDayOfWeek: DayOfWeek): number {
|
|
152
|
+
const day = new Date(year, month, 1).getDay();
|
|
153
|
+
// Calculate offset based on first day of week
|
|
154
|
+
return (day - firstDayOfWeek + 7) % 7;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSameDay(date1: Date | null, date2: Date | null): boolean {
|
|
158
|
+
if (!date1 || !date2) return false;
|
|
159
|
+
return (
|
|
160
|
+
date1.getFullYear() === date2.getFullYear() &&
|
|
161
|
+
date1.getMonth() === date2.getMonth() &&
|
|
162
|
+
date1.getDate() === date2.getDate()
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
|
|
167
|
+
if (!start || !end) return false;
|
|
168
|
+
const time = date.getTime();
|
|
169
|
+
const startTime = start.getTime();
|
|
170
|
+
const endTime = end.getTime();
|
|
171
|
+
return time > Math.min(startTime, endTime) && time < Math.max(startTime, endTime);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isToday(date: Date): boolean {
|
|
175
|
+
const today = new Date();
|
|
176
|
+
return isSameDay(date, today);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// Calendar Month Component
|
|
181
|
+
// =============================================================================
|
|
182
|
+
|
|
183
|
+
function CalendarMonth(props: CalendarMonthProps) {
|
|
184
|
+
const {
|
|
185
|
+
year,
|
|
186
|
+
month,
|
|
187
|
+
selectedDate,
|
|
188
|
+
rangeStart,
|
|
189
|
+
rangeEnd,
|
|
190
|
+
isRange,
|
|
191
|
+
onDateSelect,
|
|
192
|
+
onMonthChange,
|
|
193
|
+
showNavigation = true,
|
|
194
|
+
showLeftArrow = false,
|
|
195
|
+
showRightArrow = false,
|
|
196
|
+
onPrevMonth,
|
|
197
|
+
onNextMonth,
|
|
198
|
+
firstDayOfWeek = 1, // Default to Monday
|
|
199
|
+
locale,
|
|
200
|
+
} = props;
|
|
201
|
+
|
|
202
|
+
const names = getLocalizedNames(locale);
|
|
203
|
+
const daysInMonth = getDaysInMonth(year, month);
|
|
204
|
+
const firstDayOffset = getFirstDayOfMonth(year, month, firstDayOfWeek);
|
|
205
|
+
const daysOfWeekLabels = useMemo(
|
|
206
|
+
() => getDaysOfWeekLabels(names.weekdays, firstDayOfWeek),
|
|
207
|
+
[names, firstDayOfWeek],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const handlePrevMonth = useCallback(() => {
|
|
211
|
+
if (month === 0) {
|
|
212
|
+
onMonthChange(year - 1, 11);
|
|
213
|
+
} else {
|
|
214
|
+
onMonthChange(year, month - 1);
|
|
215
|
+
}
|
|
216
|
+
}, [year, month, onMonthChange]);
|
|
217
|
+
|
|
218
|
+
const handleNextMonth = useCallback(() => {
|
|
219
|
+
if (month === 11) {
|
|
220
|
+
onMonthChange(year + 1, 0);
|
|
221
|
+
} else {
|
|
222
|
+
onMonthChange(year, month + 1);
|
|
223
|
+
}
|
|
224
|
+
}, [year, month, onMonthChange]);
|
|
225
|
+
|
|
226
|
+
const handleMonthPickerChange = useCallback(
|
|
227
|
+
(newMonth: string) => {
|
|
228
|
+
onMonthChange(year, parseInt(newMonth, 10));
|
|
229
|
+
},
|
|
230
|
+
[year, onMonthChange],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const handleYearPickerChange = useCallback(
|
|
234
|
+
(newYear: string) => {
|
|
235
|
+
onMonthChange(parseInt(newYear, 10), month);
|
|
236
|
+
},
|
|
237
|
+
[month, onMonthChange],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const yearOptions = useMemo(() => getYearOptions(year), [year]);
|
|
241
|
+
|
|
242
|
+
const weeks = useMemo(() => {
|
|
243
|
+
const result: (number | null)[][] = [];
|
|
244
|
+
let currentWeek: (number | null)[] = [];
|
|
245
|
+
|
|
246
|
+
// Add empty cells for days before the first day of month
|
|
247
|
+
for (let i = 0; i < firstDayOffset; i++) {
|
|
248
|
+
currentWeek.push(null);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Add days of the month
|
|
252
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
253
|
+
currentWeek.push(day);
|
|
254
|
+
if (currentWeek.length === 7) {
|
|
255
|
+
result.push(currentWeek);
|
|
256
|
+
currentWeek = [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add empty cells for remaining days
|
|
261
|
+
if (currentWeek.length > 0) {
|
|
262
|
+
while (currentWeek.length < 7) {
|
|
263
|
+
currentWeek.push(null);
|
|
264
|
+
}
|
|
265
|
+
result.push(currentWeek);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return result;
|
|
269
|
+
}, [daysInMonth, firstDayOffset]);
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<View style={styles.monthContainer}>
|
|
273
|
+
{/* Header with month/year and navigation */}
|
|
274
|
+
<View style={styles.monthHeader}>
|
|
275
|
+
{showNavigation && (
|
|
276
|
+
<IconButton
|
|
277
|
+
icon="chevron-left"
|
|
278
|
+
accessibilityLabel="Previous month"
|
|
279
|
+
onPress={handlePrevMonth}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
{showLeftArrow && onPrevMonth && (
|
|
283
|
+
<IconButton
|
|
284
|
+
icon="chevron-left"
|
|
285
|
+
accessibilityLabel="Previous month"
|
|
286
|
+
onPress={onPrevMonth}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
{showNavigation ? (
|
|
290
|
+
<View style={styles.pickerContainer}>
|
|
291
|
+
<Picker
|
|
292
|
+
value={String(month)}
|
|
293
|
+
options={names.monthOptions}
|
|
294
|
+
onValueChange={handleMonthPickerChange}
|
|
295
|
+
/>
|
|
296
|
+
<Picker
|
|
297
|
+
value={String(year)}
|
|
298
|
+
options={yearOptions}
|
|
299
|
+
onValueChange={handleYearPickerChange}
|
|
300
|
+
/>
|
|
301
|
+
</View>
|
|
302
|
+
) : (
|
|
303
|
+
<View style={styles.monthYearText}>
|
|
304
|
+
<Text weight="medium" size="sm">
|
|
305
|
+
{names.months[month]} {year}
|
|
306
|
+
</Text>
|
|
307
|
+
</View>
|
|
308
|
+
)}
|
|
309
|
+
{showNavigation && (
|
|
310
|
+
<IconButton
|
|
311
|
+
icon="chevron-right"
|
|
312
|
+
accessibilityLabel="Next month"
|
|
313
|
+
onPress={handleNextMonth}
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
{showRightArrow && onNextMonth && (
|
|
317
|
+
<IconButton
|
|
318
|
+
icon="chevron-right"
|
|
319
|
+
accessibilityLabel="Next month"
|
|
320
|
+
onPress={onNextMonth}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</View>
|
|
324
|
+
|
|
325
|
+
{/* Days of week header */}
|
|
326
|
+
<View style={styles.weekHeader}>
|
|
327
|
+
{daysOfWeekLabels.map((day) => (
|
|
328
|
+
<View key={day} style={styles.dayHeaderCell}>
|
|
329
|
+
<Text size="xs" color="zinc-500">
|
|
330
|
+
{day}
|
|
331
|
+
</Text>
|
|
332
|
+
</View>
|
|
333
|
+
))}
|
|
334
|
+
</View>
|
|
335
|
+
|
|
336
|
+
{/* Calendar grid */}
|
|
337
|
+
{weeks.map((week, weekIndex) => (
|
|
338
|
+
<View key={weekIndex} style={styles.weekRow}>
|
|
339
|
+
{week.map((day, dayIndex) => {
|
|
340
|
+
if (day === null) {
|
|
341
|
+
return <View key={dayIndex} style={styles.dayCell} />;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const date = new Date(year, month, day);
|
|
345
|
+
const isSelected = isSameDay(date, selectedDate);
|
|
346
|
+
const isRangeStart = isRange && isSameDay(date, rangeStart);
|
|
347
|
+
const isRangeEnd = isRange && isSameDay(date, rangeEnd);
|
|
348
|
+
const inRange = isRange && isInRange(date, rangeStart, rangeEnd);
|
|
349
|
+
const isTodayDate = isToday(date);
|
|
350
|
+
const isActive = isSelected || isRangeStart || isRangeEnd;
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<Pressable
|
|
354
|
+
key={dayIndex}
|
|
355
|
+
accessibilityRole="button"
|
|
356
|
+
accessibilityLabel={names.formatDayLabel(date)}
|
|
357
|
+
accessibilityState={{ selected: isActive }}
|
|
358
|
+
style={({ hovered }) => [
|
|
359
|
+
styles.dayCell,
|
|
360
|
+
inRange && styles.dayCellInRange,
|
|
361
|
+
isActive && styles.dayCellSelected,
|
|
362
|
+
hovered && !isActive && styles.dayCellHovered,
|
|
363
|
+
]}
|
|
364
|
+
onPress={() => onDateSelect(date)}
|
|
365
|
+
>
|
|
366
|
+
<Text
|
|
367
|
+
size="sm"
|
|
368
|
+
color={isActive ? "inverted" : "default"}
|
|
369
|
+
userSelect="none"
|
|
370
|
+
weight={isTodayDate ? "semibold" : "regular"}
|
|
371
|
+
>
|
|
372
|
+
{day}
|
|
373
|
+
</Text>
|
|
374
|
+
{isTodayDate && (
|
|
375
|
+
<View style={[styles.todayDot, isActive && styles.todayDotInverted]} />
|
|
376
|
+
)}
|
|
377
|
+
</Pressable>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</View>
|
|
381
|
+
))}
|
|
382
|
+
</View>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// Main Calendar Component
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
export function Calendar<TMode extends "single" | "range">(
|
|
391
|
+
props: CalendarProps<TMode>,
|
|
392
|
+
): React.ReactElement {
|
|
393
|
+
const screenSize = useScreenSize();
|
|
394
|
+
const firstDayOfWeek = props.firstDayOfWeek ?? 1; // Default to Monday
|
|
395
|
+
const locale = props.locale ?? DEFAULT_LOCALE;
|
|
396
|
+
|
|
397
|
+
if (props.mode === "single") {
|
|
398
|
+
return (
|
|
399
|
+
<SingleCalendar
|
|
400
|
+
value={props.value as CalendarSingleValue}
|
|
401
|
+
onValueChange={props.onValueChange as (value: CalendarSingleValue) => void}
|
|
402
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
403
|
+
locale={locale}
|
|
404
|
+
/>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<RangeCalendar
|
|
410
|
+
ref={props.ref}
|
|
411
|
+
value={props.value as CalendarRangeValue}
|
|
412
|
+
onValueChange={props.onValueChange as (value: CalendarRangeValue) => void}
|
|
413
|
+
isSmall={screenSize.small}
|
|
414
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
415
|
+
locale={locale}
|
|
416
|
+
/>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// =============================================================================
|
|
421
|
+
// Single Calendar
|
|
422
|
+
// =============================================================================
|
|
423
|
+
|
|
424
|
+
interface SingleCalendarInternalProps {
|
|
425
|
+
value: Date | null;
|
|
426
|
+
onValueChange: (value: Date | null) => void;
|
|
427
|
+
firstDayOfWeek: DayOfWeek;
|
|
428
|
+
locale: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function SingleCalendar(props: SingleCalendarInternalProps) {
|
|
432
|
+
const { value, onValueChange, firstDayOfWeek, locale } = props;
|
|
433
|
+
|
|
434
|
+
const initialDate = useMemo(() => value || new Date(), [value]);
|
|
435
|
+
const [viewYear, setViewYear] = useState(initialDate.getFullYear());
|
|
436
|
+
const [viewMonth, setViewMonth] = useState(initialDate.getMonth());
|
|
437
|
+
|
|
438
|
+
const handleDateSelect = useCallback(
|
|
439
|
+
(date: Date) => {
|
|
440
|
+
onValueChange(date);
|
|
441
|
+
},
|
|
442
|
+
[onValueChange],
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const handleMonthChange = useCallback((year: number, month: number) => {
|
|
446
|
+
setViewYear(year);
|
|
447
|
+
setViewMonth(month);
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<View style={styles.calendarContainer}>
|
|
452
|
+
<CalendarMonth
|
|
453
|
+
year={viewYear}
|
|
454
|
+
month={viewMonth}
|
|
455
|
+
selectedDate={value}
|
|
456
|
+
rangeStart={null}
|
|
457
|
+
rangeEnd={null}
|
|
458
|
+
isRange={false}
|
|
459
|
+
onDateSelect={handleDateSelect}
|
|
460
|
+
onMonthChange={handleMonthChange}
|
|
461
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
462
|
+
locale={locale}
|
|
463
|
+
/>
|
|
464
|
+
</View>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// =============================================================================
|
|
469
|
+
// Range Calendar
|
|
470
|
+
// =============================================================================
|
|
471
|
+
|
|
472
|
+
interface RangeCalendarInternalProps {
|
|
473
|
+
value: { start: Date | null; end: Date | null };
|
|
474
|
+
onValueChange: (value: { start: Date | null; end: Date | null }) => void;
|
|
475
|
+
isSmall: boolean;
|
|
476
|
+
firstDayOfWeek: DayOfWeek;
|
|
477
|
+
locale: string;
|
|
478
|
+
ref?: React.Ref<CalendarRef>;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
482
|
+
const { value, onValueChange, isSmall, firstDayOfWeek, locale, ref } = props;
|
|
483
|
+
|
|
484
|
+
// Initialize view to show start date's month, or current month
|
|
485
|
+
const initialDate = useMemo(() => value.start || new Date(), [value.start]);
|
|
486
|
+
const [leftYear, setLeftYear] = useState(initialDate.getFullYear());
|
|
487
|
+
const [leftMonth, setLeftMonth] = useState(initialDate.getMonth());
|
|
488
|
+
|
|
489
|
+
// Expose navigation method via ref
|
|
490
|
+
useImperativeHandle(ref, () => ({
|
|
491
|
+
navigateToMonth: (year: number, month: number) => {
|
|
492
|
+
setLeftYear(year);
|
|
493
|
+
setLeftMonth(month);
|
|
494
|
+
},
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
// Right calendar shows next month
|
|
498
|
+
const rightYear = leftMonth === 11 ? leftYear + 1 : leftYear;
|
|
499
|
+
const rightMonth = leftMonth === 11 ? 0 : leftMonth + 1;
|
|
500
|
+
|
|
501
|
+
// Track which part of range we're selecting
|
|
502
|
+
const [selectingEnd, setSelectingEnd] = useState(false);
|
|
503
|
+
|
|
504
|
+
const handleDateSelect = useCallback(
|
|
505
|
+
(date: Date) => {
|
|
506
|
+
if (!value.start || (value.start && value.end) || !selectingEnd) {
|
|
507
|
+
// Start new selection
|
|
508
|
+
onValueChange({ start: date, end: null });
|
|
509
|
+
setSelectingEnd(true);
|
|
510
|
+
} else {
|
|
511
|
+
// Complete selection
|
|
512
|
+
const start = value.start;
|
|
513
|
+
// Ensure start is before end
|
|
514
|
+
if (date < start) {
|
|
515
|
+
onValueChange({ start: date, end: start });
|
|
516
|
+
} else {
|
|
517
|
+
onValueChange({ start, end: date });
|
|
518
|
+
}
|
|
519
|
+
setSelectingEnd(false);
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
[value, onValueChange, selectingEnd],
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const handleMonthChange = useCallback((year: number, month: number) => {
|
|
526
|
+
setLeftYear(year);
|
|
527
|
+
setLeftMonth(month);
|
|
528
|
+
}, []);
|
|
529
|
+
|
|
530
|
+
// Shared navigation - navigates both calendars together
|
|
531
|
+
const handlePrevMonth = useCallback(() => {
|
|
532
|
+
if (leftMonth === 0) {
|
|
533
|
+
setLeftYear(leftYear - 1);
|
|
534
|
+
setLeftMonth(11);
|
|
535
|
+
} else {
|
|
536
|
+
setLeftMonth(leftMonth - 1);
|
|
537
|
+
}
|
|
538
|
+
}, [leftYear, leftMonth]);
|
|
539
|
+
|
|
540
|
+
const handleNextMonth = useCallback(() => {
|
|
541
|
+
if (leftMonth === 11) {
|
|
542
|
+
setLeftYear(leftYear + 1);
|
|
543
|
+
setLeftMonth(0);
|
|
544
|
+
} else {
|
|
545
|
+
setLeftMonth(leftMonth + 1);
|
|
546
|
+
}
|
|
547
|
+
}, [leftYear, leftMonth]);
|
|
548
|
+
|
|
549
|
+
// On small screens: single calendar with navigation in header
|
|
550
|
+
// On desktop: horizontal layout with arrows inline
|
|
551
|
+
if (isSmall) {
|
|
552
|
+
return (
|
|
553
|
+
<View style={styles.calendarContainer}>
|
|
554
|
+
<CalendarMonth
|
|
555
|
+
year={leftYear}
|
|
556
|
+
month={leftMonth}
|
|
557
|
+
selectedDate={null}
|
|
558
|
+
rangeStart={value.start}
|
|
559
|
+
rangeEnd={value.end}
|
|
560
|
+
isRange={true}
|
|
561
|
+
onDateSelect={handleDateSelect}
|
|
562
|
+
onMonthChange={handleMonthChange}
|
|
563
|
+
showNavigation={true}
|
|
564
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
565
|
+
locale={locale}
|
|
566
|
+
/>
|
|
567
|
+
</View>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Desktop: horizontal layout with arrows inline with month/year
|
|
572
|
+
return (
|
|
573
|
+
<View style={[styles.calendarContainer, { flexDirection: "row", gap: 24 }]}>
|
|
574
|
+
<CalendarMonth
|
|
575
|
+
year={leftYear}
|
|
576
|
+
month={leftMonth}
|
|
577
|
+
selectedDate={null}
|
|
578
|
+
rangeStart={value.start}
|
|
579
|
+
rangeEnd={value.end}
|
|
580
|
+
isRange={true}
|
|
581
|
+
onDateSelect={handleDateSelect}
|
|
582
|
+
onMonthChange={handleMonthChange}
|
|
583
|
+
showNavigation={false}
|
|
584
|
+
showLeftArrow={true}
|
|
585
|
+
onPrevMonth={handlePrevMonth}
|
|
586
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
587
|
+
locale={locale}
|
|
588
|
+
/>
|
|
589
|
+
<CalendarMonth
|
|
590
|
+
year={rightYear}
|
|
591
|
+
month={rightMonth}
|
|
592
|
+
selectedDate={null}
|
|
593
|
+
rangeStart={value.start}
|
|
594
|
+
rangeEnd={value.end}
|
|
595
|
+
isRange={true}
|
|
596
|
+
onDateSelect={handleDateSelect}
|
|
597
|
+
onMonthChange={handleMonthChange}
|
|
598
|
+
showNavigation={false}
|
|
599
|
+
showRightArrow={true}
|
|
600
|
+
onNextMonth={handleNextMonth}
|
|
601
|
+
firstDayOfWeek={firstDayOfWeek}
|
|
602
|
+
locale={locale}
|
|
603
|
+
/>
|
|
604
|
+
</View>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// =============================================================================
|
|
609
|
+
// Styles
|
|
610
|
+
// =============================================================================
|
|
611
|
+
|
|
612
|
+
const styles = StyleSheet.create({
|
|
613
|
+
calendarContainer: {},
|
|
614
|
+
monthContainer: {
|
|
615
|
+
minWidth: 280,
|
|
616
|
+
},
|
|
617
|
+
monthHeader: {
|
|
618
|
+
flexDirection: "row",
|
|
619
|
+
alignItems: "center",
|
|
620
|
+
justifyContent: "space-between",
|
|
621
|
+
gap: 8,
|
|
622
|
+
},
|
|
623
|
+
pickerContainer: {
|
|
624
|
+
flex: 1,
|
|
625
|
+
flexDirection: "row",
|
|
626
|
+
gap: 8,
|
|
627
|
+
justifyContent: "center",
|
|
628
|
+
},
|
|
629
|
+
monthYearText: {
|
|
630
|
+
flex: 1,
|
|
631
|
+
alignItems: "center",
|
|
632
|
+
justifyContent: "center",
|
|
633
|
+
},
|
|
634
|
+
weekHeader: {
|
|
635
|
+
flexDirection: "row",
|
|
636
|
+
justifyContent: "space-around",
|
|
637
|
+
borderBottomWidth: 1,
|
|
638
|
+
borderBottomColor: colors.zinc["200"],
|
|
639
|
+
paddingBottom: 8,
|
|
640
|
+
marginBottom: 4,
|
|
641
|
+
},
|
|
642
|
+
dayHeaderCell: {
|
|
643
|
+
width: 36,
|
|
644
|
+
alignItems: "center",
|
|
645
|
+
justifyContent: "center",
|
|
646
|
+
height: 32,
|
|
647
|
+
},
|
|
648
|
+
weekRow: {
|
|
649
|
+
flexDirection: "row",
|
|
650
|
+
justifyContent: "space-around",
|
|
651
|
+
},
|
|
652
|
+
dayCell: {
|
|
653
|
+
width: 36,
|
|
654
|
+
height: 36,
|
|
655
|
+
alignItems: "center",
|
|
656
|
+
justifyContent: "center",
|
|
657
|
+
borderRadius: 999,
|
|
658
|
+
},
|
|
659
|
+
dayCellSelected: {
|
|
660
|
+
backgroundColor: colors.zinc["800"],
|
|
661
|
+
},
|
|
662
|
+
dayCellInRange: {
|
|
663
|
+
backgroundColor: colors.zinc["100"],
|
|
664
|
+
},
|
|
665
|
+
dayCellHovered: {
|
|
666
|
+
backgroundColor: colors.zinc["50"],
|
|
667
|
+
},
|
|
668
|
+
todayDot: {
|
|
669
|
+
position: "absolute",
|
|
670
|
+
bottom: 4,
|
|
671
|
+
width: 4,
|
|
672
|
+
height: 4,
|
|
673
|
+
borderRadius: 2,
|
|
674
|
+
backgroundColor: colors.zinc["800"],
|
|
675
|
+
},
|
|
676
|
+
todayDotInverted: {
|
|
677
|
+
backgroundColor: colors.white,
|
|
678
|
+
},
|
|
679
|
+
});
|