@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,1943 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LegendList,
|
|
3
|
+
type LegendListRef,
|
|
4
|
+
type LegendListRenderItemProps,
|
|
5
|
+
type OnViewableItemsChangedInfo,
|
|
6
|
+
} from "@legendapp/list/react-native";
|
|
7
|
+
import {
|
|
8
|
+
addDays,
|
|
9
|
+
differenceInCalendarDays,
|
|
10
|
+
format,
|
|
11
|
+
getHours,
|
|
12
|
+
getISOWeek,
|
|
13
|
+
getMinutes,
|
|
14
|
+
type Locale,
|
|
15
|
+
startOfDay,
|
|
16
|
+
startOfWeek,
|
|
17
|
+
} from "date-fns";
|
|
18
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
19
|
+
import {
|
|
20
|
+
type GestureResponderEvent,
|
|
21
|
+
Platform,
|
|
22
|
+
Pressable,
|
|
23
|
+
StyleSheet,
|
|
24
|
+
type StyleProp,
|
|
25
|
+
Text,
|
|
26
|
+
useWindowDimensions,
|
|
27
|
+
View,
|
|
28
|
+
type ViewStyle,
|
|
29
|
+
} from "react-native";
|
|
30
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
31
|
+
import Animated, {
|
|
32
|
+
runOnJS,
|
|
33
|
+
scrollTo,
|
|
34
|
+
type SharedValue,
|
|
35
|
+
useAnimatedReaction,
|
|
36
|
+
useAnimatedRef,
|
|
37
|
+
useAnimatedScrollHandler,
|
|
38
|
+
useAnimatedStyle,
|
|
39
|
+
useDerivedValue,
|
|
40
|
+
useSharedValue,
|
|
41
|
+
} from "react-native-reanimated";
|
|
42
|
+
import { useCalendarTheme } from "../theme";
|
|
43
|
+
import type {
|
|
44
|
+
BusinessHours,
|
|
45
|
+
CalendarEvent,
|
|
46
|
+
CalendarMode,
|
|
47
|
+
EventKeyExtractor,
|
|
48
|
+
RenderEvent,
|
|
49
|
+
TimeGridMode,
|
|
50
|
+
WeekStartsOn,
|
|
51
|
+
} from "../types";
|
|
52
|
+
import {
|
|
53
|
+
getIsToday,
|
|
54
|
+
getViewDays,
|
|
55
|
+
isSameCalendarDay,
|
|
56
|
+
isWeekend,
|
|
57
|
+
viewDayCount,
|
|
58
|
+
} from "@super-calendar/core";
|
|
59
|
+
import {
|
|
60
|
+
cellRangeFromDrag,
|
|
61
|
+
closedHourBands,
|
|
62
|
+
resolveDraggedBounds,
|
|
63
|
+
snapDeltaMinutes,
|
|
64
|
+
} from "@super-calendar/core";
|
|
65
|
+
import { formatHour, layoutDayEvents, type PositionedEvent } from "@super-calendar/core";
|
|
66
|
+
import { useWebGridZoom } from "../utils/useWebGridZoom";
|
|
67
|
+
import { useWebPagerKeys } from "../utils/useWebPagerKeys";
|
|
68
|
+
import { AllDayLane } from "./AllDayLane";
|
|
69
|
+
|
|
70
|
+
// Horizontal swipe paging doesn't translate to web; there we disable it and page
|
|
71
|
+
// with the arrow keys instead.
|
|
72
|
+
const isWeb = Platform.OS === "web";
|
|
73
|
+
|
|
74
|
+
// Minimal DOM shapes for the web-only Escape listener (the library targets React
|
|
75
|
+
// Native, so the TS "DOM" lib isn't available).
|
|
76
|
+
type WebKeyEvent = { key: string };
|
|
77
|
+
type WebKeyTarget = {
|
|
78
|
+
addEventListener: (type: "keydown", listener: (event: WebKeyEvent) => void) => void;
|
|
79
|
+
removeEventListener: (type: "keydown", listener: (event: WebKeyEvent) => void) => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const MINUTES_PER_HOUR = 60;
|
|
83
|
+
const HOURS_PER_DAY = 24;
|
|
84
|
+
const MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY;
|
|
85
|
+
// Steps rendered either side of the current page. LegendList virtualises, so
|
|
86
|
+
// only a few mount at once; a wide window means the user effectively never runs
|
|
87
|
+
// out of pages to swipe. Items are keyed by date and never recycled.
|
|
88
|
+
const PAGE_WINDOW = 180;
|
|
89
|
+
// A page must be ~fully on screen before it becomes the committed date.
|
|
90
|
+
const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
|
|
91
|
+
|
|
92
|
+
// Matches the dom renderer's default so both grids start at the same density.
|
|
93
|
+
export const DEFAULT_HOUR_HEIGHT = 48;
|
|
94
|
+
const DEFAULT_MIN_HOUR_HEIGHT = 32;
|
|
95
|
+
const DEFAULT_MAX_HOUR_HEIGHT = 160;
|
|
96
|
+
const DEFAULT_HOUR_COLUMN_WIDTH = 56;
|
|
97
|
+
// Short events would otherwise render only a few pixels tall and clip their
|
|
98
|
+
// content; keep them tall enough to stay legible and tappable.
|
|
99
|
+
const MIN_EVENT_HEIGHT = 32;
|
|
100
|
+
// Inset each event box within its slot so adjacent boxes (and column edges) get a
|
|
101
|
+
// little breathing room instead of butting edge-to-edge.
|
|
102
|
+
const EVENT_GAP = 2;
|
|
103
|
+
// Hold this long before drag-to-create (sweeping empty grid space) begins on
|
|
104
|
+
// native, so a normal scroll/tap isn't hijacked.
|
|
105
|
+
const DRAG_ACTIVATE_MS = 300;
|
|
106
|
+
// Moving an existing event takes a longer, deliberate hold, so a tap or scroll
|
|
107
|
+
// over a busy day never picks one up by accident.
|
|
108
|
+
const MOVE_ACTIVATE_MS = 500;
|
|
109
|
+
// Web has no long-press, so a drag-to-move activates only after the pointer
|
|
110
|
+
// moves this far vertically — below it a press stays a click (select /
|
|
111
|
+
// right-click menu).
|
|
112
|
+
const DRAG_ACTIVATE_PX = 8;
|
|
113
|
+
// Height of the resize grip at the bottom of a draggable event box.
|
|
114
|
+
const RESIZE_HANDLE_HEIGHT = 14;
|
|
115
|
+
// Default minutes a drag snaps to when `dragStepMinutes` isn't set.
|
|
116
|
+
const DEFAULT_DRAG_STEP_MINUTES = 15;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Called when an event is dragged (moved or resized) to new start/end times.
|
|
120
|
+
* Return `false` to reject the drop — the event snaps back to where it started
|
|
121
|
+
* (e.g. to forbid overlaps or out-of-bounds slots). Any other return accepts it.
|
|
122
|
+
*/
|
|
123
|
+
export type EventDragHandler<T> = (
|
|
124
|
+
event: CalendarEvent<T>,
|
|
125
|
+
start: Date,
|
|
126
|
+
end: Date,
|
|
127
|
+
) => void | boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Called when a move or resize gesture begins, before any change is committed:
|
|
130
|
+
* on grab for a move (after the long-press), and when the resize drag starts.
|
|
131
|
+
* Handy for haptic feedback (e.g. `expo-haptics`). Requires drag to be enabled
|
|
132
|
+
* via `onDragEvent`.
|
|
133
|
+
*/
|
|
134
|
+
export type EventDragStartHandler<T> = (event: CalendarEvent<T>) => void;
|
|
135
|
+
// Hour labels are nudged up so the number sits centred on its grid line. Pad the
|
|
136
|
+
// scroll content by the same amount so the top-most label is never clipped.
|
|
137
|
+
const HOUR_LABEL_TOP_INSET = 12;
|
|
138
|
+
const HOUR_LABEL_NUDGE = 6;
|
|
139
|
+
const NOW_TICK_MS = 60_000;
|
|
140
|
+
|
|
141
|
+
// A `Date` that advances every minute while `enabled`, so the now-indicator
|
|
142
|
+
// tracks the wall clock instead of freezing at mount. Off-screen pages pass
|
|
143
|
+
// `enabled = false` and re-read the time when they become active.
|
|
144
|
+
function useNow(enabled: boolean): Date {
|
|
145
|
+
const [now, setNow] = useState(() => new Date());
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!enabled) return;
|
|
148
|
+
setNow(new Date());
|
|
149
|
+
const id = setInterval(() => setNow(new Date()), NOW_TICK_MS);
|
|
150
|
+
return () => clearInterval(id);
|
|
151
|
+
}, [enabled]);
|
|
152
|
+
return now;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type AnimatedEventBoxProps<T> = {
|
|
156
|
+
positioned: PositionedEvent<T>;
|
|
157
|
+
cellHeight: SharedValue<number>;
|
|
158
|
+
minHour: number;
|
|
159
|
+
left: number;
|
|
160
|
+
width: number;
|
|
161
|
+
// Drag-to-move across days needs the column width and this box's day index
|
|
162
|
+
// within the visible range, so a horizontal drag maps to (and clamps to) days.
|
|
163
|
+
dayWidth: number;
|
|
164
|
+
dayIndex: number;
|
|
165
|
+
dayCount: number;
|
|
166
|
+
mode: CalendarMode;
|
|
167
|
+
renderEvent: RenderEvent<T>;
|
|
168
|
+
snapMinutes: number;
|
|
169
|
+
onPress: (event: CalendarEvent<T>) => void;
|
|
170
|
+
onLongPress?: (event: CalendarEvent<T>) => void;
|
|
171
|
+
onDragEvent?: EventDragHandler<T>;
|
|
172
|
+
onDragStart?: EventDragStartHandler<T>;
|
|
173
|
+
showDragHandle: boolean;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function AnimatedEventBox<T>({
|
|
177
|
+
positioned,
|
|
178
|
+
cellHeight,
|
|
179
|
+
minHour,
|
|
180
|
+
left,
|
|
181
|
+
width,
|
|
182
|
+
dayWidth,
|
|
183
|
+
dayIndex,
|
|
184
|
+
dayCount,
|
|
185
|
+
mode,
|
|
186
|
+
renderEvent,
|
|
187
|
+
snapMinutes,
|
|
188
|
+
onPress,
|
|
189
|
+
onLongPress,
|
|
190
|
+
onDragEvent,
|
|
191
|
+
onDragStart,
|
|
192
|
+
showDragHandle,
|
|
193
|
+
}: AnimatedEventBoxProps<T>) {
|
|
194
|
+
const RenderEventComponent = renderEvent;
|
|
195
|
+
const theme = useCalendarTheme();
|
|
196
|
+
// Drag-to-move/resize. Native picks the event up on long-press (so a tap or
|
|
197
|
+
// scroll isn't hijacked); web activates after a small drag threshold, so a
|
|
198
|
+
// plain click still selects and a right-click still opens a context menu.
|
|
199
|
+
const draggable = onDragEvent != null && !positioned.event.disabled;
|
|
200
|
+
// Only the segment that owns the real end may be resized.
|
|
201
|
+
const resizable = draggable && !positioned.continuesAfter;
|
|
202
|
+
|
|
203
|
+
// Live preview offsets (px), reset to 0 once the committed change re-renders.
|
|
204
|
+
// moveOffsetX shifts the box across day columns during a cross-day drag.
|
|
205
|
+
const moveOffset = useSharedValue(0);
|
|
206
|
+
const moveOffsetX = useSharedValue(0);
|
|
207
|
+
const resizeDelta = useSharedValue(0);
|
|
208
|
+
|
|
209
|
+
// Live pixel height of the box, driven on the UI thread by the shared
|
|
210
|
+
// cellHeight (plus any in-progress resize). Handed to renderEvent so custom
|
|
211
|
+
// renderers can reveal detail progressively as the grid zooms, without
|
|
212
|
+
// re-rendering. Explicit deps so the worklet re-captures the event's geometry.
|
|
213
|
+
const boxHeight = useDerivedValue(
|
|
214
|
+
() =>
|
|
215
|
+
Math.max(positioned.durationHours * cellHeight.value + resizeDelta.value, MIN_EVENT_HEIGHT),
|
|
216
|
+
[positioned.durationHours],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const boxStyle = useAnimatedStyle(
|
|
220
|
+
() => ({
|
|
221
|
+
top: (positioned.startHours - minHour) * cellHeight.value + moveOffset.value,
|
|
222
|
+
height: boxHeight.value,
|
|
223
|
+
transform: [{ translateX: moveOffsetX.value }],
|
|
224
|
+
}),
|
|
225
|
+
[positioned.startHours, positioned.durationHours, minHour],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Clear the drag preview once the committed change re-renders this box at its
|
|
229
|
+
// new geometry. The gesture holds the snapped offset through the commit (so the
|
|
230
|
+
// box never flashes back to the original spot); this drops it the moment the
|
|
231
|
+
// new start/duration lands, for consumers whose keyExtractor keeps it mounted.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
234
|
+
moveOffset.value = 0;
|
|
235
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
236
|
+
moveOffsetX.value = 0;
|
|
237
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
238
|
+
resizeDelta.value = 0;
|
|
239
|
+
}, [positioned.startHours, positioned.durationHours, moveOffset, moveOffsetX, resizeDelta]);
|
|
240
|
+
|
|
241
|
+
// Keep the latest event/handler in a ref so the gestures stay memoized but
|
|
242
|
+
// never call into a stale closure.
|
|
243
|
+
const latest = useRef({ event: positioned.event, onDragEvent, onDragStart });
|
|
244
|
+
latest.current = { event: positioned.event, onDragEvent, onDragStart };
|
|
245
|
+
|
|
246
|
+
// Snap the box back to where it started (drop rejected or degenerate).
|
|
247
|
+
const snapBack = useCallback(() => {
|
|
248
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
249
|
+
moveOffset.value = 0;
|
|
250
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
251
|
+
moveOffsetX.value = 0;
|
|
252
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
253
|
+
resizeDelta.value = 0;
|
|
254
|
+
}, [moveOffset, moveOffsetX, resizeDelta]);
|
|
255
|
+
|
|
256
|
+
const commitDrag = useCallback(
|
|
257
|
+
(deltaStartMin: number, deltaEndMin: number) => {
|
|
258
|
+
const { event, onDragEvent: handler } = latest.current;
|
|
259
|
+
if (!handler) return;
|
|
260
|
+
// Returns null when a resize would collapse below one step — snap back.
|
|
261
|
+
const next = resolveDraggedBounds(
|
|
262
|
+
event.start,
|
|
263
|
+
event.end,
|
|
264
|
+
deltaStartMin,
|
|
265
|
+
deltaEndMin,
|
|
266
|
+
snapMinutes,
|
|
267
|
+
);
|
|
268
|
+
if (!next) {
|
|
269
|
+
snapBack();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// A handler may return false to reject the drop (e.g. an overlap or an
|
|
273
|
+
// out-of-bounds slot); snap the box back to its original place.
|
|
274
|
+
if (handler(event, next.start, next.end) === false) snapBack();
|
|
275
|
+
},
|
|
276
|
+
[snapMinutes, snapBack],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Fired on the JS thread when the gesture grabs the event, so consumers can
|
|
280
|
+
// trigger haptics or other side effects the instant drag mode begins.
|
|
281
|
+
const notifyDragStart = useCallback(() => {
|
|
282
|
+
latest.current.onDragStart?.(latest.current.event);
|
|
283
|
+
}, []);
|
|
284
|
+
|
|
285
|
+
const moveGesture = useMemo(() => {
|
|
286
|
+
const pan = Gesture.Pan()
|
|
287
|
+
.enabled(draggable)
|
|
288
|
+
.onStart(() => {
|
|
289
|
+
runOnJS(notifyDragStart)();
|
|
290
|
+
})
|
|
291
|
+
.onUpdate((event) => {
|
|
292
|
+
moveOffset.value = event.translationY;
|
|
293
|
+
moveOffsetX.value = event.translationX;
|
|
294
|
+
})
|
|
295
|
+
.onEnd((event) => {
|
|
296
|
+
const minuteDelta = snapDeltaMinutes(event.translationY, cellHeight.value, snapMinutes);
|
|
297
|
+
// Map the horizontal drag to whole day columns, clamped so the event
|
|
298
|
+
// can't leave the visible range.
|
|
299
|
+
const rawDayDelta = dayWidth > 0 ? Math.round(event.translationX / dayWidth) : 0;
|
|
300
|
+
const targetDay = Math.min(Math.max(dayIndex + rawDayDelta, 0), dayCount - 1);
|
|
301
|
+
const dayDelta = targetDay - dayIndex;
|
|
302
|
+
if (minuteDelta === 0 && dayDelta === 0) {
|
|
303
|
+
moveOffset.value = 0;
|
|
304
|
+
moveOffsetX.value = 0;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Hold the snapped position so the box doesn't flash back to the
|
|
308
|
+
// original before the committed re-render lands.
|
|
309
|
+
moveOffset.value = (minuteDelta / MINUTES_PER_HOUR) * cellHeight.value;
|
|
310
|
+
moveOffsetX.value = dayDelta * dayWidth;
|
|
311
|
+
// Fold the day shift into the minute delta; shiftMinutes carries it into
|
|
312
|
+
// the date, so both edges move together and the duration is preserved.
|
|
313
|
+
const totalDelta = minuteDelta + dayDelta * MINUTES_PER_DAY;
|
|
314
|
+
runOnJS(commitDrag)(totalDelta, totalDelta);
|
|
315
|
+
});
|
|
316
|
+
// Native: long-press to pick up. Web: activate past a small drag in either
|
|
317
|
+
// axis so clicks/right-clicks pass through but horizontal drags still move.
|
|
318
|
+
return isWeb
|
|
319
|
+
? pan
|
|
320
|
+
.activeOffsetX([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
|
|
321
|
+
.activeOffsetY([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
|
|
322
|
+
: pan.activateAfterLongPress(MOVE_ACTIVATE_MS);
|
|
323
|
+
}, [
|
|
324
|
+
draggable,
|
|
325
|
+
snapMinutes,
|
|
326
|
+
cellHeight,
|
|
327
|
+
moveOffset,
|
|
328
|
+
moveOffsetX,
|
|
329
|
+
dayWidth,
|
|
330
|
+
dayIndex,
|
|
331
|
+
dayCount,
|
|
332
|
+
commitDrag,
|
|
333
|
+
notifyDragStart,
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
const resizeGesture = useMemo(
|
|
337
|
+
() =>
|
|
338
|
+
Gesture.Pan()
|
|
339
|
+
.enabled(resizable)
|
|
340
|
+
.onStart(() => {
|
|
341
|
+
runOnJS(notifyDragStart)();
|
|
342
|
+
})
|
|
343
|
+
.onUpdate((event) => {
|
|
344
|
+
resizeDelta.value = event.translationY;
|
|
345
|
+
})
|
|
346
|
+
.onEnd((event) => {
|
|
347
|
+
const delta = snapDeltaMinutes(event.translationY, cellHeight.value, snapMinutes);
|
|
348
|
+
if (delta === 0) {
|
|
349
|
+
resizeDelta.value = 0;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
resizeDelta.value = (delta / MINUTES_PER_HOUR) * cellHeight.value;
|
|
353
|
+
runOnJS(commitDrag)(0, delta);
|
|
354
|
+
}),
|
|
355
|
+
[resizable, snapMinutes, cellHeight, resizeDelta, commitDrag, notifyDragStart],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const handlePress = () => onPress(positioned.event);
|
|
359
|
+
// When draggable, a long press grabs the event to move it, so don't also fire
|
|
360
|
+
// the consumer's long-press handler.
|
|
361
|
+
const handleLongPress =
|
|
362
|
+
!draggable && onLongPress ? () => onLongPress(positioned.event) : undefined;
|
|
363
|
+
|
|
364
|
+
const box = (
|
|
365
|
+
<Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
|
|
366
|
+
<RenderEventComponent
|
|
367
|
+
event={positioned.event}
|
|
368
|
+
mode={mode}
|
|
369
|
+
boxHeight={boxHeight}
|
|
370
|
+
continuesBefore={positioned.continuesBefore}
|
|
371
|
+
continuesAfter={positioned.continuesAfter}
|
|
372
|
+
onPress={handlePress}
|
|
373
|
+
onLongPress={handleLongPress}
|
|
374
|
+
/>
|
|
375
|
+
{resizable ? (
|
|
376
|
+
<GestureDetector gesture={resizeGesture}>
|
|
377
|
+
<Animated.View style={styles.resizeHandle}>
|
|
378
|
+
{/* The grip is the only visible drag affordance; hiding it keeps the
|
|
379
|
+
resize gesture working but removes the indicator. */}
|
|
380
|
+
{showDragHandle ? (
|
|
381
|
+
<View style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]} />
|
|
382
|
+
) : null}
|
|
383
|
+
</Animated.View>
|
|
384
|
+
</GestureDetector>
|
|
385
|
+
) : null}
|
|
386
|
+
</Animated.View>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (!draggable) return box;
|
|
390
|
+
return <GestureDetector gesture={moveGesture}>{box}</GestureDetector>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
|
|
394
|
+
export type HourRenderer = (hour: number, ampm: boolean) => React.ReactNode;
|
|
395
|
+
|
|
396
|
+
type HourRowProps = {
|
|
397
|
+
hour: number;
|
|
398
|
+
minHour: number;
|
|
399
|
+
cellHeight: SharedValue<number>;
|
|
400
|
+
hourColumnWidth: number;
|
|
401
|
+
label: string;
|
|
402
|
+
ampm: boolean;
|
|
403
|
+
hourComponent?: HourRenderer;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const HourRow = ({
|
|
407
|
+
hour,
|
|
408
|
+
minHour,
|
|
409
|
+
cellHeight,
|
|
410
|
+
hourColumnWidth,
|
|
411
|
+
label,
|
|
412
|
+
ampm,
|
|
413
|
+
hourComponent,
|
|
414
|
+
}: HourRowProps) => {
|
|
415
|
+
const theme = useCalendarTheme();
|
|
416
|
+
// Position via `top` (a layout prop), not a transform. The per-row layout pass
|
|
417
|
+
// as cellHeight animates keeps the ScrollView's content size in sync while
|
|
418
|
+
// zooming; a transform is composited and leaves the scroll range stale.
|
|
419
|
+
const animatedStyle = useAnimatedStyle(
|
|
420
|
+
() => ({ top: (hour - minHour) * cellHeight.value }),
|
|
421
|
+
[hour, minHour],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<Animated.View style={[styles.hourRow, styles.nonInteractive, animatedStyle]}>
|
|
426
|
+
{hourComponent ? (
|
|
427
|
+
<View style={{ width: hourColumnWidth }}>{hourComponent(hour, ampm)}</View>
|
|
428
|
+
) : (
|
|
429
|
+
<Text
|
|
430
|
+
style={[
|
|
431
|
+
theme.text.hourLabel,
|
|
432
|
+
styles.hourLabel,
|
|
433
|
+
{ width: hourColumnWidth, color: theme.colors.textMuted },
|
|
434
|
+
]}
|
|
435
|
+
allowFontScaling={false}
|
|
436
|
+
>
|
|
437
|
+
{label}
|
|
438
|
+
</Text>
|
|
439
|
+
)}
|
|
440
|
+
<View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
|
|
441
|
+
</Animated.View>
|
|
442
|
+
);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
type TimeslotLineProps = {
|
|
446
|
+
hour: number;
|
|
447
|
+
minHour: number;
|
|
448
|
+
fraction: number;
|
|
449
|
+
cellHeight: SharedValue<number>;
|
|
450
|
+
hourColumnWidth: number;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// A faint divider inside an hour row, marking a sub-hour slot (e.g. half hours).
|
|
454
|
+
const TimeslotLine = ({
|
|
455
|
+
hour,
|
|
456
|
+
minHour,
|
|
457
|
+
fraction,
|
|
458
|
+
cellHeight,
|
|
459
|
+
hourColumnWidth,
|
|
460
|
+
}: TimeslotLineProps) => {
|
|
461
|
+
const theme = useCalendarTheme();
|
|
462
|
+
const animatedStyle = useAnimatedStyle(
|
|
463
|
+
() => ({ top: (hour - minHour + fraction) * cellHeight.value }),
|
|
464
|
+
[hour, minHour, fraction],
|
|
465
|
+
);
|
|
466
|
+
return (
|
|
467
|
+
<Animated.View
|
|
468
|
+
style={[
|
|
469
|
+
styles.timeslotLine,
|
|
470
|
+
styles.nonInteractive,
|
|
471
|
+
{ left: hourColumnWidth, backgroundColor: theme.colors.gridLine },
|
|
472
|
+
animatedStyle,
|
|
473
|
+
]}
|
|
474
|
+
/>
|
|
475
|
+
);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
type NowIndicatorProps = {
|
|
479
|
+
cellHeight: SharedValue<number>;
|
|
480
|
+
nowHours: number;
|
|
481
|
+
minHour: number;
|
|
482
|
+
left: number;
|
|
483
|
+
width: number;
|
|
484
|
+
color: string;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const NowIndicator = ({ cellHeight, nowHours, minHour, left, width, color }: NowIndicatorProps) => {
|
|
488
|
+
const animatedStyle = useAnimatedStyle(
|
|
489
|
+
() => ({ top: (nowHours - minHour) * cellHeight.value }),
|
|
490
|
+
[nowHours, minHour],
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<Animated.View
|
|
495
|
+
style={[
|
|
496
|
+
styles.nowIndicator,
|
|
497
|
+
styles.nonInteractive,
|
|
498
|
+
{ left, width, backgroundColor: color },
|
|
499
|
+
animatedStyle,
|
|
500
|
+
]}
|
|
501
|
+
/>
|
|
502
|
+
);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
type ShadeBandProps = {
|
|
506
|
+
cellHeight: SharedValue<number>;
|
|
507
|
+
startHour: number;
|
|
508
|
+
endHour: number;
|
|
509
|
+
minHour: number;
|
|
510
|
+
left: number;
|
|
511
|
+
width: number;
|
|
512
|
+
color: string;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// A muted band over a closed hour-range of one day column (driven by the live
|
|
516
|
+
// cellHeight so it tracks the zoom).
|
|
517
|
+
const ShadeBand = ({
|
|
518
|
+
cellHeight,
|
|
519
|
+
startHour,
|
|
520
|
+
endHour,
|
|
521
|
+
minHour,
|
|
522
|
+
left,
|
|
523
|
+
width,
|
|
524
|
+
color,
|
|
525
|
+
}: ShadeBandProps) => {
|
|
526
|
+
const animatedStyle = useAnimatedStyle(
|
|
527
|
+
() => ({
|
|
528
|
+
top: (startHour - minHour) * cellHeight.value,
|
|
529
|
+
height: (endHour - startHour) * cellHeight.value,
|
|
530
|
+
}),
|
|
531
|
+
[startHour, endHour, minHour],
|
|
532
|
+
);
|
|
533
|
+
return (
|
|
534
|
+
<Animated.View
|
|
535
|
+
testID="business-hours-shade"
|
|
536
|
+
style={[
|
|
537
|
+
styles.shadeBand,
|
|
538
|
+
styles.nonInteractive,
|
|
539
|
+
{ left, width, backgroundColor: color },
|
|
540
|
+
animatedStyle,
|
|
541
|
+
]}
|
|
542
|
+
/>
|
|
543
|
+
);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
type TimetablePageProps<T> = {
|
|
547
|
+
mode: TimeGridMode;
|
|
548
|
+
numberOfDays: number;
|
|
549
|
+
date: Date;
|
|
550
|
+
events: CalendarEvent<T>[];
|
|
551
|
+
cellHeight: SharedValue<number>;
|
|
552
|
+
hourHeight: number;
|
|
553
|
+
// The zoom committed at the end of the last pinch. Off-screen pages animate off
|
|
554
|
+
// this (it changes once per gesture) instead of the live cellHeight (which
|
|
555
|
+
// changes every frame), so a pinch only re-runs the visible page's worklets.
|
|
556
|
+
committedCellHeight: SharedValue<number>;
|
|
557
|
+
scrollY: SharedValue<number>;
|
|
558
|
+
isActive: boolean;
|
|
559
|
+
/** Initial vertical scroll position (px): the live shared offset, so a page that
|
|
560
|
+
* mounts after a scroll appears there rather than snapping from the default. */
|
|
561
|
+
initialScrollY: number;
|
|
562
|
+
/** Record the rested vertical offset (px) so later-mounted pages seed from it. */
|
|
563
|
+
onSettleOffset: (y: number) => void;
|
|
564
|
+
weekStartsOn: WeekStartsOn;
|
|
565
|
+
weekEndsOn?: WeekStartsOn;
|
|
566
|
+
/** Full grid width (hour gutter + day columns). Comes from the container, not the window. */
|
|
567
|
+
width: number;
|
|
568
|
+
hourColumnWidth: number;
|
|
569
|
+
minHour: number;
|
|
570
|
+
maxHour: number;
|
|
571
|
+
ampm: boolean;
|
|
572
|
+
timeslots: number;
|
|
573
|
+
isRTL: boolean;
|
|
574
|
+
showAllDayEventCell: boolean;
|
|
575
|
+
showVerticalScrollIndicator: boolean;
|
|
576
|
+
verticalScrollEnabled: boolean;
|
|
577
|
+
hourComponent?: HourRenderer;
|
|
578
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
579
|
+
businessHours?: BusinessHours;
|
|
580
|
+
minHourHeight: number;
|
|
581
|
+
maxHourHeight: number;
|
|
582
|
+
showNowIndicator: boolean;
|
|
583
|
+
renderEvent: RenderEvent<T>;
|
|
584
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
585
|
+
snapMinutes: number;
|
|
586
|
+
showDragHandle: boolean;
|
|
587
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
588
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
589
|
+
onDragEvent?: EventDragHandler<T>;
|
|
590
|
+
onDragStart?: EventDragStartHandler<T>;
|
|
591
|
+
onPressCell?: (date: Date) => void;
|
|
592
|
+
onLongPressCell?: (date: Date) => void;
|
|
593
|
+
onCreateEvent?: (start: Date, end: Date) => void;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// A single date's grid: the pinch-zoomable, vertically-scrolling time column.
|
|
597
|
+
// Three of these are mounted side by side inside the pager so the previous and
|
|
598
|
+
// next dates are ready to drag into view.
|
|
599
|
+
function TimetablePageInner<T>({
|
|
600
|
+
mode,
|
|
601
|
+
numberOfDays,
|
|
602
|
+
date,
|
|
603
|
+
events,
|
|
604
|
+
cellHeight,
|
|
605
|
+
hourHeight,
|
|
606
|
+
committedCellHeight,
|
|
607
|
+
scrollY,
|
|
608
|
+
isActive,
|
|
609
|
+
initialScrollY,
|
|
610
|
+
onSettleOffset,
|
|
611
|
+
weekStartsOn,
|
|
612
|
+
weekEndsOn,
|
|
613
|
+
width,
|
|
614
|
+
hourColumnWidth,
|
|
615
|
+
minHour,
|
|
616
|
+
maxHour,
|
|
617
|
+
ampm,
|
|
618
|
+
timeslots,
|
|
619
|
+
isRTL,
|
|
620
|
+
showAllDayEventCell,
|
|
621
|
+
showVerticalScrollIndicator,
|
|
622
|
+
verticalScrollEnabled,
|
|
623
|
+
hourComponent,
|
|
624
|
+
calendarCellStyle,
|
|
625
|
+
minHourHeight,
|
|
626
|
+
maxHourHeight,
|
|
627
|
+
showNowIndicator,
|
|
628
|
+
businessHours,
|
|
629
|
+
renderEvent,
|
|
630
|
+
keyExtractor,
|
|
631
|
+
snapMinutes,
|
|
632
|
+
showDragHandle,
|
|
633
|
+
onPressEvent,
|
|
634
|
+
onLongPressEvent,
|
|
635
|
+
onDragEvent,
|
|
636
|
+
onDragStart,
|
|
637
|
+
onPressCell,
|
|
638
|
+
onLongPressCell,
|
|
639
|
+
onCreateEvent,
|
|
640
|
+
}: TimetablePageProps<T>) {
|
|
641
|
+
const theme = useCalendarTheme();
|
|
642
|
+
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
643
|
+
|
|
644
|
+
// The visible page tracks the live cellHeight (animates every pinch frame);
|
|
645
|
+
// off-screen pages track committedCellHeight (settles once per gesture).
|
|
646
|
+
const heightSource = isActive ? cellHeight : committedCellHeight;
|
|
647
|
+
|
|
648
|
+
// Keep every page locked to the same vertical scroll position so the prev/next
|
|
649
|
+
// pages are already aligned before they drag into view — no post-swipe jump.
|
|
650
|
+
// Only a genuine user drag (and its momentum) updates the shared offset. The
|
|
651
|
+
// contentOffset seed and the programmatic scrolls that fire during paging also
|
|
652
|
+
// emit onScroll events; letting those write `scrollY` could broadcast a transient
|
|
653
|
+
// 0 to every page (the random "snaps to midnight" behaviour), so they're ignored.
|
|
654
|
+
const isDragging = useSharedValue(false);
|
|
655
|
+
// Mirror `isActive` to a shared value so the scroll worklet reads the *current*
|
|
656
|
+
// active state. A plain `isActive` captured in the worklet goes stale, so only the
|
|
657
|
+
// page active when the handler was first created would record its scrolls — which
|
|
658
|
+
// is why a position set on a later page was lost and an earlier one came back.
|
|
659
|
+
const isActiveShared = useSharedValue(isActive);
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
isActiveShared.value = isActive;
|
|
662
|
+
}, [isActive, isActiveShared]);
|
|
663
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
664
|
+
onScroll: (event) => {
|
|
665
|
+
// Native captures the active page's scroll on a real drag. (Web is handled by
|
|
666
|
+
// the container-level scroll listener in TimeGridInner, because LegendList
|
|
667
|
+
// recycles the page containers there and per-page `isActive` can't reliably
|
|
668
|
+
// tell which DOM node the user is actually scrolling.)
|
|
669
|
+
if (isActiveShared.value && isDragging.value) {
|
|
670
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
671
|
+
scrollY.value = event.contentOffset.y;
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
onBeginDrag: () => {
|
|
675
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
676
|
+
isDragging.value = true;
|
|
677
|
+
},
|
|
678
|
+
onEndDrag: (event) => {
|
|
679
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
680
|
+
isDragging.value = false;
|
|
681
|
+
// Capture the rested position. The mid-drag onScroll above stops at the
|
|
682
|
+
// finger-lift point; without this, the momentum tail after it is lost and the
|
|
683
|
+
// saved offset falls short of where the page actually settles.
|
|
684
|
+
if (!isWeb && isActiveShared.value) {
|
|
685
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
686
|
+
scrollY.value = event.contentOffset.y;
|
|
687
|
+
runOnJS(onSettleOffset)(event.contentOffset.y);
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
onMomentumEnd: (event) => {
|
|
691
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
692
|
+
isDragging.value = false;
|
|
693
|
+
if (!isWeb && isActiveShared.value) {
|
|
694
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
695
|
+
scrollY.value = event.contentOffset.y;
|
|
696
|
+
runOnJS(onSettleOffset)(event.contentOffset.y);
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Keep *inactive* pages aligned to the shared offset as it changes, so they're
|
|
702
|
+
// already in position before they drag into view. Reads `isActiveShared` (not the
|
|
703
|
+
// captured `isActive`) so the worklet sees the current state.
|
|
704
|
+
useAnimatedReaction(
|
|
705
|
+
() => scrollY.value,
|
|
706
|
+
(current, previous) => {
|
|
707
|
+
if (!isActiveShared.value && current !== previous) {
|
|
708
|
+
scrollTo(scrollRef, 0, current, false);
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
// When a page becomes the active one (paged or jumped to), align it to the shared
|
|
714
|
+
// offset. The reaction above only syncs *inactive* pages, and only when `scrollY`
|
|
715
|
+
// changes, so a page that paged in after the last change (the third page on from a
|
|
716
|
+
// drag, say) would keep its seeded default. Uses reanimated's `scrollTo` worklet:
|
|
717
|
+
// the imperative `scrollRef.current.scrollTo()` does not take effect on a
|
|
718
|
+
// useAnimatedRef, which is why that page stayed at the default. Native only; web is
|
|
719
|
+
// handled by the container-level effect in TimeGridInner.
|
|
720
|
+
useAnimatedReaction(
|
|
721
|
+
() => isActiveShared.value,
|
|
722
|
+
(active, previous) => {
|
|
723
|
+
if (isWeb || !active || active === previous) return;
|
|
724
|
+
scrollTo(scrollRef, 0, scrollY.value, false);
|
|
725
|
+
},
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
const days = useMemo(
|
|
729
|
+
() => getViewDays(mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
|
|
730
|
+
[mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
734
|
+
const dayLeft = (dayIndex: number) => hourColumnWidth + dayIndex * dayWidth;
|
|
735
|
+
|
|
736
|
+
const dayLayouts = useMemo(() => days.map((day) => layoutDayEvents(events, day)), [days, events]);
|
|
737
|
+
|
|
738
|
+
// Map a tap on empty grid space back to the date+time it represents. Reads the
|
|
739
|
+
// live row height on the JS thread to convert the touch Y into minutes.
|
|
740
|
+
const cellDateFromTouch = (event: GestureResponderEvent): Date | null => {
|
|
741
|
+
const { locationX, locationY } = event.nativeEvent;
|
|
742
|
+
const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
|
|
743
|
+
const day = days[dayIndex];
|
|
744
|
+
if (!day) return null;
|
|
745
|
+
const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
|
|
746
|
+
const pressed = new Date(day);
|
|
747
|
+
pressed.setHours(0, 0, 0, 0);
|
|
748
|
+
pressed.setMinutes(minutes);
|
|
749
|
+
return pressed;
|
|
750
|
+
};
|
|
751
|
+
const handleBackgroundPress = (event: GestureResponderEvent) => {
|
|
752
|
+
const date = onPressCell && cellDateFromTouch(event);
|
|
753
|
+
if (date) onPressCell?.(date);
|
|
754
|
+
};
|
|
755
|
+
const handleBackgroundLongPress = (event: GestureResponderEvent) => {
|
|
756
|
+
const date = onLongPressCell && cellDateFromTouch(event);
|
|
757
|
+
if (date) onLongPressCell?.(date);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// The hours (rows/labels) visible in the window [minHour, maxHour).
|
|
761
|
+
const hoursRange = useMemo(
|
|
762
|
+
() => Array.from({ length: maxHour - minHour }, (_, index) => minHour + index),
|
|
763
|
+
[minHour, maxHour],
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
const now = useNow(showNowIndicator && isActive);
|
|
767
|
+
const nowDayIndex = days.findIndex((day) => getIsToday(day));
|
|
768
|
+
const nowHours = (getHours(now) * MINUTES_PER_HOUR + getMinutes(now)) / MINUTES_PER_HOUR;
|
|
769
|
+
const nowInWindow = nowHours >= minHour && nowHours <= maxHour;
|
|
770
|
+
|
|
771
|
+
const fullHeightStyle = useAnimatedStyle(
|
|
772
|
+
() => ({ height: (maxHour - minHour) * heightSource.value }),
|
|
773
|
+
[minHour, maxHour, heightSource],
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
// Capture the row height when the pinch starts and apply `event.scale`
|
|
777
|
+
// (relative to that start) rather than multiplying per-frame deltas — deltas
|
|
778
|
+
// compound float error and the zoom never settles on a clean level.
|
|
779
|
+
const pinchStartCellHeight = useSharedValue(hourHeight);
|
|
780
|
+
const zoomGesture = useMemo(() => {
|
|
781
|
+
const pinch = Gesture.Pinch()
|
|
782
|
+
.onStart(() => {
|
|
783
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
784
|
+
pinchStartCellHeight.value = cellHeight.value;
|
|
785
|
+
})
|
|
786
|
+
.onUpdate((event) => {
|
|
787
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
788
|
+
cellHeight.value = Math.min(
|
|
789
|
+
maxHourHeight,
|
|
790
|
+
Math.max(minHourHeight, pinchStartCellHeight.value * event.scale),
|
|
791
|
+
);
|
|
792
|
+
})
|
|
793
|
+
.onEnd(() => {
|
|
794
|
+
// Publish the final zoom to the off-screen pages in one update.
|
|
795
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
796
|
+
committedCellHeight.value = cellHeight.value;
|
|
797
|
+
});
|
|
798
|
+
// Recognise the pinch and the ScrollView's native scroll together so the
|
|
799
|
+
// scroll never cancels an in-progress zoom.
|
|
800
|
+
return Gesture.Simultaneous(pinch, Gesture.Native());
|
|
801
|
+
}, [cellHeight, committedCellHeight, pinchStartCellHeight, minHourHeight, maxHourHeight]);
|
|
802
|
+
|
|
803
|
+
// Drag-to-create: sweep out a new event on empty grid. Native long-presses
|
|
804
|
+
// first (so a tap/scroll isn't hijacked); web uses a drag threshold like move,
|
|
805
|
+
// so a tap still creates a point via onPressCell and the wheel still scrolls
|
|
806
|
+
// (dragging empty space creates instead of scrolling, as on desktop calendars).
|
|
807
|
+
const createEnabled = onCreateEvent != null;
|
|
808
|
+
// Live ghost-box geometry (px), driven on the UI thread during the sweep.
|
|
809
|
+
const createActive = useSharedValue(0);
|
|
810
|
+
const createTop = useSharedValue(0);
|
|
811
|
+
const createHeight = useSharedValue(0);
|
|
812
|
+
const createLeft = useSharedValue(0);
|
|
813
|
+
const createWidth = useSharedValue(0);
|
|
814
|
+
const createStartY = useSharedValue(0);
|
|
815
|
+
const createDayIndex = useSharedValue(0);
|
|
816
|
+
// Set when the sweep is cancelled mid-drag (Escape on web); the gesture then
|
|
817
|
+
// bails instead of committing.
|
|
818
|
+
const createCancelled = useSharedValue(0);
|
|
819
|
+
|
|
820
|
+
const commitCreate = useCallback(
|
|
821
|
+
(startY: number, endY: number, dayIndex: number) => {
|
|
822
|
+
const day = days[dayIndex];
|
|
823
|
+
if (!day) return;
|
|
824
|
+
const range = cellRangeFromDrag(day, startY, endY, heightSource.value, minHour, snapMinutes);
|
|
825
|
+
if (range) onCreateEvent?.(range.start, range.end);
|
|
826
|
+
},
|
|
827
|
+
[days, heightSource, minHour, snapMinutes, onCreateEvent],
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
// Web taps fall through to here: react-native-web doesn't fire the background
|
|
831
|
+
// Pressable's onPress, so a click on empty space reports the cell via a Tap
|
|
832
|
+
// gesture instead. Mirrors cellDateFromTouch.
|
|
833
|
+
const tapCell = useCallback(
|
|
834
|
+
(x: number, y: number) => {
|
|
835
|
+
const dayIndex = days.length === 1 ? 0 : Math.floor(x / dayWidth);
|
|
836
|
+
const day = days[dayIndex];
|
|
837
|
+
if (!day) return;
|
|
838
|
+
const minutes = Math.round((minHour + y / heightSource.value) * MINUTES_PER_HOUR);
|
|
839
|
+
const pressed = new Date(day);
|
|
840
|
+
pressed.setHours(0, 0, 0, 0);
|
|
841
|
+
pressed.setMinutes(minutes);
|
|
842
|
+
onPressCell?.(pressed);
|
|
843
|
+
},
|
|
844
|
+
[days, dayWidth, minHour, heightSource, onPressCell],
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
const createGesture = useMemo(() => {
|
|
848
|
+
const pan = Gesture.Pan()
|
|
849
|
+
.enabled(createEnabled)
|
|
850
|
+
.onStart((event) => {
|
|
851
|
+
const idx = days.length === 1 ? 0 : Math.floor(event.x / dayWidth);
|
|
852
|
+
createDayIndex.value = idx;
|
|
853
|
+
createStartY.value = event.y;
|
|
854
|
+
createLeft.value = hourColumnWidth + idx * dayWidth;
|
|
855
|
+
createWidth.value = dayWidth;
|
|
856
|
+
createTop.value = event.y;
|
|
857
|
+
createHeight.value = 0;
|
|
858
|
+
createActive.value = 1;
|
|
859
|
+
createCancelled.value = 0;
|
|
860
|
+
})
|
|
861
|
+
.onUpdate((event) => {
|
|
862
|
+
if (createCancelled.value) return;
|
|
863
|
+
const stepPx = (snapMinutes / MINUTES_PER_HOUR) * heightSource.value;
|
|
864
|
+
const snap = (y: number) => (stepPx > 0 ? Math.round(y / stepPx) * stepPx : y);
|
|
865
|
+
const startSnap = snap(createStartY.value);
|
|
866
|
+
const endSnap = snap(createStartY.value + event.translationY);
|
|
867
|
+
createTop.value = Math.min(startSnap, endSnap);
|
|
868
|
+
createHeight.value = Math.max(Math.abs(endSnap - startSnap), stepPx);
|
|
869
|
+
})
|
|
870
|
+
.onEnd((event) => {
|
|
871
|
+
createActive.value = 0;
|
|
872
|
+
createHeight.value = 0;
|
|
873
|
+
if (createCancelled.value) return; // Escape pressed mid-sweep
|
|
874
|
+
runOnJS(commitCreate)(
|
|
875
|
+
createStartY.value,
|
|
876
|
+
createStartY.value + event.translationY,
|
|
877
|
+
createDayIndex.value,
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
// Native: hold to start. Web: activate past a small vertical drag so a plain
|
|
881
|
+
// tap still falls through to onPressCell.
|
|
882
|
+
return isWeb
|
|
883
|
+
? pan.activeOffsetY([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
|
|
884
|
+
: pan.activateAfterLongPress(DRAG_ACTIVATE_MS);
|
|
885
|
+
}, [
|
|
886
|
+
createEnabled,
|
|
887
|
+
days.length,
|
|
888
|
+
dayWidth,
|
|
889
|
+
hourColumnWidth,
|
|
890
|
+
heightSource,
|
|
891
|
+
snapMinutes,
|
|
892
|
+
commitCreate,
|
|
893
|
+
createActive,
|
|
894
|
+
createTop,
|
|
895
|
+
createHeight,
|
|
896
|
+
createLeft,
|
|
897
|
+
createWidth,
|
|
898
|
+
createStartY,
|
|
899
|
+
createDayIndex,
|
|
900
|
+
createCancelled,
|
|
901
|
+
]);
|
|
902
|
+
|
|
903
|
+
// Compose with a Tap so a plain web click reports the cell (onPressCell);
|
|
904
|
+
// Exclusive gives the drag-to-create priority, so a real drag still creates.
|
|
905
|
+
const backgroundGesture = useMemo(() => {
|
|
906
|
+
const tap =
|
|
907
|
+
isWeb && onPressCell != null
|
|
908
|
+
? Gesture.Tap().onEnd((event) => {
|
|
909
|
+
runOnJS(tapCell)(event.x, event.y);
|
|
910
|
+
})
|
|
911
|
+
: null;
|
|
912
|
+
if (createEnabled && tap) return Gesture.Exclusive(createGesture, tap);
|
|
913
|
+
if (createEnabled) return createGesture;
|
|
914
|
+
return tap;
|
|
915
|
+
}, [createEnabled, createGesture, onPressCell, tapCell]);
|
|
916
|
+
|
|
917
|
+
// Web: Escape cancels an in-progress sweep before it commits.
|
|
918
|
+
useEffect(() => {
|
|
919
|
+
if (!isWeb || !createEnabled) return;
|
|
920
|
+
const doc = (globalThis as { document?: WebKeyTarget }).document;
|
|
921
|
+
if (!doc) return;
|
|
922
|
+
const handler = (event: WebKeyEvent) => {
|
|
923
|
+
if (event.key !== "Escape" || !createActive.value) return;
|
|
924
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
925
|
+
createCancelled.value = 1;
|
|
926
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
927
|
+
createActive.value = 0;
|
|
928
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
929
|
+
createHeight.value = 0;
|
|
930
|
+
};
|
|
931
|
+
doc.addEventListener("keydown", handler);
|
|
932
|
+
return () => doc.removeEventListener("keydown", handler);
|
|
933
|
+
}, [createEnabled, createActive, createCancelled, createHeight]);
|
|
934
|
+
|
|
935
|
+
const createGhostStyle = useAnimatedStyle(() => ({
|
|
936
|
+
top: createTop.value,
|
|
937
|
+
height: createHeight.value,
|
|
938
|
+
left: createLeft.value,
|
|
939
|
+
width: createWidth.value,
|
|
940
|
+
opacity: createActive.value,
|
|
941
|
+
}));
|
|
942
|
+
|
|
943
|
+
// The tap/long-press layer behind events; wrapped in the create gesture when
|
|
944
|
+
// drag-to-create is on. Hidden from screen readers (a convenience gesture).
|
|
945
|
+
const cellLayer =
|
|
946
|
+
onPressCell || onLongPressCell || createEnabled ? (
|
|
947
|
+
<Pressable
|
|
948
|
+
style={[styles.cellPressLayer, { left: hourColumnWidth }]}
|
|
949
|
+
onPress={onPressCell ? handleBackgroundPress : undefined}
|
|
950
|
+
// When create is on, a long-press starts the create-drag, so don't also
|
|
951
|
+
// fire the consumer's long-press handler.
|
|
952
|
+
onLongPress={!createEnabled && onLongPressCell ? handleBackgroundLongPress : undefined}
|
|
953
|
+
// Pointer-only create surface: drag to sweep out an event. On web it's
|
|
954
|
+
// deliberately not a tab stop (react-native-web makes a Pressable focusable
|
|
955
|
+
// by default), so keyboard focus moves through events only — matching dom.
|
|
956
|
+
tabIndex={-1}
|
|
957
|
+
importantForAccessibility="no"
|
|
958
|
+
accessibilityElementsHidden
|
|
959
|
+
/>
|
|
960
|
+
) : null;
|
|
961
|
+
|
|
962
|
+
return (
|
|
963
|
+
<View style={styles.container}>
|
|
964
|
+
{showAllDayEventCell ? (
|
|
965
|
+
<AllDayLane
|
|
966
|
+
days={days}
|
|
967
|
+
events={events}
|
|
968
|
+
mode={mode}
|
|
969
|
+
hourColumnWidth={hourColumnWidth}
|
|
970
|
+
dayWidth={dayWidth}
|
|
971
|
+
renderEvent={renderEvent}
|
|
972
|
+
keyExtractor={keyExtractor}
|
|
973
|
+
onPressEvent={onPressEvent}
|
|
974
|
+
onLongPressEvent={onLongPressEvent}
|
|
975
|
+
/>
|
|
976
|
+
) : null}
|
|
977
|
+
<GestureDetector gesture={zoomGesture}>
|
|
978
|
+
<Animated.ScrollView
|
|
979
|
+
ref={scrollRef}
|
|
980
|
+
showsVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
981
|
+
scrollEnabled={verticalScrollEnabled}
|
|
982
|
+
onScroll={scrollHandler}
|
|
983
|
+
scrollEventThrottle={16}
|
|
984
|
+
contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
|
|
985
|
+
contentOffset={{ x: 0, y: initialScrollY }}
|
|
986
|
+
>
|
|
987
|
+
<Animated.View style={[styles.content, fullHeightStyle]}>
|
|
988
|
+
{/* Behind the events, so empty-space taps/drags create while event
|
|
989
|
+
taps still hit their box. */}
|
|
990
|
+
{cellLayer && backgroundGesture ? (
|
|
991
|
+
<GestureDetector gesture={backgroundGesture}>{cellLayer}</GestureDetector>
|
|
992
|
+
) : (
|
|
993
|
+
cellLayer
|
|
994
|
+
)}
|
|
995
|
+
|
|
996
|
+
{days.map((day, dayIndex) =>
|
|
997
|
+
isWeekend(day) ? (
|
|
998
|
+
<Animated.View
|
|
999
|
+
key={`weekend-${day.toISOString()}`}
|
|
1000
|
+
style={[
|
|
1001
|
+
styles.weekendColumn,
|
|
1002
|
+
styles.nonInteractive,
|
|
1003
|
+
{ backgroundColor: theme.colors.weekendBackground },
|
|
1004
|
+
{ left: dayLeft(dayIndex), width: dayWidth },
|
|
1005
|
+
fullHeightStyle,
|
|
1006
|
+
]}
|
|
1007
|
+
/>
|
|
1008
|
+
) : null,
|
|
1009
|
+
)}
|
|
1010
|
+
|
|
1011
|
+
{calendarCellStyle
|
|
1012
|
+
? days.map((day, dayIndex) => {
|
|
1013
|
+
const cellStyle = calendarCellStyle(day);
|
|
1014
|
+
return cellStyle ? (
|
|
1015
|
+
<Animated.View
|
|
1016
|
+
key={`cell-${day.toISOString()}`}
|
|
1017
|
+
style={[
|
|
1018
|
+
styles.weekendColumn,
|
|
1019
|
+
styles.nonInteractive,
|
|
1020
|
+
{ left: dayLeft(dayIndex), width: dayWidth },
|
|
1021
|
+
cellStyle,
|
|
1022
|
+
fullHeightStyle,
|
|
1023
|
+
]}
|
|
1024
|
+
/>
|
|
1025
|
+
) : null;
|
|
1026
|
+
})
|
|
1027
|
+
: null}
|
|
1028
|
+
|
|
1029
|
+
{businessHours
|
|
1030
|
+
? days.flatMap((day, dayIndex) =>
|
|
1031
|
+
closedHourBands(day, businessHours, minHour, maxHour).map((band, bandIndex) => (
|
|
1032
|
+
<ShadeBand
|
|
1033
|
+
key={`closed-${day.toISOString()}-${bandIndex}`}
|
|
1034
|
+
cellHeight={heightSource}
|
|
1035
|
+
startHour={band.start}
|
|
1036
|
+
endHour={band.end}
|
|
1037
|
+
minHour={minHour}
|
|
1038
|
+
left={dayLeft(dayIndex)}
|
|
1039
|
+
width={dayWidth}
|
|
1040
|
+
color={theme.colors.outsideHoursBackground}
|
|
1041
|
+
/>
|
|
1042
|
+
)),
|
|
1043
|
+
)
|
|
1044
|
+
: null}
|
|
1045
|
+
|
|
1046
|
+
{days.map((day, dayIndex) => (
|
|
1047
|
+
<Animated.View
|
|
1048
|
+
key={`separator-${day.toISOString()}`}
|
|
1049
|
+
style={[
|
|
1050
|
+
styles.daySeparator,
|
|
1051
|
+
styles.nonInteractive,
|
|
1052
|
+
{ backgroundColor: theme.colors.gridLine },
|
|
1053
|
+
{ left: dayLeft(dayIndex) },
|
|
1054
|
+
fullHeightStyle,
|
|
1055
|
+
]}
|
|
1056
|
+
/>
|
|
1057
|
+
))}
|
|
1058
|
+
|
|
1059
|
+
{hoursRange.map((hour) => (
|
|
1060
|
+
<HourRow
|
|
1061
|
+
key={hour}
|
|
1062
|
+
hour={hour}
|
|
1063
|
+
minHour={minHour}
|
|
1064
|
+
cellHeight={heightSource}
|
|
1065
|
+
hourColumnWidth={hourColumnWidth}
|
|
1066
|
+
label={formatHour(hour, { ampm })}
|
|
1067
|
+
ampm={ampm}
|
|
1068
|
+
hourComponent={hourComponent}
|
|
1069
|
+
/>
|
|
1070
|
+
))}
|
|
1071
|
+
|
|
1072
|
+
{timeslots > 1
|
|
1073
|
+
? hoursRange.flatMap((hour) =>
|
|
1074
|
+
Array.from({ length: timeslots - 1 }, (_, i) => (
|
|
1075
|
+
<TimeslotLine
|
|
1076
|
+
key={`slot-${hour}-${i}`}
|
|
1077
|
+
hour={hour}
|
|
1078
|
+
minHour={minHour}
|
|
1079
|
+
fraction={(i + 1) / timeslots}
|
|
1080
|
+
cellHeight={heightSource}
|
|
1081
|
+
hourColumnWidth={hourColumnWidth}
|
|
1082
|
+
/>
|
|
1083
|
+
)),
|
|
1084
|
+
)
|
|
1085
|
+
: null}
|
|
1086
|
+
|
|
1087
|
+
{dayLayouts.flatMap((layout, dayIndex) =>
|
|
1088
|
+
layout
|
|
1089
|
+
// Skip events that fall entirely outside the [minHour, maxHour) window.
|
|
1090
|
+
.filter((p) => p.startHours < maxHour && p.startHours + p.durationHours > minHour)
|
|
1091
|
+
.map((positioned, eventIndex) => {
|
|
1092
|
+
const columnWidth = dayWidth / positioned.columns;
|
|
1093
|
+
return (
|
|
1094
|
+
<AnimatedEventBox
|
|
1095
|
+
// Prefix with the day so a multi-day event's per-day segments
|
|
1096
|
+
// (which share the same event key) stay unique across the
|
|
1097
|
+
// flattened list of all days' boxes.
|
|
1098
|
+
key={`${dayIndex}:${keyExtractor(positioned.event, eventIndex)}`}
|
|
1099
|
+
positioned={positioned}
|
|
1100
|
+
cellHeight={heightSource}
|
|
1101
|
+
minHour={minHour}
|
|
1102
|
+
left={dayLeft(dayIndex) + positioned.column * columnWidth}
|
|
1103
|
+
width={columnWidth}
|
|
1104
|
+
dayWidth={dayWidth}
|
|
1105
|
+
dayIndex={dayIndex}
|
|
1106
|
+
dayCount={days.length}
|
|
1107
|
+
mode={mode}
|
|
1108
|
+
renderEvent={renderEvent}
|
|
1109
|
+
snapMinutes={snapMinutes}
|
|
1110
|
+
showDragHandle={showDragHandle}
|
|
1111
|
+
onPress={onPressEvent}
|
|
1112
|
+
onLongPress={onLongPressEvent}
|
|
1113
|
+
onDragEvent={onDragEvent}
|
|
1114
|
+
onDragStart={onDragStart}
|
|
1115
|
+
/>
|
|
1116
|
+
);
|
|
1117
|
+
}),
|
|
1118
|
+
)}
|
|
1119
|
+
|
|
1120
|
+
{showNowIndicator && nowDayIndex >= 0 && nowInWindow ? (
|
|
1121
|
+
<NowIndicator
|
|
1122
|
+
cellHeight={heightSource}
|
|
1123
|
+
nowHours={nowHours}
|
|
1124
|
+
minHour={minHour}
|
|
1125
|
+
left={dayLeft(nowDayIndex)}
|
|
1126
|
+
width={dayWidth}
|
|
1127
|
+
color={theme.colors.nowIndicator}
|
|
1128
|
+
/>
|
|
1129
|
+
) : null}
|
|
1130
|
+
|
|
1131
|
+
{createEnabled ? (
|
|
1132
|
+
<Animated.View
|
|
1133
|
+
style={[
|
|
1134
|
+
styles.createGhost,
|
|
1135
|
+
{ pointerEvents: "none" },
|
|
1136
|
+
{
|
|
1137
|
+
backgroundColor: theme.colors.eventBackground,
|
|
1138
|
+
borderColor: theme.colors.todayBackground,
|
|
1139
|
+
},
|
|
1140
|
+
createGhostStyle,
|
|
1141
|
+
]}
|
|
1142
|
+
/>
|
|
1143
|
+
) : null}
|
|
1144
|
+
</Animated.View>
|
|
1145
|
+
</Animated.ScrollView>
|
|
1146
|
+
</GestureDetector>
|
|
1147
|
+
</View>
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
|
|
1152
|
+
|
|
1153
|
+
export type TimeGridProps<T> = {
|
|
1154
|
+
mode: TimeGridMode;
|
|
1155
|
+
/** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
|
|
1156
|
+
numberOfDays?: number;
|
|
1157
|
+
/**
|
|
1158
|
+
* Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
|
|
1159
|
+
* `weekStartsOn`…`weekEndsOn` of `date`'s week and pages by week, taking
|
|
1160
|
+
* precedence over `numberOfDays`. Ignored by other modes.
|
|
1161
|
+
*/
|
|
1162
|
+
weekEndsOn?: WeekStartsOn;
|
|
1163
|
+
date: Date;
|
|
1164
|
+
events: CalendarEvent<T>[];
|
|
1165
|
+
cellHeight: SharedValue<number>;
|
|
1166
|
+
/** Initial per-hour row height in px; seeds scroll/zoom without reading the shared value during render. */
|
|
1167
|
+
hourHeight?: number;
|
|
1168
|
+
weekStartsOn: WeekStartsOn;
|
|
1169
|
+
renderEvent: RenderEvent<T>;
|
|
1170
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
1171
|
+
scrollOffsetMinutes?: number;
|
|
1172
|
+
hourColumnWidth?: number;
|
|
1173
|
+
/** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
|
|
1174
|
+
hideHours?: boolean;
|
|
1175
|
+
/** Sub-hour divider lines per hour (e.g. 2 = half-hours). Default 1 (none). */
|
|
1176
|
+
timeslots?: number;
|
|
1177
|
+
/** Show the all-day lane above the grid. Default true. */
|
|
1178
|
+
showAllDayEventCell?: boolean;
|
|
1179
|
+
/** Per-date style merged onto each day column. */
|
|
1180
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
1181
|
+
businessHours?: BusinessHours;
|
|
1182
|
+
/** Show the ISO week number in the header gutter. Default false. */
|
|
1183
|
+
showWeekNumber?: boolean;
|
|
1184
|
+
/** Element rendered between the day header and the grid. */
|
|
1185
|
+
headerComponent?: React.ReactNode;
|
|
1186
|
+
/** First hour shown (0–23). Default 0. */
|
|
1187
|
+
minHour?: number;
|
|
1188
|
+
/** Last hour shown, exclusive (1–24). Default 24. */
|
|
1189
|
+
maxHour?: number;
|
|
1190
|
+
/** Show hour labels in 12-hour AM/PM form. Default false (24h). */
|
|
1191
|
+
ampm?: boolean;
|
|
1192
|
+
/** Reverse day-column order (right-to-left). Default false. */
|
|
1193
|
+
isRTL?: boolean;
|
|
1194
|
+
minHourHeight?: number;
|
|
1195
|
+
maxHourHeight?: number;
|
|
1196
|
+
showNowIndicator?: boolean;
|
|
1197
|
+
locale?: Locale;
|
|
1198
|
+
freeSwipe?: boolean;
|
|
1199
|
+
/** Allow swiping between pages. Default true. */
|
|
1200
|
+
swipeEnabled?: boolean;
|
|
1201
|
+
/** Show the vertical scroll indicator on the time grid. Default true. */
|
|
1202
|
+
showVerticalScrollIndicator?: boolean;
|
|
1203
|
+
/** Allow vertical scrolling of the time grid. Default true. */
|
|
1204
|
+
verticalScrollEnabled?: boolean;
|
|
1205
|
+
/** Prefix for the week-number label (e.g. "W"). Default "W". */
|
|
1206
|
+
weekNumberPrefix?: string;
|
|
1207
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and `ampm`. */
|
|
1208
|
+
hourComponent?: HourRenderer;
|
|
1209
|
+
/** Highlight this date in the header instead of the real "today". */
|
|
1210
|
+
activeDate?: Date;
|
|
1211
|
+
/** After an empty-cell press, snap the pager back to the active page. Default false. */
|
|
1212
|
+
resetPageOnPressCell?: boolean;
|
|
1213
|
+
/** Minutes a drag-to-move/resize snaps to. Default 15. */
|
|
1214
|
+
dragStepMinutes?: number;
|
|
1215
|
+
/**
|
|
1216
|
+
* Show the resize grip on draggable events. Default true. Set false to keep
|
|
1217
|
+
* drag-to-move and drag-to-resize working while hiding the visible indicator.
|
|
1218
|
+
*/
|
|
1219
|
+
showDragHandle?: boolean;
|
|
1220
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
1221
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
1222
|
+
/**
|
|
1223
|
+
* Enable drag-to-move and drag-to-resize on the week/day grid. Called with the
|
|
1224
|
+
* event's new start/end (snapped to `dragStepMinutes`); update your own state.
|
|
1225
|
+
*/
|
|
1226
|
+
onDragEvent?: EventDragHandler<T>;
|
|
1227
|
+
/** Fired the moment an event is grabbed for a move or resize (e.g. for haptics). */
|
|
1228
|
+
onDragStart?: EventDragStartHandler<T>;
|
|
1229
|
+
onPressCell?: (date: Date) => void;
|
|
1230
|
+
onLongPressCell?: (date: Date) => void;
|
|
1231
|
+
onCreateEvent?: (start: Date, end: Date) => void;
|
|
1232
|
+
/** Tap a day's column header (default header only). */
|
|
1233
|
+
onPressDateHeader?: (date: Date) => void;
|
|
1234
|
+
onChangeDate: (date: Date) => void;
|
|
1235
|
+
/** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
|
|
1236
|
+
renderHeader?: (days: Date[]) => React.ReactNode;
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
function TimeGridInner<T>({
|
|
1240
|
+
mode,
|
|
1241
|
+
numberOfDays = 1,
|
|
1242
|
+
weekEndsOn,
|
|
1243
|
+
date,
|
|
1244
|
+
events,
|
|
1245
|
+
cellHeight,
|
|
1246
|
+
hourHeight = DEFAULT_HOUR_HEIGHT,
|
|
1247
|
+
weekStartsOn,
|
|
1248
|
+
renderEvent,
|
|
1249
|
+
keyExtractor,
|
|
1250
|
+
scrollOffsetMinutes = 0,
|
|
1251
|
+
hourColumnWidth: hourColumnWidthProp = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
1252
|
+
hideHours = false,
|
|
1253
|
+
timeslots = 1,
|
|
1254
|
+
showAllDayEventCell = true,
|
|
1255
|
+
calendarCellStyle,
|
|
1256
|
+
businessHours,
|
|
1257
|
+
showWeekNumber = false,
|
|
1258
|
+
headerComponent,
|
|
1259
|
+
minHour = 0,
|
|
1260
|
+
maxHour = HOURS_PER_DAY,
|
|
1261
|
+
ampm = false,
|
|
1262
|
+
isRTL = false,
|
|
1263
|
+
minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
|
|
1264
|
+
maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
|
|
1265
|
+
showNowIndicator = true,
|
|
1266
|
+
locale,
|
|
1267
|
+
freeSwipe = false,
|
|
1268
|
+
swipeEnabled = true,
|
|
1269
|
+
showVerticalScrollIndicator = true,
|
|
1270
|
+
verticalScrollEnabled = true,
|
|
1271
|
+
weekNumberPrefix = "W",
|
|
1272
|
+
hourComponent,
|
|
1273
|
+
activeDate,
|
|
1274
|
+
resetPageOnPressCell = false,
|
|
1275
|
+
dragStepMinutes = DEFAULT_DRAG_STEP_MINUTES,
|
|
1276
|
+
showDragHandle = true,
|
|
1277
|
+
onPressEvent,
|
|
1278
|
+
onLongPressEvent,
|
|
1279
|
+
onDragEvent,
|
|
1280
|
+
onDragStart,
|
|
1281
|
+
onPressCell,
|
|
1282
|
+
onLongPressCell,
|
|
1283
|
+
onCreateEvent,
|
|
1284
|
+
onPressDateHeader,
|
|
1285
|
+
onChangeDate,
|
|
1286
|
+
renderHeader,
|
|
1287
|
+
}: TimeGridProps<T>) {
|
|
1288
|
+
// Guard against an inverted/out-of-range window so the grid never collapses.
|
|
1289
|
+
const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
|
|
1290
|
+
const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
|
|
1291
|
+
// Collapse the hour gutter to zero when hours are hidden.
|
|
1292
|
+
const hourColumnWidth = hideHours ? 0 : hourColumnWidthProp;
|
|
1293
|
+
|
|
1294
|
+
const { width, height } = useWindowDimensions();
|
|
1295
|
+
const listRef = useRef<LegendListRef>(null);
|
|
1296
|
+
// The grid's outer view; on web its ref resolves to the DOM node we attach the
|
|
1297
|
+
// Ctrl/Cmd + scroll zoom listener to.
|
|
1298
|
+
const containerRef = useRef<View>(null);
|
|
1299
|
+
// Web: ignore scroll events until this time (ms). Set around a page change so the
|
|
1300
|
+
// recycle-reset and the offset-restore aren't mistaken for a user scroll.
|
|
1301
|
+
const suppressCaptureUntilRef = useRef(0);
|
|
1302
|
+
// Horizontal list items need an explicit cross-axis height; seed it with the
|
|
1303
|
+
// window height (so it renders immediately and in tests) and refine on layout.
|
|
1304
|
+
const [pageHeight, setPageHeight] = useState(height);
|
|
1305
|
+
// The grid sizes to its container width, not the window, so it fits a
|
|
1306
|
+
// constrained layout on the web (e.g. a max-width card). On native the grid
|
|
1307
|
+
// fills the window, so this equals the window width and behaviour is unchanged.
|
|
1308
|
+
// Seeded with the window width for the first paint, refined on layout.
|
|
1309
|
+
const [containerWidth, setContainerWidth] = useState(width);
|
|
1310
|
+
// The list must remount exactly once — when the real height replaces the
|
|
1311
|
+
// window-height seed — or it keeps the oversized seed and clips. It must NOT
|
|
1312
|
+
// remount on later height changes (e.g. a taller day header vs a shorter week
|
|
1313
|
+
// header on a mode switch): a remount blanks the visible page for a frame.
|
|
1314
|
+
const [measured, setMeasured] = useState(false);
|
|
1315
|
+
// Week-anchored modes page by a full week and align pages to the week start:
|
|
1316
|
+
// `week`, and `custom` when a `weekEndsOn` defines a partial-week span.
|
|
1317
|
+
const weekAnchored = mode === "week" || (mode === "custom" && weekEndsOn != null);
|
|
1318
|
+
// Days advanced per page: a full week when week-anchored, else the column count.
|
|
1319
|
+
const step = weekAnchored ? 7 : viewDayCount(mode, numberOfDays);
|
|
1320
|
+
// Shared vertical scroll offset so every mounted page stays aligned. Seeded
|
|
1321
|
+
// from the numeric hourHeight rather than reading cellHeight.value (which
|
|
1322
|
+
// would warn about reading a shared value during render).
|
|
1323
|
+
const seedDefaultY =
|
|
1324
|
+
Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - clampedMinHour) * hourHeight;
|
|
1325
|
+
const scrollY = useSharedValue(seedDefaultY);
|
|
1326
|
+
// Plain mirror of the last settled vertical offset. A page that mounts after a
|
|
1327
|
+
// scroll seeds its contentOffset here instead of the default, so it appears at the
|
|
1328
|
+
// saved time straight away rather than rendering at the default and snapping (the
|
|
1329
|
+
// one-frame flash). Null until the first scroll, when `seedDefaultY` is used.
|
|
1330
|
+
const offsetSeedRef = useRef<number | null>(null);
|
|
1331
|
+
const captureOffsetSeed = useCallback((y: number) => {
|
|
1332
|
+
offsetSeedRef.current = y;
|
|
1333
|
+
}, []);
|
|
1334
|
+
// Zoom committed at the end of the last pinch; off-screen pages animate off
|
|
1335
|
+
// this so they don't re-run their worklets every frame while the visible page
|
|
1336
|
+
// zooms.
|
|
1337
|
+
const committedCellHeight = useSharedValue(hourHeight);
|
|
1338
|
+
|
|
1339
|
+
// Web stand-in for pinch: Ctrl/Cmd + scroll zooms the grid via the same shared
|
|
1340
|
+
// values the pinch gesture drives.
|
|
1341
|
+
useWebGridZoom(
|
|
1342
|
+
isWeb,
|
|
1343
|
+
containerRef,
|
|
1344
|
+
cellHeight,
|
|
1345
|
+
committedCellHeight,
|
|
1346
|
+
minHourHeight,
|
|
1347
|
+
maxHourHeight,
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
// A fixed window of page dates, anchored once and aligned to the page boundary
|
|
1351
|
+
// (day or week start). The array never shifts as the date changes.
|
|
1352
|
+
const [anchorDate] = useState(date);
|
|
1353
|
+
const anchor = useMemo(
|
|
1354
|
+
() => (weekAnchored ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate)),
|
|
1355
|
+
[weekAnchored, anchorDate, weekStartsOn],
|
|
1356
|
+
);
|
|
1357
|
+
const pageDates = useMemo(
|
|
1358
|
+
() =>
|
|
1359
|
+
Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) =>
|
|
1360
|
+
addDays(anchor, (i - PAGE_WINDOW) * step),
|
|
1361
|
+
),
|
|
1362
|
+
[anchor, step],
|
|
1363
|
+
);
|
|
1364
|
+
const indexOfDate = useCallback(
|
|
1365
|
+
(target: Date) => {
|
|
1366
|
+
const aligned = weekAnchored ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
|
|
1367
|
+
// Floor so an arbitrary date lands on the page whose range contains it
|
|
1368
|
+
// (exact for day/week, where dates are already page-aligned).
|
|
1369
|
+
return Math.floor(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
|
|
1370
|
+
},
|
|
1371
|
+
[anchor, weekAnchored, step, weekStartsOn],
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
// The committed date's page is the centred/active one. `viewedIndexRef` tracks
|
|
1375
|
+
// where the list actually sits, telling swipe-driven changes from external ones.
|
|
1376
|
+
const activeIndex = indexOfDate(date);
|
|
1377
|
+
const viewedIndexRef = useRef(activeIndex);
|
|
1378
|
+
// While a programmatic scroll (a "today" button, prev/next, or any date set from
|
|
1379
|
+
// outside) is settling, this holds its target index. Viewability ticks for the
|
|
1380
|
+
// intermediate pages it crosses are ignored until it lands, so they can't report
|
|
1381
|
+
// a page in between back as the new date — which made jumps land one page short.
|
|
1382
|
+
const pendingScrollIndexRef = useRef<number | null>(null);
|
|
1383
|
+
|
|
1384
|
+
// Header days track the active page (page-aligned), so they always match the
|
|
1385
|
+
// columns below and a swipe never flashes another day's label.
|
|
1386
|
+
const headerDays = useMemo(
|
|
1387
|
+
() =>
|
|
1388
|
+
getViewDays(
|
|
1389
|
+
mode,
|
|
1390
|
+
pageDates[activeIndex] ?? date,
|
|
1391
|
+
weekStartsOn,
|
|
1392
|
+
numberOfDays,
|
|
1393
|
+
isRTL,
|
|
1394
|
+
weekEndsOn,
|
|
1395
|
+
),
|
|
1396
|
+
[mode, pageDates, activeIndex, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
const handleViewableItemsChanged = useCallback(
|
|
1400
|
+
(info: OnViewableItemsChangedInfo<Date>) => {
|
|
1401
|
+
// On the web the pager can't be swiped (overflow is hidden); every page change
|
|
1402
|
+
// is a programmatic scroll driven by `date` (prev/next/today/keys). Viewability
|
|
1403
|
+
// there only echoes that scroll back, and can report an intermediate page that
|
|
1404
|
+
// fights it (a multi-page "today" jump landing one page short), so ignore it.
|
|
1405
|
+
if (isWeb) return;
|
|
1406
|
+
const settled = info.viewableItems.find((token) => token.isViewable);
|
|
1407
|
+
if (settled?.index == null) return;
|
|
1408
|
+
// A programmatic scroll is settling: ignore the pages it crosses, and clear
|
|
1409
|
+
// the pending target (without reporting a date) once it reaches the target.
|
|
1410
|
+
if (pendingScrollIndexRef.current != null) {
|
|
1411
|
+
if (settled.index === pendingScrollIndexRef.current) {
|
|
1412
|
+
pendingScrollIndexRef.current = null;
|
|
1413
|
+
viewedIndexRef.current = settled.index;
|
|
1414
|
+
}
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (settled.index === viewedIndexRef.current) return;
|
|
1418
|
+
viewedIndexRef.current = settled.index;
|
|
1419
|
+
if (settled.item) onChangeDate(settled.item);
|
|
1420
|
+
},
|
|
1421
|
+
[onChangeDate],
|
|
1422
|
+
);
|
|
1423
|
+
|
|
1424
|
+
// Realign the list when the date changes from outside a swipe (e.g. a "today"
|
|
1425
|
+
// button or a month-view tap). Swipe-driven changes already match.
|
|
1426
|
+
useEffect(() => {
|
|
1427
|
+
if (activeIndex === viewedIndexRef.current) return;
|
|
1428
|
+
viewedIndexRef.current = activeIndex;
|
|
1429
|
+
pendingScrollIndexRef.current = activeIndex;
|
|
1430
|
+
void listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
|
|
1431
|
+
}, [activeIndex]);
|
|
1432
|
+
|
|
1433
|
+
// react-native-web only: paging recycles the page containers and resets their
|
|
1434
|
+
// vertical scroll, so the on-screen page randomly lands at the top. After the
|
|
1435
|
+
// page settles, restore the shared offset directly on the visible page's scroll
|
|
1436
|
+
// node — the one lever that reliably works on the web. Deferred a frame (twice,
|
|
1437
|
+
// for safety) so the paged-in grid has laid out before we scroll it.
|
|
1438
|
+
useEffect(() => {
|
|
1439
|
+
if (!isWeb) return;
|
|
1440
|
+
const root = containerRef.current as unknown as HTMLElement | null;
|
|
1441
|
+
if (!root) return;
|
|
1442
|
+
const restoreVisiblePage = () => {
|
|
1443
|
+
const vw = (root.ownerDocument?.defaultView ?? globalThis).innerWidth;
|
|
1444
|
+
for (const el of root.querySelectorAll<HTMLElement>("*")) {
|
|
1445
|
+
const style = getComputedStyle(el);
|
|
1446
|
+
const scrollable =
|
|
1447
|
+
(style.overflowY === "scroll" || style.overflowY === "auto") &&
|
|
1448
|
+
el.scrollHeight > el.clientHeight + 20 &&
|
|
1449
|
+
el.clientHeight > 100;
|
|
1450
|
+
if (!scrollable) continue;
|
|
1451
|
+
const rect = el.getBoundingClientRect();
|
|
1452
|
+
if (rect.left > -50 && rect.right <= vw + 50) {
|
|
1453
|
+
el.scrollTop = scrollY.value;
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
// Don't capture the scroll events this transition emits (recycle-reset, restore).
|
|
1459
|
+
suppressCaptureUntilRef.current = Date.now() + 400;
|
|
1460
|
+
let raf2 = 0;
|
|
1461
|
+
const raf1 = requestAnimationFrame(() => {
|
|
1462
|
+
restoreVisiblePage();
|
|
1463
|
+
raf2 = requestAnimationFrame(restoreVisiblePage);
|
|
1464
|
+
});
|
|
1465
|
+
return () => {
|
|
1466
|
+
cancelAnimationFrame(raf1);
|
|
1467
|
+
cancelAnimationFrame(raf2);
|
|
1468
|
+
};
|
|
1469
|
+
// `pageHeight` is included so this also runs once the pager measures its real
|
|
1470
|
+
// height on first open (the mount pass runs before the pages have laid out).
|
|
1471
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef/scrollY are stable
|
|
1472
|
+
}, [activeIndex, pageHeight]);
|
|
1473
|
+
|
|
1474
|
+
// Web: record the on-screen page's scroll into the shared offset on genuine user
|
|
1475
|
+
// scrolls, so switching pages preserves the position. Programmatic scrolls (the
|
|
1476
|
+
// recycle-reset and the restore above) are skipped via the suppression window, so
|
|
1477
|
+
// they can't clobber it. Scoped to the visible page so off-screen sync is ignored.
|
|
1478
|
+
useEffect(() => {
|
|
1479
|
+
if (!isWeb) return;
|
|
1480
|
+
const root = containerRef.current as unknown as HTMLElement | null;
|
|
1481
|
+
if (!root) return;
|
|
1482
|
+
const onScrollCapture = (event: Event) => {
|
|
1483
|
+
if (Date.now() < suppressCaptureUntilRef.current) return;
|
|
1484
|
+
const el = event.target as HTMLElement | null;
|
|
1485
|
+
if (!el || typeof el.scrollTop !== "number" || el.clientHeight <= 100) return;
|
|
1486
|
+
const rect = el.getBoundingClientRect();
|
|
1487
|
+
const vw = (root.ownerDocument?.defaultView ?? globalThis).innerWidth;
|
|
1488
|
+
if (rect.left <= -50 || rect.right > vw + 50) return;
|
|
1489
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
1490
|
+
scrollY.value = el.scrollTop;
|
|
1491
|
+
offsetSeedRef.current = el.scrollTop;
|
|
1492
|
+
};
|
|
1493
|
+
root.addEventListener("scroll", onScrollCapture, true);
|
|
1494
|
+
return () => root.removeEventListener("scroll", onScrollCapture, true);
|
|
1495
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef/scrollY are stable
|
|
1496
|
+
}, []);
|
|
1497
|
+
|
|
1498
|
+
// Web: LegendList's horizontal scroll container is `overflow-x: auto`, so a
|
|
1499
|
+
// trackpad swipe or horizontal wheel would scroll between pages. Paging should be
|
|
1500
|
+
// arrow-keys/toolbar only, so disable user horizontal scrolling on it (programmatic
|
|
1501
|
+
// scrollToIndex still works through `overflow: hidden`). `touch-action: pan-y`
|
|
1502
|
+
// keeps vertical scrolling of the grid working.
|
|
1503
|
+
useEffect(() => {
|
|
1504
|
+
if (!isWeb) return;
|
|
1505
|
+
const root = containerRef.current as unknown as HTMLElement | null;
|
|
1506
|
+
if (!root) return;
|
|
1507
|
+
const lockHorizontal = () => {
|
|
1508
|
+
for (const el of root.querySelectorAll<HTMLElement>("*")) {
|
|
1509
|
+
if (el.scrollWidth <= el.clientWidth + 20 || el.clientWidth <= 100) continue;
|
|
1510
|
+
const overflowX = getComputedStyle(el).overflowX;
|
|
1511
|
+
if (overflowX === "auto" || overflowX === "scroll") {
|
|
1512
|
+
el.style.overflowX = "hidden";
|
|
1513
|
+
el.style.touchAction = "pan-y";
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
const raf = requestAnimationFrame(lockHorizontal);
|
|
1518
|
+
return () => cancelAnimationFrame(raf);
|
|
1519
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef is stable
|
|
1520
|
+
}, [pageHeight]);
|
|
1521
|
+
|
|
1522
|
+
// Web arrow-key paging (swipe is disabled there); the effect above scrolls to
|
|
1523
|
+
// the new page once `onChangeDate` updates `date`.
|
|
1524
|
+
const goToPage = useCallback(
|
|
1525
|
+
(delta: number) => {
|
|
1526
|
+
const target = pageDates[activeIndex + delta];
|
|
1527
|
+
if (target) onChangeDate(target);
|
|
1528
|
+
},
|
|
1529
|
+
[pageDates, activeIndex, onChangeDate],
|
|
1530
|
+
);
|
|
1531
|
+
useWebPagerKeys(swipeEnabled, goToPage);
|
|
1532
|
+
|
|
1533
|
+
// Optionally snap the pager back to the active page after an empty-cell press
|
|
1534
|
+
// (so tapping a far-swiped page returns to the committed date).
|
|
1535
|
+
const handlePressCell = useMemo(() => {
|
|
1536
|
+
if (!onPressCell) return undefined;
|
|
1537
|
+
if (!resetPageOnPressCell) return onPressCell;
|
|
1538
|
+
return (cellDate: Date) => {
|
|
1539
|
+
onPressCell(cellDate);
|
|
1540
|
+
void listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
|
|
1541
|
+
};
|
|
1542
|
+
}, [onPressCell, resetPageOnPressCell, activeIndex]);
|
|
1543
|
+
|
|
1544
|
+
const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
|
|
1545
|
+
const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
|
|
1546
|
+
const getFixedItemSize = useCallback(() => containerWidth, [containerWidth]);
|
|
1547
|
+
const renderItem = useCallback(
|
|
1548
|
+
({ item, index }: LegendListRenderItemProps<Date>) => (
|
|
1549
|
+
<View style={{ width: containerWidth, height: pageHeight }}>
|
|
1550
|
+
<TimetablePage
|
|
1551
|
+
mode={mode}
|
|
1552
|
+
numberOfDays={numberOfDays}
|
|
1553
|
+
date={item}
|
|
1554
|
+
width={containerWidth}
|
|
1555
|
+
events={events}
|
|
1556
|
+
cellHeight={cellHeight}
|
|
1557
|
+
hourHeight={hourHeight}
|
|
1558
|
+
committedCellHeight={committedCellHeight}
|
|
1559
|
+
scrollY={scrollY}
|
|
1560
|
+
isActive={index === activeIndex}
|
|
1561
|
+
initialScrollY={offsetSeedRef.current ?? seedDefaultY}
|
|
1562
|
+
onSettleOffset={captureOffsetSeed}
|
|
1563
|
+
weekStartsOn={weekStartsOn}
|
|
1564
|
+
weekEndsOn={weekEndsOn}
|
|
1565
|
+
hourColumnWidth={hourColumnWidth}
|
|
1566
|
+
minHour={clampedMinHour}
|
|
1567
|
+
maxHour={clampedMaxHour}
|
|
1568
|
+
ampm={ampm}
|
|
1569
|
+
timeslots={timeslots}
|
|
1570
|
+
isRTL={isRTL}
|
|
1571
|
+
showAllDayEventCell={showAllDayEventCell}
|
|
1572
|
+
showVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
1573
|
+
verticalScrollEnabled={verticalScrollEnabled}
|
|
1574
|
+
hourComponent={hourComponent}
|
|
1575
|
+
calendarCellStyle={calendarCellStyle}
|
|
1576
|
+
businessHours={businessHours}
|
|
1577
|
+
minHourHeight={minHourHeight}
|
|
1578
|
+
maxHourHeight={maxHourHeight}
|
|
1579
|
+
showNowIndicator={showNowIndicator}
|
|
1580
|
+
renderEvent={renderEvent}
|
|
1581
|
+
keyExtractor={keyExtractor}
|
|
1582
|
+
snapMinutes={Math.max(1, dragStepMinutes)}
|
|
1583
|
+
showDragHandle={showDragHandle}
|
|
1584
|
+
onPressEvent={onPressEvent}
|
|
1585
|
+
onLongPressEvent={onLongPressEvent}
|
|
1586
|
+
onDragEvent={onDragEvent}
|
|
1587
|
+
onDragStart={onDragStart}
|
|
1588
|
+
onPressCell={handlePressCell}
|
|
1589
|
+
onLongPressCell={onLongPressCell}
|
|
1590
|
+
onCreateEvent={onCreateEvent}
|
|
1591
|
+
/>
|
|
1592
|
+
</View>
|
|
1593
|
+
),
|
|
1594
|
+
[
|
|
1595
|
+
containerWidth,
|
|
1596
|
+
pageHeight,
|
|
1597
|
+
mode,
|
|
1598
|
+
numberOfDays,
|
|
1599
|
+
events,
|
|
1600
|
+
cellHeight,
|
|
1601
|
+
hourHeight,
|
|
1602
|
+
committedCellHeight,
|
|
1603
|
+
scrollY,
|
|
1604
|
+
activeIndex,
|
|
1605
|
+
seedDefaultY,
|
|
1606
|
+
captureOffsetSeed,
|
|
1607
|
+
weekStartsOn,
|
|
1608
|
+
weekEndsOn,
|
|
1609
|
+
hourColumnWidth,
|
|
1610
|
+
clampedMinHour,
|
|
1611
|
+
clampedMaxHour,
|
|
1612
|
+
ampm,
|
|
1613
|
+
timeslots,
|
|
1614
|
+
isRTL,
|
|
1615
|
+
showAllDayEventCell,
|
|
1616
|
+
showVerticalScrollIndicator,
|
|
1617
|
+
verticalScrollEnabled,
|
|
1618
|
+
hourComponent,
|
|
1619
|
+
calendarCellStyle,
|
|
1620
|
+
businessHours,
|
|
1621
|
+
minHourHeight,
|
|
1622
|
+
maxHourHeight,
|
|
1623
|
+
showNowIndicator,
|
|
1624
|
+
renderEvent,
|
|
1625
|
+
keyExtractor,
|
|
1626
|
+
dragStepMinutes,
|
|
1627
|
+
showDragHandle,
|
|
1628
|
+
onPressEvent,
|
|
1629
|
+
onLongPressEvent,
|
|
1630
|
+
onDragEvent,
|
|
1631
|
+
onDragStart,
|
|
1632
|
+
handlePressCell,
|
|
1633
|
+
onLongPressCell,
|
|
1634
|
+
onCreateEvent,
|
|
1635
|
+
],
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
// Pages are keyed by date, so LegendList keeps the items it has already rendered
|
|
1639
|
+
// and only re-renders them when `data` or `extraData` changes. Feed both `events`
|
|
1640
|
+
// (so a moved event repaints in place) and `activeIndex` (so each page's
|
|
1641
|
+
// `isActive` updates as you swipe). Without `activeIndex`, a page that pages in
|
|
1642
|
+
// never learns it became active and stays at its seeded scroll offset.
|
|
1643
|
+
const listExtraData = useMemo(() => ({ events, activeIndex }), [events, activeIndex]);
|
|
1644
|
+
|
|
1645
|
+
return (
|
|
1646
|
+
<View ref={containerRef} style={styles.container}>
|
|
1647
|
+
{renderHeader ? (
|
|
1648
|
+
renderHeader(headerDays)
|
|
1649
|
+
) : (
|
|
1650
|
+
<DefaultHeader
|
|
1651
|
+
days={headerDays}
|
|
1652
|
+
mode={mode}
|
|
1653
|
+
width={containerWidth}
|
|
1654
|
+
hourColumnWidth={hourColumnWidth}
|
|
1655
|
+
showWeekNumber={showWeekNumber}
|
|
1656
|
+
weekNumberPrefix={weekNumberPrefix}
|
|
1657
|
+
locale={locale}
|
|
1658
|
+
activeDate={activeDate}
|
|
1659
|
+
onPressDateHeader={onPressDateHeader}
|
|
1660
|
+
/>
|
|
1661
|
+
)}
|
|
1662
|
+
|
|
1663
|
+
{headerComponent}
|
|
1664
|
+
|
|
1665
|
+
<View
|
|
1666
|
+
style={styles.pager}
|
|
1667
|
+
onLayout={(event) => {
|
|
1668
|
+
setPageHeight(event.nativeEvent.layout.height);
|
|
1669
|
+
setContainerWidth(event.nativeEvent.layout.width);
|
|
1670
|
+
setMeasured(true);
|
|
1671
|
+
}}
|
|
1672
|
+
>
|
|
1673
|
+
<LegendList
|
|
1674
|
+
// Remount only on the seed→measured transition (see `measured`), not on
|
|
1675
|
+
// every height change, so a day↔week header-height difference resizes the
|
|
1676
|
+
// items in place instead of remounting and blanking the page.
|
|
1677
|
+
key={measured ? "grid" : "grid-seed"}
|
|
1678
|
+
ref={listRef}
|
|
1679
|
+
style={isWeb ? [styles.pagerList, styles.webNoScroll] : styles.pagerList}
|
|
1680
|
+
data={pageDates}
|
|
1681
|
+
extraData={listExtraData}
|
|
1682
|
+
horizontal
|
|
1683
|
+
recycleItems={false}
|
|
1684
|
+
keyExtractor={keyExtractorList}
|
|
1685
|
+
getFixedItemSize={getFixedItemSize}
|
|
1686
|
+
// On web LegendList ignores these RN scroll props (it leaks them to the
|
|
1687
|
+
// DOM as unknown attributes), so omit them there and disable horizontal
|
|
1688
|
+
// scroll via `webNoScroll`; paging is driven by the arrow keys instead.
|
|
1689
|
+
// Native: paging makes each swipe hard-stop at the adjacent page, while
|
|
1690
|
+
// `freeSwipe` lets momentum carry across pages and snap to a boundary.
|
|
1691
|
+
{...(isWeb
|
|
1692
|
+
? null
|
|
1693
|
+
: {
|
|
1694
|
+
scrollEnabled: swipeEnabled,
|
|
1695
|
+
pagingEnabled: !freeSwipe,
|
|
1696
|
+
snapToIndices: freeSwipe ? snapToIndices : undefined,
|
|
1697
|
+
})}
|
|
1698
|
+
initialScrollIndex={activeIndex}
|
|
1699
|
+
showsHorizontalScrollIndicator={false}
|
|
1700
|
+
viewabilityConfig={PAGE_VIEWABILITY}
|
|
1701
|
+
onViewableItemsChanged={handleViewableItemsChanged}
|
|
1702
|
+
renderItem={renderItem}
|
|
1703
|
+
/>
|
|
1704
|
+
</View>
|
|
1705
|
+
</View>
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
export const TimeGrid = memo(TimeGridInner) as typeof TimeGridInner;
|
|
1710
|
+
|
|
1711
|
+
type DefaultHeaderProps = {
|
|
1712
|
+
days: Date[];
|
|
1713
|
+
mode: CalendarMode;
|
|
1714
|
+
width: number;
|
|
1715
|
+
hourColumnWidth: number;
|
|
1716
|
+
showWeekNumber?: boolean;
|
|
1717
|
+
weekNumberPrefix?: string;
|
|
1718
|
+
locale?: Locale;
|
|
1719
|
+
activeDate?: Date;
|
|
1720
|
+
onPressDateHeader?: (date: Date) => void;
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
const DefaultHeader = ({
|
|
1724
|
+
days,
|
|
1725
|
+
mode,
|
|
1726
|
+
width,
|
|
1727
|
+
hourColumnWidth,
|
|
1728
|
+
showWeekNumber,
|
|
1729
|
+
weekNumberPrefix = "W",
|
|
1730
|
+
locale,
|
|
1731
|
+
activeDate,
|
|
1732
|
+
onPressDateHeader,
|
|
1733
|
+
}: DefaultHeaderProps) => {
|
|
1734
|
+
const theme = useCalendarTheme();
|
|
1735
|
+
// Match the grid below: an hour-column spacer, then one column per day.
|
|
1736
|
+
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
1737
|
+
|
|
1738
|
+
return (
|
|
1739
|
+
<View style={[styles.headerRow, { borderBottomColor: theme.colors.gridLine }]}>
|
|
1740
|
+
<View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
|
|
1741
|
+
{showWeekNumber && hourColumnWidth > 0 && days[0] ? (
|
|
1742
|
+
<Text
|
|
1743
|
+
style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
|
|
1744
|
+
allowFontScaling={false}
|
|
1745
|
+
>
|
|
1746
|
+
{`${weekNumberPrefix}${getISOWeek(days[0])}`}
|
|
1747
|
+
</Text>
|
|
1748
|
+
) : null}
|
|
1749
|
+
</View>
|
|
1750
|
+
{days.map((day) => (
|
|
1751
|
+
<DayHeader
|
|
1752
|
+
key={day.toISOString()}
|
|
1753
|
+
day={day}
|
|
1754
|
+
mode={mode}
|
|
1755
|
+
width={dayWidth}
|
|
1756
|
+
locale={locale}
|
|
1757
|
+
activeDate={activeDate}
|
|
1758
|
+
onPressDateHeader={onPressDateHeader}
|
|
1759
|
+
/>
|
|
1760
|
+
))}
|
|
1761
|
+
</View>
|
|
1762
|
+
);
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
type DayHeaderProps = {
|
|
1766
|
+
day: Date;
|
|
1767
|
+
mode: CalendarMode;
|
|
1768
|
+
width: number;
|
|
1769
|
+
locale?: Locale;
|
|
1770
|
+
activeDate?: Date;
|
|
1771
|
+
onPressDateHeader?: (date: Date) => void;
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
const DayHeader = ({ day, width, locale, activeDate, onPressDateHeader }: DayHeaderProps) => {
|
|
1775
|
+
const theme = useCalendarTheme();
|
|
1776
|
+
const isToday = getIsToday(day);
|
|
1777
|
+
// Highlight the chosen `activeDate` when supplied, else the real today.
|
|
1778
|
+
const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
|
|
1779
|
+
|
|
1780
|
+
return (
|
|
1781
|
+
<Pressable
|
|
1782
|
+
style={[styles.dayHeader, { width }]}
|
|
1783
|
+
onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
|
|
1784
|
+
disabled={!onPressDateHeader}
|
|
1785
|
+
accessibilityRole={onPressDateHeader ? "button" : undefined}
|
|
1786
|
+
>
|
|
1787
|
+
{/* Mirrors the dom renderer's header: the muted weekday label sits above the
|
|
1788
|
+
day number, and the number's circle fills for today / the active date. */}
|
|
1789
|
+
<Text
|
|
1790
|
+
style={[styles.dayHeaderWeekday, { color: theme.colors.textMuted }]}
|
|
1791
|
+
allowFontScaling={false}
|
|
1792
|
+
>
|
|
1793
|
+
{format(day, "EEE", { locale })}
|
|
1794
|
+
</Text>
|
|
1795
|
+
<View
|
|
1796
|
+
style={[
|
|
1797
|
+
styles.dayHeaderBadge,
|
|
1798
|
+
isHighlighted && { backgroundColor: theme.colors.todayBackground },
|
|
1799
|
+
]}
|
|
1800
|
+
>
|
|
1801
|
+
<Text
|
|
1802
|
+
style={[
|
|
1803
|
+
styles.dayHeaderNumber,
|
|
1804
|
+
{ color: isHighlighted ? theme.colors.todayText : theme.colors.text },
|
|
1805
|
+
]}
|
|
1806
|
+
allowFontScaling={false}
|
|
1807
|
+
{...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
|
|
1808
|
+
>
|
|
1809
|
+
{day.getDate()}
|
|
1810
|
+
</Text>
|
|
1811
|
+
</View>
|
|
1812
|
+
</Pressable>
|
|
1813
|
+
);
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
const styles = StyleSheet.create({
|
|
1817
|
+
pager: {
|
|
1818
|
+
flex: 1,
|
|
1819
|
+
},
|
|
1820
|
+
pagerList: {
|
|
1821
|
+
flex: 1,
|
|
1822
|
+
},
|
|
1823
|
+
container: {
|
|
1824
|
+
flex: 1,
|
|
1825
|
+
},
|
|
1826
|
+
headerRow: {
|
|
1827
|
+
flexDirection: "row",
|
|
1828
|
+
// Center the weekday/number block vertically; the day header's own symmetric
|
|
1829
|
+
// paddingVertical provides the spacing, matching the dom renderer (no extra
|
|
1830
|
+
// bottom padding that would push the content up).
|
|
1831
|
+
alignItems: "center",
|
|
1832
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
1833
|
+
},
|
|
1834
|
+
weekNumberGutter: {
|
|
1835
|
+
alignItems: "center",
|
|
1836
|
+
justifyContent: "flex-end",
|
|
1837
|
+
},
|
|
1838
|
+
dayHeader: {
|
|
1839
|
+
alignItems: "center",
|
|
1840
|
+
gap: 2,
|
|
1841
|
+
paddingVertical: 6,
|
|
1842
|
+
},
|
|
1843
|
+
dayHeaderWeekday: {
|
|
1844
|
+
fontSize: 11,
|
|
1845
|
+
fontWeight: "600",
|
|
1846
|
+
},
|
|
1847
|
+
dayHeaderBadge: {
|
|
1848
|
+
// A fixed circle so the today/active fill never shifts the header's height.
|
|
1849
|
+
width: 28,
|
|
1850
|
+
height: 28,
|
|
1851
|
+
borderRadius: 999,
|
|
1852
|
+
justifyContent: "center",
|
|
1853
|
+
alignItems: "center",
|
|
1854
|
+
},
|
|
1855
|
+
dayHeaderNumber: {
|
|
1856
|
+
fontSize: 15,
|
|
1857
|
+
fontWeight: "600",
|
|
1858
|
+
},
|
|
1859
|
+
content: {
|
|
1860
|
+
width: "100%",
|
|
1861
|
+
position: "relative",
|
|
1862
|
+
},
|
|
1863
|
+
cellPressLayer: {
|
|
1864
|
+
position: "absolute",
|
|
1865
|
+
top: 0,
|
|
1866
|
+
bottom: 0,
|
|
1867
|
+
right: 0,
|
|
1868
|
+
},
|
|
1869
|
+
createGhost: {
|
|
1870
|
+
position: "absolute",
|
|
1871
|
+
borderRadius: 6,
|
|
1872
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
1873
|
+
marginHorizontal: EVENT_GAP,
|
|
1874
|
+
},
|
|
1875
|
+
weekendColumn: {
|
|
1876
|
+
position: "absolute",
|
|
1877
|
+
top: 0,
|
|
1878
|
+
},
|
|
1879
|
+
shadeBand: {
|
|
1880
|
+
position: "absolute",
|
|
1881
|
+
},
|
|
1882
|
+
daySeparator: {
|
|
1883
|
+
position: "absolute",
|
|
1884
|
+
top: 0,
|
|
1885
|
+
width: StyleSheet.hairlineWidth,
|
|
1886
|
+
},
|
|
1887
|
+
hourRow: {
|
|
1888
|
+
position: "absolute",
|
|
1889
|
+
left: 0,
|
|
1890
|
+
right: 0,
|
|
1891
|
+
flexDirection: "row",
|
|
1892
|
+
alignItems: "flex-start",
|
|
1893
|
+
},
|
|
1894
|
+
hourLabel: {
|
|
1895
|
+
marginTop: -HOUR_LABEL_NUDGE,
|
|
1896
|
+
textAlign: "right",
|
|
1897
|
+
paddingRight: 6,
|
|
1898
|
+
},
|
|
1899
|
+
hourLine: {
|
|
1900
|
+
flex: 1,
|
|
1901
|
+
height: StyleSheet.hairlineWidth,
|
|
1902
|
+
},
|
|
1903
|
+
timeslotLine: {
|
|
1904
|
+
position: "absolute",
|
|
1905
|
+
right: 0,
|
|
1906
|
+
height: StyleSheet.hairlineWidth,
|
|
1907
|
+
opacity: 0.5,
|
|
1908
|
+
},
|
|
1909
|
+
resizeHandle: {
|
|
1910
|
+
position: "absolute",
|
|
1911
|
+
left: 0,
|
|
1912
|
+
right: 0,
|
|
1913
|
+
bottom: 0,
|
|
1914
|
+
height: RESIZE_HANDLE_HEIGHT,
|
|
1915
|
+
alignItems: "center",
|
|
1916
|
+
justifyContent: "center",
|
|
1917
|
+
},
|
|
1918
|
+
resizeGrip: {
|
|
1919
|
+
width: 24,
|
|
1920
|
+
height: 3,
|
|
1921
|
+
borderRadius: 2,
|
|
1922
|
+
opacity: 0.4,
|
|
1923
|
+
},
|
|
1924
|
+
eventBox: {
|
|
1925
|
+
position: "absolute",
|
|
1926
|
+
overflow: "hidden",
|
|
1927
|
+
// Border-box padding insets the visible box (the flex child) on all sides,
|
|
1928
|
+
// giving a small gap without touching the slot geometry above.
|
|
1929
|
+
padding: EVENT_GAP,
|
|
1930
|
+
},
|
|
1931
|
+
nowIndicator: {
|
|
1932
|
+
position: "absolute",
|
|
1933
|
+
height: 2,
|
|
1934
|
+
},
|
|
1935
|
+
// `pointerEvents` as a style (not a prop) — the prop form is deprecated on web.
|
|
1936
|
+
nonInteractive: {
|
|
1937
|
+
pointerEvents: "none",
|
|
1938
|
+
},
|
|
1939
|
+
// Disable user-driven horizontal scroll on web; programmatic paging still works.
|
|
1940
|
+
webNoScroll: {
|
|
1941
|
+
overflow: "hidden",
|
|
1942
|
+
},
|
|
1943
|
+
});
|