react-native-bigger-calendar 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -437,6 +560,7 @@ function TimetablePageInner<T>({
437
560
  mode={mode}
438
561
  renderEvent={renderEvent}
439
562
  onPress={onPressEvent}
563
+ onLongPress={onLongPressEvent}
440
564
  />
441
565
  );
442
566
  }),
@@ -461,7 +585,15 @@ function TimetablePageInner<T>({
461
585
  const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
462
586
 
463
587
  export type TimeGridProps<T> = {
464
- mode: 'day' | 'week';
588
+ mode: TimeGridMode;
589
+ /** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
590
+ numberOfDays?: number;
591
+ /**
592
+ * Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
593
+ * `weekStartsOn`…`weekEndsOn` of `date`'s week and pages by week, taking
594
+ * precedence over `numberOfDays`. Ignored by other modes.
595
+ */
596
+ weekEndsOn?: WeekStartsOn;
465
597
  date: Date;
466
598
  events: CalendarEvent<T>[];
467
599
  cellHeight: SharedValue<number>;
@@ -472,19 +604,49 @@ export type TimeGridProps<T> = {
472
604
  keyExtractor: EventKeyExtractor<T>;
473
605
  scrollOffsetMinutes?: number;
474
606
  hourColumnWidth?: number;
607
+ /** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
608
+ hideHours?: boolean;
609
+ /** Sub-hour divider lines per hour (e.g. 2 = half-hours). Default 1 (none). */
610
+ timeslots?: number;
611
+ /** Per-date style merged onto each day column. */
612
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
613
+ /** Show the ISO week number in the header gutter. Default false. */
614
+ showWeekNumber?: boolean;
615
+ /** Element rendered between the day header and the grid. */
616
+ headerComponent?: React.ReactNode;
475
617
  /** First hour shown (0–23). Default 0. */
476
618
  minHour?: number;
477
619
  /** Last hour shown, exclusive (1–24). Default 24. */
478
620
  maxHour?: number;
479
621
  /** Show hour labels in 12-hour AM/PM form. Default false (24h). */
480
622
  ampm?: boolean;
623
+ /** Reverse day-column order (right-to-left). Default false. */
624
+ isRTL?: boolean;
481
625
  minHourHeight?: number;
482
626
  maxHourHeight?: number;
483
627
  showNowIndicator?: boolean;
484
- locale?: string;
628
+ locale?: Locale;
485
629
  freeSwipe?: boolean;
630
+ /** Allow swiping between pages. Default true. */
631
+ swipeEnabled?: boolean;
632
+ /** Show the vertical scroll indicator on the time grid. Default true. */
633
+ showVerticalScrollIndicator?: boolean;
634
+ /** Allow vertical scrolling of the time grid. Default true. */
635
+ verticalScrollEnabled?: boolean;
636
+ /** Prefix for the week-number label (e.g. "W"). Default "W". */
637
+ weekNumberPrefix?: string;
638
+ /** Replace the hour-axis label. Receives the hour (0–23) and `ampm`. */
639
+ hourComponent?: HourRenderer;
640
+ /** Highlight this date in the header instead of the real "today". */
641
+ activeDate?: Date;
642
+ /** After an empty-cell press, snap the pager back to the active page. Default false. */
643
+ resetPageOnPressCell?: boolean;
486
644
  onPressEvent: (event: CalendarEvent<T>) => void;
645
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
487
646
  onPressCell?: (date: Date) => void;
647
+ onLongPressCell?: (date: Date) => void;
648
+ /** Tap a day's column header (default header only). */
649
+ onPressDateHeader?: (date: Date) => void;
488
650
  onChangeDate: (date: Date) => void;
489
651
  /** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
490
652
  renderHeader?: (days: Date[]) => React.ReactNode;
@@ -492,6 +654,8 @@ export type TimeGridProps<T> = {
492
654
 
493
655
  function TimeGridInner<T>({
494
656
  mode,
657
+ numberOfDays = 1,
658
+ weekEndsOn,
495
659
  date,
496
660
  events,
497
661
  cellHeight,
@@ -500,30 +664,57 @@ function TimeGridInner<T>({
500
664
  renderEvent,
501
665
  keyExtractor,
502
666
  scrollOffsetMinutes = 0,
503
- hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH,
667
+ hourColumnWidth: hourColumnWidthProp = DEFAULT_HOUR_COLUMN_WIDTH,
668
+ hideHours = false,
669
+ timeslots = 1,
670
+ calendarCellStyle,
671
+ showWeekNumber = false,
672
+ headerComponent,
504
673
  minHour = 0,
505
674
  maxHour = HOURS_PER_DAY,
506
675
  ampm = false,
676
+ isRTL = false,
507
677
  minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
508
678
  maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
509
679
  showNowIndicator = true,
510
680
  locale,
511
681
  freeSwipe = false,
682
+ swipeEnabled = true,
683
+ showVerticalScrollIndicator = true,
684
+ verticalScrollEnabled = true,
685
+ weekNumberPrefix = 'W',
686
+ hourComponent,
687
+ activeDate,
688
+ resetPageOnPressCell = false,
512
689
  onPressEvent,
690
+ onLongPressEvent,
513
691
  onPressCell,
692
+ onLongPressCell,
693
+ onPressDateHeader,
514
694
  onChangeDate,
515
695
  renderHeader,
516
696
  }: TimeGridProps<T>) {
517
697
  // Guard against an inverted/out-of-range window so the grid never collapses.
518
698
  const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
519
699
  const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
700
+ // Collapse the hour gutter to zero when hours are hidden.
701
+ const hourColumnWidth = hideHours ? 0 : hourColumnWidthProp;
520
702
 
521
703
  const { width, height } = useWindowDimensions();
522
704
  const listRef = useRef<LegendListRef>(null);
523
705
  // Horizontal list items need an explicit cross-axis height; seed it with the
524
706
  // window height (so it renders immediately and in tests) and refine on layout.
525
707
  const [pageHeight, setPageHeight] = useState(height);
526
- const step = mode === 'week' ? WEEK_VIEW_STEP : DAY_VIEW_STEP;
708
+ // The list must remount exactly once when the real height replaces the
709
+ // window-height seed — or it keeps the oversized seed and clips. It must NOT
710
+ // remount on later height changes (e.g. a taller day header vs a shorter week
711
+ // header on a mode switch): a remount blanks the visible page for a frame.
712
+ const [measured, setMeasured] = useState(false);
713
+ // Week-anchored modes page by a full week and align pages to the week start:
714
+ // `week`, and `custom` when a `weekEndsOn` defines a partial-week span.
715
+ const weekAnchored = mode === 'week' || (mode === 'custom' && weekEndsOn != null);
716
+ // Days advanced per page: a full week when week-anchored, else the column count.
717
+ const step = weekAnchored ? 7 : viewDayCount(mode, numberOfDays);
527
718
  // Shared vertical scroll offset so every mounted page stays aligned. Seeded
528
719
  // from the numeric hourHeight rather than reading cellHeight.value (which
529
720
  // would warn about reading a shared value during render).
@@ -540,8 +731,8 @@ function TimeGridInner<T>({
540
731
  const [anchorDate] = useState(date);
541
732
  const anchor = useMemo(
542
733
  () =>
543
- mode === 'week' ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
544
- [mode, anchorDate, weekStartsOn],
734
+ weekAnchored ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
735
+ [weekAnchored, anchorDate, weekStartsOn],
545
736
  );
546
737
  const pageDates = useMemo(
547
738
  () =>
@@ -553,10 +744,12 @@ function TimeGridInner<T>({
553
744
  const indexOfDate = useCallback(
554
745
  (target: Date) => {
555
746
  const aligned =
556
- mode === 'week' ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
557
- return Math.round(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
747
+ weekAnchored ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
748
+ // Floor so an arbitrary date lands on the page whose range contains it
749
+ // (exact for day/week, where dates are already page-aligned).
750
+ return Math.floor(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
558
751
  },
559
- [anchor, mode, step, weekStartsOn],
752
+ [anchor, weekAnchored, step, weekStartsOn],
560
753
  );
561
754
 
562
755
  // The committed date's page is the centred/active one. `viewedIndexRef` tracks
@@ -564,11 +757,12 @@ function TimeGridInner<T>({
564
757
  const activeIndex = indexOfDate(date);
565
758
  const viewedIndexRef = useRef(activeIndex);
566
759
 
567
- // Header days track the committed date and render outside the list, so a swipe
568
- // never flashes another day's label.
760
+ // Header days track the active page (page-aligned), so they always match the
761
+ // columns below and a swipe never flashes another day's label.
569
762
  const headerDays = useMemo(
570
- () => (mode === 'week' ? getWeekDays(date, weekStartsOn) : [date]),
571
- [mode, date, weekStartsOn],
763
+ () =>
764
+ getViewDays(mode, pageDates[activeIndex] ?? date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
765
+ [mode, pageDates, activeIndex, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
572
766
  );
573
767
 
574
768
  const handleViewableItemsChanged = useCallback(
@@ -589,6 +783,17 @@ function TimeGridInner<T>({
589
783
  listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
590
784
  }, [activeIndex]);
591
785
 
786
+ // Optionally snap the pager back to the active page after an empty-cell press
787
+ // (so tapping a far-swiped page returns to the committed date).
788
+ const handlePressCell = useMemo(() => {
789
+ if (!onPressCell) return undefined;
790
+ if (!resetPageOnPressCell) return onPressCell;
791
+ return (cellDate: Date) => {
792
+ onPressCell(cellDate);
793
+ listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
794
+ };
795
+ }, [onPressCell, resetPageOnPressCell, activeIndex]);
796
+
592
797
  const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
593
798
  const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
594
799
  const getFixedItemSize = useCallback(() => width, [width]);
@@ -597,6 +802,7 @@ function TimeGridInner<T>({
597
802
  <View style={{ width, height: pageHeight }}>
598
803
  <TimetablePage
599
804
  mode={mode}
805
+ numberOfDays={numberOfDays}
600
806
  date={item}
601
807
  events={events}
602
808
  cellHeight={cellHeight}
@@ -606,17 +812,26 @@ function TimeGridInner<T>({
606
812
  isActive={index === activeIndex}
607
813
  scrollOffsetMinutes={scrollOffsetMinutes}
608
814
  weekStartsOn={weekStartsOn}
815
+ weekEndsOn={weekEndsOn}
609
816
  hourColumnWidth={hourColumnWidth}
610
817
  minHour={clampedMinHour}
611
818
  maxHour={clampedMaxHour}
612
819
  ampm={ampm}
820
+ timeslots={timeslots}
821
+ isRTL={isRTL}
822
+ showVerticalScrollIndicator={showVerticalScrollIndicator}
823
+ verticalScrollEnabled={verticalScrollEnabled}
824
+ hourComponent={hourComponent}
825
+ calendarCellStyle={calendarCellStyle}
613
826
  minHourHeight={minHourHeight}
614
827
  maxHourHeight={maxHourHeight}
615
828
  showNowIndicator={showNowIndicator}
616
829
  renderEvent={renderEvent}
617
830
  keyExtractor={keyExtractor}
618
831
  onPressEvent={onPressEvent}
619
- onPressCell={onPressCell}
832
+ onLongPressEvent={onLongPressEvent}
833
+ onPressCell={handlePressCell}
834
+ onLongPressCell={onLongPressCell}
620
835
  />
621
836
  </View>
622
837
  ),
@@ -624,6 +839,7 @@ function TimeGridInner<T>({
624
839
  width,
625
840
  pageHeight,
626
841
  mode,
842
+ numberOfDays,
627
843
  events,
628
844
  cellHeight,
629
845
  hourHeight,
@@ -632,17 +848,26 @@ function TimeGridInner<T>({
632
848
  activeIndex,
633
849
  scrollOffsetMinutes,
634
850
  weekStartsOn,
851
+ weekEndsOn,
635
852
  hourColumnWidth,
636
853
  clampedMinHour,
637
854
  clampedMaxHour,
638
855
  ampm,
856
+ timeslots,
857
+ isRTL,
858
+ showVerticalScrollIndicator,
859
+ verticalScrollEnabled,
860
+ hourComponent,
861
+ calendarCellStyle,
639
862
  minHourHeight,
640
863
  maxHourHeight,
641
864
  showNowIndicator,
642
865
  renderEvent,
643
866
  keyExtractor,
644
867
  onPressEvent,
645
- onPressCell,
868
+ onLongPressEvent,
869
+ handlePressCell,
870
+ onLongPressCell,
646
871
  ],
647
872
  );
648
873
 
@@ -656,18 +881,28 @@ function TimeGridInner<T>({
656
881
  mode={mode}
657
882
  width={width}
658
883
  hourColumnWidth={hourColumnWidth}
884
+ showWeekNumber={showWeekNumber}
885
+ weekNumberPrefix={weekNumberPrefix}
659
886
  locale={locale}
887
+ activeDate={activeDate}
888
+ onPressDateHeader={onPressDateHeader}
660
889
  />
661
890
  )}
662
891
 
892
+ {headerComponent}
893
+
663
894
  <View
664
895
  style={styles.pager}
665
- onLayout={(event) => setPageHeight(event.nativeEvent.layout.height)}
896
+ onLayout={(event) => {
897
+ setPageHeight(event.nativeEvent.layout.height);
898
+ setMeasured(true);
899
+ }}
666
900
  >
667
901
  <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}
902
+ // Remount only on the seed→measured transition (see `measured`), not on
903
+ // every height change, so a day↔week header-height difference resizes the
904
+ // items in place instead of remounting and blanking the page.
905
+ key={measured ? 'grid' : 'grid-seed'}
671
906
  ref={listRef}
672
907
  style={styles.pagerList}
673
908
  data={pageDates}
@@ -675,6 +910,7 @@ function TimeGridInner<T>({
675
910
  recycleItems={false}
676
911
  keyExtractor={keyExtractorList}
677
912
  getFixedItemSize={getFixedItemSize}
913
+ scrollEnabled={swipeEnabled}
678
914
  // Default: native paging — each page is the viewport width, so a swipe
679
915
  // hard-stops at the adjacent page and can't fling past it. With
680
916
  // `freeSwipe`, momentum carries across pages and snaps to a boundary.
@@ -698,17 +934,50 @@ type DefaultHeaderProps = {
698
934
  mode: CalendarMode;
699
935
  width: number;
700
936
  hourColumnWidth: number;
701
- locale?: string;
937
+ showWeekNumber?: boolean;
938
+ weekNumberPrefix?: string;
939
+ locale?: Locale;
940
+ activeDate?: Date;
941
+ onPressDateHeader?: (date: Date) => void;
702
942
  };
703
943
 
704
- const DefaultHeader = ({ days, mode, width, hourColumnWidth, locale }: DefaultHeaderProps) => {
705
- const dayWidth = mode === 'week' ? (width - hourColumnWidth) / days.length : width;
944
+ const DefaultHeader = ({
945
+ days,
946
+ mode,
947
+ width,
948
+ hourColumnWidth,
949
+ showWeekNumber,
950
+ weekNumberPrefix = 'W',
951
+ locale,
952
+ activeDate,
953
+ onPressDateHeader,
954
+ }: DefaultHeaderProps) => {
955
+ const theme = useCalendarTheme();
956
+ // Match the grid below: an hour-column spacer, then one column per day.
957
+ const dayWidth = (width - hourColumnWidth) / days.length;
706
958
 
707
959
  return (
708
960
  <View style={styles.headerRow}>
709
- {mode === 'week' ? <View style={{ width: hourColumnWidth }} /> : null}
961
+ <View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
962
+ {showWeekNumber && hourColumnWidth > 0 && days[0] ? (
963
+ <Text
964
+ style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
965
+ allowFontScaling={false}
966
+ >
967
+ {`${weekNumberPrefix}${getISOWeek(days[0])}`}
968
+ </Text>
969
+ ) : null}
970
+ </View>
710
971
  {days.map((day) => (
711
- <DayHeader key={day.toISOString()} day={day} mode={mode} width={dayWidth} locale={locale} />
972
+ <DayHeader
973
+ key={day.toISOString()}
974
+ day={day}
975
+ mode={mode}
976
+ width={dayWidth}
977
+ locale={locale}
978
+ activeDate={activeDate}
979
+ onPressDateHeader={onPressDateHeader}
980
+ />
712
981
  ))}
713
982
  </View>
714
983
  );
@@ -718,20 +987,29 @@ type DayHeaderProps = {
718
987
  day: Date;
719
988
  mode: CalendarMode;
720
989
  width: number;
721
- locale?: string;
990
+ locale?: Locale;
991
+ activeDate?: Date;
992
+ onPressDateHeader?: (date: Date) => void;
722
993
  };
723
994
 
724
- const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
995
+ const DayHeader = ({ day, mode, width, locale, activeDate, onPressDateHeader }: DayHeaderProps) => {
725
996
  const theme = useCalendarTheme();
726
997
  const isToday = getIsToday(day);
998
+ // Highlight the chosen `activeDate` when supplied, else the real today.
999
+ const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
727
1000
  const badgeSize = mode === 'day' ? 44 : 32;
728
1001
 
729
1002
  return (
730
- <View style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}>
1003
+ <Pressable
1004
+ style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}
1005
+ onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
1006
+ disabled={!onPressDateHeader}
1007
+ accessibilityRole={onPressDateHeader ? 'button' : undefined}
1008
+ >
731
1009
  <View
732
1010
  style={[
733
1011
  styles.dayHeaderBadge,
734
- isToday && {
1012
+ isHighlighted && {
735
1013
  backgroundColor: theme.colors.todayBackground,
736
1014
  borderRadius: 999,
737
1015
  width: badgeSize,
@@ -742,7 +1020,7 @@ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
742
1020
  <Text
743
1021
  style={[
744
1022
  theme.text.dayNumber,
745
- { color: isToday ? theme.colors.todayText : theme.colors.text },
1023
+ { color: isHighlighted ? theme.colors.todayText : theme.colors.text },
746
1024
  ]}
747
1025
  allowFontScaling={false}
748
1026
  {...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
@@ -751,9 +1029,9 @@ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
751
1029
  </Text>
752
1030
  </View>
753
1031
  <Text style={[theme.text.weekday, { color: theme.colors.text }]} allowFontScaling={false}>
754
- {day.toLocaleDateString(locale, { weekday: 'short' })}
1032
+ {format(day, 'EEE', { locale })}
755
1033
  </Text>
756
- </View>
1034
+ </Pressable>
757
1035
  );
758
1036
  };
759
1037
 
@@ -772,6 +1050,10 @@ const styles = StyleSheet.create({
772
1050
  alignItems: 'center',
773
1051
  paddingBottom: 8,
774
1052
  },
1053
+ weekNumberGutter: {
1054
+ alignItems: 'center',
1055
+ justifyContent: 'flex-end',
1056
+ },
775
1057
  dayHeader: {
776
1058
  alignItems: 'center',
777
1059
  },
@@ -813,6 +1095,12 @@ const styles = StyleSheet.create({
813
1095
  flex: 1,
814
1096
  height: StyleSheet.hairlineWidth,
815
1097
  },
1098
+ timeslotLine: {
1099
+ position: 'absolute',
1100
+ right: 0,
1101
+ height: StyleSheet.hairlineWidth,
1102
+ opacity: 0.5,
1103
+ },
816
1104
  eventBox: {
817
1105
  position: 'absolute',
818
1106
  overflow: 'hidden',