@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.
- package/LICENSE +21 -0
- package/dist/DefaultMonthEvent-86XCNCge.d.ts +326 -0
- package/dist/DefaultMonthEvent-CpRoOloh.d.mts +326 -0
- package/dist/MonthList-BCfN-UGz.js +1089 -0
- package/dist/MonthList-CyrEJ_U6.mjs +1030 -0
- package/dist/index.d.mts +386 -0
- package/dist/index.d.ts +386 -0
- package/dist/index.js +1700 -0
- package/dist/index.mjs +1543 -0
- package/dist/picker.d.mts +3 -0
- package/dist/picker.d.ts +3 -0
- package/dist/picker.js +96 -0
- package/dist/picker.mjs +3 -0
- package/package.json +92 -0
- package/src/components/Agenda.tsx +133 -0
- package/src/components/AllDayLane.tsx +98 -0
- package/src/components/Calendar.tsx +456 -0
- package/src/components/DefaultEvent.tsx +223 -0
- package/src/components/DefaultMonthEvent.tsx +105 -0
- package/src/components/MonthList.tsx +570 -0
- package/src/components/MonthPager.tsx +377 -0
- package/src/components/MonthView.tsx +518 -0
- package/src/components/TimeGrid.tsx +1943 -0
- package/src/index.tsx +70 -0
- package/src/picker.tsx +55 -0
- package/src/theme.ts +86 -0
- package/src/types.ts +62 -0
- package/src/utils/useWebGridZoom.ts +59 -0
- package/src/utils/useWebPagerKeys.ts +56 -0
|
@@ -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
|
+
});
|