react-native-bigger-calendar 0.1.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/README.md +182 -0
- package/dist/index.d.mts +302 -0
- package/dist/index.d.ts +302 -0
- package/dist/index.js +930 -0
- package/dist/index.mjs +891 -0
- package/package.json +75 -0
- package/src/components/Calendar.tsx +147 -0
- package/src/components/DefaultEvent.tsx +57 -0
- package/src/components/MonthPager.tsx +165 -0
- package/src/components/MonthView.tsx +178 -0
- package/src/components/TimeGrid.tsx +825 -0
- package/src/index.tsx +30 -0
- package/src/theme.ts +94 -0
- package/src/types.ts +56 -0
- package/src/utils/dates.ts +20 -0
- package/src/utils/layout.ts +119 -0
|
@@ -0,0 +1,825 @@
|
|
|
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
|
+
getHours,
|
|
11
|
+
getMinutes,
|
|
12
|
+
startOfDay,
|
|
13
|
+
startOfWeek,
|
|
14
|
+
} from 'date-fns';
|
|
15
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
type GestureResponderEvent,
|
|
18
|
+
Pressable,
|
|
19
|
+
StyleSheet,
|
|
20
|
+
Text,
|
|
21
|
+
useWindowDimensions,
|
|
22
|
+
View,
|
|
23
|
+
} from 'react-native';
|
|
24
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
25
|
+
import Animated, {
|
|
26
|
+
scrollTo,
|
|
27
|
+
type SharedValue,
|
|
28
|
+
useAnimatedReaction,
|
|
29
|
+
useAnimatedRef,
|
|
30
|
+
useAnimatedScrollHandler,
|
|
31
|
+
useAnimatedStyle,
|
|
32
|
+
useDerivedValue,
|
|
33
|
+
useSharedValue,
|
|
34
|
+
} from 'react-native-reanimated';
|
|
35
|
+
import { useCalendarTheme } from '../theme';
|
|
36
|
+
import type {
|
|
37
|
+
CalendarEvent,
|
|
38
|
+
CalendarMode,
|
|
39
|
+
EventKeyExtractor,
|
|
40
|
+
RenderEvent,
|
|
41
|
+
WeekStartsOn,
|
|
42
|
+
} from '../types';
|
|
43
|
+
import { getIsToday, getWeekDays, isWeekend } from '../utils/dates';
|
|
44
|
+
import { layoutDayEvents, type PositionedEvent } from '../utils/layout';
|
|
45
|
+
|
|
46
|
+
const MINUTES_PER_HOUR = 60;
|
|
47
|
+
const HOURS_PER_DAY = 24;
|
|
48
|
+
// Days (day view) or weeks (week view) to step when paging to an adjacent page.
|
|
49
|
+
const DAY_VIEW_STEP = 1;
|
|
50
|
+
const WEEK_VIEW_STEP = 7;
|
|
51
|
+
// Steps rendered either side of the current page. LegendList virtualises, so
|
|
52
|
+
// only a few mount at once; a wide window means the user effectively never runs
|
|
53
|
+
// out of pages to swipe. Items are keyed by date and never recycled.
|
|
54
|
+
const PAGE_WINDOW = 180;
|
|
55
|
+
// A page must be ~fully on screen before it becomes the committed date.
|
|
56
|
+
const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_HOUR_HEIGHT = 64;
|
|
59
|
+
const DEFAULT_MIN_HOUR_HEIGHT = 32;
|
|
60
|
+
const DEFAULT_MAX_HOUR_HEIGHT = 160;
|
|
61
|
+
const DEFAULT_HOUR_COLUMN_WIDTH = 50;
|
|
62
|
+
// Short events would otherwise render only a few pixels tall and clip their
|
|
63
|
+
// content; keep them tall enough to stay legible and tappable.
|
|
64
|
+
const MIN_EVENT_HEIGHT = 32;
|
|
65
|
+
// Hour labels are nudged up so the number sits centred on its grid line. Pad the
|
|
66
|
+
// scroll content by the same amount so the top-most label is never clipped.
|
|
67
|
+
const HOUR_LABEL_TOP_INSET = 12;
|
|
68
|
+
const HOUR_LABEL_NUDGE = 8;
|
|
69
|
+
const NOW_TICK_MS = 60_000;
|
|
70
|
+
|
|
71
|
+
// A `Date` that advances every minute while `enabled`, so the now-indicator
|
|
72
|
+
// tracks the wall clock instead of freezing at mount. Off-screen pages pass
|
|
73
|
+
// `enabled = false` and re-read the time when they become active.
|
|
74
|
+
function useNow(enabled: boolean): Date {
|
|
75
|
+
const [now, setNow] = useState(() => new Date());
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!enabled) return;
|
|
78
|
+
setNow(new Date());
|
|
79
|
+
const id = setInterval(() => setNow(new Date()), NOW_TICK_MS);
|
|
80
|
+
return () => clearInterval(id);
|
|
81
|
+
}, [enabled]);
|
|
82
|
+
return now;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// "13" in 24h, or "1 PM" in 12h. Midnight/noon read as 12 AM / 12 PM.
|
|
86
|
+
function formatHourLabel(hour: number, ampm: boolean): string {
|
|
87
|
+
if (!ampm) return String(hour);
|
|
88
|
+
const period = hour < 12 ? 'AM' : 'PM';
|
|
89
|
+
const hour12 = hour % 12 === 0 ? 12 : hour % 12;
|
|
90
|
+
return `${hour12} ${period}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type AnimatedEventBoxProps<T> = {
|
|
94
|
+
positioned: PositionedEvent<T>;
|
|
95
|
+
cellHeight: SharedValue<number>;
|
|
96
|
+
minHour: number;
|
|
97
|
+
left: number;
|
|
98
|
+
width: number;
|
|
99
|
+
mode: CalendarMode;
|
|
100
|
+
renderEvent: RenderEvent<T>;
|
|
101
|
+
onPress: (event: CalendarEvent<T>) => void;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function AnimatedEventBox<T>({
|
|
105
|
+
positioned,
|
|
106
|
+
cellHeight,
|
|
107
|
+
minHour,
|
|
108
|
+
left,
|
|
109
|
+
width,
|
|
110
|
+
mode,
|
|
111
|
+
renderEvent,
|
|
112
|
+
onPress,
|
|
113
|
+
}: AnimatedEventBoxProps<T>) {
|
|
114
|
+
const RenderEventComponent = renderEvent;
|
|
115
|
+
// Live pixel height of the box, driven on the UI thread by the shared
|
|
116
|
+
// cellHeight. Handed to renderEvent so custom renderers can reveal detail
|
|
117
|
+
// progressively as the grid zooms, without re-rendering. Explicit deps so the
|
|
118
|
+
// worklet re-captures the event's geometry when its time/duration changes.
|
|
119
|
+
const boxHeight = useDerivedValue(
|
|
120
|
+
() => Math.max(positioned.durationHours * cellHeight.value, MIN_EVENT_HEIGHT),
|
|
121
|
+
[positioned.durationHours],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const boxStyle = useAnimatedStyle(
|
|
125
|
+
() => ({
|
|
126
|
+
top: (positioned.startHours - minHour) * cellHeight.value,
|
|
127
|
+
height: boxHeight.value,
|
|
128
|
+
}),
|
|
129
|
+
[positioned.startHours, positioned.durationHours, minHour],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handlePress = () => onPress(positioned.event);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
|
|
136
|
+
<RenderEventComponent
|
|
137
|
+
event={positioned.event}
|
|
138
|
+
mode={mode}
|
|
139
|
+
boxHeight={boxHeight}
|
|
140
|
+
continuesBefore={positioned.continuesBefore}
|
|
141
|
+
continuesAfter={positioned.continuesAfter}
|
|
142
|
+
onPress={handlePress}
|
|
143
|
+
/>
|
|
144
|
+
</Animated.View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type HourRowProps = {
|
|
149
|
+
hour: number;
|
|
150
|
+
minHour: number;
|
|
151
|
+
cellHeight: SharedValue<number>;
|
|
152
|
+
hourColumnWidth: number;
|
|
153
|
+
label: string;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const HourRow = ({ hour, minHour, cellHeight, hourColumnWidth, label }: HourRowProps) => {
|
|
157
|
+
const theme = useCalendarTheme();
|
|
158
|
+
// Position via `top` (a layout prop), not a transform. The per-row layout pass
|
|
159
|
+
// as cellHeight animates keeps the ScrollView's content size in sync while
|
|
160
|
+
// zooming; a transform is composited and leaves the scroll range stale.
|
|
161
|
+
const animatedStyle = useAnimatedStyle(
|
|
162
|
+
() => ({ top: (hour - minHour) * cellHeight.value }),
|
|
163
|
+
[hour, minHour],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Animated.View style={[styles.hourRow, animatedStyle]} pointerEvents="none">
|
|
168
|
+
<Text
|
|
169
|
+
style={[
|
|
170
|
+
theme.text.hourLabel,
|
|
171
|
+
styles.hourLabel,
|
|
172
|
+
{ width: hourColumnWidth, color: theme.colors.textMuted },
|
|
173
|
+
]}
|
|
174
|
+
allowFontScaling={false}
|
|
175
|
+
>
|
|
176
|
+
{label}
|
|
177
|
+
</Text>
|
|
178
|
+
<View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
|
|
179
|
+
</Animated.View>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type NowIndicatorProps = {
|
|
184
|
+
cellHeight: SharedValue<number>;
|
|
185
|
+
nowHours: number;
|
|
186
|
+
minHour: number;
|
|
187
|
+
left: number;
|
|
188
|
+
color: string;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const NowIndicator = ({ cellHeight, nowHours, minHour, left, color }: NowIndicatorProps) => {
|
|
192
|
+
const animatedStyle = useAnimatedStyle(
|
|
193
|
+
() => ({ top: (nowHours - minHour) * cellHeight.value }),
|
|
194
|
+
[nowHours, minHour],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Animated.View
|
|
199
|
+
style={[styles.nowIndicator, { left, backgroundColor: color }, animatedStyle]}
|
|
200
|
+
pointerEvents="none"
|
|
201
|
+
/>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
type TimetablePageProps<T> = {
|
|
206
|
+
mode: 'day' | 'week';
|
|
207
|
+
date: Date;
|
|
208
|
+
events: CalendarEvent<T>[];
|
|
209
|
+
cellHeight: SharedValue<number>;
|
|
210
|
+
hourHeight: number;
|
|
211
|
+
// The zoom committed at the end of the last pinch. Off-screen pages animate off
|
|
212
|
+
// this (it changes once per gesture) instead of the live cellHeight (which
|
|
213
|
+
// changes every frame), so a pinch only re-runs the visible page's worklets.
|
|
214
|
+
committedCellHeight: SharedValue<number>;
|
|
215
|
+
scrollY: SharedValue<number>;
|
|
216
|
+
isActive: boolean;
|
|
217
|
+
scrollOffsetMinutes: number;
|
|
218
|
+
weekStartsOn: WeekStartsOn;
|
|
219
|
+
hourColumnWidth: number;
|
|
220
|
+
minHour: number;
|
|
221
|
+
maxHour: number;
|
|
222
|
+
ampm: boolean;
|
|
223
|
+
minHourHeight: number;
|
|
224
|
+
maxHourHeight: number;
|
|
225
|
+
showNowIndicator: boolean;
|
|
226
|
+
renderEvent: RenderEvent<T>;
|
|
227
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
228
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
229
|
+
onPressCell?: (date: Date) => void;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// A single date's grid: the pinch-zoomable, vertically-scrolling time column.
|
|
233
|
+
// Three of these are mounted side by side inside the pager so the previous and
|
|
234
|
+
// next dates are ready to drag into view.
|
|
235
|
+
function TimetablePageInner<T>({
|
|
236
|
+
mode,
|
|
237
|
+
date,
|
|
238
|
+
events,
|
|
239
|
+
cellHeight,
|
|
240
|
+
hourHeight,
|
|
241
|
+
committedCellHeight,
|
|
242
|
+
scrollY,
|
|
243
|
+
isActive,
|
|
244
|
+
scrollOffsetMinutes,
|
|
245
|
+
weekStartsOn,
|
|
246
|
+
hourColumnWidth,
|
|
247
|
+
minHour,
|
|
248
|
+
maxHour,
|
|
249
|
+
ampm,
|
|
250
|
+
minHourHeight,
|
|
251
|
+
maxHourHeight,
|
|
252
|
+
showNowIndicator,
|
|
253
|
+
renderEvent,
|
|
254
|
+
keyExtractor,
|
|
255
|
+
onPressEvent,
|
|
256
|
+
onPressCell,
|
|
257
|
+
}: TimetablePageProps<T>) {
|
|
258
|
+
const theme = useCalendarTheme();
|
|
259
|
+
const { width } = useWindowDimensions();
|
|
260
|
+
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
261
|
+
|
|
262
|
+
// The visible page tracks the live cellHeight (animates every pinch frame);
|
|
263
|
+
// off-screen pages track committedCellHeight (settles once per gesture).
|
|
264
|
+
const heightSource = isActive ? cellHeight : committedCellHeight;
|
|
265
|
+
|
|
266
|
+
// Keep every page locked to the same vertical scroll position so the prev/next
|
|
267
|
+
// pages are already aligned before they drag into view — no post-swipe jump.
|
|
268
|
+
const scrollHandler = useAnimatedScrollHandler((event) => {
|
|
269
|
+
if (isActive) {
|
|
270
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
271
|
+
scrollY.value = event.contentOffset.y;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
useAnimatedReaction(
|
|
276
|
+
() => scrollY.value,
|
|
277
|
+
(current, previous) => {
|
|
278
|
+
if (!isActive && current !== previous) {
|
|
279
|
+
scrollTo(scrollRef, 0, current, false);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const days = useMemo(
|
|
285
|
+
() => (mode === 'week' ? getWeekDays(date, weekStartsOn) : [date]),
|
|
286
|
+
[mode, date, weekStartsOn],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
290
|
+
const dayLeft = (dayIndex: number) => hourColumnWidth + dayIndex * dayWidth;
|
|
291
|
+
|
|
292
|
+
const dayLayouts = useMemo(
|
|
293
|
+
() => days.map((day) => layoutDayEvents(events, day)),
|
|
294
|
+
[days, events],
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Map a tap on empty grid space back to the date+time it represents. Reads the
|
|
298
|
+
// live row height on the JS thread to convert the touch Y into minutes.
|
|
299
|
+
const handleBackgroundPress = (event: GestureResponderEvent) => {
|
|
300
|
+
if (!onPressCell) return;
|
|
301
|
+
const { locationX, locationY } = event.nativeEvent;
|
|
302
|
+
const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
|
|
303
|
+
const day = days[dayIndex];
|
|
304
|
+
if (!day) return;
|
|
305
|
+
const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
|
|
306
|
+
const pressed = new Date(day);
|
|
307
|
+
pressed.setHours(0, 0, 0, 0);
|
|
308
|
+
pressed.setMinutes(minutes);
|
|
309
|
+
onPressCell(pressed);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// The hours (rows/labels) visible in the window [minHour, maxHour).
|
|
313
|
+
const hoursRange = useMemo(
|
|
314
|
+
() => Array.from({ length: maxHour - minHour }, (_, index) => minHour + index),
|
|
315
|
+
[minHour, maxHour],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const now = useNow(showNowIndicator && isActive);
|
|
319
|
+
const nowDayIndex = days.findIndex((day) => getIsToday(day));
|
|
320
|
+
const nowHours = (getHours(now) * MINUTES_PER_HOUR + getMinutes(now)) / MINUTES_PER_HOUR;
|
|
321
|
+
const nowInWindow = nowHours >= minHour && nowHours <= maxHour;
|
|
322
|
+
|
|
323
|
+
const fullHeightStyle = useAnimatedStyle(
|
|
324
|
+
() => ({ height: (maxHour - minHour) * heightSource.value }),
|
|
325
|
+
[minHour, maxHour, heightSource],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Capture the row height when the pinch starts and apply `event.scale`
|
|
329
|
+
// (relative to that start) rather than multiplying per-frame deltas — deltas
|
|
330
|
+
// compound float error and the zoom never settles on a clean level.
|
|
331
|
+
const pinchStartCellHeight = useSharedValue(hourHeight);
|
|
332
|
+
const zoomGesture = useMemo(() => {
|
|
333
|
+
const pinch = Gesture.Pinch()
|
|
334
|
+
.onStart(() => {
|
|
335
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
336
|
+
pinchStartCellHeight.value = cellHeight.value;
|
|
337
|
+
})
|
|
338
|
+
.onUpdate((event) => {
|
|
339
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
340
|
+
cellHeight.value = Math.min(
|
|
341
|
+
maxHourHeight,
|
|
342
|
+
Math.max(minHourHeight, pinchStartCellHeight.value * event.scale),
|
|
343
|
+
);
|
|
344
|
+
})
|
|
345
|
+
.onEnd(() => {
|
|
346
|
+
// Publish the final zoom to the off-screen pages in one update.
|
|
347
|
+
// eslint-disable-next-line react-hooks/immutability -- Reanimated shared value: assigning .value is the intended mutation API
|
|
348
|
+
committedCellHeight.value = cellHeight.value;
|
|
349
|
+
});
|
|
350
|
+
// Recognise the pinch and the ScrollView's native scroll together so the
|
|
351
|
+
// scroll never cancels an in-progress zoom.
|
|
352
|
+
return Gesture.Simultaneous(pinch, Gesture.Native());
|
|
353
|
+
}, [cellHeight, committedCellHeight, pinchStartCellHeight, minHourHeight, maxHourHeight]);
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<View style={styles.container}>
|
|
357
|
+
<GestureDetector gesture={zoomGesture}>
|
|
358
|
+
<Animated.ScrollView
|
|
359
|
+
ref={scrollRef}
|
|
360
|
+
showsVerticalScrollIndicator
|
|
361
|
+
onScroll={scrollHandler}
|
|
362
|
+
scrollEventThrottle={16}
|
|
363
|
+
contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
|
|
364
|
+
contentOffset={{
|
|
365
|
+
x: 0,
|
|
366
|
+
y: Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - minHour) * hourHeight,
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<Animated.View style={[styles.content, fullHeightStyle]}>
|
|
370
|
+
{onPressCell ? (
|
|
371
|
+
// Behind the events, so empty-space taps create while event taps
|
|
372
|
+
// still hit their box. Hidden from screen readers (a convenience
|
|
373
|
+
// gesture, not the primary create path).
|
|
374
|
+
<Pressable
|
|
375
|
+
style={[styles.cellPressLayer, { left: hourColumnWidth }]}
|
|
376
|
+
onPress={handleBackgroundPress}
|
|
377
|
+
importantForAccessibility="no"
|
|
378
|
+
accessibilityElementsHidden
|
|
379
|
+
/>
|
|
380
|
+
) : null}
|
|
381
|
+
|
|
382
|
+
{days.map((day, dayIndex) =>
|
|
383
|
+
isWeekend(day) ? (
|
|
384
|
+
<Animated.View
|
|
385
|
+
key={`weekend-${day.toISOString()}`}
|
|
386
|
+
style={[
|
|
387
|
+
styles.weekendColumn,
|
|
388
|
+
{ backgroundColor: theme.colors.weekendBackground },
|
|
389
|
+
{ left: dayLeft(dayIndex), width: dayWidth },
|
|
390
|
+
fullHeightStyle,
|
|
391
|
+
]}
|
|
392
|
+
pointerEvents="none"
|
|
393
|
+
/>
|
|
394
|
+
) : null,
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{days.map((day, dayIndex) => (
|
|
398
|
+
<Animated.View
|
|
399
|
+
key={`separator-${day.toISOString()}`}
|
|
400
|
+
style={[
|
|
401
|
+
styles.daySeparator,
|
|
402
|
+
{ backgroundColor: theme.colors.gridLine },
|
|
403
|
+
{ left: dayLeft(dayIndex) },
|
|
404
|
+
fullHeightStyle,
|
|
405
|
+
]}
|
|
406
|
+
pointerEvents="none"
|
|
407
|
+
/>
|
|
408
|
+
))}
|
|
409
|
+
|
|
410
|
+
{hoursRange.map((hour) => (
|
|
411
|
+
<HourRow
|
|
412
|
+
key={hour}
|
|
413
|
+
hour={hour}
|
|
414
|
+
minHour={minHour}
|
|
415
|
+
cellHeight={heightSource}
|
|
416
|
+
hourColumnWidth={hourColumnWidth}
|
|
417
|
+
label={formatHourLabel(hour, ampm)}
|
|
418
|
+
/>
|
|
419
|
+
))}
|
|
420
|
+
|
|
421
|
+
{dayLayouts.flatMap((layout, dayIndex) =>
|
|
422
|
+
layout
|
|
423
|
+
// Skip events that fall entirely outside the [minHour, maxHour) window.
|
|
424
|
+
.filter(
|
|
425
|
+
(p) => p.startHours < maxHour && p.startHours + p.durationHours > minHour,
|
|
426
|
+
)
|
|
427
|
+
.map((positioned, eventIndex) => {
|
|
428
|
+
const columnWidth = dayWidth / positioned.columns;
|
|
429
|
+
return (
|
|
430
|
+
<AnimatedEventBox
|
|
431
|
+
key={keyExtractor(positioned.event, eventIndex)}
|
|
432
|
+
positioned={positioned}
|
|
433
|
+
cellHeight={heightSource}
|
|
434
|
+
minHour={minHour}
|
|
435
|
+
left={dayLeft(dayIndex) + positioned.column * columnWidth}
|
|
436
|
+
width={columnWidth}
|
|
437
|
+
mode={mode}
|
|
438
|
+
renderEvent={renderEvent}
|
|
439
|
+
onPress={onPressEvent}
|
|
440
|
+
/>
|
|
441
|
+
);
|
|
442
|
+
}),
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{showNowIndicator && nowDayIndex >= 0 && nowInWindow ? (
|
|
446
|
+
<NowIndicator
|
|
447
|
+
cellHeight={heightSource}
|
|
448
|
+
nowHours={nowHours}
|
|
449
|
+
minHour={minHour}
|
|
450
|
+
left={dayLeft(nowDayIndex)}
|
|
451
|
+
color={theme.colors.nowIndicator}
|
|
452
|
+
/>
|
|
453
|
+
) : null}
|
|
454
|
+
</Animated.View>
|
|
455
|
+
</Animated.ScrollView>
|
|
456
|
+
</GestureDetector>
|
|
457
|
+
</View>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
|
|
462
|
+
|
|
463
|
+
export type TimeGridProps<T> = {
|
|
464
|
+
mode: 'day' | 'week';
|
|
465
|
+
date: Date;
|
|
466
|
+
events: CalendarEvent<T>[];
|
|
467
|
+
cellHeight: SharedValue<number>;
|
|
468
|
+
/** Initial per-hour row height in px; seeds scroll/zoom without reading the shared value during render. */
|
|
469
|
+
hourHeight?: number;
|
|
470
|
+
weekStartsOn: WeekStartsOn;
|
|
471
|
+
renderEvent: RenderEvent<T>;
|
|
472
|
+
keyExtractor: EventKeyExtractor<T>;
|
|
473
|
+
scrollOffsetMinutes?: number;
|
|
474
|
+
hourColumnWidth?: number;
|
|
475
|
+
/** First hour shown (0–23). Default 0. */
|
|
476
|
+
minHour?: number;
|
|
477
|
+
/** Last hour shown, exclusive (1–24). Default 24. */
|
|
478
|
+
maxHour?: number;
|
|
479
|
+
/** Show hour labels in 12-hour AM/PM form. Default false (24h). */
|
|
480
|
+
ampm?: boolean;
|
|
481
|
+
minHourHeight?: number;
|
|
482
|
+
maxHourHeight?: number;
|
|
483
|
+
showNowIndicator?: boolean;
|
|
484
|
+
locale?: string;
|
|
485
|
+
freeSwipe?: boolean;
|
|
486
|
+
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
487
|
+
onPressCell?: (date: Date) => void;
|
|
488
|
+
onChangeDate: (date: Date) => void;
|
|
489
|
+
/** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
|
|
490
|
+
renderHeader?: (days: Date[]) => React.ReactNode;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
function TimeGridInner<T>({
|
|
494
|
+
mode,
|
|
495
|
+
date,
|
|
496
|
+
events,
|
|
497
|
+
cellHeight,
|
|
498
|
+
hourHeight = DEFAULT_HOUR_HEIGHT,
|
|
499
|
+
weekStartsOn,
|
|
500
|
+
renderEvent,
|
|
501
|
+
keyExtractor,
|
|
502
|
+
scrollOffsetMinutes = 0,
|
|
503
|
+
hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
504
|
+
minHour = 0,
|
|
505
|
+
maxHour = HOURS_PER_DAY,
|
|
506
|
+
ampm = false,
|
|
507
|
+
minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
|
|
508
|
+
maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
|
|
509
|
+
showNowIndicator = true,
|
|
510
|
+
locale,
|
|
511
|
+
freeSwipe = false,
|
|
512
|
+
onPressEvent,
|
|
513
|
+
onPressCell,
|
|
514
|
+
onChangeDate,
|
|
515
|
+
renderHeader,
|
|
516
|
+
}: TimeGridProps<T>) {
|
|
517
|
+
// Guard against an inverted/out-of-range window so the grid never collapses.
|
|
518
|
+
const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
|
|
519
|
+
const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
|
|
520
|
+
|
|
521
|
+
const { width, height } = useWindowDimensions();
|
|
522
|
+
const listRef = useRef<LegendListRef>(null);
|
|
523
|
+
// Horizontal list items need an explicit cross-axis height; seed it with the
|
|
524
|
+
// window height (so it renders immediately and in tests) and refine on layout.
|
|
525
|
+
const [pageHeight, setPageHeight] = useState(height);
|
|
526
|
+
const step = mode === 'week' ? WEEK_VIEW_STEP : DAY_VIEW_STEP;
|
|
527
|
+
// Shared vertical scroll offset so every mounted page stays aligned. Seeded
|
|
528
|
+
// from the numeric hourHeight rather than reading cellHeight.value (which
|
|
529
|
+
// would warn about reading a shared value during render).
|
|
530
|
+
const scrollY = useSharedValue(
|
|
531
|
+
Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - clampedMinHour) * hourHeight,
|
|
532
|
+
);
|
|
533
|
+
// Zoom committed at the end of the last pinch; off-screen pages animate off
|
|
534
|
+
// this so they don't re-run their worklets every frame while the visible page
|
|
535
|
+
// zooms.
|
|
536
|
+
const committedCellHeight = useSharedValue(hourHeight);
|
|
537
|
+
|
|
538
|
+
// A fixed window of page dates, anchored once and aligned to the page boundary
|
|
539
|
+
// (day or week start). The array never shifts as the date changes.
|
|
540
|
+
const [anchorDate] = useState(date);
|
|
541
|
+
const anchor = useMemo(
|
|
542
|
+
() =>
|
|
543
|
+
mode === 'week' ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
|
|
544
|
+
[mode, anchorDate, weekStartsOn],
|
|
545
|
+
);
|
|
546
|
+
const pageDates = useMemo(
|
|
547
|
+
() =>
|
|
548
|
+
Array.from({ length: PAGE_WINDOW * 2 + 1 }, (_, i) =>
|
|
549
|
+
addDays(anchor, (i - PAGE_WINDOW) * step),
|
|
550
|
+
),
|
|
551
|
+
[anchor, step],
|
|
552
|
+
);
|
|
553
|
+
const indexOfDate = useCallback(
|
|
554
|
+
(target: Date) => {
|
|
555
|
+
const aligned =
|
|
556
|
+
mode === 'week' ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
|
|
557
|
+
return Math.round(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
|
|
558
|
+
},
|
|
559
|
+
[anchor, mode, step, weekStartsOn],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// The committed date's page is the centred/active one. `viewedIndexRef` tracks
|
|
563
|
+
// where the list actually sits, telling swipe-driven changes from external ones.
|
|
564
|
+
const activeIndex = indexOfDate(date);
|
|
565
|
+
const viewedIndexRef = useRef(activeIndex);
|
|
566
|
+
|
|
567
|
+
// Header days track the committed date and render outside the list, so a swipe
|
|
568
|
+
// never flashes another day's label.
|
|
569
|
+
const headerDays = useMemo(
|
|
570
|
+
() => (mode === 'week' ? getWeekDays(date, weekStartsOn) : [date]),
|
|
571
|
+
[mode, date, weekStartsOn],
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const handleViewableItemsChanged = useCallback(
|
|
575
|
+
(info: OnViewableItemsChangedInfo<Date>) => {
|
|
576
|
+
const settled = info.viewableItems.find((token) => token.isViewable);
|
|
577
|
+
if (settled?.index == null || settled.index === viewedIndexRef.current) return;
|
|
578
|
+
viewedIndexRef.current = settled.index;
|
|
579
|
+
if (settled.item) onChangeDate(settled.item);
|
|
580
|
+
},
|
|
581
|
+
[onChangeDate],
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
// Realign the list when the date changes from outside a swipe (e.g. a "today"
|
|
585
|
+
// button or a month-view tap). Swipe-driven changes already match.
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
if (activeIndex === viewedIndexRef.current) return;
|
|
588
|
+
viewedIndexRef.current = activeIndex;
|
|
589
|
+
listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
|
|
590
|
+
}, [activeIndex]);
|
|
591
|
+
|
|
592
|
+
const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
|
|
593
|
+
const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
|
|
594
|
+
const getFixedItemSize = useCallback(() => width, [width]);
|
|
595
|
+
const renderItem = useCallback(
|
|
596
|
+
({ item, index }: LegendListRenderItemProps<Date>) => (
|
|
597
|
+
<View style={{ width, height: pageHeight }}>
|
|
598
|
+
<TimetablePage
|
|
599
|
+
mode={mode}
|
|
600
|
+
date={item}
|
|
601
|
+
events={events}
|
|
602
|
+
cellHeight={cellHeight}
|
|
603
|
+
hourHeight={hourHeight}
|
|
604
|
+
committedCellHeight={committedCellHeight}
|
|
605
|
+
scrollY={scrollY}
|
|
606
|
+
isActive={index === activeIndex}
|
|
607
|
+
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
608
|
+
weekStartsOn={weekStartsOn}
|
|
609
|
+
hourColumnWidth={hourColumnWidth}
|
|
610
|
+
minHour={clampedMinHour}
|
|
611
|
+
maxHour={clampedMaxHour}
|
|
612
|
+
ampm={ampm}
|
|
613
|
+
minHourHeight={minHourHeight}
|
|
614
|
+
maxHourHeight={maxHourHeight}
|
|
615
|
+
showNowIndicator={showNowIndicator}
|
|
616
|
+
renderEvent={renderEvent}
|
|
617
|
+
keyExtractor={keyExtractor}
|
|
618
|
+
onPressEvent={onPressEvent}
|
|
619
|
+
onPressCell={onPressCell}
|
|
620
|
+
/>
|
|
621
|
+
</View>
|
|
622
|
+
),
|
|
623
|
+
[
|
|
624
|
+
width,
|
|
625
|
+
pageHeight,
|
|
626
|
+
mode,
|
|
627
|
+
events,
|
|
628
|
+
cellHeight,
|
|
629
|
+
hourHeight,
|
|
630
|
+
committedCellHeight,
|
|
631
|
+
scrollY,
|
|
632
|
+
activeIndex,
|
|
633
|
+
scrollOffsetMinutes,
|
|
634
|
+
weekStartsOn,
|
|
635
|
+
hourColumnWidth,
|
|
636
|
+
clampedMinHour,
|
|
637
|
+
clampedMaxHour,
|
|
638
|
+
ampm,
|
|
639
|
+
minHourHeight,
|
|
640
|
+
maxHourHeight,
|
|
641
|
+
showNowIndicator,
|
|
642
|
+
renderEvent,
|
|
643
|
+
keyExtractor,
|
|
644
|
+
onPressEvent,
|
|
645
|
+
onPressCell,
|
|
646
|
+
],
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<View style={styles.container}>
|
|
651
|
+
{renderHeader ? (
|
|
652
|
+
renderHeader(headerDays)
|
|
653
|
+
) : (
|
|
654
|
+
<DefaultHeader
|
|
655
|
+
days={headerDays}
|
|
656
|
+
mode={mode}
|
|
657
|
+
width={width}
|
|
658
|
+
hourColumnWidth={hourColumnWidth}
|
|
659
|
+
locale={locale}
|
|
660
|
+
/>
|
|
661
|
+
)}
|
|
662
|
+
|
|
663
|
+
<View
|
|
664
|
+
style={styles.pager}
|
|
665
|
+
onLayout={(event) => setPageHeight(event.nativeEvent.layout.height)}
|
|
666
|
+
>
|
|
667
|
+
<LegendList
|
|
668
|
+
// Remount when the measured page height changes so the list adopts
|
|
669
|
+
// the corrected item height (avoids keeping the oversized window seed).
|
|
670
|
+
key={pageHeight}
|
|
671
|
+
ref={listRef}
|
|
672
|
+
style={styles.pagerList}
|
|
673
|
+
data={pageDates}
|
|
674
|
+
horizontal
|
|
675
|
+
recycleItems={false}
|
|
676
|
+
keyExtractor={keyExtractorList}
|
|
677
|
+
getFixedItemSize={getFixedItemSize}
|
|
678
|
+
// Default: native paging — each page is the viewport width, so a swipe
|
|
679
|
+
// hard-stops at the adjacent page and can't fling past it. With
|
|
680
|
+
// `freeSwipe`, momentum carries across pages and snaps to a boundary.
|
|
681
|
+
pagingEnabled={!freeSwipe}
|
|
682
|
+
snapToIndices={freeSwipe ? snapToIndices : undefined}
|
|
683
|
+
initialScrollIndex={activeIndex}
|
|
684
|
+
showsHorizontalScrollIndicator={false}
|
|
685
|
+
viewabilityConfig={PAGE_VIEWABILITY}
|
|
686
|
+
onViewableItemsChanged={handleViewableItemsChanged}
|
|
687
|
+
renderItem={renderItem}
|
|
688
|
+
/>
|
|
689
|
+
</View>
|
|
690
|
+
</View>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export const TimeGrid = memo(TimeGridInner) as typeof TimeGridInner;
|
|
695
|
+
|
|
696
|
+
type DefaultHeaderProps = {
|
|
697
|
+
days: Date[];
|
|
698
|
+
mode: CalendarMode;
|
|
699
|
+
width: number;
|
|
700
|
+
hourColumnWidth: number;
|
|
701
|
+
locale?: string;
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const DefaultHeader = ({ days, mode, width, hourColumnWidth, locale }: DefaultHeaderProps) => {
|
|
705
|
+
const dayWidth = mode === 'week' ? (width - hourColumnWidth) / days.length : width;
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
<View style={styles.headerRow}>
|
|
709
|
+
{mode === 'week' ? <View style={{ width: hourColumnWidth }} /> : null}
|
|
710
|
+
{days.map((day) => (
|
|
711
|
+
<DayHeader key={day.toISOString()} day={day} mode={mode} width={dayWidth} locale={locale} />
|
|
712
|
+
))}
|
|
713
|
+
</View>
|
|
714
|
+
);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
type DayHeaderProps = {
|
|
718
|
+
day: Date;
|
|
719
|
+
mode: CalendarMode;
|
|
720
|
+
width: number;
|
|
721
|
+
locale?: string;
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
725
|
+
const theme = useCalendarTheme();
|
|
726
|
+
const isToday = getIsToday(day);
|
|
727
|
+
const badgeSize = mode === 'day' ? 44 : 32;
|
|
728
|
+
|
|
729
|
+
return (
|
|
730
|
+
<View style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}>
|
|
731
|
+
<View
|
|
732
|
+
style={[
|
|
733
|
+
styles.dayHeaderBadge,
|
|
734
|
+
isToday && {
|
|
735
|
+
backgroundColor: theme.colors.todayBackground,
|
|
736
|
+
borderRadius: 999,
|
|
737
|
+
width: badgeSize,
|
|
738
|
+
height: badgeSize,
|
|
739
|
+
},
|
|
740
|
+
]}
|
|
741
|
+
>
|
|
742
|
+
<Text
|
|
743
|
+
style={[
|
|
744
|
+
theme.text.dayNumber,
|
|
745
|
+
{ color: isToday ? theme.colors.todayText : theme.colors.text },
|
|
746
|
+
]}
|
|
747
|
+
allowFontScaling={false}
|
|
748
|
+
{...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
|
|
749
|
+
>
|
|
750
|
+
{day.getDate()}
|
|
751
|
+
</Text>
|
|
752
|
+
</View>
|
|
753
|
+
<Text style={[theme.text.weekday, { color: theme.colors.text }]} allowFontScaling={false}>
|
|
754
|
+
{day.toLocaleDateString(locale, { weekday: 'short' })}
|
|
755
|
+
</Text>
|
|
756
|
+
</View>
|
|
757
|
+
);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const styles = StyleSheet.create({
|
|
761
|
+
pager: {
|
|
762
|
+
flex: 1,
|
|
763
|
+
},
|
|
764
|
+
pagerList: {
|
|
765
|
+
flex: 1,
|
|
766
|
+
},
|
|
767
|
+
container: {
|
|
768
|
+
flex: 1,
|
|
769
|
+
},
|
|
770
|
+
headerRow: {
|
|
771
|
+
flexDirection: 'row',
|
|
772
|
+
alignItems: 'center',
|
|
773
|
+
paddingBottom: 8,
|
|
774
|
+
},
|
|
775
|
+
dayHeader: {
|
|
776
|
+
alignItems: 'center',
|
|
777
|
+
},
|
|
778
|
+
dayHeaderBadge: {
|
|
779
|
+
justifyContent: 'center',
|
|
780
|
+
alignItems: 'center',
|
|
781
|
+
},
|
|
782
|
+
content: {
|
|
783
|
+
width: '100%',
|
|
784
|
+
position: 'relative',
|
|
785
|
+
},
|
|
786
|
+
cellPressLayer: {
|
|
787
|
+
position: 'absolute',
|
|
788
|
+
top: 0,
|
|
789
|
+
bottom: 0,
|
|
790
|
+
right: 0,
|
|
791
|
+
},
|
|
792
|
+
weekendColumn: {
|
|
793
|
+
position: 'absolute',
|
|
794
|
+
top: 0,
|
|
795
|
+
},
|
|
796
|
+
daySeparator: {
|
|
797
|
+
position: 'absolute',
|
|
798
|
+
top: 0,
|
|
799
|
+
width: StyleSheet.hairlineWidth,
|
|
800
|
+
},
|
|
801
|
+
hourRow: {
|
|
802
|
+
position: 'absolute',
|
|
803
|
+
left: 0,
|
|
804
|
+
right: 0,
|
|
805
|
+
flexDirection: 'row',
|
|
806
|
+
alignItems: 'flex-start',
|
|
807
|
+
},
|
|
808
|
+
hourLabel: {
|
|
809
|
+
marginTop: -HOUR_LABEL_NUDGE,
|
|
810
|
+
textAlign: 'center',
|
|
811
|
+
},
|
|
812
|
+
hourLine: {
|
|
813
|
+
flex: 1,
|
|
814
|
+
height: StyleSheet.hairlineWidth,
|
|
815
|
+
},
|
|
816
|
+
eventBox: {
|
|
817
|
+
position: 'absolute',
|
|
818
|
+
overflow: 'hidden',
|
|
819
|
+
},
|
|
820
|
+
nowIndicator: {
|
|
821
|
+
position: 'absolute',
|
|
822
|
+
right: 0,
|
|
823
|
+
height: 2,
|
|
824
|
+
},
|
|
825
|
+
});
|