@super-calendar/native 2.0.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.
@@ -0,0 +1,1943 @@
1
+ import {
2
+ LegendList,
3
+ type LegendListRef,
4
+ type LegendListRenderItemProps,
5
+ type OnViewableItemsChangedInfo,
6
+ } from "@legendapp/list/react-native";
7
+ import {
8
+ addDays,
9
+ differenceInCalendarDays,
10
+ format,
11
+ getHours,
12
+ getISOWeek,
13
+ getMinutes,
14
+ type Locale,
15
+ startOfDay,
16
+ startOfWeek,
17
+ } from "date-fns";
18
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
19
+ import {
20
+ type GestureResponderEvent,
21
+ Platform,
22
+ Pressable,
23
+ StyleSheet,
24
+ type StyleProp,
25
+ Text,
26
+ useWindowDimensions,
27
+ View,
28
+ type ViewStyle,
29
+ } from "react-native";
30
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
31
+ import Animated, {
32
+ runOnJS,
33
+ scrollTo,
34
+ type SharedValue,
35
+ useAnimatedReaction,
36
+ useAnimatedRef,
37
+ useAnimatedScrollHandler,
38
+ useAnimatedStyle,
39
+ useDerivedValue,
40
+ useSharedValue,
41
+ } from "react-native-reanimated";
42
+ import { useCalendarTheme } from "../theme";
43
+ import type {
44
+ BusinessHours,
45
+ CalendarEvent,
46
+ CalendarMode,
47
+ EventKeyExtractor,
48
+ RenderEvent,
49
+ TimeGridMode,
50
+ WeekStartsOn,
51
+ } from "../types";
52
+ import {
53
+ getIsToday,
54
+ getViewDays,
55
+ isSameCalendarDay,
56
+ isWeekend,
57
+ viewDayCount,
58
+ } from "@super-calendar/core";
59
+ import {
60
+ cellRangeFromDrag,
61
+ closedHourBands,
62
+ resolveDraggedBounds,
63
+ snapDeltaMinutes,
64
+ } from "@super-calendar/core";
65
+ import { formatHour, layoutDayEvents, type PositionedEvent } from "@super-calendar/core";
66
+ import { useWebGridZoom } from "../utils/useWebGridZoom";
67
+ import { useWebPagerKeys } from "../utils/useWebPagerKeys";
68
+ import { AllDayLane } from "./AllDayLane";
69
+
70
+ // Horizontal swipe paging doesn't translate to web; there we disable it and page
71
+ // with the arrow keys instead.
72
+ const isWeb = Platform.OS === "web";
73
+
74
+ // Minimal DOM shapes for the web-only Escape listener (the library targets React
75
+ // Native, so the TS "DOM" lib isn't available).
76
+ type WebKeyEvent = { key: string };
77
+ type WebKeyTarget = {
78
+ addEventListener: (type: "keydown", listener: (event: WebKeyEvent) => void) => void;
79
+ removeEventListener: (type: "keydown", listener: (event: WebKeyEvent) => void) => void;
80
+ };
81
+
82
+ const MINUTES_PER_HOUR = 60;
83
+ const HOURS_PER_DAY = 24;
84
+ const MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY;
85
+ // Steps rendered either side of the current page. LegendList virtualises, so
86
+ // only a few mount at once; a wide window means the user effectively never runs
87
+ // out of pages to swipe. Items are keyed by date and never recycled.
88
+ const PAGE_WINDOW = 180;
89
+ // A page must be ~fully on screen before it becomes the committed date.
90
+ const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
91
+
92
+ // Matches the dom renderer's default so both grids start at the same density.
93
+ export const DEFAULT_HOUR_HEIGHT = 48;
94
+ const DEFAULT_MIN_HOUR_HEIGHT = 32;
95
+ const DEFAULT_MAX_HOUR_HEIGHT = 160;
96
+ const DEFAULT_HOUR_COLUMN_WIDTH = 56;
97
+ // Short events would otherwise render only a few pixels tall and clip their
98
+ // content; keep them tall enough to stay legible and tappable.
99
+ const MIN_EVENT_HEIGHT = 32;
100
+ // Inset each event box within its slot so adjacent boxes (and column edges) get a
101
+ // little breathing room instead of butting edge-to-edge.
102
+ const EVENT_GAP = 2;
103
+ // Hold this long before drag-to-create (sweeping empty grid space) begins on
104
+ // native, so a normal scroll/tap isn't hijacked.
105
+ const DRAG_ACTIVATE_MS = 300;
106
+ // Moving an existing event takes a longer, deliberate hold, so a tap or scroll
107
+ // over a busy day never picks one up by accident.
108
+ const MOVE_ACTIVATE_MS = 500;
109
+ // Web has no long-press, so a drag-to-move activates only after the pointer
110
+ // moves this far vertically — below it a press stays a click (select /
111
+ // right-click menu).
112
+ const DRAG_ACTIVATE_PX = 8;
113
+ // Height of the resize grip at the bottom of a draggable event box.
114
+ const RESIZE_HANDLE_HEIGHT = 14;
115
+ // Default minutes a drag snaps to when `dragStepMinutes` isn't set.
116
+ const DEFAULT_DRAG_STEP_MINUTES = 15;
117
+
118
+ /**
119
+ * Called when an event is dragged (moved or resized) to new start/end times.
120
+ * Return `false` to reject the drop — the event snaps back to where it started
121
+ * (e.g. to forbid overlaps or out-of-bounds slots). Any other return accepts it.
122
+ */
123
+ export type EventDragHandler<T> = (
124
+ event: CalendarEvent<T>,
125
+ start: Date,
126
+ end: Date,
127
+ ) => void | boolean;
128
+ /**
129
+ * Called when a move or resize gesture begins, before any change is committed:
130
+ * on grab for a move (after the long-press), and when the resize drag starts.
131
+ * Handy for haptic feedback (e.g. `expo-haptics`). Requires drag to be enabled
132
+ * via `onDragEvent`.
133
+ */
134
+ export type EventDragStartHandler<T> = (event: CalendarEvent<T>) => void;
135
+ // Hour labels are nudged up so the number sits centred on its grid line. Pad the
136
+ // scroll content by the same amount so the top-most label is never clipped.
137
+ const HOUR_LABEL_TOP_INSET = 12;
138
+ const HOUR_LABEL_NUDGE = 6;
139
+ const NOW_TICK_MS = 60_000;
140
+
141
+ // A `Date` that advances every minute while `enabled`, so the now-indicator
142
+ // tracks the wall clock instead of freezing at mount. Off-screen pages pass
143
+ // `enabled = false` and re-read the time when they become active.
144
+ function useNow(enabled: boolean): Date {
145
+ const [now, setNow] = useState(() => new Date());
146
+ useEffect(() => {
147
+ if (!enabled) return;
148
+ setNow(new Date());
149
+ const id = setInterval(() => setNow(new Date()), NOW_TICK_MS);
150
+ return () => clearInterval(id);
151
+ }, [enabled]);
152
+ return now;
153
+ }
154
+
155
+ type AnimatedEventBoxProps<T> = {
156
+ positioned: PositionedEvent<T>;
157
+ cellHeight: SharedValue<number>;
158
+ minHour: number;
159
+ left: number;
160
+ width: number;
161
+ // Drag-to-move across days needs the column width and this box's day index
162
+ // within the visible range, so a horizontal drag maps to (and clamps to) days.
163
+ dayWidth: number;
164
+ dayIndex: number;
165
+ dayCount: number;
166
+ mode: CalendarMode;
167
+ renderEvent: RenderEvent<T>;
168
+ snapMinutes: number;
169
+ onPress: (event: CalendarEvent<T>) => void;
170
+ onLongPress?: (event: CalendarEvent<T>) => void;
171
+ onDragEvent?: EventDragHandler<T>;
172
+ onDragStart?: EventDragStartHandler<T>;
173
+ showDragHandle: boolean;
174
+ };
175
+
176
+ function AnimatedEventBox<T>({
177
+ positioned,
178
+ cellHeight,
179
+ minHour,
180
+ left,
181
+ width,
182
+ dayWidth,
183
+ dayIndex,
184
+ dayCount,
185
+ mode,
186
+ renderEvent,
187
+ snapMinutes,
188
+ onPress,
189
+ onLongPress,
190
+ onDragEvent,
191
+ onDragStart,
192
+ showDragHandle,
193
+ }: AnimatedEventBoxProps<T>) {
194
+ const RenderEventComponent = renderEvent;
195
+ const theme = useCalendarTheme();
196
+ // Drag-to-move/resize. Native picks the event up on long-press (so a tap or
197
+ // scroll isn't hijacked); web activates after a small drag threshold, so a
198
+ // plain click still selects and a right-click still opens a context menu.
199
+ const draggable = onDragEvent != null && !positioned.event.disabled;
200
+ // Only the segment that owns the real end may be resized.
201
+ const resizable = draggable && !positioned.continuesAfter;
202
+
203
+ // Live preview offsets (px), reset to 0 once the committed change re-renders.
204
+ // moveOffsetX shifts the box across day columns during a cross-day drag.
205
+ const moveOffset = useSharedValue(0);
206
+ const moveOffsetX = useSharedValue(0);
207
+ const resizeDelta = useSharedValue(0);
208
+
209
+ // Live pixel height of the box, driven on the UI thread by the shared
210
+ // cellHeight (plus any in-progress resize). Handed to renderEvent so custom
211
+ // renderers can reveal detail progressively as the grid zooms, without
212
+ // re-rendering. Explicit deps so the worklet re-captures the event's geometry.
213
+ const boxHeight = useDerivedValue(
214
+ () =>
215
+ Math.max(positioned.durationHours * cellHeight.value + resizeDelta.value, MIN_EVENT_HEIGHT),
216
+ [positioned.durationHours],
217
+ );
218
+
219
+ const boxStyle = useAnimatedStyle(
220
+ () => ({
221
+ top: (positioned.startHours - minHour) * cellHeight.value + moveOffset.value,
222
+ height: boxHeight.value,
223
+ transform: [{ translateX: moveOffsetX.value }],
224
+ }),
225
+ [positioned.startHours, positioned.durationHours, minHour],
226
+ );
227
+
228
+ // Clear the drag preview once the committed change re-renders this box at its
229
+ // new geometry. The gesture holds the snapped offset through the commit (so the
230
+ // box never flashes back to the original spot); this drops it the moment the
231
+ // new start/duration lands, for consumers whose keyExtractor keeps it mounted.
232
+ useEffect(() => {
233
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
234
+ moveOffset.value = 0;
235
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
236
+ moveOffsetX.value = 0;
237
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
238
+ resizeDelta.value = 0;
239
+ }, [positioned.startHours, positioned.durationHours, moveOffset, moveOffsetX, resizeDelta]);
240
+
241
+ // Keep the latest event/handler in a ref so the gestures stay memoized but
242
+ // never call into a stale closure.
243
+ const latest = useRef({ event: positioned.event, onDragEvent, onDragStart });
244
+ latest.current = { event: positioned.event, onDragEvent, onDragStart };
245
+
246
+ // Snap the box back to where it started (drop rejected or degenerate).
247
+ const snapBack = useCallback(() => {
248
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
249
+ moveOffset.value = 0;
250
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
251
+ moveOffsetX.value = 0;
252
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
253
+ resizeDelta.value = 0;
254
+ }, [moveOffset, moveOffsetX, resizeDelta]);
255
+
256
+ const commitDrag = useCallback(
257
+ (deltaStartMin: number, deltaEndMin: number) => {
258
+ const { event, onDragEvent: handler } = latest.current;
259
+ if (!handler) return;
260
+ // Returns null when a resize would collapse below one step — snap back.
261
+ const next = resolveDraggedBounds(
262
+ event.start,
263
+ event.end,
264
+ deltaStartMin,
265
+ deltaEndMin,
266
+ snapMinutes,
267
+ );
268
+ if (!next) {
269
+ snapBack();
270
+ return;
271
+ }
272
+ // A handler may return false to reject the drop (e.g. an overlap or an
273
+ // out-of-bounds slot); snap the box back to its original place.
274
+ if (handler(event, next.start, next.end) === false) snapBack();
275
+ },
276
+ [snapMinutes, snapBack],
277
+ );
278
+
279
+ // Fired on the JS thread when the gesture grabs the event, so consumers can
280
+ // trigger haptics or other side effects the instant drag mode begins.
281
+ const notifyDragStart = useCallback(() => {
282
+ latest.current.onDragStart?.(latest.current.event);
283
+ }, []);
284
+
285
+ const moveGesture = useMemo(() => {
286
+ const pan = Gesture.Pan()
287
+ .enabled(draggable)
288
+ .onStart(() => {
289
+ runOnJS(notifyDragStart)();
290
+ })
291
+ .onUpdate((event) => {
292
+ moveOffset.value = event.translationY;
293
+ moveOffsetX.value = event.translationX;
294
+ })
295
+ .onEnd((event) => {
296
+ const minuteDelta = snapDeltaMinutes(event.translationY, cellHeight.value, snapMinutes);
297
+ // Map the horizontal drag to whole day columns, clamped so the event
298
+ // can't leave the visible range.
299
+ const rawDayDelta = dayWidth > 0 ? Math.round(event.translationX / dayWidth) : 0;
300
+ const targetDay = Math.min(Math.max(dayIndex + rawDayDelta, 0), dayCount - 1);
301
+ const dayDelta = targetDay - dayIndex;
302
+ if (minuteDelta === 0 && dayDelta === 0) {
303
+ moveOffset.value = 0;
304
+ moveOffsetX.value = 0;
305
+ return;
306
+ }
307
+ // Hold the snapped position so the box doesn't flash back to the
308
+ // original before the committed re-render lands.
309
+ moveOffset.value = (minuteDelta / MINUTES_PER_HOUR) * cellHeight.value;
310
+ moveOffsetX.value = dayDelta * dayWidth;
311
+ // Fold the day shift into the minute delta; shiftMinutes carries it into
312
+ // the date, so both edges move together and the duration is preserved.
313
+ const totalDelta = minuteDelta + dayDelta * MINUTES_PER_DAY;
314
+ runOnJS(commitDrag)(totalDelta, totalDelta);
315
+ });
316
+ // Native: long-press to pick up. Web: activate past a small drag in either
317
+ // axis so clicks/right-clicks pass through but horizontal drags still move.
318
+ return isWeb
319
+ ? pan
320
+ .activeOffsetX([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
321
+ .activeOffsetY([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
322
+ : pan.activateAfterLongPress(MOVE_ACTIVATE_MS);
323
+ }, [
324
+ draggable,
325
+ snapMinutes,
326
+ cellHeight,
327
+ moveOffset,
328
+ moveOffsetX,
329
+ dayWidth,
330
+ dayIndex,
331
+ dayCount,
332
+ commitDrag,
333
+ notifyDragStart,
334
+ ]);
335
+
336
+ const resizeGesture = useMemo(
337
+ () =>
338
+ Gesture.Pan()
339
+ .enabled(resizable)
340
+ .onStart(() => {
341
+ runOnJS(notifyDragStart)();
342
+ })
343
+ .onUpdate((event) => {
344
+ resizeDelta.value = event.translationY;
345
+ })
346
+ .onEnd((event) => {
347
+ const delta = snapDeltaMinutes(event.translationY, cellHeight.value, snapMinutes);
348
+ if (delta === 0) {
349
+ resizeDelta.value = 0;
350
+ return;
351
+ }
352
+ resizeDelta.value = (delta / MINUTES_PER_HOUR) * cellHeight.value;
353
+ runOnJS(commitDrag)(0, delta);
354
+ }),
355
+ [resizable, snapMinutes, cellHeight, resizeDelta, commitDrag, notifyDragStart],
356
+ );
357
+
358
+ const handlePress = () => onPress(positioned.event);
359
+ // When draggable, a long press grabs the event to move it, so don't also fire
360
+ // the consumer's long-press handler.
361
+ const handleLongPress =
362
+ !draggable && onLongPress ? () => onLongPress(positioned.event) : undefined;
363
+
364
+ const box = (
365
+ <Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
366
+ <RenderEventComponent
367
+ event={positioned.event}
368
+ mode={mode}
369
+ boxHeight={boxHeight}
370
+ continuesBefore={positioned.continuesBefore}
371
+ continuesAfter={positioned.continuesAfter}
372
+ onPress={handlePress}
373
+ onLongPress={handleLongPress}
374
+ />
375
+ {resizable ? (
376
+ <GestureDetector gesture={resizeGesture}>
377
+ <Animated.View style={styles.resizeHandle}>
378
+ {/* The grip is the only visible drag affordance; hiding it keeps the
379
+ resize gesture working but removes the indicator. */}
380
+ {showDragHandle ? (
381
+ <View style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]} />
382
+ ) : null}
383
+ </Animated.View>
384
+ </GestureDetector>
385
+ ) : null}
386
+ </Animated.View>
387
+ );
388
+
389
+ if (!draggable) return box;
390
+ return <GestureDetector gesture={moveGesture}>{box}</GestureDetector>;
391
+ }
392
+
393
+ /** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
394
+ export type HourRenderer = (hour: number, ampm: boolean) => React.ReactNode;
395
+
396
+ type HourRowProps = {
397
+ hour: number;
398
+ minHour: number;
399
+ cellHeight: SharedValue<number>;
400
+ hourColumnWidth: number;
401
+ label: string;
402
+ ampm: boolean;
403
+ hourComponent?: HourRenderer;
404
+ };
405
+
406
+ const HourRow = ({
407
+ hour,
408
+ minHour,
409
+ cellHeight,
410
+ hourColumnWidth,
411
+ label,
412
+ ampm,
413
+ hourComponent,
414
+ }: HourRowProps) => {
415
+ const theme = useCalendarTheme();
416
+ // Position via `top` (a layout prop), not a transform. The per-row layout pass
417
+ // as cellHeight animates keeps the ScrollView's content size in sync while
418
+ // zooming; a transform is composited and leaves the scroll range stale.
419
+ const animatedStyle = useAnimatedStyle(
420
+ () => ({ top: (hour - minHour) * cellHeight.value }),
421
+ [hour, minHour],
422
+ );
423
+
424
+ return (
425
+ <Animated.View style={[styles.hourRow, styles.nonInteractive, animatedStyle]}>
426
+ {hourComponent ? (
427
+ <View style={{ width: hourColumnWidth }}>{hourComponent(hour, ampm)}</View>
428
+ ) : (
429
+ <Text
430
+ style={[
431
+ theme.text.hourLabel,
432
+ styles.hourLabel,
433
+ { width: hourColumnWidth, color: theme.colors.textMuted },
434
+ ]}
435
+ allowFontScaling={false}
436
+ >
437
+ {label}
438
+ </Text>
439
+ )}
440
+ <View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
441
+ </Animated.View>
442
+ );
443
+ };
444
+
445
+ type TimeslotLineProps = {
446
+ hour: number;
447
+ minHour: number;
448
+ fraction: number;
449
+ cellHeight: SharedValue<number>;
450
+ hourColumnWidth: number;
451
+ };
452
+
453
+ // A faint divider inside an hour row, marking a sub-hour slot (e.g. half hours).
454
+ const TimeslotLine = ({
455
+ hour,
456
+ minHour,
457
+ fraction,
458
+ cellHeight,
459
+ hourColumnWidth,
460
+ }: TimeslotLineProps) => {
461
+ const theme = useCalendarTheme();
462
+ const animatedStyle = useAnimatedStyle(
463
+ () => ({ top: (hour - minHour + fraction) * cellHeight.value }),
464
+ [hour, minHour, fraction],
465
+ );
466
+ return (
467
+ <Animated.View
468
+ style={[
469
+ styles.timeslotLine,
470
+ styles.nonInteractive,
471
+ { left: hourColumnWidth, backgroundColor: theme.colors.gridLine },
472
+ animatedStyle,
473
+ ]}
474
+ />
475
+ );
476
+ };
477
+
478
+ type NowIndicatorProps = {
479
+ cellHeight: SharedValue<number>;
480
+ nowHours: number;
481
+ minHour: number;
482
+ left: number;
483
+ width: number;
484
+ color: string;
485
+ };
486
+
487
+ const NowIndicator = ({ cellHeight, nowHours, minHour, left, width, color }: NowIndicatorProps) => {
488
+ const animatedStyle = useAnimatedStyle(
489
+ () => ({ top: (nowHours - minHour) * cellHeight.value }),
490
+ [nowHours, minHour],
491
+ );
492
+
493
+ return (
494
+ <Animated.View
495
+ style={[
496
+ styles.nowIndicator,
497
+ styles.nonInteractive,
498
+ { left, width, backgroundColor: color },
499
+ animatedStyle,
500
+ ]}
501
+ />
502
+ );
503
+ };
504
+
505
+ type ShadeBandProps = {
506
+ cellHeight: SharedValue<number>;
507
+ startHour: number;
508
+ endHour: number;
509
+ minHour: number;
510
+ left: number;
511
+ width: number;
512
+ color: string;
513
+ };
514
+
515
+ // A muted band over a closed hour-range of one day column (driven by the live
516
+ // cellHeight so it tracks the zoom).
517
+ const ShadeBand = ({
518
+ cellHeight,
519
+ startHour,
520
+ endHour,
521
+ minHour,
522
+ left,
523
+ width,
524
+ color,
525
+ }: ShadeBandProps) => {
526
+ const animatedStyle = useAnimatedStyle(
527
+ () => ({
528
+ top: (startHour - minHour) * cellHeight.value,
529
+ height: (endHour - startHour) * cellHeight.value,
530
+ }),
531
+ [startHour, endHour, minHour],
532
+ );
533
+ return (
534
+ <Animated.View
535
+ testID="business-hours-shade"
536
+ style={[
537
+ styles.shadeBand,
538
+ styles.nonInteractive,
539
+ { left, width, backgroundColor: color },
540
+ animatedStyle,
541
+ ]}
542
+ />
543
+ );
544
+ };
545
+
546
+ type TimetablePageProps<T> = {
547
+ mode: TimeGridMode;
548
+ numberOfDays: number;
549
+ date: Date;
550
+ events: CalendarEvent<T>[];
551
+ cellHeight: SharedValue<number>;
552
+ hourHeight: number;
553
+ // The zoom committed at the end of the last pinch. Off-screen pages animate off
554
+ // this (it changes once per gesture) instead of the live cellHeight (which
555
+ // changes every frame), so a pinch only re-runs the visible page's worklets.
556
+ committedCellHeight: SharedValue<number>;
557
+ scrollY: SharedValue<number>;
558
+ isActive: boolean;
559
+ /** Initial vertical scroll position (px): the live shared offset, so a page that
560
+ * mounts after a scroll appears there rather than snapping from the default. */
561
+ initialScrollY: number;
562
+ /** Record the rested vertical offset (px) so later-mounted pages seed from it. */
563
+ onSettleOffset: (y: number) => void;
564
+ weekStartsOn: WeekStartsOn;
565
+ weekEndsOn?: WeekStartsOn;
566
+ /** Full grid width (hour gutter + day columns). Comes from the container, not the window. */
567
+ width: number;
568
+ hourColumnWidth: number;
569
+ minHour: number;
570
+ maxHour: number;
571
+ ampm: boolean;
572
+ timeslots: number;
573
+ isRTL: boolean;
574
+ showAllDayEventCell: boolean;
575
+ showVerticalScrollIndicator: boolean;
576
+ verticalScrollEnabled: boolean;
577
+ hourComponent?: HourRenderer;
578
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
579
+ businessHours?: BusinessHours;
580
+ minHourHeight: number;
581
+ maxHourHeight: number;
582
+ showNowIndicator: boolean;
583
+ renderEvent: RenderEvent<T>;
584
+ keyExtractor: EventKeyExtractor<T>;
585
+ snapMinutes: number;
586
+ showDragHandle: boolean;
587
+ onPressEvent: (event: CalendarEvent<T>) => void;
588
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
589
+ onDragEvent?: EventDragHandler<T>;
590
+ onDragStart?: EventDragStartHandler<T>;
591
+ onPressCell?: (date: Date) => void;
592
+ onLongPressCell?: (date: Date) => void;
593
+ onCreateEvent?: (start: Date, end: Date) => void;
594
+ };
595
+
596
+ // A single date's grid: the pinch-zoomable, vertically-scrolling time column.
597
+ // Three of these are mounted side by side inside the pager so the previous and
598
+ // next dates are ready to drag into view.
599
+ function TimetablePageInner<T>({
600
+ mode,
601
+ numberOfDays,
602
+ date,
603
+ events,
604
+ cellHeight,
605
+ hourHeight,
606
+ committedCellHeight,
607
+ scrollY,
608
+ isActive,
609
+ initialScrollY,
610
+ onSettleOffset,
611
+ weekStartsOn,
612
+ weekEndsOn,
613
+ width,
614
+ hourColumnWidth,
615
+ minHour,
616
+ maxHour,
617
+ ampm,
618
+ timeslots,
619
+ isRTL,
620
+ showAllDayEventCell,
621
+ showVerticalScrollIndicator,
622
+ verticalScrollEnabled,
623
+ hourComponent,
624
+ calendarCellStyle,
625
+ minHourHeight,
626
+ maxHourHeight,
627
+ showNowIndicator,
628
+ businessHours,
629
+ renderEvent,
630
+ keyExtractor,
631
+ snapMinutes,
632
+ showDragHandle,
633
+ onPressEvent,
634
+ onLongPressEvent,
635
+ onDragEvent,
636
+ onDragStart,
637
+ onPressCell,
638
+ onLongPressCell,
639
+ onCreateEvent,
640
+ }: TimetablePageProps<T>) {
641
+ const theme = useCalendarTheme();
642
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
643
+
644
+ // The visible page tracks the live cellHeight (animates every pinch frame);
645
+ // off-screen pages track committedCellHeight (settles once per gesture).
646
+ const heightSource = isActive ? cellHeight : committedCellHeight;
647
+
648
+ // Keep every page locked to the same vertical scroll position so the prev/next
649
+ // pages are already aligned before they drag into view — no post-swipe jump.
650
+ // Only a genuine user drag (and its momentum) updates the shared offset. The
651
+ // contentOffset seed and the programmatic scrolls that fire during paging also
652
+ // emit onScroll events; letting those write `scrollY` could broadcast a transient
653
+ // 0 to every page (the random "snaps to midnight" behaviour), so they're ignored.
654
+ const isDragging = useSharedValue(false);
655
+ // Mirror `isActive` to a shared value so the scroll worklet reads the *current*
656
+ // active state. A plain `isActive` captured in the worklet goes stale, so only the
657
+ // page active when the handler was first created would record its scrolls — which
658
+ // is why a position set on a later page was lost and an earlier one came back.
659
+ const isActiveShared = useSharedValue(isActive);
660
+ useEffect(() => {
661
+ isActiveShared.value = isActive;
662
+ }, [isActive, isActiveShared]);
663
+ const scrollHandler = useAnimatedScrollHandler({
664
+ onScroll: (event) => {
665
+ // Native captures the active page's scroll on a real drag. (Web is handled by
666
+ // the container-level scroll listener in TimeGridInner, because LegendList
667
+ // recycles the page containers there and per-page `isActive` can't reliably
668
+ // tell which DOM node the user is actually scrolling.)
669
+ if (isActiveShared.value && isDragging.value) {
670
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
671
+ scrollY.value = event.contentOffset.y;
672
+ }
673
+ },
674
+ onBeginDrag: () => {
675
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
676
+ isDragging.value = true;
677
+ },
678
+ onEndDrag: (event) => {
679
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
680
+ isDragging.value = false;
681
+ // Capture the rested position. The mid-drag onScroll above stops at the
682
+ // finger-lift point; without this, the momentum tail after it is lost and the
683
+ // saved offset falls short of where the page actually settles.
684
+ if (!isWeb && isActiveShared.value) {
685
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
686
+ scrollY.value = event.contentOffset.y;
687
+ runOnJS(onSettleOffset)(event.contentOffset.y);
688
+ }
689
+ },
690
+ onMomentumEnd: (event) => {
691
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
692
+ isDragging.value = false;
693
+ if (!isWeb && isActiveShared.value) {
694
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
695
+ scrollY.value = event.contentOffset.y;
696
+ runOnJS(onSettleOffset)(event.contentOffset.y);
697
+ }
698
+ },
699
+ });
700
+
701
+ // Keep *inactive* pages aligned to the shared offset as it changes, so they're
702
+ // already in position before they drag into view. Reads `isActiveShared` (not the
703
+ // captured `isActive`) so the worklet sees the current state.
704
+ useAnimatedReaction(
705
+ () => scrollY.value,
706
+ (current, previous) => {
707
+ if (!isActiveShared.value && current !== previous) {
708
+ scrollTo(scrollRef, 0, current, false);
709
+ }
710
+ },
711
+ );
712
+
713
+ // When a page becomes the active one (paged or jumped to), align it to the shared
714
+ // offset. The reaction above only syncs *inactive* pages, and only when `scrollY`
715
+ // changes, so a page that paged in after the last change (the third page on from a
716
+ // drag, say) would keep its seeded default. Uses reanimated's `scrollTo` worklet:
717
+ // the imperative `scrollRef.current.scrollTo()` does not take effect on a
718
+ // useAnimatedRef, which is why that page stayed at the default. Native only; web is
719
+ // handled by the container-level effect in TimeGridInner.
720
+ useAnimatedReaction(
721
+ () => isActiveShared.value,
722
+ (active, previous) => {
723
+ if (isWeb || !active || active === previous) return;
724
+ scrollTo(scrollRef, 0, scrollY.value, false);
725
+ },
726
+ );
727
+
728
+ const days = useMemo(
729
+ () => getViewDays(mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
730
+ [mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
731
+ );
732
+
733
+ const dayWidth = (width - hourColumnWidth) / days.length;
734
+ const dayLeft = (dayIndex: number) => hourColumnWidth + dayIndex * dayWidth;
735
+
736
+ const dayLayouts = useMemo(() => days.map((day) => layoutDayEvents(events, day)), [days, events]);
737
+
738
+ // Map a tap on empty grid space back to the date+time it represents. Reads the
739
+ // live row height on the JS thread to convert the touch Y into minutes.
740
+ const cellDateFromTouch = (event: GestureResponderEvent): Date | null => {
741
+ const { locationX, locationY } = event.nativeEvent;
742
+ const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
743
+ const day = days[dayIndex];
744
+ if (!day) return null;
745
+ const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
746
+ const pressed = new Date(day);
747
+ pressed.setHours(0, 0, 0, 0);
748
+ pressed.setMinutes(minutes);
749
+ return pressed;
750
+ };
751
+ const handleBackgroundPress = (event: GestureResponderEvent) => {
752
+ const date = onPressCell && cellDateFromTouch(event);
753
+ if (date) onPressCell?.(date);
754
+ };
755
+ const handleBackgroundLongPress = (event: GestureResponderEvent) => {
756
+ const date = onLongPressCell && cellDateFromTouch(event);
757
+ if (date) onLongPressCell?.(date);
758
+ };
759
+
760
+ // The hours (rows/labels) visible in the window [minHour, maxHour).
761
+ const hoursRange = useMemo(
762
+ () => Array.from({ length: maxHour - minHour }, (_, index) => minHour + index),
763
+ [minHour, maxHour],
764
+ );
765
+
766
+ const now = useNow(showNowIndicator && isActive);
767
+ const nowDayIndex = days.findIndex((day) => getIsToday(day));
768
+ const nowHours = (getHours(now) * MINUTES_PER_HOUR + getMinutes(now)) / MINUTES_PER_HOUR;
769
+ const nowInWindow = nowHours >= minHour && nowHours <= maxHour;
770
+
771
+ const fullHeightStyle = useAnimatedStyle(
772
+ () => ({ height: (maxHour - minHour) * heightSource.value }),
773
+ [minHour, maxHour, heightSource],
774
+ );
775
+
776
+ // Capture the row height when the pinch starts and apply `event.scale`
777
+ // (relative to that start) rather than multiplying per-frame deltas — deltas
778
+ // compound float error and the zoom never settles on a clean level.
779
+ const pinchStartCellHeight = useSharedValue(hourHeight);
780
+ const zoomGesture = useMemo(() => {
781
+ const pinch = Gesture.Pinch()
782
+ .onStart(() => {
783
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
784
+ pinchStartCellHeight.value = cellHeight.value;
785
+ })
786
+ .onUpdate((event) => {
787
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
788
+ cellHeight.value = Math.min(
789
+ maxHourHeight,
790
+ Math.max(minHourHeight, pinchStartCellHeight.value * event.scale),
791
+ );
792
+ })
793
+ .onEnd(() => {
794
+ // Publish the final zoom to the off-screen pages in one update.
795
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
796
+ committedCellHeight.value = cellHeight.value;
797
+ });
798
+ // Recognise the pinch and the ScrollView's native scroll together so the
799
+ // scroll never cancels an in-progress zoom.
800
+ return Gesture.Simultaneous(pinch, Gesture.Native());
801
+ }, [cellHeight, committedCellHeight, pinchStartCellHeight, minHourHeight, maxHourHeight]);
802
+
803
+ // Drag-to-create: sweep out a new event on empty grid. Native long-presses
804
+ // first (so a tap/scroll isn't hijacked); web uses a drag threshold like move,
805
+ // so a tap still creates a point via onPressCell and the wheel still scrolls
806
+ // (dragging empty space creates instead of scrolling, as on desktop calendars).
807
+ const createEnabled = onCreateEvent != null;
808
+ // Live ghost-box geometry (px), driven on the UI thread during the sweep.
809
+ const createActive = useSharedValue(0);
810
+ const createTop = useSharedValue(0);
811
+ const createHeight = useSharedValue(0);
812
+ const createLeft = useSharedValue(0);
813
+ const createWidth = useSharedValue(0);
814
+ const createStartY = useSharedValue(0);
815
+ const createDayIndex = useSharedValue(0);
816
+ // Set when the sweep is cancelled mid-drag (Escape on web); the gesture then
817
+ // bails instead of committing.
818
+ const createCancelled = useSharedValue(0);
819
+
820
+ const commitCreate = useCallback(
821
+ (startY: number, endY: number, dayIndex: number) => {
822
+ const day = days[dayIndex];
823
+ if (!day) return;
824
+ const range = cellRangeFromDrag(day, startY, endY, heightSource.value, minHour, snapMinutes);
825
+ if (range) onCreateEvent?.(range.start, range.end);
826
+ },
827
+ [days, heightSource, minHour, snapMinutes, onCreateEvent],
828
+ );
829
+
830
+ // Web taps fall through to here: react-native-web doesn't fire the background
831
+ // Pressable's onPress, so a click on empty space reports the cell via a Tap
832
+ // gesture instead. Mirrors cellDateFromTouch.
833
+ const tapCell = useCallback(
834
+ (x: number, y: number) => {
835
+ const dayIndex = days.length === 1 ? 0 : Math.floor(x / dayWidth);
836
+ const day = days[dayIndex];
837
+ if (!day) return;
838
+ const minutes = Math.round((minHour + y / heightSource.value) * MINUTES_PER_HOUR);
839
+ const pressed = new Date(day);
840
+ pressed.setHours(0, 0, 0, 0);
841
+ pressed.setMinutes(minutes);
842
+ onPressCell?.(pressed);
843
+ },
844
+ [days, dayWidth, minHour, heightSource, onPressCell],
845
+ );
846
+
847
+ const createGesture = useMemo(() => {
848
+ const pan = Gesture.Pan()
849
+ .enabled(createEnabled)
850
+ .onStart((event) => {
851
+ const idx = days.length === 1 ? 0 : Math.floor(event.x / dayWidth);
852
+ createDayIndex.value = idx;
853
+ createStartY.value = event.y;
854
+ createLeft.value = hourColumnWidth + idx * dayWidth;
855
+ createWidth.value = dayWidth;
856
+ createTop.value = event.y;
857
+ createHeight.value = 0;
858
+ createActive.value = 1;
859
+ createCancelled.value = 0;
860
+ })
861
+ .onUpdate((event) => {
862
+ if (createCancelled.value) return;
863
+ const stepPx = (snapMinutes / MINUTES_PER_HOUR) * heightSource.value;
864
+ const snap = (y: number) => (stepPx > 0 ? Math.round(y / stepPx) * stepPx : y);
865
+ const startSnap = snap(createStartY.value);
866
+ const endSnap = snap(createStartY.value + event.translationY);
867
+ createTop.value = Math.min(startSnap, endSnap);
868
+ createHeight.value = Math.max(Math.abs(endSnap - startSnap), stepPx);
869
+ })
870
+ .onEnd((event) => {
871
+ createActive.value = 0;
872
+ createHeight.value = 0;
873
+ if (createCancelled.value) return; // Escape pressed mid-sweep
874
+ runOnJS(commitCreate)(
875
+ createStartY.value,
876
+ createStartY.value + event.translationY,
877
+ createDayIndex.value,
878
+ );
879
+ });
880
+ // Native: hold to start. Web: activate past a small vertical drag so a plain
881
+ // tap still falls through to onPressCell.
882
+ return isWeb
883
+ ? pan.activeOffsetY([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
884
+ : pan.activateAfterLongPress(DRAG_ACTIVATE_MS);
885
+ }, [
886
+ createEnabled,
887
+ days.length,
888
+ dayWidth,
889
+ hourColumnWidth,
890
+ heightSource,
891
+ snapMinutes,
892
+ commitCreate,
893
+ createActive,
894
+ createTop,
895
+ createHeight,
896
+ createLeft,
897
+ createWidth,
898
+ createStartY,
899
+ createDayIndex,
900
+ createCancelled,
901
+ ]);
902
+
903
+ // Compose with a Tap so a plain web click reports the cell (onPressCell);
904
+ // Exclusive gives the drag-to-create priority, so a real drag still creates.
905
+ const backgroundGesture = useMemo(() => {
906
+ const tap =
907
+ isWeb && onPressCell != null
908
+ ? Gesture.Tap().onEnd((event) => {
909
+ runOnJS(tapCell)(event.x, event.y);
910
+ })
911
+ : null;
912
+ if (createEnabled && tap) return Gesture.Exclusive(createGesture, tap);
913
+ if (createEnabled) return createGesture;
914
+ return tap;
915
+ }, [createEnabled, createGesture, onPressCell, tapCell]);
916
+
917
+ // Web: Escape cancels an in-progress sweep before it commits.
918
+ useEffect(() => {
919
+ if (!isWeb || !createEnabled) return;
920
+ const doc = (globalThis as { document?: WebKeyTarget }).document;
921
+ if (!doc) return;
922
+ const handler = (event: WebKeyEvent) => {
923
+ if (event.key !== "Escape" || !createActive.value) return;
924
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
925
+ createCancelled.value = 1;
926
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
927
+ createActive.value = 0;
928
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
929
+ createHeight.value = 0;
930
+ };
931
+ doc.addEventListener("keydown", handler);
932
+ return () => doc.removeEventListener("keydown", handler);
933
+ }, [createEnabled, createActive, createCancelled, createHeight]);
934
+
935
+ const createGhostStyle = useAnimatedStyle(() => ({
936
+ top: createTop.value,
937
+ height: createHeight.value,
938
+ left: createLeft.value,
939
+ width: createWidth.value,
940
+ opacity: createActive.value,
941
+ }));
942
+
943
+ // The tap/long-press layer behind events; wrapped in the create gesture when
944
+ // drag-to-create is on. Hidden from screen readers (a convenience gesture).
945
+ const cellLayer =
946
+ onPressCell || onLongPressCell || createEnabled ? (
947
+ <Pressable
948
+ style={[styles.cellPressLayer, { left: hourColumnWidth }]}
949
+ onPress={onPressCell ? handleBackgroundPress : undefined}
950
+ // When create is on, a long-press starts the create-drag, so don't also
951
+ // fire the consumer's long-press handler.
952
+ onLongPress={!createEnabled && onLongPressCell ? handleBackgroundLongPress : undefined}
953
+ // Pointer-only create surface: drag to sweep out an event. On web it's
954
+ // deliberately not a tab stop (react-native-web makes a Pressable focusable
955
+ // by default), so keyboard focus moves through events only — matching dom.
956
+ tabIndex={-1}
957
+ importantForAccessibility="no"
958
+ accessibilityElementsHidden
959
+ />
960
+ ) : null;
961
+
962
+ return (
963
+ <View style={styles.container}>
964
+ {showAllDayEventCell ? (
965
+ <AllDayLane
966
+ days={days}
967
+ events={events}
968
+ mode={mode}
969
+ hourColumnWidth={hourColumnWidth}
970
+ dayWidth={dayWidth}
971
+ renderEvent={renderEvent}
972
+ keyExtractor={keyExtractor}
973
+ onPressEvent={onPressEvent}
974
+ onLongPressEvent={onLongPressEvent}
975
+ />
976
+ ) : null}
977
+ <GestureDetector gesture={zoomGesture}>
978
+ <Animated.ScrollView
979
+ ref={scrollRef}
980
+ showsVerticalScrollIndicator={showVerticalScrollIndicator}
981
+ scrollEnabled={verticalScrollEnabled}
982
+ onScroll={scrollHandler}
983
+ scrollEventThrottle={16}
984
+ contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
985
+ contentOffset={{ x: 0, y: initialScrollY }}
986
+ >
987
+ <Animated.View style={[styles.content, fullHeightStyle]}>
988
+ {/* Behind the events, so empty-space taps/drags create while event
989
+ taps still hit their box. */}
990
+ {cellLayer && backgroundGesture ? (
991
+ <GestureDetector gesture={backgroundGesture}>{cellLayer}</GestureDetector>
992
+ ) : (
993
+ cellLayer
994
+ )}
995
+
996
+ {days.map((day, dayIndex) =>
997
+ isWeekend(day) ? (
998
+ <Animated.View
999
+ key={`weekend-${day.toISOString()}`}
1000
+ style={[
1001
+ styles.weekendColumn,
1002
+ styles.nonInteractive,
1003
+ { backgroundColor: theme.colors.weekendBackground },
1004
+ { left: dayLeft(dayIndex), width: dayWidth },
1005
+ fullHeightStyle,
1006
+ ]}
1007
+ />
1008
+ ) : null,
1009
+ )}
1010
+
1011
+ {calendarCellStyle
1012
+ ? days.map((day, dayIndex) => {
1013
+ const cellStyle = calendarCellStyle(day);
1014
+ return cellStyle ? (
1015
+ <Animated.View
1016
+ key={`cell-${day.toISOString()}`}
1017
+ style={[
1018
+ styles.weekendColumn,
1019
+ styles.nonInteractive,
1020
+ { left: dayLeft(dayIndex), width: dayWidth },
1021
+ cellStyle,
1022
+ fullHeightStyle,
1023
+ ]}
1024
+ />
1025
+ ) : null;
1026
+ })
1027
+ : null}
1028
+
1029
+ {businessHours
1030
+ ? days.flatMap((day, dayIndex) =>
1031
+ closedHourBands(day, businessHours, minHour, maxHour).map((band, bandIndex) => (
1032
+ <ShadeBand
1033
+ key={`closed-${day.toISOString()}-${bandIndex}`}
1034
+ cellHeight={heightSource}
1035
+ startHour={band.start}
1036
+ endHour={band.end}
1037
+ minHour={minHour}
1038
+ left={dayLeft(dayIndex)}
1039
+ width={dayWidth}
1040
+ color={theme.colors.outsideHoursBackground}
1041
+ />
1042
+ )),
1043
+ )
1044
+ : null}
1045
+
1046
+ {days.map((day, dayIndex) => (
1047
+ <Animated.View
1048
+ key={`separator-${day.toISOString()}`}
1049
+ style={[
1050
+ styles.daySeparator,
1051
+ styles.nonInteractive,
1052
+ { backgroundColor: theme.colors.gridLine },
1053
+ { left: dayLeft(dayIndex) },
1054
+ fullHeightStyle,
1055
+ ]}
1056
+ />
1057
+ ))}
1058
+
1059
+ {hoursRange.map((hour) => (
1060
+ <HourRow
1061
+ key={hour}
1062
+ hour={hour}
1063
+ minHour={minHour}
1064
+ cellHeight={heightSource}
1065
+ hourColumnWidth={hourColumnWidth}
1066
+ label={formatHour(hour, { ampm })}
1067
+ ampm={ampm}
1068
+ hourComponent={hourComponent}
1069
+ />
1070
+ ))}
1071
+
1072
+ {timeslots > 1
1073
+ ? hoursRange.flatMap((hour) =>
1074
+ Array.from({ length: timeslots - 1 }, (_, i) => (
1075
+ <TimeslotLine
1076
+ key={`slot-${hour}-${i}`}
1077
+ hour={hour}
1078
+ minHour={minHour}
1079
+ fraction={(i + 1) / timeslots}
1080
+ cellHeight={heightSource}
1081
+ hourColumnWidth={hourColumnWidth}
1082
+ />
1083
+ )),
1084
+ )
1085
+ : null}
1086
+
1087
+ {dayLayouts.flatMap((layout, dayIndex) =>
1088
+ layout
1089
+ // Skip events that fall entirely outside the [minHour, maxHour) window.
1090
+ .filter((p) => p.startHours < maxHour && p.startHours + p.durationHours > minHour)
1091
+ .map((positioned, eventIndex) => {
1092
+ const columnWidth = dayWidth / positioned.columns;
1093
+ return (
1094
+ <AnimatedEventBox
1095
+ // Prefix with the day so a multi-day event's per-day segments
1096
+ // (which share the same event key) stay unique across the
1097
+ // flattened list of all days' boxes.
1098
+ key={`${dayIndex}:${keyExtractor(positioned.event, eventIndex)}`}
1099
+ positioned={positioned}
1100
+ cellHeight={heightSource}
1101
+ minHour={minHour}
1102
+ left={dayLeft(dayIndex) + positioned.column * columnWidth}
1103
+ width={columnWidth}
1104
+ dayWidth={dayWidth}
1105
+ dayIndex={dayIndex}
1106
+ dayCount={days.length}
1107
+ mode={mode}
1108
+ renderEvent={renderEvent}
1109
+ snapMinutes={snapMinutes}
1110
+ showDragHandle={showDragHandle}
1111
+ onPress={onPressEvent}
1112
+ onLongPress={onLongPressEvent}
1113
+ onDragEvent={onDragEvent}
1114
+ onDragStart={onDragStart}
1115
+ />
1116
+ );
1117
+ }),
1118
+ )}
1119
+
1120
+ {showNowIndicator && nowDayIndex >= 0 && nowInWindow ? (
1121
+ <NowIndicator
1122
+ cellHeight={heightSource}
1123
+ nowHours={nowHours}
1124
+ minHour={minHour}
1125
+ left={dayLeft(nowDayIndex)}
1126
+ width={dayWidth}
1127
+ color={theme.colors.nowIndicator}
1128
+ />
1129
+ ) : null}
1130
+
1131
+ {createEnabled ? (
1132
+ <Animated.View
1133
+ style={[
1134
+ styles.createGhost,
1135
+ { pointerEvents: "none" },
1136
+ {
1137
+ backgroundColor: theme.colors.eventBackground,
1138
+ borderColor: theme.colors.todayBackground,
1139
+ },
1140
+ createGhostStyle,
1141
+ ]}
1142
+ />
1143
+ ) : null}
1144
+ </Animated.View>
1145
+ </Animated.ScrollView>
1146
+ </GestureDetector>
1147
+ </View>
1148
+ );
1149
+ }
1150
+
1151
+ const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
1152
+
1153
+ export type TimeGridProps<T> = {
1154
+ mode: TimeGridMode;
1155
+ /** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
1156
+ numberOfDays?: number;
1157
+ /**
1158
+ * Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
1159
+ * `weekStartsOn`…`weekEndsOn` of `date`'s week and pages by week, taking
1160
+ * precedence over `numberOfDays`. Ignored by other modes.
1161
+ */
1162
+ weekEndsOn?: WeekStartsOn;
1163
+ date: Date;
1164
+ events: CalendarEvent<T>[];
1165
+ cellHeight: SharedValue<number>;
1166
+ /** Initial per-hour row height in px; seeds scroll/zoom without reading the shared value during render. */
1167
+ hourHeight?: number;
1168
+ weekStartsOn: WeekStartsOn;
1169
+ renderEvent: RenderEvent<T>;
1170
+ keyExtractor: EventKeyExtractor<T>;
1171
+ scrollOffsetMinutes?: number;
1172
+ hourColumnWidth?: number;
1173
+ /** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
1174
+ hideHours?: boolean;
1175
+ /** Sub-hour divider lines per hour (e.g. 2 = half-hours). Default 1 (none). */
1176
+ timeslots?: number;
1177
+ /** Show the all-day lane above the grid. Default true. */
1178
+ showAllDayEventCell?: boolean;
1179
+ /** Per-date style merged onto each day column. */
1180
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
1181
+ businessHours?: BusinessHours;
1182
+ /** Show the ISO week number in the header gutter. Default false. */
1183
+ showWeekNumber?: boolean;
1184
+ /** Element rendered between the day header and the grid. */
1185
+ headerComponent?: React.ReactNode;
1186
+ /** First hour shown (0–23). Default 0. */
1187
+ minHour?: number;
1188
+ /** Last hour shown, exclusive (1–24). Default 24. */
1189
+ maxHour?: number;
1190
+ /** Show hour labels in 12-hour AM/PM form. Default false (24h). */
1191
+ ampm?: boolean;
1192
+ /** Reverse day-column order (right-to-left). Default false. */
1193
+ isRTL?: boolean;
1194
+ minHourHeight?: number;
1195
+ maxHourHeight?: number;
1196
+ showNowIndicator?: boolean;
1197
+ locale?: Locale;
1198
+ freeSwipe?: boolean;
1199
+ /** Allow swiping between pages. Default true. */
1200
+ swipeEnabled?: boolean;
1201
+ /** Show the vertical scroll indicator on the time grid. Default true. */
1202
+ showVerticalScrollIndicator?: boolean;
1203
+ /** Allow vertical scrolling of the time grid. Default true. */
1204
+ verticalScrollEnabled?: boolean;
1205
+ /** Prefix for the week-number label (e.g. "W"). Default "W". */
1206
+ weekNumberPrefix?: string;
1207
+ /** Replace the hour-axis label. Receives the hour (0–23) and `ampm`. */
1208
+ hourComponent?: HourRenderer;
1209
+ /** Highlight this date in the header instead of the real "today". */
1210
+ activeDate?: Date;
1211
+ /** After an empty-cell press, snap the pager back to the active page. Default false. */
1212
+ resetPageOnPressCell?: boolean;
1213
+ /** Minutes a drag-to-move/resize snaps to. Default 15. */
1214
+ dragStepMinutes?: number;
1215
+ /**
1216
+ * Show the resize grip on draggable events. Default true. Set false to keep
1217
+ * drag-to-move and drag-to-resize working while hiding the visible indicator.
1218
+ */
1219
+ showDragHandle?: boolean;
1220
+ onPressEvent: (event: CalendarEvent<T>) => void;
1221
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
1222
+ /**
1223
+ * Enable drag-to-move and drag-to-resize on the week/day grid. Called with the
1224
+ * event's new start/end (snapped to `dragStepMinutes`); update your own state.
1225
+ */
1226
+ onDragEvent?: EventDragHandler<T>;
1227
+ /** Fired the moment an event is grabbed for a move or resize (e.g. for haptics). */
1228
+ onDragStart?: EventDragStartHandler<T>;
1229
+ onPressCell?: (date: Date) => void;
1230
+ onLongPressCell?: (date: Date) => void;
1231
+ onCreateEvent?: (start: Date, end: Date) => void;
1232
+ /** Tap a day's column header (default header only). */
1233
+ onPressDateHeader?: (date: Date) => void;
1234
+ onChangeDate: (date: Date) => void;
1235
+ /** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
1236
+ renderHeader?: (days: Date[]) => React.ReactNode;
1237
+ };
1238
+
1239
+ function TimeGridInner<T>({
1240
+ mode,
1241
+ numberOfDays = 1,
1242
+ weekEndsOn,
1243
+ date,
1244
+ events,
1245
+ cellHeight,
1246
+ hourHeight = DEFAULT_HOUR_HEIGHT,
1247
+ weekStartsOn,
1248
+ renderEvent,
1249
+ keyExtractor,
1250
+ scrollOffsetMinutes = 0,
1251
+ hourColumnWidth: hourColumnWidthProp = DEFAULT_HOUR_COLUMN_WIDTH,
1252
+ hideHours = false,
1253
+ timeslots = 1,
1254
+ showAllDayEventCell = true,
1255
+ calendarCellStyle,
1256
+ businessHours,
1257
+ showWeekNumber = false,
1258
+ headerComponent,
1259
+ minHour = 0,
1260
+ maxHour = HOURS_PER_DAY,
1261
+ ampm = false,
1262
+ isRTL = false,
1263
+ minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
1264
+ maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
1265
+ showNowIndicator = true,
1266
+ locale,
1267
+ freeSwipe = false,
1268
+ swipeEnabled = true,
1269
+ showVerticalScrollIndicator = true,
1270
+ verticalScrollEnabled = true,
1271
+ weekNumberPrefix = "W",
1272
+ hourComponent,
1273
+ activeDate,
1274
+ resetPageOnPressCell = false,
1275
+ dragStepMinutes = DEFAULT_DRAG_STEP_MINUTES,
1276
+ showDragHandle = true,
1277
+ onPressEvent,
1278
+ onLongPressEvent,
1279
+ onDragEvent,
1280
+ onDragStart,
1281
+ onPressCell,
1282
+ onLongPressCell,
1283
+ onCreateEvent,
1284
+ onPressDateHeader,
1285
+ onChangeDate,
1286
+ renderHeader,
1287
+ }: TimeGridProps<T>) {
1288
+ // Guard against an inverted/out-of-range window so the grid never collapses.
1289
+ const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
1290
+ const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
1291
+ // Collapse the hour gutter to zero when hours are hidden.
1292
+ const hourColumnWidth = hideHours ? 0 : hourColumnWidthProp;
1293
+
1294
+ const { width, height } = useWindowDimensions();
1295
+ const listRef = useRef<LegendListRef>(null);
1296
+ // The grid's outer view; on web its ref resolves to the DOM node we attach the
1297
+ // Ctrl/Cmd + scroll zoom listener to.
1298
+ const containerRef = useRef<View>(null);
1299
+ // Web: ignore scroll events until this time (ms). Set around a page change so the
1300
+ // recycle-reset and the offset-restore aren't mistaken for a user scroll.
1301
+ const suppressCaptureUntilRef = useRef(0);
1302
+ // Horizontal list items need an explicit cross-axis height; seed it with the
1303
+ // window height (so it renders immediately and in tests) and refine on layout.
1304
+ const [pageHeight, setPageHeight] = useState(height);
1305
+ // The grid sizes to its container width, not the window, so it fits a
1306
+ // constrained layout on the web (e.g. a max-width card). On native the grid
1307
+ // fills the window, so this equals the window width and behaviour is unchanged.
1308
+ // Seeded with the window width for the first paint, refined on layout.
1309
+ const [containerWidth, setContainerWidth] = useState(width);
1310
+ // The list must remount exactly once — when the real height replaces the
1311
+ // window-height seed — or it keeps the oversized seed and clips. It must NOT
1312
+ // remount on later height changes (e.g. a taller day header vs a shorter week
1313
+ // header on a mode switch): a remount blanks the visible page for a frame.
1314
+ const [measured, setMeasured] = useState(false);
1315
+ // Week-anchored modes page by a full week and align pages to the week start:
1316
+ // `week`, and `custom` when a `weekEndsOn` defines a partial-week span.
1317
+ const weekAnchored = mode === "week" || (mode === "custom" && weekEndsOn != null);
1318
+ // Days advanced per page: a full week when week-anchored, else the column count.
1319
+ const step = weekAnchored ? 7 : viewDayCount(mode, numberOfDays);
1320
+ // Shared vertical scroll offset so every mounted page stays aligned. Seeded
1321
+ // from the numeric hourHeight rather than reading cellHeight.value (which
1322
+ // would warn about reading a shared value during render).
1323
+ const seedDefaultY =
1324
+ Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - clampedMinHour) * hourHeight;
1325
+ const scrollY = useSharedValue(seedDefaultY);
1326
+ // Plain mirror of the last settled vertical offset. A page that mounts after a
1327
+ // scroll seeds its contentOffset here instead of the default, so it appears at the
1328
+ // saved time straight away rather than rendering at the default and snapping (the
1329
+ // one-frame flash). Null until the first scroll, when `seedDefaultY` is used.
1330
+ const offsetSeedRef = useRef<number | null>(null);
1331
+ const captureOffsetSeed = useCallback((y: number) => {
1332
+ offsetSeedRef.current = y;
1333
+ }, []);
1334
+ // Zoom committed at the end of the last pinch; off-screen pages animate off
1335
+ // this so they don't re-run their worklets every frame while the visible page
1336
+ // zooms.
1337
+ const committedCellHeight = useSharedValue(hourHeight);
1338
+
1339
+ // Web stand-in for pinch: Ctrl/Cmd + scroll zooms the grid via the same shared
1340
+ // values the pinch gesture drives.
1341
+ useWebGridZoom(
1342
+ isWeb,
1343
+ containerRef,
1344
+ cellHeight,
1345
+ committedCellHeight,
1346
+ minHourHeight,
1347
+ maxHourHeight,
1348
+ );
1349
+
1350
+ // A fixed window of page dates, anchored once and aligned to the page boundary
1351
+ // (day or week start). The array never shifts as the date changes.
1352
+ const [anchorDate] = useState(date);
1353
+ const anchor = useMemo(
1354
+ () => (weekAnchored ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate)),
1355
+ [weekAnchored, anchorDate, weekStartsOn],
1356
+ );
1357
+ const pageDates = useMemo(
1358
+ () =>
1359
+ Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) =>
1360
+ addDays(anchor, (i - PAGE_WINDOW) * step),
1361
+ ),
1362
+ [anchor, step],
1363
+ );
1364
+ const indexOfDate = useCallback(
1365
+ (target: Date) => {
1366
+ const aligned = weekAnchored ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
1367
+ // Floor so an arbitrary date lands on the page whose range contains it
1368
+ // (exact for day/week, where dates are already page-aligned).
1369
+ return Math.floor(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
1370
+ },
1371
+ [anchor, weekAnchored, step, weekStartsOn],
1372
+ );
1373
+
1374
+ // The committed date's page is the centred/active one. `viewedIndexRef` tracks
1375
+ // where the list actually sits, telling swipe-driven changes from external ones.
1376
+ const activeIndex = indexOfDate(date);
1377
+ const viewedIndexRef = useRef(activeIndex);
1378
+ // While a programmatic scroll (a "today" button, prev/next, or any date set from
1379
+ // outside) is settling, this holds its target index. Viewability ticks for the
1380
+ // intermediate pages it crosses are ignored until it lands, so they can't report
1381
+ // a page in between back as the new date — which made jumps land one page short.
1382
+ const pendingScrollIndexRef = useRef<number | null>(null);
1383
+
1384
+ // Header days track the active page (page-aligned), so they always match the
1385
+ // columns below and a swipe never flashes another day's label.
1386
+ const headerDays = useMemo(
1387
+ () =>
1388
+ getViewDays(
1389
+ mode,
1390
+ pageDates[activeIndex] ?? date,
1391
+ weekStartsOn,
1392
+ numberOfDays,
1393
+ isRTL,
1394
+ weekEndsOn,
1395
+ ),
1396
+ [mode, pageDates, activeIndex, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
1397
+ );
1398
+
1399
+ const handleViewableItemsChanged = useCallback(
1400
+ (info: OnViewableItemsChangedInfo<Date>) => {
1401
+ // On the web the pager can't be swiped (overflow is hidden); every page change
1402
+ // is a programmatic scroll driven by `date` (prev/next/today/keys). Viewability
1403
+ // there only echoes that scroll back, and can report an intermediate page that
1404
+ // fights it (a multi-page "today" jump landing one page short), so ignore it.
1405
+ if (isWeb) return;
1406
+ const settled = info.viewableItems.find((token) => token.isViewable);
1407
+ if (settled?.index == null) return;
1408
+ // A programmatic scroll is settling: ignore the pages it crosses, and clear
1409
+ // the pending target (without reporting a date) once it reaches the target.
1410
+ if (pendingScrollIndexRef.current != null) {
1411
+ if (settled.index === pendingScrollIndexRef.current) {
1412
+ pendingScrollIndexRef.current = null;
1413
+ viewedIndexRef.current = settled.index;
1414
+ }
1415
+ return;
1416
+ }
1417
+ if (settled.index === viewedIndexRef.current) return;
1418
+ viewedIndexRef.current = settled.index;
1419
+ if (settled.item) onChangeDate(settled.item);
1420
+ },
1421
+ [onChangeDate],
1422
+ );
1423
+
1424
+ // Realign the list when the date changes from outside a swipe (e.g. a "today"
1425
+ // button or a month-view tap). Swipe-driven changes already match.
1426
+ useEffect(() => {
1427
+ if (activeIndex === viewedIndexRef.current) return;
1428
+ viewedIndexRef.current = activeIndex;
1429
+ pendingScrollIndexRef.current = activeIndex;
1430
+ void listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
1431
+ }, [activeIndex]);
1432
+
1433
+ // react-native-web only: paging recycles the page containers and resets their
1434
+ // vertical scroll, so the on-screen page randomly lands at the top. After the
1435
+ // page settles, restore the shared offset directly on the visible page's scroll
1436
+ // node — the one lever that reliably works on the web. Deferred a frame (twice,
1437
+ // for safety) so the paged-in grid has laid out before we scroll it.
1438
+ useEffect(() => {
1439
+ if (!isWeb) return;
1440
+ const root = containerRef.current as unknown as HTMLElement | null;
1441
+ if (!root) return;
1442
+ const restoreVisiblePage = () => {
1443
+ const vw = (root.ownerDocument?.defaultView ?? globalThis).innerWidth;
1444
+ for (const el of root.querySelectorAll<HTMLElement>("*")) {
1445
+ const style = getComputedStyle(el);
1446
+ const scrollable =
1447
+ (style.overflowY === "scroll" || style.overflowY === "auto") &&
1448
+ el.scrollHeight > el.clientHeight + 20 &&
1449
+ el.clientHeight > 100;
1450
+ if (!scrollable) continue;
1451
+ const rect = el.getBoundingClientRect();
1452
+ if (rect.left > -50 && rect.right <= vw + 50) {
1453
+ el.scrollTop = scrollY.value;
1454
+ break;
1455
+ }
1456
+ }
1457
+ };
1458
+ // Don't capture the scroll events this transition emits (recycle-reset, restore).
1459
+ suppressCaptureUntilRef.current = Date.now() + 400;
1460
+ let raf2 = 0;
1461
+ const raf1 = requestAnimationFrame(() => {
1462
+ restoreVisiblePage();
1463
+ raf2 = requestAnimationFrame(restoreVisiblePage);
1464
+ });
1465
+ return () => {
1466
+ cancelAnimationFrame(raf1);
1467
+ cancelAnimationFrame(raf2);
1468
+ };
1469
+ // `pageHeight` is included so this also runs once the pager measures its real
1470
+ // height on first open (the mount pass runs before the pages have laid out).
1471
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef/scrollY are stable
1472
+ }, [activeIndex, pageHeight]);
1473
+
1474
+ // Web: record the on-screen page's scroll into the shared offset on genuine user
1475
+ // scrolls, so switching pages preserves the position. Programmatic scrolls (the
1476
+ // recycle-reset and the restore above) are skipped via the suppression window, so
1477
+ // they can't clobber it. Scoped to the visible page so off-screen sync is ignored.
1478
+ useEffect(() => {
1479
+ if (!isWeb) return;
1480
+ const root = containerRef.current as unknown as HTMLElement | null;
1481
+ if (!root) return;
1482
+ const onScrollCapture = (event: Event) => {
1483
+ if (Date.now() < suppressCaptureUntilRef.current) return;
1484
+ const el = event.target as HTMLElement | null;
1485
+ if (!el || typeof el.scrollTop !== "number" || el.clientHeight <= 100) return;
1486
+ const rect = el.getBoundingClientRect();
1487
+ const vw = (root.ownerDocument?.defaultView ?? globalThis).innerWidth;
1488
+ if (rect.left <= -50 || rect.right > vw + 50) return;
1489
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
1490
+ scrollY.value = el.scrollTop;
1491
+ offsetSeedRef.current = el.scrollTop;
1492
+ };
1493
+ root.addEventListener("scroll", onScrollCapture, true);
1494
+ return () => root.removeEventListener("scroll", onScrollCapture, true);
1495
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef/scrollY are stable
1496
+ }, []);
1497
+
1498
+ // Web: LegendList's horizontal scroll container is `overflow-x: auto`, so a
1499
+ // trackpad swipe or horizontal wheel would scroll between pages. Paging should be
1500
+ // arrow-keys/toolbar only, so disable user horizontal scrolling on it (programmatic
1501
+ // scrollToIndex still works through `overflow: hidden`). `touch-action: pan-y`
1502
+ // keeps vertical scrolling of the grid working.
1503
+ useEffect(() => {
1504
+ if (!isWeb) return;
1505
+ const root = containerRef.current as unknown as HTMLElement | null;
1506
+ if (!root) return;
1507
+ const lockHorizontal = () => {
1508
+ for (const el of root.querySelectorAll<HTMLElement>("*")) {
1509
+ if (el.scrollWidth <= el.clientWidth + 20 || el.clientWidth <= 100) continue;
1510
+ const overflowX = getComputedStyle(el).overflowX;
1511
+ if (overflowX === "auto" || overflowX === "scroll") {
1512
+ el.style.overflowX = "hidden";
1513
+ el.style.touchAction = "pan-y";
1514
+ }
1515
+ }
1516
+ };
1517
+ const raf = requestAnimationFrame(lockHorizontal);
1518
+ return () => cancelAnimationFrame(raf);
1519
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef is stable
1520
+ }, [pageHeight]);
1521
+
1522
+ // Web arrow-key paging (swipe is disabled there); the effect above scrolls to
1523
+ // the new page once `onChangeDate` updates `date`.
1524
+ const goToPage = useCallback(
1525
+ (delta: number) => {
1526
+ const target = pageDates[activeIndex + delta];
1527
+ if (target) onChangeDate(target);
1528
+ },
1529
+ [pageDates, activeIndex, onChangeDate],
1530
+ );
1531
+ useWebPagerKeys(swipeEnabled, goToPage);
1532
+
1533
+ // Optionally snap the pager back to the active page after an empty-cell press
1534
+ // (so tapping a far-swiped page returns to the committed date).
1535
+ const handlePressCell = useMemo(() => {
1536
+ if (!onPressCell) return undefined;
1537
+ if (!resetPageOnPressCell) return onPressCell;
1538
+ return (cellDate: Date) => {
1539
+ onPressCell(cellDate);
1540
+ void listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
1541
+ };
1542
+ }, [onPressCell, resetPageOnPressCell, activeIndex]);
1543
+
1544
+ const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
1545
+ const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
1546
+ const getFixedItemSize = useCallback(() => containerWidth, [containerWidth]);
1547
+ const renderItem = useCallback(
1548
+ ({ item, index }: LegendListRenderItemProps<Date>) => (
1549
+ <View style={{ width: containerWidth, height: pageHeight }}>
1550
+ <TimetablePage
1551
+ mode={mode}
1552
+ numberOfDays={numberOfDays}
1553
+ date={item}
1554
+ width={containerWidth}
1555
+ events={events}
1556
+ cellHeight={cellHeight}
1557
+ hourHeight={hourHeight}
1558
+ committedCellHeight={committedCellHeight}
1559
+ scrollY={scrollY}
1560
+ isActive={index === activeIndex}
1561
+ initialScrollY={offsetSeedRef.current ?? seedDefaultY}
1562
+ onSettleOffset={captureOffsetSeed}
1563
+ weekStartsOn={weekStartsOn}
1564
+ weekEndsOn={weekEndsOn}
1565
+ hourColumnWidth={hourColumnWidth}
1566
+ minHour={clampedMinHour}
1567
+ maxHour={clampedMaxHour}
1568
+ ampm={ampm}
1569
+ timeslots={timeslots}
1570
+ isRTL={isRTL}
1571
+ showAllDayEventCell={showAllDayEventCell}
1572
+ showVerticalScrollIndicator={showVerticalScrollIndicator}
1573
+ verticalScrollEnabled={verticalScrollEnabled}
1574
+ hourComponent={hourComponent}
1575
+ calendarCellStyle={calendarCellStyle}
1576
+ businessHours={businessHours}
1577
+ minHourHeight={minHourHeight}
1578
+ maxHourHeight={maxHourHeight}
1579
+ showNowIndicator={showNowIndicator}
1580
+ renderEvent={renderEvent}
1581
+ keyExtractor={keyExtractor}
1582
+ snapMinutes={Math.max(1, dragStepMinutes)}
1583
+ showDragHandle={showDragHandle}
1584
+ onPressEvent={onPressEvent}
1585
+ onLongPressEvent={onLongPressEvent}
1586
+ onDragEvent={onDragEvent}
1587
+ onDragStart={onDragStart}
1588
+ onPressCell={handlePressCell}
1589
+ onLongPressCell={onLongPressCell}
1590
+ onCreateEvent={onCreateEvent}
1591
+ />
1592
+ </View>
1593
+ ),
1594
+ [
1595
+ containerWidth,
1596
+ pageHeight,
1597
+ mode,
1598
+ numberOfDays,
1599
+ events,
1600
+ cellHeight,
1601
+ hourHeight,
1602
+ committedCellHeight,
1603
+ scrollY,
1604
+ activeIndex,
1605
+ seedDefaultY,
1606
+ captureOffsetSeed,
1607
+ weekStartsOn,
1608
+ weekEndsOn,
1609
+ hourColumnWidth,
1610
+ clampedMinHour,
1611
+ clampedMaxHour,
1612
+ ampm,
1613
+ timeslots,
1614
+ isRTL,
1615
+ showAllDayEventCell,
1616
+ showVerticalScrollIndicator,
1617
+ verticalScrollEnabled,
1618
+ hourComponent,
1619
+ calendarCellStyle,
1620
+ businessHours,
1621
+ minHourHeight,
1622
+ maxHourHeight,
1623
+ showNowIndicator,
1624
+ renderEvent,
1625
+ keyExtractor,
1626
+ dragStepMinutes,
1627
+ showDragHandle,
1628
+ onPressEvent,
1629
+ onLongPressEvent,
1630
+ onDragEvent,
1631
+ onDragStart,
1632
+ handlePressCell,
1633
+ onLongPressCell,
1634
+ onCreateEvent,
1635
+ ],
1636
+ );
1637
+
1638
+ // Pages are keyed by date, so LegendList keeps the items it has already rendered
1639
+ // and only re-renders them when `data` or `extraData` changes. Feed both `events`
1640
+ // (so a moved event repaints in place) and `activeIndex` (so each page's
1641
+ // `isActive` updates as you swipe). Without `activeIndex`, a page that pages in
1642
+ // never learns it became active and stays at its seeded scroll offset.
1643
+ const listExtraData = useMemo(() => ({ events, activeIndex }), [events, activeIndex]);
1644
+
1645
+ return (
1646
+ <View ref={containerRef} style={styles.container}>
1647
+ {renderHeader ? (
1648
+ renderHeader(headerDays)
1649
+ ) : (
1650
+ <DefaultHeader
1651
+ days={headerDays}
1652
+ mode={mode}
1653
+ width={containerWidth}
1654
+ hourColumnWidth={hourColumnWidth}
1655
+ showWeekNumber={showWeekNumber}
1656
+ weekNumberPrefix={weekNumberPrefix}
1657
+ locale={locale}
1658
+ activeDate={activeDate}
1659
+ onPressDateHeader={onPressDateHeader}
1660
+ />
1661
+ )}
1662
+
1663
+ {headerComponent}
1664
+
1665
+ <View
1666
+ style={styles.pager}
1667
+ onLayout={(event) => {
1668
+ setPageHeight(event.nativeEvent.layout.height);
1669
+ setContainerWidth(event.nativeEvent.layout.width);
1670
+ setMeasured(true);
1671
+ }}
1672
+ >
1673
+ <LegendList
1674
+ // Remount only on the seed→measured transition (see `measured`), not on
1675
+ // every height change, so a day↔week header-height difference resizes the
1676
+ // items in place instead of remounting and blanking the page.
1677
+ key={measured ? "grid" : "grid-seed"}
1678
+ ref={listRef}
1679
+ style={isWeb ? [styles.pagerList, styles.webNoScroll] : styles.pagerList}
1680
+ data={pageDates}
1681
+ extraData={listExtraData}
1682
+ horizontal
1683
+ recycleItems={false}
1684
+ keyExtractor={keyExtractorList}
1685
+ getFixedItemSize={getFixedItemSize}
1686
+ // On web LegendList ignores these RN scroll props (it leaks them to the
1687
+ // DOM as unknown attributes), so omit them there and disable horizontal
1688
+ // scroll via `webNoScroll`; paging is driven by the arrow keys instead.
1689
+ // Native: paging makes each swipe hard-stop at the adjacent page, while
1690
+ // `freeSwipe` lets momentum carry across pages and snap to a boundary.
1691
+ {...(isWeb
1692
+ ? null
1693
+ : {
1694
+ scrollEnabled: swipeEnabled,
1695
+ pagingEnabled: !freeSwipe,
1696
+ snapToIndices: freeSwipe ? snapToIndices : undefined,
1697
+ })}
1698
+ initialScrollIndex={activeIndex}
1699
+ showsHorizontalScrollIndicator={false}
1700
+ viewabilityConfig={PAGE_VIEWABILITY}
1701
+ onViewableItemsChanged={handleViewableItemsChanged}
1702
+ renderItem={renderItem}
1703
+ />
1704
+ </View>
1705
+ </View>
1706
+ );
1707
+ }
1708
+
1709
+ export const TimeGrid = memo(TimeGridInner) as typeof TimeGridInner;
1710
+
1711
+ type DefaultHeaderProps = {
1712
+ days: Date[];
1713
+ mode: CalendarMode;
1714
+ width: number;
1715
+ hourColumnWidth: number;
1716
+ showWeekNumber?: boolean;
1717
+ weekNumberPrefix?: string;
1718
+ locale?: Locale;
1719
+ activeDate?: Date;
1720
+ onPressDateHeader?: (date: Date) => void;
1721
+ };
1722
+
1723
+ const DefaultHeader = ({
1724
+ days,
1725
+ mode,
1726
+ width,
1727
+ hourColumnWidth,
1728
+ showWeekNumber,
1729
+ weekNumberPrefix = "W",
1730
+ locale,
1731
+ activeDate,
1732
+ onPressDateHeader,
1733
+ }: DefaultHeaderProps) => {
1734
+ const theme = useCalendarTheme();
1735
+ // Match the grid below: an hour-column spacer, then one column per day.
1736
+ const dayWidth = (width - hourColumnWidth) / days.length;
1737
+
1738
+ return (
1739
+ <View style={[styles.headerRow, { borderBottomColor: theme.colors.gridLine }]}>
1740
+ <View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
1741
+ {showWeekNumber && hourColumnWidth > 0 && days[0] ? (
1742
+ <Text
1743
+ style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
1744
+ allowFontScaling={false}
1745
+ >
1746
+ {`${weekNumberPrefix}${getISOWeek(days[0])}`}
1747
+ </Text>
1748
+ ) : null}
1749
+ </View>
1750
+ {days.map((day) => (
1751
+ <DayHeader
1752
+ key={day.toISOString()}
1753
+ day={day}
1754
+ mode={mode}
1755
+ width={dayWidth}
1756
+ locale={locale}
1757
+ activeDate={activeDate}
1758
+ onPressDateHeader={onPressDateHeader}
1759
+ />
1760
+ ))}
1761
+ </View>
1762
+ );
1763
+ };
1764
+
1765
+ type DayHeaderProps = {
1766
+ day: Date;
1767
+ mode: CalendarMode;
1768
+ width: number;
1769
+ locale?: Locale;
1770
+ activeDate?: Date;
1771
+ onPressDateHeader?: (date: Date) => void;
1772
+ };
1773
+
1774
+ const DayHeader = ({ day, width, locale, activeDate, onPressDateHeader }: DayHeaderProps) => {
1775
+ const theme = useCalendarTheme();
1776
+ const isToday = getIsToday(day);
1777
+ // Highlight the chosen `activeDate` when supplied, else the real today.
1778
+ const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
1779
+
1780
+ return (
1781
+ <Pressable
1782
+ style={[styles.dayHeader, { width }]}
1783
+ onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
1784
+ disabled={!onPressDateHeader}
1785
+ accessibilityRole={onPressDateHeader ? "button" : undefined}
1786
+ >
1787
+ {/* Mirrors the dom renderer's header: the muted weekday label sits above the
1788
+ day number, and the number's circle fills for today / the active date. */}
1789
+ <Text
1790
+ style={[styles.dayHeaderWeekday, { color: theme.colors.textMuted }]}
1791
+ allowFontScaling={false}
1792
+ >
1793
+ {format(day, "EEE", { locale })}
1794
+ </Text>
1795
+ <View
1796
+ style={[
1797
+ styles.dayHeaderBadge,
1798
+ isHighlighted && { backgroundColor: theme.colors.todayBackground },
1799
+ ]}
1800
+ >
1801
+ <Text
1802
+ style={[
1803
+ styles.dayHeaderNumber,
1804
+ { color: isHighlighted ? theme.colors.todayText : theme.colors.text },
1805
+ ]}
1806
+ allowFontScaling={false}
1807
+ {...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
1808
+ >
1809
+ {day.getDate()}
1810
+ </Text>
1811
+ </View>
1812
+ </Pressable>
1813
+ );
1814
+ };
1815
+
1816
+ const styles = StyleSheet.create({
1817
+ pager: {
1818
+ flex: 1,
1819
+ },
1820
+ pagerList: {
1821
+ flex: 1,
1822
+ },
1823
+ container: {
1824
+ flex: 1,
1825
+ },
1826
+ headerRow: {
1827
+ flexDirection: "row",
1828
+ // Center the weekday/number block vertically; the day header's own symmetric
1829
+ // paddingVertical provides the spacing, matching the dom renderer (no extra
1830
+ // bottom padding that would push the content up).
1831
+ alignItems: "center",
1832
+ borderBottomWidth: StyleSheet.hairlineWidth,
1833
+ },
1834
+ weekNumberGutter: {
1835
+ alignItems: "center",
1836
+ justifyContent: "flex-end",
1837
+ },
1838
+ dayHeader: {
1839
+ alignItems: "center",
1840
+ gap: 2,
1841
+ paddingVertical: 6,
1842
+ },
1843
+ dayHeaderWeekday: {
1844
+ fontSize: 11,
1845
+ fontWeight: "600",
1846
+ },
1847
+ dayHeaderBadge: {
1848
+ // A fixed circle so the today/active fill never shifts the header's height.
1849
+ width: 28,
1850
+ height: 28,
1851
+ borderRadius: 999,
1852
+ justifyContent: "center",
1853
+ alignItems: "center",
1854
+ },
1855
+ dayHeaderNumber: {
1856
+ fontSize: 15,
1857
+ fontWeight: "600",
1858
+ },
1859
+ content: {
1860
+ width: "100%",
1861
+ position: "relative",
1862
+ },
1863
+ cellPressLayer: {
1864
+ position: "absolute",
1865
+ top: 0,
1866
+ bottom: 0,
1867
+ right: 0,
1868
+ },
1869
+ createGhost: {
1870
+ position: "absolute",
1871
+ borderRadius: 6,
1872
+ borderWidth: StyleSheet.hairlineWidth,
1873
+ marginHorizontal: EVENT_GAP,
1874
+ },
1875
+ weekendColumn: {
1876
+ position: "absolute",
1877
+ top: 0,
1878
+ },
1879
+ shadeBand: {
1880
+ position: "absolute",
1881
+ },
1882
+ daySeparator: {
1883
+ position: "absolute",
1884
+ top: 0,
1885
+ width: StyleSheet.hairlineWidth,
1886
+ },
1887
+ hourRow: {
1888
+ position: "absolute",
1889
+ left: 0,
1890
+ right: 0,
1891
+ flexDirection: "row",
1892
+ alignItems: "flex-start",
1893
+ },
1894
+ hourLabel: {
1895
+ marginTop: -HOUR_LABEL_NUDGE,
1896
+ textAlign: "right",
1897
+ paddingRight: 6,
1898
+ },
1899
+ hourLine: {
1900
+ flex: 1,
1901
+ height: StyleSheet.hairlineWidth,
1902
+ },
1903
+ timeslotLine: {
1904
+ position: "absolute",
1905
+ right: 0,
1906
+ height: StyleSheet.hairlineWidth,
1907
+ opacity: 0.5,
1908
+ },
1909
+ resizeHandle: {
1910
+ position: "absolute",
1911
+ left: 0,
1912
+ right: 0,
1913
+ bottom: 0,
1914
+ height: RESIZE_HANDLE_HEIGHT,
1915
+ alignItems: "center",
1916
+ justifyContent: "center",
1917
+ },
1918
+ resizeGrip: {
1919
+ width: 24,
1920
+ height: 3,
1921
+ borderRadius: 2,
1922
+ opacity: 0.4,
1923
+ },
1924
+ eventBox: {
1925
+ position: "absolute",
1926
+ overflow: "hidden",
1927
+ // Border-box padding insets the visible box (the flex child) on all sides,
1928
+ // giving a small gap without touching the slot geometry above.
1929
+ padding: EVENT_GAP,
1930
+ },
1931
+ nowIndicator: {
1932
+ position: "absolute",
1933
+ height: 2,
1934
+ },
1935
+ // `pointerEvents` as a style (not a prop) — the prop form is deprecated on web.
1936
+ nonInteractive: {
1937
+ pointerEvents: "none",
1938
+ },
1939
+ // Disable user-driven horizontal scroll on web; programmatic paging still works.
1940
+ webNoScroll: {
1941
+ overflow: "hidden",
1942
+ },
1943
+ });