react-native-bigger-calendar 0.1.0 → 0.2.1

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