@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,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
|
+
});
|