@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,377 @@
1
+ import {
2
+ LegendList,
3
+ type LegendListRef,
4
+ type LegendListRenderItemProps,
5
+ type OnViewableItemsChangedInfo,
6
+ } from "@legendapp/list/react-native";
7
+ import { addMonths, differenceInCalendarMonths, format, type Locale, startOfMonth } from "date-fns";
8
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import {
10
+ Platform,
11
+ StyleSheet,
12
+ type StyleProp,
13
+ Text,
14
+ useWindowDimensions,
15
+ View,
16
+ type ViewStyle,
17
+ } from "react-native";
18
+ import { useCalendarTheme } from "../theme";
19
+ import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from "../types";
20
+ import { getWeekDays } from "@super-calendar/core";
21
+ import { useWebPagerKeys } from "../utils/useWebPagerKeys";
22
+ import { MonthView } from "./MonthView";
23
+
24
+ // Horizontal swipe paging doesn't translate to web; there we disable it and page
25
+ // with the arrow keys instead.
26
+ const isWeb = Platform.OS === "web";
27
+
28
+ // Months rendered either side of the current page. LegendList virtualises, so
29
+ // only a few mount at once; a wide window (5 years each way) means the user
30
+ // effectively never runs out of months to swipe. Items are keyed by month and
31
+ // never recycled.
32
+ const PAGE_WINDOW = 60;
33
+ // A page must be ~fully on screen before it becomes the committed month, so
34
+ // paging commits once per settle rather than mid-swipe.
35
+ const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
36
+
37
+ export type MonthPagerProps<T> = {
38
+ date: Date;
39
+ events: CalendarEvent<T>[];
40
+ maxVisibleEventCount?: number;
41
+ weekStartsOn: WeekStartsOn;
42
+ locale?: Locale;
43
+ sortedMonthView?: boolean;
44
+ moreLabel?: string;
45
+ showAdjacentMonths?: boolean;
46
+ disableMonthEventCellPress?: boolean;
47
+ isRTL?: boolean;
48
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
49
+ renderEvent: RenderEvent<T>;
50
+ keyExtractor: EventKeyExtractor<T>;
51
+ onPressDay?: (date: Date) => void;
52
+ onLongPressDay?: (date: Date) => void;
53
+ onPressEvent: (event: CalendarEvent<T>) => void;
54
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
55
+ onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
56
+ onChangeDate: (date: Date) => void;
57
+ freeSwipe?: boolean;
58
+ swipeEnabled?: boolean;
59
+ showSixWeeks?: boolean;
60
+ activeDate?: Date;
61
+ /** Replace the weekday-label header above the month grid. Receives the week's days. */
62
+ renderHeaderForMonthView?: (weekDays: Date[]) => React.ReactNode;
63
+ /** Replace the default date badge in each day cell. Receives the day. */
64
+ renderCustomDateForMonth?: (date: Date) => React.ReactNode;
65
+ };
66
+
67
+ function MonthPagerInner<T>({
68
+ date,
69
+ events,
70
+ maxVisibleEventCount,
71
+ weekStartsOn,
72
+ locale,
73
+ sortedMonthView,
74
+ moreLabel,
75
+ showAdjacentMonths,
76
+ disableMonthEventCellPress,
77
+ isRTL,
78
+ calendarCellStyle,
79
+ renderEvent,
80
+ keyExtractor,
81
+ onPressDay,
82
+ onLongPressDay,
83
+ onPressEvent,
84
+ onLongPressEvent,
85
+ onPressMore,
86
+ onChangeDate,
87
+ freeSwipe = false,
88
+ swipeEnabled = true,
89
+ showSixWeeks = false,
90
+ activeDate,
91
+ renderHeaderForMonthView,
92
+ renderCustomDateForMonth,
93
+ }: MonthPagerProps<T>) {
94
+ const theme = useCalendarTheme();
95
+ const { width, height } = useWindowDimensions();
96
+ const listRef = useRef<LegendListRef>(null);
97
+ const containerRef = useRef<View>(null);
98
+
99
+ // Web: LegendList's horizontal scroll container is `overflow-x: auto`, so a
100
+ // trackpad swipe or horizontal wheel would page between months. Paging should be
101
+ // arrow-keys/toolbar only, so disable user horizontal scrolling (programmatic
102
+ // scrollToIndex still works through `overflow: hidden`).
103
+ useEffect(() => {
104
+ if (!isWeb) return;
105
+ const root = containerRef.current as unknown as HTMLElement | null;
106
+ if (!root) return;
107
+ const raf = requestAnimationFrame(() => {
108
+ for (const el of root.querySelectorAll<HTMLElement>("*")) {
109
+ if (el.scrollWidth <= el.clientWidth + 20 || el.clientWidth <= 100) continue;
110
+ const overflowX = getComputedStyle(el).overflowX;
111
+ if (overflowX === "auto" || overflowX === "scroll") {
112
+ el.style.overflowX = "hidden";
113
+ el.style.touchAction = "pan-y";
114
+ }
115
+ }
116
+ });
117
+ return () => cancelAnimationFrame(raf);
118
+ }, []);
119
+ // Horizontal list items need an explicit cross-axis height; seed it with the
120
+ // window height (so it renders immediately and in tests) and refine to the
121
+ // exact area on layout. Without this the grid collapses to 0px.
122
+ const [pageHeight, setPageHeight] = useState(height);
123
+ // Each month page sizes to the container width, not the window, so it fits a
124
+ // constrained layout on the web (e.g. a max-width card). On native the pager
125
+ // fills the window, so this equals the window width and behaviour is unchanged.
126
+ const [containerWidth, setContainerWidth] = useState(width);
127
+
128
+ // A fixed window of months, anchored once and aligned to the month start. The
129
+ // array never shifts as the date changes, so paging never re-renders a page's
130
+ // content — LegendList virtualises and keys by month.
131
+ const [anchorDate] = useState(date);
132
+ const anchor = useMemo(() => startOfMonth(anchorDate), [anchorDate]);
133
+ const monthDates = useMemo(
134
+ () => Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) => addMonths(anchor, i - PAGE_WINDOW)),
135
+ [anchor],
136
+ );
137
+ const indexOfMonth = useCallback(
138
+ (target: Date) => differenceInCalendarMonths(startOfMonth(target), anchor) + PAGE_WINDOW,
139
+ [anchor],
140
+ );
141
+
142
+ // The committed month's page is the centred/active one. Derived (not stored)
143
+ // so it always reflects the date. `viewedIndexRef` tracks where the list
144
+ // actually sits, letting us tell swipe-driven month changes from external ones.
145
+ const activeIndex = indexOfMonth(date);
146
+ const viewedIndexRef = useRef(activeIndex);
147
+ // While a programmatic scroll (a "today" button, prev/next, or any date set from
148
+ // outside) is settling, this holds its target index. Viewability ticks for the
149
+ // months it crosses are ignored until it lands, so they can't report a month in
150
+ // between back as the new date — which made jumps land one month short.
151
+ const pendingScrollIndexRef = useRef<number | null>(null);
152
+
153
+ const handleViewableItemsChanged = useCallback(
154
+ (info: OnViewableItemsChangedInfo<Date>) => {
155
+ // On the web the pager can't be swiped (overflow is hidden); every page change
156
+ // is a programmatic scroll driven by `date` (prev/next/today/keys). Viewability
157
+ // there only echoes that scroll back, and can report an intermediate month that
158
+ // fights it (a multi-month "today" jump landing one month short), so ignore it.
159
+ if (isWeb) return;
160
+ const settled = info.viewableItems.find((token) => token.isViewable);
161
+ if (settled?.index == null) return;
162
+ // A programmatic scroll is settling: ignore the months it crosses, and clear
163
+ // the pending target (without reporting a date) once it reaches the target.
164
+ if (pendingScrollIndexRef.current != null) {
165
+ if (settled.index === pendingScrollIndexRef.current) {
166
+ pendingScrollIndexRef.current = null;
167
+ viewedIndexRef.current = settled.index;
168
+ }
169
+ return;
170
+ }
171
+ if (settled.index === viewedIndexRef.current) return;
172
+ viewedIndexRef.current = settled.index;
173
+ if (settled.item) onChangeDate(settled.item);
174
+ },
175
+ [onChangeDate],
176
+ );
177
+
178
+ // Realign the list when the month changes from outside a swipe (e.g. a "today"
179
+ // button). Swipe-driven changes already match.
180
+ useEffect(() => {
181
+ if (activeIndex === viewedIndexRef.current) return;
182
+ viewedIndexRef.current = activeIndex;
183
+ pendingScrollIndexRef.current = activeIndex;
184
+ void listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
185
+ }, [activeIndex]);
186
+
187
+ // Web arrow-key paging (swipe is disabled there); the effect above scrolls to
188
+ // the new month once `onChangeDate` updates `date`.
189
+ const goToPage = useCallback(
190
+ (delta: number) => {
191
+ const target = monthDates[activeIndex + delta];
192
+ if (target) onChangeDate(target);
193
+ },
194
+ [monthDates, activeIndex, onChangeDate],
195
+ );
196
+ useWebPagerKeys(swipeEnabled, goToPage);
197
+
198
+ // The seven weekday labels for the header above the grid. Weekday names depend
199
+ // only on `weekStartsOn`, so any week works; reuse the anchor. Reversed in RTL
200
+ // to line up with the mirrored day cells.
201
+ const weekDays = useMemo(() => {
202
+ const days = getWeekDays(anchor, weekStartsOn);
203
+ return isRTL ? days.reverse() : days;
204
+ }, [anchor, weekStartsOn, isRTL]);
205
+
206
+ const snapToIndices = useMemo(() => monthDates.map((_, index) => index), [monthDates]);
207
+ const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
208
+ const getFixedItemSize = useCallback(() => containerWidth, [containerWidth]);
209
+ const renderItem = useCallback(
210
+ ({ item }: LegendListRenderItemProps<Date>) => (
211
+ <View style={{ width: containerWidth, height: pageHeight }}>
212
+ <MonthView
213
+ date={item}
214
+ events={events}
215
+ // The pager shows one shared weekday header and the month title above it
216
+ // (see below), so each page's grid omits its own title and weekday row.
217
+ showTitle={false}
218
+ showWeekdays={false}
219
+ maxVisibleEventCount={maxVisibleEventCount}
220
+ weekStartsOn={weekStartsOn}
221
+ locale={locale}
222
+ sortedMonthView={sortedMonthView}
223
+ moreLabel={moreLabel}
224
+ showAdjacentMonths={showAdjacentMonths}
225
+ disableMonthEventCellPress={disableMonthEventCellPress}
226
+ isRTL={isRTL}
227
+ showSixWeeks={showSixWeeks}
228
+ activeDate={activeDate}
229
+ calendarCellStyle={calendarCellStyle}
230
+ renderEvent={renderEvent}
231
+ keyExtractor={keyExtractor}
232
+ onPressDay={onPressDay}
233
+ onLongPressDay={onLongPressDay}
234
+ onPressEvent={onPressEvent}
235
+ onLongPressEvent={onLongPressEvent}
236
+ onPressMore={onPressMore}
237
+ renderCustomDateForMonth={renderCustomDateForMonth}
238
+ />
239
+ </View>
240
+ ),
241
+ [
242
+ containerWidth,
243
+ pageHeight,
244
+ events,
245
+ maxVisibleEventCount,
246
+ weekStartsOn,
247
+ locale,
248
+ sortedMonthView,
249
+ moreLabel,
250
+ showAdjacentMonths,
251
+ disableMonthEventCellPress,
252
+ isRTL,
253
+ showSixWeeks,
254
+ activeDate,
255
+ calendarCellStyle,
256
+ renderEvent,
257
+ keyExtractor,
258
+ onPressDay,
259
+ onLongPressDay,
260
+ onPressEvent,
261
+ onLongPressEvent,
262
+ onPressMore,
263
+ renderCustomDateForMonth,
264
+ ],
265
+ );
266
+
267
+ return (
268
+ <View ref={containerRef} style={styles.container}>
269
+ {/* The active month's title, above the (shared) weekday header — mirrors the
270
+ dom MonthView's title. The grids below omit their own title/weekdays. */}
271
+ <Text style={[styles.monthTitle, { color: theme.colors.text }]} allowFontScaling={false}>
272
+ {format(date, "MMMM yyyy", locale ? { locale } : undefined)}
273
+ </Text>
274
+ {renderHeaderForMonthView ? (
275
+ renderHeaderForMonthView(weekDays)
276
+ ) : (
277
+ <MonthWeekdayHeader weekDays={weekDays} locale={locale} />
278
+ )}
279
+ <View
280
+ style={styles.pager}
281
+ onLayout={(event) => {
282
+ setPageHeight(event.nativeEvent.layout.height);
283
+ setContainerWidth(event.nativeEvent.layout.width);
284
+ }}
285
+ >
286
+ <LegendList
287
+ // Remount when the measured page height changes so the list adopts the
288
+ // corrected item height. Without this the list can keep the oversized
289
+ // initial (window-height) seed and clip the last week row.
290
+ key={pageHeight}
291
+ ref={listRef}
292
+ style={isWeb ? [styles.pagerList, styles.webNoScroll] : styles.pagerList}
293
+ data={monthDates}
294
+ horizontal
295
+ recycleItems={false}
296
+ keyExtractor={keyExtractorList}
297
+ getFixedItemSize={getFixedItemSize}
298
+ // On web LegendList ignores these RN scroll props (it leaks them to the
299
+ // DOM as unknown attributes), so omit them there and disable horizontal
300
+ // scroll via `webNoScroll`; paging is driven by the arrow keys instead.
301
+ // Native: paging makes each swipe hard-stop at the adjacent month, while
302
+ // `freeSwipe` lets momentum carry across months and snap to a boundary.
303
+ {...(isWeb
304
+ ? null
305
+ : {
306
+ scrollEnabled: swipeEnabled,
307
+ pagingEnabled: !freeSwipe,
308
+ snapToIndices: freeSwipe ? snapToIndices : undefined,
309
+ })}
310
+ initialScrollIndex={activeIndex}
311
+ showsHorizontalScrollIndicator={false}
312
+ viewabilityConfig={PAGE_VIEWABILITY}
313
+ onViewableItemsChanged={handleViewableItemsChanged}
314
+ renderItem={renderItem}
315
+ />
316
+ </View>
317
+ </View>
318
+ );
319
+ }
320
+
321
+ export const MonthPager = memo(MonthPagerInner) as typeof MonthPagerInner;
322
+
323
+ type MonthWeekdayHeaderProps = {
324
+ weekDays: Date[];
325
+ locale?: Locale;
326
+ };
327
+
328
+ // The default weekday-label row above the month grid (e.g. "Mon Tue Wed…"),
329
+ // one flex column per day to line up with the grid cells below.
330
+ const MonthWeekdayHeader = ({ weekDays, locale }: MonthWeekdayHeaderProps) => {
331
+ const theme = useCalendarTheme();
332
+ return (
333
+ <View style={styles.weekdayHeader}>
334
+ {weekDays.map((day) => (
335
+ <Text
336
+ key={day.toISOString()}
337
+ style={[theme.text.weekday, styles.weekdayLabel, { color: theme.colors.textMuted }]}
338
+ allowFontScaling={false}
339
+ >
340
+ {format(day, "EEE", { locale })}
341
+ </Text>
342
+ ))}
343
+ </View>
344
+ );
345
+ };
346
+
347
+ const styles = StyleSheet.create({
348
+ container: {
349
+ flex: 1,
350
+ },
351
+ pager: {
352
+ flex: 1,
353
+ },
354
+ pagerList: {
355
+ flex: 1,
356
+ },
357
+ // Disable user-driven horizontal scroll on web; programmatic paging still works.
358
+ webNoScroll: {
359
+ overflow: "hidden",
360
+ },
361
+ // Matches the dom MonthView title: "MMMM yyyy" above the weekday row.
362
+ monthTitle: {
363
+ fontSize: 17,
364
+ fontWeight: "700",
365
+ paddingTop: 10,
366
+ paddingHorizontal: 14,
367
+ paddingBottom: 6,
368
+ },
369
+ weekdayHeader: {
370
+ flexDirection: "row",
371
+ paddingBottom: 4,
372
+ },
373
+ weekdayLabel: {
374
+ flex: 1,
375
+ textAlign: "center",
376
+ },
377
+ });