react-native-bigger-calendar 0.1.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,825 @@
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
+ getHours,
11
+ getMinutes,
12
+ startOfDay,
13
+ startOfWeek,
14
+ } from 'date-fns';
15
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import {
17
+ type GestureResponderEvent,
18
+ Pressable,
19
+ StyleSheet,
20
+ Text,
21
+ useWindowDimensions,
22
+ View,
23
+ } from 'react-native';
24
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
25
+ import Animated, {
26
+ scrollTo,
27
+ type SharedValue,
28
+ useAnimatedReaction,
29
+ useAnimatedRef,
30
+ useAnimatedScrollHandler,
31
+ useAnimatedStyle,
32
+ useDerivedValue,
33
+ useSharedValue,
34
+ } from 'react-native-reanimated';
35
+ import { useCalendarTheme } from '../theme';
36
+ import type {
37
+ CalendarEvent,
38
+ CalendarMode,
39
+ EventKeyExtractor,
40
+ RenderEvent,
41
+ WeekStartsOn,
42
+ } from '../types';
43
+ import { getIsToday, getWeekDays, isWeekend } from '../utils/dates';
44
+ import { layoutDayEvents, type PositionedEvent } from '../utils/layout';
45
+
46
+ const MINUTES_PER_HOUR = 60;
47
+ 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
+ // Steps rendered either side of the current page. LegendList virtualises, so
52
+ // only a few mount at once; a wide window means the user effectively never runs
53
+ // out of pages to swipe. Items are keyed by date and never recycled.
54
+ const PAGE_WINDOW = 180;
55
+ // A page must be ~fully on screen before it becomes the committed date.
56
+ const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
57
+
58
+ export const DEFAULT_HOUR_HEIGHT = 64;
59
+ const DEFAULT_MIN_HOUR_HEIGHT = 32;
60
+ const DEFAULT_MAX_HOUR_HEIGHT = 160;
61
+ const DEFAULT_HOUR_COLUMN_WIDTH = 50;
62
+ // Short events would otherwise render only a few pixels tall and clip their
63
+ // content; keep them tall enough to stay legible and tappable.
64
+ const MIN_EVENT_HEIGHT = 32;
65
+ // Hour labels are nudged up so the number sits centred on its grid line. Pad the
66
+ // scroll content by the same amount so the top-most label is never clipped.
67
+ const HOUR_LABEL_TOP_INSET = 12;
68
+ const HOUR_LABEL_NUDGE = 8;
69
+ const NOW_TICK_MS = 60_000;
70
+
71
+ // A `Date` that advances every minute while `enabled`, so the now-indicator
72
+ // tracks the wall clock instead of freezing at mount. Off-screen pages pass
73
+ // `enabled = false` and re-read the time when they become active.
74
+ function useNow(enabled: boolean): Date {
75
+ const [now, setNow] = useState(() => new Date());
76
+ useEffect(() => {
77
+ if (!enabled) return;
78
+ setNow(new Date());
79
+ const id = setInterval(() => setNow(new Date()), NOW_TICK_MS);
80
+ return () => clearInterval(id);
81
+ }, [enabled]);
82
+ return now;
83
+ }
84
+
85
+ // "13" in 24h, or "1 PM" in 12h. Midnight/noon read as 12 AM / 12 PM.
86
+ function formatHourLabel(hour: number, ampm: boolean): string {
87
+ if (!ampm) return String(hour);
88
+ const period = hour < 12 ? 'AM' : 'PM';
89
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
90
+ return `${hour12} ${period}`;
91
+ }
92
+
93
+ type AnimatedEventBoxProps<T> = {
94
+ positioned: PositionedEvent<T>;
95
+ cellHeight: SharedValue<number>;
96
+ minHour: number;
97
+ left: number;
98
+ width: number;
99
+ mode: CalendarMode;
100
+ renderEvent: RenderEvent<T>;
101
+ onPress: (event: CalendarEvent<T>) => void;
102
+ };
103
+
104
+ function AnimatedEventBox<T>({
105
+ positioned,
106
+ cellHeight,
107
+ minHour,
108
+ left,
109
+ width,
110
+ mode,
111
+ renderEvent,
112
+ onPress,
113
+ }: AnimatedEventBoxProps<T>) {
114
+ const RenderEventComponent = renderEvent;
115
+ // Live pixel height of the box, driven on the UI thread by the shared
116
+ // cellHeight. Handed to renderEvent so custom renderers can reveal detail
117
+ // progressively as the grid zooms, without re-rendering. Explicit deps so the
118
+ // worklet re-captures the event's geometry when its time/duration changes.
119
+ const boxHeight = useDerivedValue(
120
+ () => Math.max(positioned.durationHours * cellHeight.value, MIN_EVENT_HEIGHT),
121
+ [positioned.durationHours],
122
+ );
123
+
124
+ const boxStyle = useAnimatedStyle(
125
+ () => ({
126
+ top: (positioned.startHours - minHour) * cellHeight.value,
127
+ height: boxHeight.value,
128
+ }),
129
+ [positioned.startHours, positioned.durationHours, minHour],
130
+ );
131
+
132
+ const handlePress = () => onPress(positioned.event);
133
+
134
+ return (
135
+ <Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
136
+ <RenderEventComponent
137
+ event={positioned.event}
138
+ mode={mode}
139
+ boxHeight={boxHeight}
140
+ continuesBefore={positioned.continuesBefore}
141
+ continuesAfter={positioned.continuesAfter}
142
+ onPress={handlePress}
143
+ />
144
+ </Animated.View>
145
+ );
146
+ }
147
+
148
+ type HourRowProps = {
149
+ hour: number;
150
+ minHour: number;
151
+ cellHeight: SharedValue<number>;
152
+ hourColumnWidth: number;
153
+ label: string;
154
+ };
155
+
156
+ const HourRow = ({ hour, minHour, cellHeight, hourColumnWidth, label }: HourRowProps) => {
157
+ const theme = useCalendarTheme();
158
+ // Position via `top` (a layout prop), not a transform. The per-row layout pass
159
+ // as cellHeight animates keeps the ScrollView's content size in sync while
160
+ // zooming; a transform is composited and leaves the scroll range stale.
161
+ const animatedStyle = useAnimatedStyle(
162
+ () => ({ top: (hour - minHour) * cellHeight.value }),
163
+ [hour, minHour],
164
+ );
165
+
166
+ return (
167
+ <Animated.View style={[styles.hourRow, animatedStyle]} pointerEvents="none">
168
+ <Text
169
+ style={[
170
+ theme.text.hourLabel,
171
+ styles.hourLabel,
172
+ { width: hourColumnWidth, color: theme.colors.textMuted },
173
+ ]}
174
+ allowFontScaling={false}
175
+ >
176
+ {label}
177
+ </Text>
178
+ <View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
179
+ </Animated.View>
180
+ );
181
+ };
182
+
183
+ type NowIndicatorProps = {
184
+ cellHeight: SharedValue<number>;
185
+ nowHours: number;
186
+ minHour: number;
187
+ left: number;
188
+ color: string;
189
+ };
190
+
191
+ const NowIndicator = ({ cellHeight, nowHours, minHour, left, color }: NowIndicatorProps) => {
192
+ const animatedStyle = useAnimatedStyle(
193
+ () => ({ top: (nowHours - minHour) * cellHeight.value }),
194
+ [nowHours, minHour],
195
+ );
196
+
197
+ return (
198
+ <Animated.View
199
+ style={[styles.nowIndicator, { left, backgroundColor: color }, animatedStyle]}
200
+ pointerEvents="none"
201
+ />
202
+ );
203
+ };
204
+
205
+ type TimetablePageProps<T> = {
206
+ mode: 'day' | 'week';
207
+ date: Date;
208
+ events: CalendarEvent<T>[];
209
+ cellHeight: SharedValue<number>;
210
+ hourHeight: number;
211
+ // The zoom committed at the end of the last pinch. Off-screen pages animate off
212
+ // this (it changes once per gesture) instead of the live cellHeight (which
213
+ // changes every frame), so a pinch only re-runs the visible page's worklets.
214
+ committedCellHeight: SharedValue<number>;
215
+ scrollY: SharedValue<number>;
216
+ isActive: boolean;
217
+ scrollOffsetMinutes: number;
218
+ weekStartsOn: WeekStartsOn;
219
+ hourColumnWidth: number;
220
+ minHour: number;
221
+ maxHour: number;
222
+ ampm: boolean;
223
+ minHourHeight: number;
224
+ maxHourHeight: number;
225
+ showNowIndicator: boolean;
226
+ renderEvent: RenderEvent<T>;
227
+ keyExtractor: EventKeyExtractor<T>;
228
+ onPressEvent: (event: CalendarEvent<T>) => void;
229
+ onPressCell?: (date: Date) => void;
230
+ };
231
+
232
+ // A single date's grid: the pinch-zoomable, vertically-scrolling time column.
233
+ // Three of these are mounted side by side inside the pager so the previous and
234
+ // next dates are ready to drag into view.
235
+ function TimetablePageInner<T>({
236
+ mode,
237
+ date,
238
+ events,
239
+ cellHeight,
240
+ hourHeight,
241
+ committedCellHeight,
242
+ scrollY,
243
+ isActive,
244
+ scrollOffsetMinutes,
245
+ weekStartsOn,
246
+ hourColumnWidth,
247
+ minHour,
248
+ maxHour,
249
+ ampm,
250
+ minHourHeight,
251
+ maxHourHeight,
252
+ showNowIndicator,
253
+ renderEvent,
254
+ keyExtractor,
255
+ onPressEvent,
256
+ onPressCell,
257
+ }: TimetablePageProps<T>) {
258
+ const theme = useCalendarTheme();
259
+ const { width } = useWindowDimensions();
260
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
261
+
262
+ // The visible page tracks the live cellHeight (animates every pinch frame);
263
+ // off-screen pages track committedCellHeight (settles once per gesture).
264
+ const heightSource = isActive ? cellHeight : committedCellHeight;
265
+
266
+ // Keep every page locked to the same vertical scroll position so the prev/next
267
+ // pages are already aligned before they drag into view — no post-swipe jump.
268
+ const scrollHandler = useAnimatedScrollHandler((event) => {
269
+ if (isActive) {
270
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
271
+ scrollY.value = event.contentOffset.y;
272
+ }
273
+ });
274
+
275
+ useAnimatedReaction(
276
+ () => scrollY.value,
277
+ (current, previous) => {
278
+ if (!isActive && current !== previous) {
279
+ scrollTo(scrollRef, 0, current, false);
280
+ }
281
+ },
282
+ );
283
+
284
+ const days = useMemo(
285
+ () => (mode === 'week' ? getWeekDays(date, weekStartsOn) : [date]),
286
+ [mode, date, weekStartsOn],
287
+ );
288
+
289
+ const dayWidth = (width - hourColumnWidth) / days.length;
290
+ const dayLeft = (dayIndex: number) => hourColumnWidth + dayIndex * dayWidth;
291
+
292
+ const dayLayouts = useMemo(
293
+ () => days.map((day) => layoutDayEvents(events, day)),
294
+ [days, events],
295
+ );
296
+
297
+ // Map a tap on empty grid space back to the date+time it represents. Reads the
298
+ // live row height on the JS thread to convert the touch Y into minutes.
299
+ const handleBackgroundPress = (event: GestureResponderEvent) => {
300
+ if (!onPressCell) return;
301
+ const { locationX, locationY } = event.nativeEvent;
302
+ const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
303
+ const day = days[dayIndex];
304
+ if (!day) return;
305
+ const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
306
+ const pressed = new Date(day);
307
+ pressed.setHours(0, 0, 0, 0);
308
+ pressed.setMinutes(minutes);
309
+ onPressCell(pressed);
310
+ };
311
+
312
+ // The hours (rows/labels) visible in the window [minHour, maxHour).
313
+ const hoursRange = useMemo(
314
+ () => Array.from({ length: maxHour - minHour }, (_, index) => minHour + index),
315
+ [minHour, maxHour],
316
+ );
317
+
318
+ const now = useNow(showNowIndicator && isActive);
319
+ const nowDayIndex = days.findIndex((day) => getIsToday(day));
320
+ const nowHours = (getHours(now) * MINUTES_PER_HOUR + getMinutes(now)) / MINUTES_PER_HOUR;
321
+ const nowInWindow = nowHours >= minHour && nowHours <= maxHour;
322
+
323
+ const fullHeightStyle = useAnimatedStyle(
324
+ () => ({ height: (maxHour - minHour) * heightSource.value }),
325
+ [minHour, maxHour, heightSource],
326
+ );
327
+
328
+ // Capture the row height when the pinch starts and apply `event.scale`
329
+ // (relative to that start) rather than multiplying per-frame deltas — deltas
330
+ // compound float error and the zoom never settles on a clean level.
331
+ const pinchStartCellHeight = useSharedValue(hourHeight);
332
+ const zoomGesture = useMemo(() => {
333
+ const pinch = Gesture.Pinch()
334
+ .onStart(() => {
335
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
336
+ pinchStartCellHeight.value = cellHeight.value;
337
+ })
338
+ .onUpdate((event) => {
339
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
340
+ cellHeight.value = Math.min(
341
+ maxHourHeight,
342
+ Math.max(minHourHeight, pinchStartCellHeight.value * event.scale),
343
+ );
344
+ })
345
+ .onEnd(() => {
346
+ // Publish the final zoom to the off-screen pages in one update.
347
+ // eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
348
+ committedCellHeight.value = cellHeight.value;
349
+ });
350
+ // Recognise the pinch and the ScrollView's native scroll together so the
351
+ // scroll never cancels an in-progress zoom.
352
+ return Gesture.Simultaneous(pinch, Gesture.Native());
353
+ }, [cellHeight, committedCellHeight, pinchStartCellHeight, minHourHeight, maxHourHeight]);
354
+
355
+ return (
356
+ <View style={styles.container}>
357
+ <GestureDetector gesture={zoomGesture}>
358
+ <Animated.ScrollView
359
+ ref={scrollRef}
360
+ showsVerticalScrollIndicator
361
+ onScroll={scrollHandler}
362
+ scrollEventThrottle={16}
363
+ contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
364
+ contentOffset={{
365
+ x: 0,
366
+ y: Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - minHour) * hourHeight,
367
+ }}
368
+ >
369
+ <Animated.View style={[styles.content, fullHeightStyle]}>
370
+ {onPressCell ? (
371
+ // Behind the events, so empty-space taps create while event taps
372
+ // still hit their box. Hidden from screen readers (a convenience
373
+ // gesture, not the primary create path).
374
+ <Pressable
375
+ style={[styles.cellPressLayer, { left: hourColumnWidth }]}
376
+ onPress={handleBackgroundPress}
377
+ importantForAccessibility="no"
378
+ accessibilityElementsHidden
379
+ />
380
+ ) : null}
381
+
382
+ {days.map((day, dayIndex) =>
383
+ isWeekend(day) ? (
384
+ <Animated.View
385
+ key={`weekend-${day.toISOString()}`}
386
+ style={[
387
+ styles.weekendColumn,
388
+ { backgroundColor: theme.colors.weekendBackground },
389
+ { left: dayLeft(dayIndex), width: dayWidth },
390
+ fullHeightStyle,
391
+ ]}
392
+ pointerEvents="none"
393
+ />
394
+ ) : null,
395
+ )}
396
+
397
+ {days.map((day, dayIndex) => (
398
+ <Animated.View
399
+ key={`separator-${day.toISOString()}`}
400
+ style={[
401
+ styles.daySeparator,
402
+ { backgroundColor: theme.colors.gridLine },
403
+ { left: dayLeft(dayIndex) },
404
+ fullHeightStyle,
405
+ ]}
406
+ pointerEvents="none"
407
+ />
408
+ ))}
409
+
410
+ {hoursRange.map((hour) => (
411
+ <HourRow
412
+ key={hour}
413
+ hour={hour}
414
+ minHour={minHour}
415
+ cellHeight={heightSource}
416
+ hourColumnWidth={hourColumnWidth}
417
+ label={formatHourLabel(hour, ampm)}
418
+ />
419
+ ))}
420
+
421
+ {dayLayouts.flatMap((layout, dayIndex) =>
422
+ layout
423
+ // Skip events that fall entirely outside the [minHour, maxHour) window.
424
+ .filter(
425
+ (p) => p.startHours < maxHour && p.startHours + p.durationHours > minHour,
426
+ )
427
+ .map((positioned, eventIndex) => {
428
+ const columnWidth = dayWidth / positioned.columns;
429
+ return (
430
+ <AnimatedEventBox
431
+ key={keyExtractor(positioned.event, eventIndex)}
432
+ positioned={positioned}
433
+ cellHeight={heightSource}
434
+ minHour={minHour}
435
+ left={dayLeft(dayIndex) + positioned.column * columnWidth}
436
+ width={columnWidth}
437
+ mode={mode}
438
+ renderEvent={renderEvent}
439
+ onPress={onPressEvent}
440
+ />
441
+ );
442
+ }),
443
+ )}
444
+
445
+ {showNowIndicator && nowDayIndex >= 0 && nowInWindow ? (
446
+ <NowIndicator
447
+ cellHeight={heightSource}
448
+ nowHours={nowHours}
449
+ minHour={minHour}
450
+ left={dayLeft(nowDayIndex)}
451
+ color={theme.colors.nowIndicator}
452
+ />
453
+ ) : null}
454
+ </Animated.View>
455
+ </Animated.ScrollView>
456
+ </GestureDetector>
457
+ </View>
458
+ );
459
+ }
460
+
461
+ const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
462
+
463
+ export type TimeGridProps<T> = {
464
+ mode: 'day' | 'week';
465
+ date: Date;
466
+ events: CalendarEvent<T>[];
467
+ cellHeight: SharedValue<number>;
468
+ /** Initial per-hour row height in px; seeds scroll/zoom without reading the shared value during render. */
469
+ hourHeight?: number;
470
+ weekStartsOn: WeekStartsOn;
471
+ renderEvent: RenderEvent<T>;
472
+ keyExtractor: EventKeyExtractor<T>;
473
+ scrollOffsetMinutes?: number;
474
+ hourColumnWidth?: number;
475
+ /** First hour shown (0–23). Default 0. */
476
+ minHour?: number;
477
+ /** Last hour shown, exclusive (1–24). Default 24. */
478
+ maxHour?: number;
479
+ /** Show hour labels in 12-hour AM/PM form. Default false (24h). */
480
+ ampm?: boolean;
481
+ minHourHeight?: number;
482
+ maxHourHeight?: number;
483
+ showNowIndicator?: boolean;
484
+ locale?: string;
485
+ freeSwipe?: boolean;
486
+ onPressEvent: (event: CalendarEvent<T>) => void;
487
+ onPressCell?: (date: Date) => void;
488
+ onChangeDate: (date: Date) => void;
489
+ /** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
490
+ renderHeader?: (days: Date[]) => React.ReactNode;
491
+ };
492
+
493
+ function TimeGridInner<T>({
494
+ mode,
495
+ date,
496
+ events,
497
+ cellHeight,
498
+ hourHeight = DEFAULT_HOUR_HEIGHT,
499
+ weekStartsOn,
500
+ renderEvent,
501
+ keyExtractor,
502
+ scrollOffsetMinutes = 0,
503
+ hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH,
504
+ minHour = 0,
505
+ maxHour = HOURS_PER_DAY,
506
+ ampm = false,
507
+ minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
508
+ maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
509
+ showNowIndicator = true,
510
+ locale,
511
+ freeSwipe = false,
512
+ onPressEvent,
513
+ onPressCell,
514
+ onChangeDate,
515
+ renderHeader,
516
+ }: TimeGridProps<T>) {
517
+ // Guard against an inverted/out-of-range window so the grid never collapses.
518
+ const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
519
+ const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
520
+
521
+ const { width, height } = useWindowDimensions();
522
+ const listRef = useRef<LegendListRef>(null);
523
+ // Horizontal list items need an explicit cross-axis height; seed it with the
524
+ // window height (so it renders immediately and in tests) and refine on layout.
525
+ const [pageHeight, setPageHeight] = useState(height);
526
+ const step = mode === 'week' ? WEEK_VIEW_STEP : DAY_VIEW_STEP;
527
+ // Shared vertical scroll offset so every mounted page stays aligned. Seeded
528
+ // from the numeric hourHeight rather than reading cellHeight.value (which
529
+ // would warn about reading a shared value during render).
530
+ const scrollY = useSharedValue(
531
+ Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - clampedMinHour) * hourHeight,
532
+ );
533
+ // Zoom committed at the end of the last pinch; off-screen pages animate off
534
+ // this so they don't re-run their worklets every frame while the visible page
535
+ // zooms.
536
+ const committedCellHeight = useSharedValue(hourHeight);
537
+
538
+ // A fixed window of page dates, anchored once and aligned to the page boundary
539
+ // (day or week start). The array never shifts as the date changes.
540
+ const [anchorDate] = useState(date);
541
+ const anchor = useMemo(
542
+ () =>
543
+ mode === 'week' ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
544
+ [mode, anchorDate, weekStartsOn],
545
+ );
546
+ const pageDates = useMemo(
547
+ () =>
548
+ Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) =>
549
+ addDays(anchor, (i - PAGE_WINDOW) * step),
550
+ ),
551
+ [anchor, step],
552
+ );
553
+ const indexOfDate = useCallback(
554
+ (target: Date) => {
555
+ const aligned =
556
+ mode === 'week' ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
557
+ return Math.round(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
558
+ },
559
+ [anchor, mode, step, weekStartsOn],
560
+ );
561
+
562
+ // The committed date's page is the centred/active one. `viewedIndexRef` tracks
563
+ // where the list actually sits, telling swipe-driven changes from external ones.
564
+ const activeIndex = indexOfDate(date);
565
+ const viewedIndexRef = useRef(activeIndex);
566
+
567
+ // Header days track the committed date and render outside the list, so a swipe
568
+ // never flashes another day's label.
569
+ const headerDays = useMemo(
570
+ () => (mode === 'week' ? getWeekDays(date, weekStartsOn) : [date]),
571
+ [mode, date, weekStartsOn],
572
+ );
573
+
574
+ const handleViewableItemsChanged = useCallback(
575
+ (info: OnViewableItemsChangedInfo<Date>) => {
576
+ const settled = info.viewableItems.find((token) => token.isViewable);
577
+ if (settled?.index == null || settled.index === viewedIndexRef.current) return;
578
+ viewedIndexRef.current = settled.index;
579
+ if (settled.item) onChangeDate(settled.item);
580
+ },
581
+ [onChangeDate],
582
+ );
583
+
584
+ // Realign the list when the date changes from outside a swipe (e.g. a "today"
585
+ // button or a month-view tap). Swipe-driven changes already match.
586
+ useEffect(() => {
587
+ if (activeIndex === viewedIndexRef.current) return;
588
+ viewedIndexRef.current = activeIndex;
589
+ listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
590
+ }, [activeIndex]);
591
+
592
+ const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
593
+ const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
594
+ const getFixedItemSize = useCallback(() => width, [width]);
595
+ const renderItem = useCallback(
596
+ ({ item, index }: LegendListRenderItemProps<Date>) => (
597
+ <View style={{ width, height: pageHeight }}>
598
+ <TimetablePage
599
+ mode={mode}
600
+ date={item}
601
+ events={events}
602
+ cellHeight={cellHeight}
603
+ hourHeight={hourHeight}
604
+ committedCellHeight={committedCellHeight}
605
+ scrollY={scrollY}
606
+ isActive={index === activeIndex}
607
+ scrollOffsetMinutes={scrollOffsetMinutes}
608
+ weekStartsOn={weekStartsOn}
609
+ hourColumnWidth={hourColumnWidth}
610
+ minHour={clampedMinHour}
611
+ maxHour={clampedMaxHour}
612
+ ampm={ampm}
613
+ minHourHeight={minHourHeight}
614
+ maxHourHeight={maxHourHeight}
615
+ showNowIndicator={showNowIndicator}
616
+ renderEvent={renderEvent}
617
+ keyExtractor={keyExtractor}
618
+ onPressEvent={onPressEvent}
619
+ onPressCell={onPressCell}
620
+ />
621
+ </View>
622
+ ),
623
+ [
624
+ width,
625
+ pageHeight,
626
+ mode,
627
+ events,
628
+ cellHeight,
629
+ hourHeight,
630
+ committedCellHeight,
631
+ scrollY,
632
+ activeIndex,
633
+ scrollOffsetMinutes,
634
+ weekStartsOn,
635
+ hourColumnWidth,
636
+ clampedMinHour,
637
+ clampedMaxHour,
638
+ ampm,
639
+ minHourHeight,
640
+ maxHourHeight,
641
+ showNowIndicator,
642
+ renderEvent,
643
+ keyExtractor,
644
+ onPressEvent,
645
+ onPressCell,
646
+ ],
647
+ );
648
+
649
+ return (
650
+ <View style={styles.container}>
651
+ {renderHeader ? (
652
+ renderHeader(headerDays)
653
+ ) : (
654
+ <DefaultHeader
655
+ days={headerDays}
656
+ mode={mode}
657
+ width={width}
658
+ hourColumnWidth={hourColumnWidth}
659
+ locale={locale}
660
+ />
661
+ )}
662
+
663
+ <View
664
+ style={styles.pager}
665
+ onLayout={(event) => setPageHeight(event.nativeEvent.layout.height)}
666
+ >
667
+ <LegendList
668
+ // Remount when the measured page height changes so the list adopts
669
+ // the corrected item height (avoids keeping the oversized window seed).
670
+ key={pageHeight}
671
+ ref={listRef}
672
+ style={styles.pagerList}
673
+ data={pageDates}
674
+ horizontal
675
+ recycleItems={false}
676
+ keyExtractor={keyExtractorList}
677
+ getFixedItemSize={getFixedItemSize}
678
+ // Default: native paging — each page is the viewport width, so a swipe
679
+ // hard-stops at the adjacent page and can't fling past it. With
680
+ // `freeSwipe`, momentum carries across pages and snaps to a boundary.
681
+ pagingEnabled={!freeSwipe}
682
+ snapToIndices={freeSwipe ? snapToIndices : undefined}
683
+ initialScrollIndex={activeIndex}
684
+ showsHorizontalScrollIndicator={false}
685
+ viewabilityConfig={PAGE_VIEWABILITY}
686
+ onViewableItemsChanged={handleViewableItemsChanged}
687
+ renderItem={renderItem}
688
+ />
689
+ </View>
690
+ </View>
691
+ );
692
+ }
693
+
694
+ export const TimeGrid = memo(TimeGridInner) as typeof TimeGridInner;
695
+
696
+ type DefaultHeaderProps = {
697
+ days: Date[];
698
+ mode: CalendarMode;
699
+ width: number;
700
+ hourColumnWidth: number;
701
+ locale?: string;
702
+ };
703
+
704
+ const DefaultHeader = ({ days, mode, width, hourColumnWidth, locale }: DefaultHeaderProps) => {
705
+ const dayWidth = mode === 'week' ? (width - hourColumnWidth) / days.length : width;
706
+
707
+ return (
708
+ <View style={styles.headerRow}>
709
+ {mode === 'week' ? <View style={{ width: hourColumnWidth }} /> : null}
710
+ {days.map((day) => (
711
+ <DayHeader key={day.toISOString()} day={day} mode={mode} width={dayWidth} locale={locale} />
712
+ ))}
713
+ </View>
714
+ );
715
+ };
716
+
717
+ type DayHeaderProps = {
718
+ day: Date;
719
+ mode: CalendarMode;
720
+ width: number;
721
+ locale?: string;
722
+ };
723
+
724
+ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
725
+ const theme = useCalendarTheme();
726
+ const isToday = getIsToday(day);
727
+ const badgeSize = mode === 'day' ? 44 : 32;
728
+
729
+ return (
730
+ <View style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}>
731
+ <View
732
+ style={[
733
+ styles.dayHeaderBadge,
734
+ isToday && {
735
+ backgroundColor: theme.colors.todayBackground,
736
+ borderRadius: 999,
737
+ width: badgeSize,
738
+ height: badgeSize,
739
+ },
740
+ ]}
741
+ >
742
+ <Text
743
+ style={[
744
+ theme.text.dayNumber,
745
+ { color: isToday ? theme.colors.todayText : theme.colors.text },
746
+ ]}
747
+ allowFontScaling={false}
748
+ {...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
749
+ >
750
+ {day.getDate()}
751
+ </Text>
752
+ </View>
753
+ <Text style={[theme.text.weekday, { color: theme.colors.text }]} allowFontScaling={false}>
754
+ {day.toLocaleDateString(locale, { weekday: 'short' })}
755
+ </Text>
756
+ </View>
757
+ );
758
+ };
759
+
760
+ const styles = StyleSheet.create({
761
+ pager: {
762
+ flex: 1,
763
+ },
764
+ pagerList: {
765
+ flex: 1,
766
+ },
767
+ container: {
768
+ flex: 1,
769
+ },
770
+ headerRow: {
771
+ flexDirection: 'row',
772
+ alignItems: 'center',
773
+ paddingBottom: 8,
774
+ },
775
+ dayHeader: {
776
+ alignItems: 'center',
777
+ },
778
+ dayHeaderBadge: {
779
+ justifyContent: 'center',
780
+ alignItems: 'center',
781
+ },
782
+ content: {
783
+ width: '100%',
784
+ position: 'relative',
785
+ },
786
+ cellPressLayer: {
787
+ position: 'absolute',
788
+ top: 0,
789
+ bottom: 0,
790
+ right: 0,
791
+ },
792
+ weekendColumn: {
793
+ position: 'absolute',
794
+ top: 0,
795
+ },
796
+ daySeparator: {
797
+ position: 'absolute',
798
+ top: 0,
799
+ width: StyleSheet.hairlineWidth,
800
+ },
801
+ hourRow: {
802
+ position: 'absolute',
803
+ left: 0,
804
+ right: 0,
805
+ flexDirection: 'row',
806
+ alignItems: 'flex-start',
807
+ },
808
+ hourLabel: {
809
+ marginTop: -HOUR_LABEL_NUDGE,
810
+ textAlign: 'center',
811
+ },
812
+ hourLine: {
813
+ flex: 1,
814
+ height: StyleSheet.hairlineWidth,
815
+ },
816
+ eventBox: {
817
+ position: 'absolute',
818
+ overflow: 'hidden',
819
+ },
820
+ nowIndicator: {
821
+ position: 'absolute',
822
+ right: 0,
823
+ height: 2,
824
+ },
825
+ });