react-native-bigger-calendar 0.1.0 → 0.2.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/README.md +82 -2
- package/dist/index.d.mts +289 -73
- package/dist/index.d.ts +289 -73
- package/dist/index.js +650 -144
- package/dist/index.mjs +649 -145
- package/package.json +1 -1
- package/src/components/Agenda.tsx +125 -0
- package/src/components/AllDayLane.tsx +83 -0
- package/src/components/Calendar.tsx +221 -11
- package/src/components/DefaultEvent.tsx +24 -5
- package/src/components/MonthPager.tsx +138 -26
- package/src/components/MonthView.tsx +82 -14
- package/src/components/TimeGrid.tsx +344 -56
- package/src/index.tsx +9 -2
- package/src/types.ts +23 -1
- package/src/utils/dates.ts +67 -2
- package/src/utils/layout.ts +18 -0
|
@@ -7,8 +7,11 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
addDays,
|
|
9
9
|
differenceInCalendarDays,
|
|
10
|
+
format,
|
|
10
11
|
getHours,
|
|
12
|
+
getISOWeek,
|
|
11
13
|
getMinutes,
|
|
14
|
+
type Locale,
|
|
12
15
|
startOfDay,
|
|
13
16
|
startOfWeek,
|
|
14
17
|
} from 'date-fns';
|
|
@@ -17,9 +20,11 @@ import {
|
|
|
17
20
|
type GestureResponderEvent,
|
|
18
21
|
Pressable,
|
|
19
22
|
StyleSheet,
|
|
23
|
+
type StyleProp,
|
|
20
24
|
Text,
|
|
21
25
|
useWindowDimensions,
|
|
22
26
|
View,
|
|
27
|
+
type ViewStyle,
|
|
23
28
|
} from 'react-native';
|
|
24
29
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
25
30
|
import Animated, {
|
|
@@ -38,16 +43,15 @@ import type {
|
|
|
38
43
|
CalendarMode,
|
|
39
44
|
EventKeyExtractor,
|
|
40
45
|
RenderEvent,
|
|
46
|
+
TimeGridMode,
|
|
41
47
|
WeekStartsOn,
|
|
42
48
|
} from '../types';
|
|
43
|
-
import { getIsToday,
|
|
49
|
+
import { getIsToday, getViewDays, isSameCalendarDay, isWeekend, viewDayCount } from '../utils/dates';
|
|
44
50
|
import { layoutDayEvents, type PositionedEvent } from '../utils/layout';
|
|
51
|
+
import { AllDayLane } from './AllDayLane';
|
|
45
52
|
|
|
46
53
|
const MINUTES_PER_HOUR = 60;
|
|
47
54
|
const HOURS_PER_DAY = 24;
|
|
48
|
-
// Days (day view) or weeks (week view) to step when paging to an adjacent page.
|
|
49
|
-
const DAY_VIEW_STEP = 1;
|
|
50
|
-
const WEEK_VIEW_STEP = 7;
|
|
51
55
|
// Steps rendered either side of the current page. LegendList virtualises, so
|
|
52
56
|
// only a few mount at once; a wide window means the user effectively never runs
|
|
53
57
|
// out of pages to swipe. Items are keyed by date and never recycled.
|
|
@@ -99,6 +103,7 @@ type AnimatedEventBoxProps<T> = {
|
|
|
99
103
|
mode: CalendarMode;
|
|
100
104
|
renderEvent: RenderEvent<T>;
|
|
101
105
|
onPress: (event: CalendarEvent<T>) => void;
|
|
106
|
+
onLongPress?: (event: CalendarEvent<T>) => void;
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
function AnimatedEventBox<T>({
|
|
@@ -110,6 +115,7 @@ function AnimatedEventBox<T>({
|
|
|
110
115
|
mode,
|
|
111
116
|
renderEvent,
|
|
112
117
|
onPress,
|
|
118
|
+
onLongPress,
|
|
113
119
|
}: AnimatedEventBoxProps<T>) {
|
|
114
120
|
const RenderEventComponent = renderEvent;
|
|
115
121
|
// Live pixel height of the box, driven on the UI thread by the shared
|
|
@@ -130,6 +136,7 @@ function AnimatedEventBox<T>({
|
|
|
130
136
|
);
|
|
131
137
|
|
|
132
138
|
const handlePress = () => onPress(positioned.event);
|
|
139
|
+
const handleLongPress = onLongPress ? () => onLongPress(positioned.event) : undefined;
|
|
133
140
|
|
|
134
141
|
return (
|
|
135
142
|
<Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
|
|
@@ -140,20 +147,34 @@ function AnimatedEventBox<T>({
|
|
|
140
147
|
continuesBefore={positioned.continuesBefore}
|
|
141
148
|
continuesAfter={positioned.continuesAfter}
|
|
142
149
|
onPress={handlePress}
|
|
150
|
+
onLongPress={handleLongPress}
|
|
143
151
|
/>
|
|
144
152
|
</Animated.View>
|
|
145
153
|
);
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
|
|
157
|
+
export type HourRenderer = (hour: number, ampm: boolean) => React.ReactNode;
|
|
158
|
+
|
|
148
159
|
type HourRowProps = {
|
|
149
160
|
hour: number;
|
|
150
161
|
minHour: number;
|
|
151
162
|
cellHeight: SharedValue<number>;
|
|
152
163
|
hourColumnWidth: number;
|
|
153
164
|
label: string;
|
|
165
|
+
ampm: boolean;
|
|
166
|
+
hourComponent?: HourRenderer;
|
|
154
167
|
};
|
|
155
168
|
|
|
156
|
-
const HourRow = ({
|
|
169
|
+
const HourRow = ({
|
|
170
|
+
hour,
|
|
171
|
+
minHour,
|
|
172
|
+
cellHeight,
|
|
173
|
+
hourColumnWidth,
|
|
174
|
+
label,
|
|
175
|
+
ampm,
|
|
176
|
+
hourComponent,
|
|
177
|
+
}: HourRowProps) => {
|
|
157
178
|
const theme = useCalendarTheme();
|
|
158
179
|
// Position via `top` (a layout prop), not a transform. The per-row layout pass
|
|
159
180
|
// as cellHeight animates keeps the ScrollView's content size in sync while
|
|
@@ -165,21 +186,48 @@ const HourRow = ({ hour, minHour, cellHeight, hourColumnWidth, label }: HourRowP
|
|
|
165
186
|
|
|
166
187
|
return (
|
|
167
188
|
<Animated.View style={[styles.hourRow, animatedStyle]} pointerEvents="none">
|
|
168
|
-
|
|
169
|
-
style={
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
{hourComponent ? (
|
|
190
|
+
<View style={{ width: hourColumnWidth }}>{hourComponent(hour, ampm)}</View>
|
|
191
|
+
) : (
|
|
192
|
+
<Text
|
|
193
|
+
style={[
|
|
194
|
+
theme.text.hourLabel,
|
|
195
|
+
styles.hourLabel,
|
|
196
|
+
{ width: hourColumnWidth, color: theme.colors.textMuted },
|
|
197
|
+
]}
|
|
198
|
+
allowFontScaling={false}
|
|
199
|
+
>
|
|
200
|
+
{label}
|
|
201
|
+
</Text>
|
|
202
|
+
)}
|
|
178
203
|
<View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
|
|
179
204
|
</Animated.View>
|
|
180
205
|
);
|
|
181
206
|
};
|
|
182
207
|
|
|
208
|
+
type TimeslotLineProps = {
|
|
209
|
+
hour: number;
|
|
210
|
+
minHour: number;
|
|
211
|
+
fraction: number;
|
|
212
|
+
cellHeight: SharedValue<number>;
|
|
213
|
+
hourColumnWidth: number;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// A faint divider inside an hour row, marking a sub-hour slot (e.g. half hours).
|
|
217
|
+
const TimeslotLine = ({ hour, minHour, fraction, cellHeight, hourColumnWidth }: TimeslotLineProps) => {
|
|
218
|
+
const theme = useCalendarTheme();
|
|
219
|
+
const animatedStyle = useAnimatedStyle(
|
|
220
|
+
() => ({ top: (hour - minHour + fraction) * cellHeight.value }),
|
|
221
|
+
[hour, minHour, fraction],
|
|
222
|
+
);
|
|
223
|
+
return (
|
|
224
|
+
<Animated.View
|
|
225
|
+
style={[styles.timeslotLine, { left: hourColumnWidth, backgroundColor: theme.colors.gridLine }, animatedStyle]}
|
|
226
|
+
pointerEvents="none"
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
183
231
|
type NowIndicatorProps = {
|
|
184
232
|
cellHeight: SharedValue<number>;
|
|
185
233
|
nowHours: number;
|
|
@@ -203,7 +251,8 @@ const NowIndicator = ({ cellHeight, nowHours, minHour, left, color }: NowIndicat
|
|
|
203
251
|
};
|
|
204
252
|
|
|
205
253
|
type TimetablePageProps<T> = {
|
|
206
|
-
mode:
|
|
254
|
+
mode: TimeGridMode;
|
|
255
|
+
numberOfDays: number;
|
|
207
256
|
date: Date;
|
|
208
257
|
events: CalendarEvent<T>[];
|
|
209
258
|
cellHeight: SharedValue<number>;
|
|
@@ -216,17 +265,26 @@ type TimetablePageProps<T> = {
|
|
|
216
265
|
isActive: boolean;
|
|
217
266
|
scrollOffsetMinutes: number;
|
|
218
267
|
weekStartsOn: WeekStartsOn;
|
|
268
|
+
weekEndsOn?: WeekStartsOn;
|
|
219
269
|
hourColumnWidth: number;
|
|
220
270
|
minHour: number;
|
|
221
271
|
maxHour: number;
|
|
222
272
|
ampm: boolean;
|
|
273
|
+
timeslots: number;
|
|
274
|
+
isRTL: boolean;
|
|
275
|
+
showVerticalScrollIndicator: boolean;
|
|
276
|
+
verticalScrollEnabled: boolean;
|
|
277
|
+
hourComponent?: HourRenderer;
|
|
278
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
223
279
|
minHourHeight: number;
|
|
224
280
|
maxHourHeight: number;
|
|
225
281
|
showNowIndicator: boolean;
|
|
226
282
|
renderEvent: RenderEvent<T>;
|
|
227
283
|
keyExtractor: EventKeyExtractor<T>;
|
|
228
284
|
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
285
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
229
286
|
onPressCell?: (date: Date) => void;
|
|
287
|
+
onLongPressCell?: (date: Date) => void;
|
|
230
288
|
};
|
|
231
289
|
|
|
232
290
|
// A single date's grid: the pinch-zoomable, vertically-scrolling time column.
|
|
@@ -234,6 +292,7 @@ type TimetablePageProps<T> = {
|
|
|
234
292
|
// next dates are ready to drag into view.
|
|
235
293
|
function TimetablePageInner<T>({
|
|
236
294
|
mode,
|
|
295
|
+
numberOfDays,
|
|
237
296
|
date,
|
|
238
297
|
events,
|
|
239
298
|
cellHeight,
|
|
@@ -243,17 +302,26 @@ function TimetablePageInner<T>({
|
|
|
243
302
|
isActive,
|
|
244
303
|
scrollOffsetMinutes,
|
|
245
304
|
weekStartsOn,
|
|
305
|
+
weekEndsOn,
|
|
246
306
|
hourColumnWidth,
|
|
247
307
|
minHour,
|
|
248
308
|
maxHour,
|
|
249
309
|
ampm,
|
|
310
|
+
timeslots,
|
|
311
|
+
isRTL,
|
|
312
|
+
showVerticalScrollIndicator,
|
|
313
|
+
verticalScrollEnabled,
|
|
314
|
+
hourComponent,
|
|
315
|
+
calendarCellStyle,
|
|
250
316
|
minHourHeight,
|
|
251
317
|
maxHourHeight,
|
|
252
318
|
showNowIndicator,
|
|
253
319
|
renderEvent,
|
|
254
320
|
keyExtractor,
|
|
255
321
|
onPressEvent,
|
|
322
|
+
onLongPressEvent,
|
|
256
323
|
onPressCell,
|
|
324
|
+
onLongPressCell,
|
|
257
325
|
}: TimetablePageProps<T>) {
|
|
258
326
|
const theme = useCalendarTheme();
|
|
259
327
|
const { width } = useWindowDimensions();
|
|
@@ -282,8 +350,8 @@ function TimetablePageInner<T>({
|
|
|
282
350
|
);
|
|
283
351
|
|
|
284
352
|
const days = useMemo(
|
|
285
|
-
() => (mode
|
|
286
|
-
[mode, date, weekStartsOn],
|
|
353
|
+
() => getViewDays(mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
|
|
354
|
+
[mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
287
355
|
);
|
|
288
356
|
|
|
289
357
|
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
@@ -296,17 +364,24 @@ function TimetablePageInner<T>({
|
|
|
296
364
|
|
|
297
365
|
// Map a tap on empty grid space back to the date+time it represents. Reads the
|
|
298
366
|
// live row height on the JS thread to convert the touch Y into minutes.
|
|
299
|
-
const
|
|
300
|
-
if (!onPressCell) return;
|
|
367
|
+
const cellDateFromTouch = (event: GestureResponderEvent): Date | null => {
|
|
301
368
|
const { locationX, locationY } = event.nativeEvent;
|
|
302
369
|
const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
|
|
303
370
|
const day = days[dayIndex];
|
|
304
|
-
if (!day) return;
|
|
371
|
+
if (!day) return null;
|
|
305
372
|
const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
|
|
306
373
|
const pressed = new Date(day);
|
|
307
374
|
pressed.setHours(0, 0, 0, 0);
|
|
308
375
|
pressed.setMinutes(minutes);
|
|
309
|
-
|
|
376
|
+
return pressed;
|
|
377
|
+
};
|
|
378
|
+
const handleBackgroundPress = (event: GestureResponderEvent) => {
|
|
379
|
+
const date = onPressCell && cellDateFromTouch(event);
|
|
380
|
+
if (date) onPressCell?.(date);
|
|
381
|
+
};
|
|
382
|
+
const handleBackgroundLongPress = (event: GestureResponderEvent) => {
|
|
383
|
+
const date = onLongPressCell && cellDateFromTouch(event);
|
|
384
|
+
if (date) onLongPressCell?.(date);
|
|
310
385
|
};
|
|
311
386
|
|
|
312
387
|
// The hours (rows/labels) visible in the window [minHour, maxHour).
|
|
@@ -354,10 +429,22 @@ function TimetablePageInner<T>({
|
|
|
354
429
|
|
|
355
430
|
return (
|
|
356
431
|
<View style={styles.container}>
|
|
432
|
+
<AllDayLane
|
|
433
|
+
days={days}
|
|
434
|
+
events={events}
|
|
435
|
+
mode={mode}
|
|
436
|
+
hourColumnWidth={hourColumnWidth}
|
|
437
|
+
dayWidth={dayWidth}
|
|
438
|
+
renderEvent={renderEvent}
|
|
439
|
+
keyExtractor={keyExtractor}
|
|
440
|
+
onPressEvent={onPressEvent}
|
|
441
|
+
onLongPressEvent={onLongPressEvent}
|
|
442
|
+
/>
|
|
357
443
|
<GestureDetector gesture={zoomGesture}>
|
|
358
444
|
<Animated.ScrollView
|
|
359
445
|
ref={scrollRef}
|
|
360
|
-
showsVerticalScrollIndicator
|
|
446
|
+
showsVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
447
|
+
scrollEnabled={verticalScrollEnabled}
|
|
361
448
|
onScroll={scrollHandler}
|
|
362
449
|
scrollEventThrottle={16}
|
|
363
450
|
contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
|
|
@@ -367,13 +454,14 @@ function TimetablePageInner<T>({
|
|
|
367
454
|
}}
|
|
368
455
|
>
|
|
369
456
|
<Animated.View style={[styles.content, fullHeightStyle]}>
|
|
370
|
-
{onPressCell ? (
|
|
457
|
+
{onPressCell || onLongPressCell ? (
|
|
371
458
|
// Behind the events, so empty-space taps create while event taps
|
|
372
459
|
// still hit their box. Hidden from screen readers (a convenience
|
|
373
460
|
// gesture, not the primary create path).
|
|
374
461
|
<Pressable
|
|
375
462
|
style={[styles.cellPressLayer, { left: hourColumnWidth }]}
|
|
376
|
-
onPress={handleBackgroundPress}
|
|
463
|
+
onPress={onPressCell ? handleBackgroundPress : undefined}
|
|
464
|
+
onLongPress={onLongPressCell ? handleBackgroundLongPress : undefined}
|
|
377
465
|
importantForAccessibility="no"
|
|
378
466
|
accessibilityElementsHidden
|
|
379
467
|
/>
|
|
@@ -394,6 +482,24 @@ function TimetablePageInner<T>({
|
|
|
394
482
|
) : null,
|
|
395
483
|
)}
|
|
396
484
|
|
|
485
|
+
{calendarCellStyle
|
|
486
|
+
? days.map((day, dayIndex) => {
|
|
487
|
+
const cellStyle = calendarCellStyle(day);
|
|
488
|
+
return cellStyle ? (
|
|
489
|
+
<Animated.View
|
|
490
|
+
key={`cell-${day.toISOString()}`}
|
|
491
|
+
style={[
|
|
492
|
+
styles.weekendColumn,
|
|
493
|
+
{ left: dayLeft(dayIndex), width: dayWidth },
|
|
494
|
+
cellStyle,
|
|
495
|
+
fullHeightStyle,
|
|
496
|
+
]}
|
|
497
|
+
pointerEvents="none"
|
|
498
|
+
/>
|
|
499
|
+
) : null;
|
|
500
|
+
})
|
|
501
|
+
: null}
|
|
502
|
+
|
|
397
503
|
{days.map((day, dayIndex) => (
|
|
398
504
|
<Animated.View
|
|
399
505
|
key={`separator-${day.toISOString()}`}
|
|
@@ -415,9 +521,26 @@ function TimetablePageInner<T>({
|
|
|
415
521
|
cellHeight={heightSource}
|
|
416
522
|
hourColumnWidth={hourColumnWidth}
|
|
417
523
|
label={formatHourLabel(hour, ampm)}
|
|
524
|
+
ampm={ampm}
|
|
525
|
+
hourComponent={hourComponent}
|
|
418
526
|
/>
|
|
419
527
|
))}
|
|
420
528
|
|
|
529
|
+
{timeslots > 1
|
|
530
|
+
? hoursRange.flatMap((hour) =>
|
|
531
|
+
Array.from({ length: timeslots - 1 }, (_, i) => (
|
|
532
|
+
<TimeslotLine
|
|
533
|
+
key={`slot-${hour}-${i}`}
|
|
534
|
+
hour={hour}
|
|
535
|
+
minHour={minHour}
|
|
536
|
+
fraction={(i + 1) / timeslots}
|
|
537
|
+
cellHeight={heightSource}
|
|
538
|
+
hourColumnWidth={hourColumnWidth}
|
|
539
|
+
/>
|
|
540
|
+
)),
|
|
541
|
+
)
|
|
542
|
+
: null}
|
|
543
|
+
|
|
421
544
|
{dayLayouts.flatMap((layout, dayIndex) =>
|
|
422
545
|
layout
|
|
423
546
|
// Skip events that fall entirely outside the [minHour, maxHour) window.
|
|
@@ -437,6 +560,7 @@ function TimetablePageInner<T>({
|
|
|
437
560
|
mode={mode}
|
|
438
561
|
renderEvent={renderEvent}
|
|
439
562
|
onPress={onPressEvent}
|
|
563
|
+
onLongPress={onLongPressEvent}
|
|
440
564
|
/>
|
|
441
565
|
);
|
|
442
566
|
}),
|
|
@@ -461,7 +585,15 @@ function TimetablePageInner<T>({
|
|
|
461
585
|
const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
|
|
462
586
|
|
|
463
587
|
export type TimeGridProps<T> = {
|
|
464
|
-
mode:
|
|
588
|
+
mode: TimeGridMode;
|
|
589
|
+
/** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
|
|
590
|
+
numberOfDays?: number;
|
|
591
|
+
/**
|
|
592
|
+
* Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
|
|
593
|
+
* `weekStartsOn`…`weekEndsOn` of `date`'s week and pages by week, taking
|
|
594
|
+
* precedence over `numberOfDays`. Ignored by other modes.
|
|
595
|
+
*/
|
|
596
|
+
weekEndsOn?: WeekStartsOn;
|
|
465
597
|
date: Date;
|
|
466
598
|
events: CalendarEvent<T>[];
|
|
467
599
|
cellHeight: SharedValue<number>;
|
|
@@ -472,19 +604,49 @@ export type TimeGridProps<T> = {
|
|
|
472
604
|
keyExtractor: EventKeyExtractor<T>;
|
|
473
605
|
scrollOffsetMinutes?: number;
|
|
474
606
|
hourColumnWidth?: number;
|
|
607
|
+
/** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
|
|
608
|
+
hideHours?: boolean;
|
|
609
|
+
/** Sub-hour divider lines per hour (e.g. 2 = half-hours). Default 1 (none). */
|
|
610
|
+
timeslots?: number;
|
|
611
|
+
/** Per-date style merged onto each day column. */
|
|
612
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
613
|
+
/** Show the ISO week number in the header gutter. Default false. */
|
|
614
|
+
showWeekNumber?: boolean;
|
|
615
|
+
/** Element rendered between the day header and the grid. */
|
|
616
|
+
headerComponent?: React.ReactNode;
|
|
475
617
|
/** First hour shown (0–23). Default 0. */
|
|
476
618
|
minHour?: number;
|
|
477
619
|
/** Last hour shown, exclusive (1–24). Default 24. */
|
|
478
620
|
maxHour?: number;
|
|
479
621
|
/** Show hour labels in 12-hour AM/PM form. Default false (24h). */
|
|
480
622
|
ampm?: boolean;
|
|
623
|
+
/** Reverse day-column order (right-to-left). Default false. */
|
|
624
|
+
isRTL?: boolean;
|
|
481
625
|
minHourHeight?: number;
|
|
482
626
|
maxHourHeight?: number;
|
|
483
627
|
showNowIndicator?: boolean;
|
|
484
|
-
locale?:
|
|
628
|
+
locale?: Locale;
|
|
485
629
|
freeSwipe?: boolean;
|
|
630
|
+
/** Allow swiping between pages. Default true. */
|
|
631
|
+
swipeEnabled?: boolean;
|
|
632
|
+
/** Show the vertical scroll indicator on the time grid. Default true. */
|
|
633
|
+
showVerticalScrollIndicator?: boolean;
|
|
634
|
+
/** Allow vertical scrolling of the time grid. Default true. */
|
|
635
|
+
verticalScrollEnabled?: boolean;
|
|
636
|
+
/** Prefix for the week-number label (e.g. "W"). Default "W". */
|
|
637
|
+
weekNumberPrefix?: string;
|
|
638
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and `ampm`. */
|
|
639
|
+
hourComponent?: HourRenderer;
|
|
640
|
+
/** Highlight this date in the header instead of the real "today". */
|
|
641
|
+
activeDate?: Date;
|
|
642
|
+
/** After an empty-cell press, snap the pager back to the active page. Default false. */
|
|
643
|
+
resetPageOnPressCell?: boolean;
|
|
486
644
|
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
645
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
487
646
|
onPressCell?: (date: Date) => void;
|
|
647
|
+
onLongPressCell?: (date: Date) => void;
|
|
648
|
+
/** Tap a day's column header (default header only). */
|
|
649
|
+
onPressDateHeader?: (date: Date) => void;
|
|
488
650
|
onChangeDate: (date: Date) => void;
|
|
489
651
|
/** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
|
|
490
652
|
renderHeader?: (days: Date[]) => React.ReactNode;
|
|
@@ -492,6 +654,8 @@ export type TimeGridProps<T> = {
|
|
|
492
654
|
|
|
493
655
|
function TimeGridInner<T>({
|
|
494
656
|
mode,
|
|
657
|
+
numberOfDays = 1,
|
|
658
|
+
weekEndsOn,
|
|
495
659
|
date,
|
|
496
660
|
events,
|
|
497
661
|
cellHeight,
|
|
@@ -500,30 +664,57 @@ function TimeGridInner<T>({
|
|
|
500
664
|
renderEvent,
|
|
501
665
|
keyExtractor,
|
|
502
666
|
scrollOffsetMinutes = 0,
|
|
503
|
-
hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
667
|
+
hourColumnWidth: hourColumnWidthProp = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
668
|
+
hideHours = false,
|
|
669
|
+
timeslots = 1,
|
|
670
|
+
calendarCellStyle,
|
|
671
|
+
showWeekNumber = false,
|
|
672
|
+
headerComponent,
|
|
504
673
|
minHour = 0,
|
|
505
674
|
maxHour = HOURS_PER_DAY,
|
|
506
675
|
ampm = false,
|
|
676
|
+
isRTL = false,
|
|
507
677
|
minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
|
|
508
678
|
maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
|
|
509
679
|
showNowIndicator = true,
|
|
510
680
|
locale,
|
|
511
681
|
freeSwipe = false,
|
|
682
|
+
swipeEnabled = true,
|
|
683
|
+
showVerticalScrollIndicator = true,
|
|
684
|
+
verticalScrollEnabled = true,
|
|
685
|
+
weekNumberPrefix = 'W',
|
|
686
|
+
hourComponent,
|
|
687
|
+
activeDate,
|
|
688
|
+
resetPageOnPressCell = false,
|
|
512
689
|
onPressEvent,
|
|
690
|
+
onLongPressEvent,
|
|
513
691
|
onPressCell,
|
|
692
|
+
onLongPressCell,
|
|
693
|
+
onPressDateHeader,
|
|
514
694
|
onChangeDate,
|
|
515
695
|
renderHeader,
|
|
516
696
|
}: TimeGridProps<T>) {
|
|
517
697
|
// Guard against an inverted/out-of-range window so the grid never collapses.
|
|
518
698
|
const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
|
|
519
699
|
const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
|
|
700
|
+
// Collapse the hour gutter to zero when hours are hidden.
|
|
701
|
+
const hourColumnWidth = hideHours ? 0 : hourColumnWidthProp;
|
|
520
702
|
|
|
521
703
|
const { width, height } = useWindowDimensions();
|
|
522
704
|
const listRef = useRef<LegendListRef>(null);
|
|
523
705
|
// Horizontal list items need an explicit cross-axis height; seed it with the
|
|
524
706
|
// window height (so it renders immediately and in tests) and refine on layout.
|
|
525
707
|
const [pageHeight, setPageHeight] = useState(height);
|
|
526
|
-
|
|
708
|
+
// The list must remount exactly once — when the real height replaces the
|
|
709
|
+
// window-height seed — or it keeps the oversized seed and clips. It must NOT
|
|
710
|
+
// remount on later height changes (e.g. a taller day header vs a shorter week
|
|
711
|
+
// header on a mode switch): a remount blanks the visible page for a frame.
|
|
712
|
+
const [measured, setMeasured] = useState(false);
|
|
713
|
+
// Week-anchored modes page by a full week and align pages to the week start:
|
|
714
|
+
// `week`, and `custom` when a `weekEndsOn` defines a partial-week span.
|
|
715
|
+
const weekAnchored = mode === 'week' || (mode === 'custom' && weekEndsOn != null);
|
|
716
|
+
// Days advanced per page: a full week when week-anchored, else the column count.
|
|
717
|
+
const step = weekAnchored ? 7 : viewDayCount(mode, numberOfDays);
|
|
527
718
|
// Shared vertical scroll offset so every mounted page stays aligned. Seeded
|
|
528
719
|
// from the numeric hourHeight rather than reading cellHeight.value (which
|
|
529
720
|
// would warn about reading a shared value during render).
|
|
@@ -540,8 +731,8 @@ function TimeGridInner<T>({
|
|
|
540
731
|
const [anchorDate] = useState(date);
|
|
541
732
|
const anchor = useMemo(
|
|
542
733
|
() =>
|
|
543
|
-
|
|
544
|
-
[
|
|
734
|
+
weekAnchored ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
|
|
735
|
+
[weekAnchored, anchorDate, weekStartsOn],
|
|
545
736
|
);
|
|
546
737
|
const pageDates = useMemo(
|
|
547
738
|
() =>
|
|
@@ -553,10 +744,12 @@ function TimeGridInner<T>({
|
|
|
553
744
|
const indexOfDate = useCallback(
|
|
554
745
|
(target: Date) => {
|
|
555
746
|
const aligned =
|
|
556
|
-
|
|
557
|
-
|
|
747
|
+
weekAnchored ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
|
|
748
|
+
// Floor so an arbitrary date lands on the page whose range contains it
|
|
749
|
+
// (exact for day/week, where dates are already page-aligned).
|
|
750
|
+
return Math.floor(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
|
|
558
751
|
},
|
|
559
|
-
[anchor,
|
|
752
|
+
[anchor, weekAnchored, step, weekStartsOn],
|
|
560
753
|
);
|
|
561
754
|
|
|
562
755
|
// The committed date's page is the centred/active one. `viewedIndexRef` tracks
|
|
@@ -564,11 +757,12 @@ function TimeGridInner<T>({
|
|
|
564
757
|
const activeIndex = indexOfDate(date);
|
|
565
758
|
const viewedIndexRef = useRef(activeIndex);
|
|
566
759
|
|
|
567
|
-
// Header days track the
|
|
568
|
-
// never flashes another day's label.
|
|
760
|
+
// Header days track the active page (page-aligned), so they always match the
|
|
761
|
+
// columns below and a swipe never flashes another day's label.
|
|
569
762
|
const headerDays = useMemo(
|
|
570
|
-
() =>
|
|
571
|
-
|
|
763
|
+
() =>
|
|
764
|
+
getViewDays(mode, pageDates[activeIndex] ?? date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
|
|
765
|
+
[mode, pageDates, activeIndex, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
572
766
|
);
|
|
573
767
|
|
|
574
768
|
const handleViewableItemsChanged = useCallback(
|
|
@@ -589,6 +783,17 @@ function TimeGridInner<T>({
|
|
|
589
783
|
listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
|
|
590
784
|
}, [activeIndex]);
|
|
591
785
|
|
|
786
|
+
// Optionally snap the pager back to the active page after an empty-cell press
|
|
787
|
+
// (so tapping a far-swiped page returns to the committed date).
|
|
788
|
+
const handlePressCell = useMemo(() => {
|
|
789
|
+
if (!onPressCell) return undefined;
|
|
790
|
+
if (!resetPageOnPressCell) return onPressCell;
|
|
791
|
+
return (cellDate: Date) => {
|
|
792
|
+
onPressCell(cellDate);
|
|
793
|
+
listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
|
|
794
|
+
};
|
|
795
|
+
}, [onPressCell, resetPageOnPressCell, activeIndex]);
|
|
796
|
+
|
|
592
797
|
const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
|
|
593
798
|
const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
|
|
594
799
|
const getFixedItemSize = useCallback(() => width, [width]);
|
|
@@ -597,6 +802,7 @@ function TimeGridInner<T>({
|
|
|
597
802
|
<View style={{ width, height: pageHeight }}>
|
|
598
803
|
<TimetablePage
|
|
599
804
|
mode={mode}
|
|
805
|
+
numberOfDays={numberOfDays}
|
|
600
806
|
date={item}
|
|
601
807
|
events={events}
|
|
602
808
|
cellHeight={cellHeight}
|
|
@@ -606,17 +812,26 @@ function TimeGridInner<T>({
|
|
|
606
812
|
isActive={index === activeIndex}
|
|
607
813
|
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
608
814
|
weekStartsOn={weekStartsOn}
|
|
815
|
+
weekEndsOn={weekEndsOn}
|
|
609
816
|
hourColumnWidth={hourColumnWidth}
|
|
610
817
|
minHour={clampedMinHour}
|
|
611
818
|
maxHour={clampedMaxHour}
|
|
612
819
|
ampm={ampm}
|
|
820
|
+
timeslots={timeslots}
|
|
821
|
+
isRTL={isRTL}
|
|
822
|
+
showVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
823
|
+
verticalScrollEnabled={verticalScrollEnabled}
|
|
824
|
+
hourComponent={hourComponent}
|
|
825
|
+
calendarCellStyle={calendarCellStyle}
|
|
613
826
|
minHourHeight={minHourHeight}
|
|
614
827
|
maxHourHeight={maxHourHeight}
|
|
615
828
|
showNowIndicator={showNowIndicator}
|
|
616
829
|
renderEvent={renderEvent}
|
|
617
830
|
keyExtractor={keyExtractor}
|
|
618
831
|
onPressEvent={onPressEvent}
|
|
619
|
-
|
|
832
|
+
onLongPressEvent={onLongPressEvent}
|
|
833
|
+
onPressCell={handlePressCell}
|
|
834
|
+
onLongPressCell={onLongPressCell}
|
|
620
835
|
/>
|
|
621
836
|
</View>
|
|
622
837
|
),
|
|
@@ -624,6 +839,7 @@ function TimeGridInner<T>({
|
|
|
624
839
|
width,
|
|
625
840
|
pageHeight,
|
|
626
841
|
mode,
|
|
842
|
+
numberOfDays,
|
|
627
843
|
events,
|
|
628
844
|
cellHeight,
|
|
629
845
|
hourHeight,
|
|
@@ -632,17 +848,26 @@ function TimeGridInner<T>({
|
|
|
632
848
|
activeIndex,
|
|
633
849
|
scrollOffsetMinutes,
|
|
634
850
|
weekStartsOn,
|
|
851
|
+
weekEndsOn,
|
|
635
852
|
hourColumnWidth,
|
|
636
853
|
clampedMinHour,
|
|
637
854
|
clampedMaxHour,
|
|
638
855
|
ampm,
|
|
856
|
+
timeslots,
|
|
857
|
+
isRTL,
|
|
858
|
+
showVerticalScrollIndicator,
|
|
859
|
+
verticalScrollEnabled,
|
|
860
|
+
hourComponent,
|
|
861
|
+
calendarCellStyle,
|
|
639
862
|
minHourHeight,
|
|
640
863
|
maxHourHeight,
|
|
641
864
|
showNowIndicator,
|
|
642
865
|
renderEvent,
|
|
643
866
|
keyExtractor,
|
|
644
867
|
onPressEvent,
|
|
645
|
-
|
|
868
|
+
onLongPressEvent,
|
|
869
|
+
handlePressCell,
|
|
870
|
+
onLongPressCell,
|
|
646
871
|
],
|
|
647
872
|
);
|
|
648
873
|
|
|
@@ -656,18 +881,28 @@ function TimeGridInner<T>({
|
|
|
656
881
|
mode={mode}
|
|
657
882
|
width={width}
|
|
658
883
|
hourColumnWidth={hourColumnWidth}
|
|
884
|
+
showWeekNumber={showWeekNumber}
|
|
885
|
+
weekNumberPrefix={weekNumberPrefix}
|
|
659
886
|
locale={locale}
|
|
887
|
+
activeDate={activeDate}
|
|
888
|
+
onPressDateHeader={onPressDateHeader}
|
|
660
889
|
/>
|
|
661
890
|
)}
|
|
662
891
|
|
|
892
|
+
{headerComponent}
|
|
893
|
+
|
|
663
894
|
<View
|
|
664
895
|
style={styles.pager}
|
|
665
|
-
onLayout={(event) =>
|
|
896
|
+
onLayout={(event) => {
|
|
897
|
+
setPageHeight(event.nativeEvent.layout.height);
|
|
898
|
+
setMeasured(true);
|
|
899
|
+
}}
|
|
666
900
|
>
|
|
667
901
|
<LegendList
|
|
668
|
-
// Remount
|
|
669
|
-
//
|
|
670
|
-
|
|
902
|
+
// Remount only on the seed→measured transition (see `measured`), not on
|
|
903
|
+
// every height change, so a day↔week header-height difference resizes the
|
|
904
|
+
// items in place instead of remounting and blanking the page.
|
|
905
|
+
key={measured ? 'grid' : 'grid-seed'}
|
|
671
906
|
ref={listRef}
|
|
672
907
|
style={styles.pagerList}
|
|
673
908
|
data={pageDates}
|
|
@@ -675,6 +910,7 @@ function TimeGridInner<T>({
|
|
|
675
910
|
recycleItems={false}
|
|
676
911
|
keyExtractor={keyExtractorList}
|
|
677
912
|
getFixedItemSize={getFixedItemSize}
|
|
913
|
+
scrollEnabled={swipeEnabled}
|
|
678
914
|
// Default: native paging — each page is the viewport width, so a swipe
|
|
679
915
|
// hard-stops at the adjacent page and can't fling past it. With
|
|
680
916
|
// `freeSwipe`, momentum carries across pages and snaps to a boundary.
|
|
@@ -698,17 +934,50 @@ type DefaultHeaderProps = {
|
|
|
698
934
|
mode: CalendarMode;
|
|
699
935
|
width: number;
|
|
700
936
|
hourColumnWidth: number;
|
|
701
|
-
|
|
937
|
+
showWeekNumber?: boolean;
|
|
938
|
+
weekNumberPrefix?: string;
|
|
939
|
+
locale?: Locale;
|
|
940
|
+
activeDate?: Date;
|
|
941
|
+
onPressDateHeader?: (date: Date) => void;
|
|
702
942
|
};
|
|
703
943
|
|
|
704
|
-
const DefaultHeader = ({
|
|
705
|
-
|
|
944
|
+
const DefaultHeader = ({
|
|
945
|
+
days,
|
|
946
|
+
mode,
|
|
947
|
+
width,
|
|
948
|
+
hourColumnWidth,
|
|
949
|
+
showWeekNumber,
|
|
950
|
+
weekNumberPrefix = 'W',
|
|
951
|
+
locale,
|
|
952
|
+
activeDate,
|
|
953
|
+
onPressDateHeader,
|
|
954
|
+
}: DefaultHeaderProps) => {
|
|
955
|
+
const theme = useCalendarTheme();
|
|
956
|
+
// Match the grid below: an hour-column spacer, then one column per day.
|
|
957
|
+
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
706
958
|
|
|
707
959
|
return (
|
|
708
960
|
<View style={styles.headerRow}>
|
|
709
|
-
|
|
961
|
+
<View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
|
|
962
|
+
{showWeekNumber && hourColumnWidth > 0 && days[0] ? (
|
|
963
|
+
<Text
|
|
964
|
+
style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
|
|
965
|
+
allowFontScaling={false}
|
|
966
|
+
>
|
|
967
|
+
{`${weekNumberPrefix}${getISOWeek(days[0])}`}
|
|
968
|
+
</Text>
|
|
969
|
+
) : null}
|
|
970
|
+
</View>
|
|
710
971
|
{days.map((day) => (
|
|
711
|
-
<DayHeader
|
|
972
|
+
<DayHeader
|
|
973
|
+
key={day.toISOString()}
|
|
974
|
+
day={day}
|
|
975
|
+
mode={mode}
|
|
976
|
+
width={dayWidth}
|
|
977
|
+
locale={locale}
|
|
978
|
+
activeDate={activeDate}
|
|
979
|
+
onPressDateHeader={onPressDateHeader}
|
|
980
|
+
/>
|
|
712
981
|
))}
|
|
713
982
|
</View>
|
|
714
983
|
);
|
|
@@ -718,20 +987,29 @@ type DayHeaderProps = {
|
|
|
718
987
|
day: Date;
|
|
719
988
|
mode: CalendarMode;
|
|
720
989
|
width: number;
|
|
721
|
-
locale?:
|
|
990
|
+
locale?: Locale;
|
|
991
|
+
activeDate?: Date;
|
|
992
|
+
onPressDateHeader?: (date: Date) => void;
|
|
722
993
|
};
|
|
723
994
|
|
|
724
|
-
const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
995
|
+
const DayHeader = ({ day, mode, width, locale, activeDate, onPressDateHeader }: DayHeaderProps) => {
|
|
725
996
|
const theme = useCalendarTheme();
|
|
726
997
|
const isToday = getIsToday(day);
|
|
998
|
+
// Highlight the chosen `activeDate` when supplied, else the real today.
|
|
999
|
+
const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
|
|
727
1000
|
const badgeSize = mode === 'day' ? 44 : 32;
|
|
728
1001
|
|
|
729
1002
|
return (
|
|
730
|
-
<
|
|
1003
|
+
<Pressable
|
|
1004
|
+
style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}
|
|
1005
|
+
onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
|
|
1006
|
+
disabled={!onPressDateHeader}
|
|
1007
|
+
accessibilityRole={onPressDateHeader ? 'button' : undefined}
|
|
1008
|
+
>
|
|
731
1009
|
<View
|
|
732
1010
|
style={[
|
|
733
1011
|
styles.dayHeaderBadge,
|
|
734
|
-
|
|
1012
|
+
isHighlighted && {
|
|
735
1013
|
backgroundColor: theme.colors.todayBackground,
|
|
736
1014
|
borderRadius: 999,
|
|
737
1015
|
width: badgeSize,
|
|
@@ -742,7 +1020,7 @@ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
|
742
1020
|
<Text
|
|
743
1021
|
style={[
|
|
744
1022
|
theme.text.dayNumber,
|
|
745
|
-
{ color:
|
|
1023
|
+
{ color: isHighlighted ? theme.colors.todayText : theme.colors.text },
|
|
746
1024
|
]}
|
|
747
1025
|
allowFontScaling={false}
|
|
748
1026
|
{...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
|
|
@@ -751,9 +1029,9 @@ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
|
751
1029
|
</Text>
|
|
752
1030
|
</View>
|
|
753
1031
|
<Text style={[theme.text.weekday, { color: theme.colors.text }]} allowFontScaling={false}>
|
|
754
|
-
{day
|
|
1032
|
+
{format(day, 'EEE', { locale })}
|
|
755
1033
|
</Text>
|
|
756
|
-
</
|
|
1034
|
+
</Pressable>
|
|
757
1035
|
);
|
|
758
1036
|
};
|
|
759
1037
|
|
|
@@ -772,6 +1050,10 @@ const styles = StyleSheet.create({
|
|
|
772
1050
|
alignItems: 'center',
|
|
773
1051
|
paddingBottom: 8,
|
|
774
1052
|
},
|
|
1053
|
+
weekNumberGutter: {
|
|
1054
|
+
alignItems: 'center',
|
|
1055
|
+
justifyContent: 'flex-end',
|
|
1056
|
+
},
|
|
775
1057
|
dayHeader: {
|
|
776
1058
|
alignItems: 'center',
|
|
777
1059
|
},
|
|
@@ -813,6 +1095,12 @@ const styles = StyleSheet.create({
|
|
|
813
1095
|
flex: 1,
|
|
814
1096
|
height: StyleSheet.hairlineWidth,
|
|
815
1097
|
},
|
|
1098
|
+
timeslotLine: {
|
|
1099
|
+
position: 'absolute',
|
|
1100
|
+
right: 0,
|
|
1101
|
+
height: StyleSheet.hairlineWidth,
|
|
1102
|
+
opacity: 0.5,
|
|
1103
|
+
},
|
|
816
1104
|
eventBox: {
|
|
817
1105
|
position: 'absolute',
|
|
818
1106
|
overflow: 'hidden',
|