@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,105 @@
1
+ import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
2
+ import { useCalendarTheme } from "../theme";
3
+ import type { RenderEventArgs } from "../types";
4
+ import {
5
+ eventAccessibilityLabel,
6
+ eventTimeLabel,
7
+ titleEllipsizeMode,
8
+ titleNumberOfLines,
9
+ } from "@super-calendar/core";
10
+
11
+ /**
12
+ * A lightweight event chip for the month grid and the date picker: the same
13
+ * titled box as {@link DefaultEvent} but with no Reanimated dependency (it drops
14
+ * the pinch-zoom time reveal, which only applies to the week/day grid). It's the
15
+ * default renderer for `MonthList`, so the `/picker` entry point stays free of
16
+ * Reanimated.
17
+ */
18
+ export function DefaultMonthEvent<T>({
19
+ event,
20
+ mode,
21
+ isAllDay,
22
+ ampm = false,
23
+ showTime = true,
24
+ ellipsizeTitle = false,
25
+ allDayLabel,
26
+ cellStyle,
27
+ onPress,
28
+ onLongPress,
29
+ }: RenderEventArgs<T>) {
30
+ const theme = useCalendarTheme();
31
+ const isAllDayEvent = isAllDay ?? false;
32
+ const timeLabel = eventTimeLabel({
33
+ mode,
34
+ isAllDay: isAllDayEvent,
35
+ start: event.start,
36
+ end: event.end,
37
+ ampm,
38
+ showTime,
39
+ allDayLabel,
40
+ });
41
+ const ellipsizeMode = titleEllipsizeMode(ellipsizeTitle);
42
+ const accessibilityLabel = eventAccessibilityLabel({
43
+ title: event.title,
44
+ isAllDay: isAllDayEvent,
45
+ start: event.start,
46
+ end: event.end,
47
+ ampm,
48
+ allDayLabel,
49
+ });
50
+
51
+ return (
52
+ <TouchableOpacity
53
+ style={[
54
+ styles.box,
55
+ { backgroundColor: theme.colors.eventBackground },
56
+ event.disabled && styles.disabled,
57
+ cellStyle,
58
+ ]}
59
+ onPress={onPress}
60
+ onLongPress={onLongPress}
61
+ activeOpacity={0.7}
62
+ accessibilityRole="button"
63
+ accessibilityLabel={accessibilityLabel}
64
+ accessibilityState={{ disabled: event.disabled ?? false }}
65
+ >
66
+ {event.title ? (
67
+ <Text
68
+ style={[theme.text.eventTitle, styles.title, { color: theme.colors.eventText }]}
69
+ numberOfLines={titleNumberOfLines(mode, isAllDayEvent)}
70
+ ellipsizeMode={ellipsizeMode}
71
+ allowFontScaling={false}
72
+ >
73
+ {event.title}
74
+ </Text>
75
+ ) : null}
76
+ {timeLabel ? (
77
+ <View>
78
+ <Text
79
+ style={[styles.time, { color: theme.colors.eventText }]}
80
+ numberOfLines={2}
81
+ ellipsizeMode={ellipsizeMode}
82
+ allowFontScaling={false}
83
+ >
84
+ {timeLabel}
85
+ </Text>
86
+ </View>
87
+ ) : null}
88
+ </TouchableOpacity>
89
+ );
90
+ }
91
+
92
+ const styles = StyleSheet.create({
93
+ box: {
94
+ flexGrow: 1,
95
+ flexShrink: 1,
96
+ flexBasis: "auto",
97
+ borderRadius: 6,
98
+ paddingVertical: 2,
99
+ paddingHorizontal: 4,
100
+ overflow: "hidden",
101
+ },
102
+ title: { flexShrink: 1 },
103
+ time: { fontSize: 11 },
104
+ disabled: { opacity: 0.5 },
105
+ });
@@ -0,0 +1,570 @@
1
+ import {
2
+ LegendList,
3
+ type LegendListRef,
4
+ type LegendListRenderItemProps,
5
+ type OnViewableItemsChangedInfo,
6
+ } from "@legendapp/list/react-native";
7
+ import {
8
+ addMonths,
9
+ differenceInCalendarMonths,
10
+ format,
11
+ isSameMonth,
12
+ type Locale,
13
+ startOfMonth,
14
+ } from "date-fns";
15
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import {
17
+ type LayoutChangeEvent,
18
+ type NativeScrollEvent,
19
+ type NativeSyntheticEvent,
20
+ Platform,
21
+ StyleSheet,
22
+ type StyleProp,
23
+ Text,
24
+ View,
25
+ type ViewStyle,
26
+ } from "react-native";
27
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
28
+ import { useCalendarTheme } from "../theme";
29
+ import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from "../types";
30
+ import {
31
+ type CalendarSelection,
32
+ CalendarSelectionProvider,
33
+ type DateRange,
34
+ isDateSelectable,
35
+ } from "@super-calendar/core";
36
+ import { buildMonthWeeks, getWeekDays } from "@super-calendar/core";
37
+ import { DefaultMonthEvent } from "./DefaultMonthEvent";
38
+ import { MonthView } from "./MonthView";
39
+
40
+ const isWeb = Platform.OS === "web";
41
+
42
+ // Months rendered either side of the anchor. LegendList virtualises, so only a
43
+ // few mount at once; a wide window means the user effectively never runs out.
44
+ const PAGE_WINDOW = 60;
45
+ const VIEWABILITY = { itemVisiblePercentThreshold: 60 };
46
+ // Each month block is the title plus its own week rows. Sizing per month (rather
47
+ // than a fixed height) keeps row heights consistent and avoids a blank padding
48
+ // row, since months show only their own days (no adjacent-month fill). The
49
+ // header height is a single source of truth: it sets the title's box, the block
50
+ // height, and the drag hit-test, so the rendered layout and the mapping agree.
51
+ const DEFAULT_MONTH_HEADER_HEIGHT = 44;
52
+ const DEFAULT_WEEK_ROW_HEIGHT = 56;
53
+ // When events are shown, give each week row enough height for the day cell to
54
+ // auto-fit about three event chips (or two plus a "+N more" row) instead of a
55
+ // single clipped event: the date badge (24) + top padding (4) + three ~22px chip
56
+ // rows ≈ 94, rounded up for headroom. The events-free picker keeps the compact
57
+ // default above.
58
+ const DEFAULT_EVENT_WEEK_ROW_HEIGHT = 96;
59
+ // Drag-to-select: native holds to start (so a scroll/tap isn't hijacked) then
60
+ // pans across months; nearing an edge auto-scrolls the list.
61
+ const LONG_PRESS_MS = 500;
62
+ const AUTOSCROLL_EDGE_PX = 64;
63
+ const AUTOSCROLL_STEP_PX = 14;
64
+ const AUTOSCROLL_INTERVAL_MS = 16;
65
+
66
+ // Stable empty events array, so a picker (no events) doesn't churn memoised props.
67
+ const NO_EVENTS: CalendarEvent<unknown>[] = [];
68
+ const noop = () => {};
69
+ const defaultKeyExtractor: EventKeyExtractor<unknown> = (event) =>
70
+ `${event.start.toISOString()}|${event.end.toISOString()}|${event.title ?? ""}`;
71
+
72
+ export type MonthListProps<T> = {
73
+ /** The month scrolled to on mount. */
74
+ date: Date;
75
+ /** Events to render in the grids. Omit for an events-free date picker. */
76
+ events?: CalendarEvent<T>[];
77
+ weekStartsOn: WeekStartsOn;
78
+ /**
79
+ * Height of each week row (px). The month block sizes to its row count. Defaults
80
+ * to a taller row when `events` are shown (so a day fits ~3 chips) and a compact
81
+ * row for the events-free picker.
82
+ */
83
+ weekRowHeight?: number;
84
+ /**
85
+ * Height of each month's title row (px). Default 44. A custom `renderMonthHeader`
86
+ * must fit this height, since it also anchors the drag hit-test.
87
+ */
88
+ monthHeaderHeight?: number;
89
+ maxVisibleEventCount?: number;
90
+ locale?: Locale;
91
+ sortedMonthView?: boolean;
92
+ moreLabel?: string;
93
+ /** Show dimmed adjacent-month days. Default false (each month shows only its own days). */
94
+ showAdjacentMonths?: boolean;
95
+ disableMonthEventCellPress?: boolean;
96
+ isRTL?: boolean;
97
+ activeDate?: Date;
98
+ selectedDates?: Date[];
99
+ selectedRange?: DateRange;
100
+ /** Fill the whole cell on selection instead of the default rounded pill band. */
101
+ fillCellOnSelection?: boolean;
102
+ minDate?: Date;
103
+ maxDate?: Date;
104
+ isDateDisabled?: (date: Date) => boolean;
105
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
106
+ /** Replace the built-in event box. Defaults to `DefaultEvent`. */
107
+ renderEvent?: RenderEvent<T>;
108
+ /** Stable key per event. Defaults to start-time + index. */
109
+ keyExtractor?: EventKeyExtractor<T>;
110
+ onPressDay?: (date: Date) => void;
111
+ onLongPressDay?: (date: Date) => void;
112
+ onPressEvent?: (event: CalendarEvent<T>) => void;
113
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
114
+ onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
115
+ /**
116
+ * Enable drag-to-select. Long-press a day and drag to sweep out a range,
117
+ * continuing across months (the list auto-scrolls at the edges). Fired with
118
+ * the ordered `[start, end]`; pair with `useDateRange`'s `selectRange`.
119
+ */
120
+ onSelectDrag?: (start: Date, end: Date) => void;
121
+ /** Fired with the month that scrolls into view. */
122
+ onChangeVisibleMonth?: (month: Date) => void;
123
+ /** Replace the per-month title (default "LLLL yyyy"). */
124
+ renderMonthHeader?: (month: Date) => React.ReactNode;
125
+ };
126
+
127
+ function MonthListInner<T>({
128
+ date,
129
+ events = NO_EVENTS as CalendarEvent<T>[],
130
+ weekStartsOn,
131
+ weekRowHeight: weekRowHeightProp,
132
+ monthHeaderHeight = DEFAULT_MONTH_HEADER_HEIGHT,
133
+ maxVisibleEventCount,
134
+ locale,
135
+ sortedMonthView,
136
+ moreLabel,
137
+ showAdjacentMonths = false,
138
+ disableMonthEventCellPress,
139
+ isRTL,
140
+ activeDate,
141
+ selectedDates,
142
+ selectedRange,
143
+ fillCellOnSelection,
144
+ minDate,
145
+ maxDate,
146
+ isDateDisabled,
147
+ calendarCellStyle,
148
+ renderEvent = DefaultMonthEvent,
149
+ keyExtractor = defaultKeyExtractor as EventKeyExtractor<T>,
150
+ onPressDay,
151
+ onLongPressDay,
152
+ onPressEvent = noop,
153
+ onLongPressEvent,
154
+ onPressMore,
155
+ onSelectDrag,
156
+ onChangeVisibleMonth,
157
+ renderMonthHeader,
158
+ }: MonthListProps<T>) {
159
+ const theme = useCalendarTheme();
160
+ const listRef = useRef<LegendListRef>(null);
161
+
162
+ // Compact rows for the picker; taller rows once events are shown, so a day cell
163
+ // fits about three chips. An explicit prop always wins.
164
+ const weekRowHeight =
165
+ weekRowHeightProp ??
166
+ (events.length > 0 ? DEFAULT_EVENT_WEEK_ROW_HEIGHT : DEFAULT_WEEK_ROW_HEIGHT);
167
+
168
+ // Web focus containment: the index of the topmost month at least partly in the
169
+ // viewport. Months above it are marked `inert` (see MonthBlock) so Tab doesn't
170
+ // land on an off-screen month and scroll the list backwards.
171
+ const [firstViewableIndex, setFirstViewableIndex] = useState(0);
172
+
173
+ // A fixed window of months anchored once, aligned to the month start.
174
+ const [anchorDate] = useState(date);
175
+ const anchor = useMemo(() => startOfMonth(anchorDate), [anchorDate]);
176
+ const monthDates = useMemo(
177
+ () => Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) => addMonths(anchor, i - PAGE_WINDOW)),
178
+ [anchor],
179
+ );
180
+ const initialIndex = useMemo(
181
+ () => differenceInCalendarMonths(startOfMonth(date), anchor) + PAGE_WINDOW,
182
+ [date, anchor],
183
+ );
184
+
185
+ // The constant weekday labels shown once above the scrolling months.
186
+ const weekDays = useMemo(() => {
187
+ const days = getWeekDays(anchor, weekStartsOn);
188
+ return isRTL ? days.reverse() : days;
189
+ }, [anchor, weekStartsOn, isRTL]);
190
+
191
+ const selection = useMemo<CalendarSelection>(
192
+ () => ({ selectedDates, selectedRange, minDate, maxDate, isDateDisabled }),
193
+ [selectedDates, selectedRange, minDate, maxDate, isDateDisabled],
194
+ );
195
+
196
+ const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
197
+ // Build every window month's week rows once (honouring isRTL, so drag hit-tests
198
+ // match the rendered column order). Reused for sizing and the drag mapping
199
+ // instead of rebuilding per frame.
200
+ const monthWeeks = useMemo(
201
+ () => monthDates.map((month) => buildMonthWeeks(month, weekStartsOn, { isRTL })),
202
+ [monthDates, weekStartsOn, isRTL],
203
+ );
204
+ // Each month is as tall as its own week rows (4–6) plus the title; no padding
205
+ // row, since adjacent-month days aren't filled in.
206
+ const blockHeightAt = useCallback(
207
+ (index: number) => monthHeaderHeight + monthWeeks[index].length * weekRowHeight,
208
+ [monthWeeks, weekRowHeight, monthHeaderHeight],
209
+ );
210
+ const getFixedItemSize = useCallback(
211
+ (_item: Date, index: number) => blockHeightAt(index),
212
+ [blockHeightAt],
213
+ );
214
+
215
+ // Content-Y of each month's top (prefix sums), plus the total content height,
216
+ // so a drag's absolute position maps to a month and day.
217
+ const offsets = useMemo(() => {
218
+ const out: number[] = [0];
219
+ for (let i = 0; i < monthWeeks.length; i++) out.push(out[i] + blockHeightAt(i));
220
+ return out;
221
+ }, [monthWeeks, blockHeightAt]);
222
+
223
+ // ---- drag-to-select -------------------------------------------------------
224
+ const dragStartRef = useRef<Date | null>(null);
225
+ const dragMovedRef = useRef(false);
226
+ const pointerDownRef = useRef(false); // web
227
+ const scrollYRef = useRef(0);
228
+ const viewportRef = useRef({ width: 0, height: 0 });
229
+ const lastPanRef = useRef({ x: 0, y: 0 }); // native, viewport-relative
230
+ const autoScrollRef = useRef<{ id: ReturnType<typeof setInterval>; dir: number } | null>(null);
231
+
232
+ // isDateSelectable already returns true when no constraints are set, so no
233
+ // separate short-circuit guard is needed.
234
+ const isSelectable = useCallback(
235
+ (day: Date) => isDateSelectable(day, { minDate, maxDate, isDateDisabled }),
236
+ [minDate, maxDate, isDateDisabled],
237
+ );
238
+
239
+ // Map an absolute content position to a selectable day, or null on a title,
240
+ // a blanked adjacent-month cell, or a disabled day.
241
+ const dayAtContent = useCallback(
242
+ (x: number, contentY: number): Date | null => {
243
+ const width = viewportRef.current.width;
244
+ if (width <= 0) return null;
245
+ const clampedY = Math.min(Math.max(contentY, 0), offsets[offsets.length - 1] - 1);
246
+ let index = 0;
247
+ while (index < monthWeeks.length - 1 && offsets[index + 1] <= clampedY) index++;
248
+ const localY = clampedY - offsets[index] - monthHeaderHeight;
249
+ if (localY < 0) return null; // on the month title
250
+ const weeks = monthWeeks[index];
251
+ const row = Math.min(weeks.length - 1, Math.max(0, Math.floor(localY / weekRowHeight)));
252
+ const col = Math.min(6, Math.max(0, Math.floor(x / (width / 7))));
253
+ const day = weeks[row]?.[col];
254
+ if (!day) return null;
255
+ if (!showAdjacentMonths && !isSameMonth(day, monthDates[index])) return null;
256
+ return isSelectable(day) ? day : null;
257
+ },
258
+ [
259
+ offsets,
260
+ monthWeeks,
261
+ monthDates,
262
+ weekRowHeight,
263
+ monthHeaderHeight,
264
+ showAdjacentMonths,
265
+ isSelectable,
266
+ ],
267
+ );
268
+
269
+ const extendTo = useCallback(
270
+ (day: Date | null) => {
271
+ const start = dragStartRef.current;
272
+ if (!start || !day) return;
273
+ if (day.getTime() !== start.getTime()) dragMovedRef.current = true;
274
+ onSelectDrag?.(start, day);
275
+ },
276
+ [onSelectDrag],
277
+ );
278
+
279
+ const stopAutoScroll = useCallback(() => {
280
+ if (autoScrollRef.current) {
281
+ clearInterval(autoScrollRef.current.id);
282
+ autoScrollRef.current = null;
283
+ }
284
+ }, []);
285
+
286
+ const startAutoScroll = useCallback(
287
+ (dir: number) => {
288
+ if (autoScrollRef.current?.dir === dir) return;
289
+ stopAutoScroll();
290
+ const max = Math.max(0, offsets[offsets.length - 1] - viewportRef.current.height);
291
+ const id = setInterval(() => {
292
+ const next = Math.min(max, Math.max(0, scrollYRef.current + dir * AUTOSCROLL_STEP_PX));
293
+ scrollYRef.current = next;
294
+ void listRef.current?.scrollToOffset({ offset: next, animated: false });
295
+ extendTo(dayAtContent(lastPanRef.current.x, next + lastPanRef.current.y));
296
+ }, AUTOSCROLL_INTERVAL_MS);
297
+ autoScrollRef.current = { id, dir };
298
+ },
299
+ [offsets, stopAutoScroll, extendTo, dayAtContent],
300
+ );
301
+
302
+ // Native: a list-level pan, so one drag spans every month.
303
+ const dragGesture = useMemo(() => {
304
+ if (!onSelectDrag) return undefined;
305
+ return Gesture.Pan()
306
+ .activateAfterLongPress(LONG_PRESS_MS)
307
+ .runOnJS(true)
308
+ .onStart((event) => {
309
+ lastPanRef.current = { x: event.x, y: event.y };
310
+ dragMovedRef.current = false;
311
+ dragStartRef.current = dayAtContent(event.x, scrollYRef.current + event.y);
312
+ })
313
+ .onUpdate((event) => {
314
+ lastPanRef.current = { x: event.x, y: event.y };
315
+ const height = viewportRef.current.height;
316
+ if (event.y < AUTOSCROLL_EDGE_PX) startAutoScroll(-1);
317
+ else if (height > 0 && event.y > height - AUTOSCROLL_EDGE_PX) startAutoScroll(1);
318
+ else stopAutoScroll();
319
+ extendTo(dayAtContent(event.x, scrollYRef.current + event.y));
320
+ })
321
+ .onFinalize(() => {
322
+ stopAutoScroll();
323
+ dragStartRef.current = null;
324
+ });
325
+ }, [onSelectDrag, dayAtContent, extendTo, startAutoScroll, stopAutoScroll]);
326
+
327
+ // Web: cells relay pointer enter/leave (the pan above is swallowed by the cell
328
+ // touchables on web). A pressed pointer entering a cell extends the range;
329
+ // cross-month works because every visible cell relays independently.
330
+ const onDayPointerDown = useCallback(
331
+ (day: Date) => {
332
+ pointerDownRef.current = true;
333
+ dragMovedRef.current = false;
334
+ dragStartRef.current = isSelectable(day) ? day : null;
335
+ },
336
+ [isSelectable],
337
+ );
338
+ const onDayPointerEnter = useCallback(
339
+ (day: Date) => {
340
+ if (pointerDownRef.current && dragStartRef.current && isSelectable(day)) extendTo(day);
341
+ },
342
+ [extendTo, isSelectable],
343
+ );
344
+ useEffect(() => {
345
+ if (!isWeb || !onSelectDrag) return;
346
+ const end = () => {
347
+ pointerDownRef.current = false;
348
+ dragStartRef.current = null;
349
+ };
350
+ const target = globalThis as unknown as {
351
+ addEventListener?: (t: string, cb: () => void) => void;
352
+ removeEventListener?: (t: string, cb: () => void) => void;
353
+ };
354
+ target.addEventListener?.("pointerup", end);
355
+ return () => target.removeEventListener?.("pointerup", end);
356
+ }, [onSelectDrag]);
357
+
358
+ // Swallow the tap that follows a drag so it doesn't reset the just-swept range.
359
+ const handlePressDay = useCallback(
360
+ (day: Date) => {
361
+ if (dragMovedRef.current) {
362
+ dragMovedRef.current = false;
363
+ return;
364
+ }
365
+ onPressDay?.(day);
366
+ },
367
+ [onPressDay],
368
+ );
369
+
370
+ const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
371
+ scrollYRef.current = event.nativeEvent.contentOffset.y;
372
+ }, []);
373
+ const handleViewableItemsChanged = useCallback(
374
+ (info: OnViewableItemsChangedInfo<Date>) => {
375
+ // Report the topmost month that's at least ~60% visible (the viewability
376
+ // threshold), i.e. the one anchored at the top of the viewport.
377
+ const settled = info.viewableItems.find((token) => token.isViewable);
378
+ if (settled?.item && onChangeVisibleMonth) onChangeVisibleMonth(settled.item);
379
+ // Track the topmost viewable index so months above it can be made inert on
380
+ // web (see MonthBlock). Only the web path consumes this.
381
+ const topIndex = settled?.index;
382
+ if (isWeb && typeof topIndex === "number") {
383
+ setFirstViewableIndex((prev) => (prev === topIndex ? prev : topIndex));
384
+ }
385
+ },
386
+ [onChangeVisibleMonth],
387
+ );
388
+
389
+ const dragEnabled = onSelectDrag != null;
390
+ const renderItem = useCallback(
391
+ ({ item, index }: LegendListRenderItemProps<Date>) => (
392
+ <MonthBlock
393
+ height={blockHeightAt(index)}
394
+ // Above-viewport months are inert on web so Tab can't jump backwards into
395
+ // them. Only meaningful for the events list; the picker stays navigable.
396
+ inert={isWeb && events.length > 0 && index < firstViewableIndex}
397
+ >
398
+ {/* Pin the header to monthHeaderHeight so the grid below is exactly
399
+ weeks * weekRowHeight, keeping the drag hit-test aligned. */}
400
+ <View style={[styles.monthHeader, { height: monthHeaderHeight }]}>
401
+ {renderMonthHeader ? (
402
+ renderMonthHeader(item)
403
+ ) : (
404
+ <Text style={[styles.monthTitle, { color: theme.colors.text }]}>
405
+ {format(item, "LLLL yyyy", { locale })}
406
+ </Text>
407
+ )}
408
+ </View>
409
+ <View style={styles.grid}>
410
+ {/* Each mounted month re-groups the full events array (cheap for the
411
+ empty-events picker; with large event sets, group once upstream
412
+ and pass a per-month slice if it ever profiles as hot). */}
413
+ <MonthView
414
+ date={item}
415
+ events={events}
416
+ // The list renders its own per-month header and a shared weekday row,
417
+ // so the grid itself omits the title and weekday labels.
418
+ showTitle={false}
419
+ showWeekdays={false}
420
+ maxVisibleEventCount={maxVisibleEventCount}
421
+ weekStartsOn={weekStartsOn}
422
+ locale={locale}
423
+ sortedMonthView={sortedMonthView}
424
+ moreLabel={moreLabel}
425
+ showAdjacentMonths={showAdjacentMonths}
426
+ disableMonthEventCellPress={disableMonthEventCellPress}
427
+ isRTL={isRTL}
428
+ activeDate={activeDate}
429
+ fillCellOnSelection={fillCellOnSelection}
430
+ calendarCellStyle={calendarCellStyle}
431
+ renderEvent={renderEvent}
432
+ keyExtractor={keyExtractor}
433
+ onPressDay={dragEnabled ? handlePressDay : onPressDay}
434
+ onLongPressDay={onLongPressDay}
435
+ onPressEvent={onPressEvent}
436
+ onLongPressEvent={onLongPressEvent}
437
+ onPressMore={onPressMore}
438
+ onDayPointerDown={isWeb && dragEnabled ? onDayPointerDown : undefined}
439
+ onDayPointerEnter={isWeb && dragEnabled ? onDayPointerEnter : undefined}
440
+ />
441
+ </View>
442
+ </MonthBlock>
443
+ ),
444
+ [
445
+ blockHeightAt,
446
+ firstViewableIndex,
447
+ monthHeaderHeight,
448
+ renderMonthHeader,
449
+ theme,
450
+ locale,
451
+ events,
452
+ maxVisibleEventCount,
453
+ weekStartsOn,
454
+ sortedMonthView,
455
+ moreLabel,
456
+ showAdjacentMonths,
457
+ disableMonthEventCellPress,
458
+ isRTL,
459
+ activeDate,
460
+ fillCellOnSelection,
461
+ calendarCellStyle,
462
+ renderEvent,
463
+ keyExtractor,
464
+ dragEnabled,
465
+ handlePressDay,
466
+ onPressDay,
467
+ onLongPressDay,
468
+ onPressEvent,
469
+ onLongPressEvent,
470
+ onPressMore,
471
+ onDayPointerDown,
472
+ onDayPointerEnter,
473
+ ],
474
+ );
475
+
476
+ const list = (
477
+ <LegendList
478
+ ref={listRef}
479
+ style={styles.list}
480
+ data={monthDates}
481
+ recycleItems={false}
482
+ // Re-render mounted months when the viewport cutoff changes, so the inert
483
+ // flag on above-viewport months (web focus containment) stays current.
484
+ extraData={firstViewableIndex}
485
+ keyExtractor={keyExtractorList}
486
+ getFixedItemSize={getFixedItemSize}
487
+ initialScrollIndex={initialIndex}
488
+ showsVerticalScrollIndicator={false}
489
+ scrollEventThrottle={16}
490
+ onScroll={handleScroll}
491
+ viewabilityConfig={VIEWABILITY}
492
+ onViewableItemsChanged={handleViewableItemsChanged}
493
+ onLayout={(event: LayoutChangeEvent) => {
494
+ viewportRef.current = {
495
+ width: event.nativeEvent.layout.width,
496
+ height: event.nativeEvent.layout.height,
497
+ };
498
+ }}
499
+ renderItem={renderItem}
500
+ />
501
+ );
502
+
503
+ return (
504
+ <CalendarSelectionProvider value={selection}>
505
+ <View style={styles.container}>
506
+ <View style={[styles.weekdayHeader, { borderBottomColor: theme.colors.gridLine }]}>
507
+ {weekDays.map((day) => (
508
+ <Text
509
+ key={day.toISOString()}
510
+ style={[styles.weekdayLabel, { color: theme.colors.textMuted }]}
511
+ allowFontScaling={false}
512
+ >
513
+ {format(day, "EEE", { locale })}
514
+ </Text>
515
+ ))}
516
+ </View>
517
+ {dragGesture && !isWeb ? (
518
+ <GestureDetector gesture={dragGesture}>{list}</GestureDetector>
519
+ ) : (
520
+ list
521
+ )}
522
+ </View>
523
+ </CalendarSelectionProvider>
524
+ );
525
+ }
526
+
527
+ // A virtualized list keeps a band of rendered months just outside the viewport.
528
+ // On web their event chips are real tab stops, so tabbing in from outside would
529
+ // land on a month above the viewport and the browser would scroll it into view,
530
+ // jumping the visible month. Marking off-screen-above months `inert` keeps them
531
+ // out of the tab order (and pointer/AT), so Tab enters at the first visible month
532
+ // without a jump. Months below stay tabbable, so forward Tab scrolls down as
533
+ // expected. `inert` is web-only and a no-op on native.
534
+ function MonthBlock({
535
+ height,
536
+ inert,
537
+ children,
538
+ }: {
539
+ height: number;
540
+ inert: boolean;
541
+ children: React.ReactNode;
542
+ }) {
543
+ const ref = useRef<View>(null);
544
+ useEffect(() => {
545
+ if (!isWeb) return;
546
+ const node = ref.current as unknown as { inert?: boolean } | null;
547
+ if (node) node.inert = inert;
548
+ }, [inert]);
549
+ return (
550
+ <View ref={ref} style={{ height }}>
551
+ {children}
552
+ </View>
553
+ );
554
+ }
555
+
556
+ export const MonthList = memo(MonthListInner) as typeof MonthListInner;
557
+
558
+ const styles = StyleSheet.create({
559
+ container: { flex: 1 },
560
+ list: { flex: 1 },
561
+ monthHeader: { justifyContent: "center" },
562
+ monthTitle: { paddingHorizontal: 8, fontSize: 17, fontWeight: "700" },
563
+ grid: { flex: 1 },
564
+ weekdayHeader: {
565
+ flexDirection: "row",
566
+ paddingVertical: 8,
567
+ borderBottomWidth: StyleSheet.hairlineWidth,
568
+ },
569
+ weekdayLabel: { flex: 1, textAlign: "center", fontSize: 12, fontWeight: "600" },
570
+ });