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/dist/index.mjs ADDED
@@ -0,0 +1,891 @@
1
+ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import Animated, { scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useDerivedValue, useSharedValue } from "react-native-reanimated";
3
+ import { addDays, addMonths, differenceInCalendarDays, differenceInCalendarMonths, differenceInMinutes, eachDayOfInterval, endOfMonth, endOfWeek, format, getHours, getMinutes, isSameDay, isSameMonth, isToday, max, min, startOfDay, startOfMonth, startOfWeek } from "date-fns";
4
+ import { Pressable, StyleSheet, Text, TouchableOpacity, View, useWindowDimensions } from "react-native";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { LegendList } from "@legendapp/list/react-native";
7
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
8
+ //#region src/theme.ts
9
+ const defaultTheme = {
10
+ colors: {
11
+ gridLine: "#E2E4E9",
12
+ weekendBackground: "#F6F7F9",
13
+ todayBackground: "#1F6FEB",
14
+ todayText: "#FFFFFF",
15
+ nowIndicator: "#E5484D",
16
+ text: "#1A1B1E",
17
+ textMuted: "#6B7280",
18
+ textDisabled: "#B5B9C0",
19
+ eventBackground: "#DCE7FF",
20
+ eventText: "#1A1B1E"
21
+ },
22
+ text: {
23
+ dayNumber: {
24
+ fontSize: 22,
25
+ fontWeight: "700"
26
+ },
27
+ weekday: {
28
+ fontSize: 13,
29
+ fontWeight: "700"
30
+ },
31
+ dateCell: {
32
+ fontSize: 13,
33
+ fontWeight: "700"
34
+ },
35
+ hourLabel: { fontSize: 12 },
36
+ more: {
37
+ fontSize: 11,
38
+ fontWeight: "700"
39
+ },
40
+ eventTitle: {
41
+ fontSize: 12,
42
+ fontWeight: "700"
43
+ }
44
+ },
45
+ todayBadgeRadius: 999
46
+ };
47
+ /** Deep-merge a partial theme over {@link defaultTheme}. */
48
+ function mergeTheme(theme) {
49
+ if (!theme) return defaultTheme;
50
+ return {
51
+ colors: {
52
+ ...defaultTheme.colors,
53
+ ...theme.colors
54
+ },
55
+ text: {
56
+ ...defaultTheme.text,
57
+ ...theme.text
58
+ },
59
+ todayBadgeRadius: theme.todayBadgeRadius ?? defaultTheme.todayBadgeRadius
60
+ };
61
+ }
62
+ const CalendarThemeContext = createContext(defaultTheme);
63
+ const CalendarThemeProvider = CalendarThemeContext.Provider;
64
+ const useCalendarTheme = () => useContext(CalendarThemeContext);
65
+ //#endregion
66
+ //#region src/components/DefaultEvent.tsx
67
+ /**
68
+ * The built-in event renderer: a filled, rounded box showing the event title
69
+ * and (on the day/week grid) its time range. Pass your own `renderEvent` to
70
+ * `<Calendar>` to replace it entirely.
71
+ */
72
+ function DefaultEvent({ event, mode, onPress }) {
73
+ const theme = useCalendarTheme();
74
+ const showTime = mode !== "month";
75
+ return /* @__PURE__ */ jsxs(TouchableOpacity, {
76
+ style: [styles$3.box, { backgroundColor: theme.colors.eventBackground }],
77
+ onPress,
78
+ activeOpacity: .7,
79
+ accessibilityRole: "button",
80
+ accessibilityLabel: event.title,
81
+ children: [event.title ? /* @__PURE__ */ jsx(Text, {
82
+ style: [theme.text.eventTitle, { color: theme.colors.eventText }],
83
+ numberOfLines: mode === "day" ? void 0 : 1,
84
+ ellipsizeMode: "tail",
85
+ allowFontScaling: false,
86
+ children: event.title
87
+ }) : null, showTime ? /* @__PURE__ */ jsx(Text, {
88
+ style: [styles$3.time, { color: theme.colors.eventText }],
89
+ numberOfLines: 1,
90
+ allowFontScaling: false,
91
+ children: `${format(event.start, "HH:mm")} - ${format(event.end, "HH:mm")}`
92
+ }) : null]
93
+ });
94
+ }
95
+ const styles$3 = StyleSheet.create({
96
+ box: {
97
+ flex: 1,
98
+ borderRadius: 6,
99
+ paddingVertical: 2,
100
+ paddingHorizontal: 4,
101
+ overflow: "hidden"
102
+ },
103
+ time: { fontSize: 11 }
104
+ });
105
+ //#endregion
106
+ //#region src/utils/dates.ts
107
+ /** The seven dates of the week containing `date`, starting on `weekStartsOn`. */
108
+ const getWeekDays = (date, weekStartsOn) => {
109
+ const start = startOfWeek(date, { weekStartsOn });
110
+ return Array.from({ length: 7 }, (_, index) => addDays(start, index));
111
+ };
112
+ const isWeekend = (date) => {
113
+ const day = date.getDay();
114
+ return day === 0 || day === 6;
115
+ };
116
+ const getIsToday = (date) => isToday(date);
117
+ const isSameCalendarDay = (a, b) => isSameDay(a, b);
118
+ /** Minutes elapsed since midnight (0–1439). */
119
+ const minutesIntoDay = (date) => getHours(date) * 60 + getMinutes(date);
120
+ //#endregion
121
+ //#region src/utils/layout.ts
122
+ const MINUTES_PER_HOUR$1 = 60;
123
+ const MIN_DURATION_HOURS = .25;
124
+ /**
125
+ * Lay out a single day's events: events that overlap in time are split into
126
+ * side-by-side columns. Multi-day events are clipped to the portion that falls
127
+ * on `day` (e.g. a 23:00→01:00 event renders 23:00–24:00 on the start day and
128
+ * 00:00–01:00 on the next). Pure — safe to call per render, never per frame.
129
+ */
130
+ function layoutDayEvents(events, day) {
131
+ const dayStart = startOfDay(day);
132
+ const nextDayStart = addDays(dayStart, 1);
133
+ const segments = events.filter((event) => event.start < nextDayStart && event.end > dayStart).map((event) => {
134
+ const segStart = max([event.start, dayStart]);
135
+ const segEnd = min([event.end, nextDayStart]);
136
+ return {
137
+ event,
138
+ start: differenceInMinutes(segStart, dayStart) / MINUTES_PER_HOUR$1,
139
+ end: differenceInMinutes(segEnd, dayStart) / MINUTES_PER_HOUR$1,
140
+ continuesBefore: event.start < dayStart,
141
+ continuesAfter: event.end > nextDayStart
142
+ };
143
+ }).sort((a, b) => a.start - b.start);
144
+ const positioned = [];
145
+ let cluster = [];
146
+ let clusterEnd = Number.NEGATIVE_INFINITY;
147
+ const flushCluster = () => {
148
+ const columnEnds = [];
149
+ const columnOf = /* @__PURE__ */ new Map();
150
+ for (const seg of cluster) {
151
+ let column = columnEnds.findIndex((end) => end <= seg.start);
152
+ if (column === -1) {
153
+ column = columnEnds.length;
154
+ columnEnds.push(seg.end);
155
+ } else columnEnds[column] = seg.end;
156
+ columnOf.set(seg, column);
157
+ }
158
+ for (const seg of cluster) positioned.push({
159
+ event: seg.event,
160
+ startHours: seg.start,
161
+ durationHours: Math.max(seg.end - seg.start, MIN_DURATION_HOURS),
162
+ column: columnOf.get(seg) ?? 0,
163
+ columns: columnEnds.length,
164
+ continuesBefore: seg.continuesBefore,
165
+ continuesAfter: seg.continuesAfter
166
+ });
167
+ cluster = [];
168
+ };
169
+ for (const seg of segments) {
170
+ if (cluster.length > 0 && seg.start >= clusterEnd) flushCluster();
171
+ cluster.push(seg);
172
+ clusterEnd = Math.max(clusterEnd, seg.end);
173
+ }
174
+ if (cluster.length > 0) flushCluster();
175
+ return positioned;
176
+ }
177
+ /**
178
+ * The `startOfDay` ISO keys of every calendar day an event touches (inclusive).
179
+ * An event ending exactly at midnight does not count the following day. Used to
180
+ * index events by day for the month grid. Pure.
181
+ */
182
+ function eventDayKeys(event) {
183
+ const first = startOfDay(event.start);
184
+ const last = startOfDay(event.end > event.start ? /* @__PURE__ */ new Date(event.end.getTime() - 1) : event.start);
185
+ const keys = [];
186
+ for (let cursor = first; cursor <= last; cursor = addDays(cursor, 1)) keys.push(cursor.toISOString());
187
+ return keys;
188
+ }
189
+ //#endregion
190
+ //#region src/components/MonthView.tsx
191
+ const chunkIntoWeeks = (days) => {
192
+ const weeks = [];
193
+ for (let index = 0; index < days.length; index += 7) weeks.push(days.slice(index, index + 7));
194
+ return weeks;
195
+ };
196
+ function MonthViewInner({ date, events, maxVisibleEventCount, weekStartsOn, renderEvent, keyExtractor, onPressDay, onPressEvent, onPressMore }) {
197
+ const theme = useCalendarTheme();
198
+ const RenderEventComponent = renderEvent;
199
+ const weeks = useMemo(() => {
200
+ return chunkIntoWeeks(eachDayOfInterval({
201
+ start: startOfWeek(startOfMonth(date), { weekStartsOn }),
202
+ end: endOfWeek(endOfMonth(date), { weekStartsOn })
203
+ }));
204
+ }, [date, weekStartsOn]);
205
+ const eventsByDay = useMemo(() => {
206
+ const map = /* @__PURE__ */ new Map();
207
+ for (const event of events) for (const key of eventDayKeys(event)) {
208
+ const existing = map.get(key);
209
+ if (existing) existing.push(event);
210
+ else map.set(key, [event]);
211
+ }
212
+ return map;
213
+ }, [events]);
214
+ const renderDay = (day) => {
215
+ const dayEvents = eventsByDay.get(startOfDay(day).toISOString()) ?? [];
216
+ const isCurrentMonth = isSameMonth(day, date);
217
+ const isToday = getIsToday(day);
218
+ const hiddenCount = dayEvents.length - maxVisibleEventCount;
219
+ const dateColor = isToday ? theme.colors.todayText : isCurrentMonth ? theme.colors.text : theme.colors.textDisabled;
220
+ const eventCount = dayEvents.length;
221
+ const accessibilityLabel = `${format(day, "EEEE, d LLLL yyyy")}${isToday ? ", today" : ""}, ${eventCount} ${eventCount === 1 ? "event" : "events"}`;
222
+ return /* @__PURE__ */ jsxs(TouchableOpacity, {
223
+ style: [
224
+ styles$2.dayCell,
225
+ { borderColor: theme.colors.gridLine },
226
+ isWeekend(day) && { backgroundColor: theme.colors.weekendBackground }
227
+ ],
228
+ onPress: onPressDay ? () => onPressDay(day) : void 0,
229
+ disabled: !onPressDay,
230
+ accessibilityRole: onPressDay ? "button" : void 0,
231
+ accessibilityLabel,
232
+ children: [
233
+ /* @__PURE__ */ jsx(View, {
234
+ style: [styles$2.dateBadge, isToday && {
235
+ backgroundColor: theme.colors.todayBackground,
236
+ borderRadius: theme.todayBadgeRadius
237
+ }],
238
+ children: /* @__PURE__ */ jsx(Text, {
239
+ style: [theme.text.dateCell, { color: dateColor }],
240
+ allowFontScaling: false,
241
+ children: format(day, "d")
242
+ })
243
+ }),
244
+ dayEvents.slice(0, maxVisibleEventCount).map((event, index) => /* @__PURE__ */ jsx(View, {
245
+ style: styles$2.monthEvent,
246
+ children: /* @__PURE__ */ jsx(RenderEventComponent, {
247
+ event,
248
+ mode: "month",
249
+ onPress: () => onPressEvent(event)
250
+ })
251
+ }, keyExtractor(event, index))),
252
+ hiddenCount > 0 ? /* @__PURE__ */ jsx(Text, {
253
+ style: [
254
+ theme.text.more,
255
+ styles$2.moreLabel,
256
+ { color: theme.colors.textMuted }
257
+ ],
258
+ onPress: onPressMore ? () => onPressMore(dayEvents, day) : void 0,
259
+ accessibilityRole: "button",
260
+ accessibilityLabel: `Show ${hiddenCount} more events`,
261
+ allowFontScaling: false,
262
+ children: `${hiddenCount} More`
263
+ }) : null
264
+ ]
265
+ }, day.toISOString());
266
+ };
267
+ return /* @__PURE__ */ jsx(View, {
268
+ style: styles$2.container,
269
+ children: weeks.map((week) => /* @__PURE__ */ jsx(View, {
270
+ style: styles$2.weekRow,
271
+ children: week.map((day) => renderDay(day))
272
+ }, week[0].toISOString()))
273
+ });
274
+ }
275
+ const MonthView = memo(MonthViewInner);
276
+ const styles$2 = StyleSheet.create({
277
+ container: { flex: 1 },
278
+ weekRow: {
279
+ flex: 1,
280
+ flexDirection: "row"
281
+ },
282
+ dayCell: {
283
+ flex: 1,
284
+ alignItems: "center",
285
+ paddingTop: 4,
286
+ gap: 2,
287
+ overflow: "hidden",
288
+ borderTopWidth: StyleSheet.hairlineWidth,
289
+ borderRightWidth: StyleSheet.hairlineWidth
290
+ },
291
+ dateBadge: {
292
+ justifyContent: "center",
293
+ alignItems: "center",
294
+ height: 24,
295
+ width: 24
296
+ },
297
+ monthEvent: { width: "92%" },
298
+ moreLabel: { marginTop: 2 }
299
+ });
300
+ //#endregion
301
+ //#region src/components/MonthPager.tsx
302
+ const PAGE_WINDOW$1 = 60;
303
+ const PAGE_VIEWABILITY$1 = { itemVisiblePercentThreshold: 90 };
304
+ function MonthPagerInner({ date, events, maxVisibleEventCount, weekStartsOn, renderEvent, keyExtractor, onPressDay, onPressEvent, onPressMore, onChangeDate, freeSwipe = false }) {
305
+ const { width, height } = useWindowDimensions();
306
+ const listRef = useRef(null);
307
+ const [pageHeight, setPageHeight] = useState(height);
308
+ const [anchorDate] = useState(date);
309
+ const anchor = useMemo(() => startOfMonth(anchorDate), [anchorDate]);
310
+ const monthDates = useMemo(() => Array.from({ length: 121 }, (_, i) => addMonths(anchor, i - PAGE_WINDOW$1)), [anchor]);
311
+ const activeIndex = useCallback((target) => differenceInCalendarMonths(startOfMonth(target), anchor) + PAGE_WINDOW$1, [anchor])(date);
312
+ const viewedIndexRef = useRef(activeIndex);
313
+ const handleViewableItemsChanged = useCallback((info) => {
314
+ const settled = info.viewableItems.find((token) => token.isViewable);
315
+ if (settled?.index == null || settled.index === viewedIndexRef.current) return;
316
+ viewedIndexRef.current = settled.index;
317
+ if (settled.item) onChangeDate(settled.item);
318
+ }, [onChangeDate]);
319
+ useEffect(() => {
320
+ if (activeIndex === viewedIndexRef.current) return;
321
+ viewedIndexRef.current = activeIndex;
322
+ listRef.current?.scrollToIndex({
323
+ index: activeIndex,
324
+ animated: false
325
+ });
326
+ }, [activeIndex]);
327
+ const snapToIndices = useMemo(() => monthDates.map((_, index) => index), [monthDates]);
328
+ const keyExtractorList = useCallback((item) => item.toISOString(), []);
329
+ const getFixedItemSize = useCallback(() => width, [width]);
330
+ const renderItem = useCallback(({ item }) => /* @__PURE__ */ jsx(View, {
331
+ style: {
332
+ width,
333
+ height: pageHeight
334
+ },
335
+ children: /* @__PURE__ */ jsx(MonthView, {
336
+ date: item,
337
+ events,
338
+ maxVisibleEventCount,
339
+ weekStartsOn,
340
+ renderEvent,
341
+ keyExtractor,
342
+ onPressDay,
343
+ onPressEvent,
344
+ onPressMore
345
+ })
346
+ }), [
347
+ width,
348
+ pageHeight,
349
+ events,
350
+ maxVisibleEventCount,
351
+ weekStartsOn,
352
+ renderEvent,
353
+ keyExtractor,
354
+ onPressDay,
355
+ onPressEvent,
356
+ onPressMore
357
+ ]);
358
+ return /* @__PURE__ */ jsx(View, {
359
+ style: styles$1.pager,
360
+ onLayout: (event) => setPageHeight(event.nativeEvent.layout.height),
361
+ children: /* @__PURE__ */ jsx(LegendList, {
362
+ ref: listRef,
363
+ style: styles$1.pagerList,
364
+ data: monthDates,
365
+ horizontal: true,
366
+ recycleItems: false,
367
+ keyExtractor: keyExtractorList,
368
+ getFixedItemSize,
369
+ pagingEnabled: !freeSwipe,
370
+ snapToIndices: freeSwipe ? snapToIndices : void 0,
371
+ initialScrollIndex: activeIndex,
372
+ showsHorizontalScrollIndicator: false,
373
+ viewabilityConfig: PAGE_VIEWABILITY$1,
374
+ onViewableItemsChanged: handleViewableItemsChanged,
375
+ renderItem
376
+ }, pageHeight)
377
+ });
378
+ }
379
+ const MonthPager = memo(MonthPagerInner);
380
+ const styles$1 = StyleSheet.create({
381
+ pager: { flex: 1 },
382
+ pagerList: { flex: 1 }
383
+ });
384
+ //#endregion
385
+ //#region src/components/TimeGrid.tsx
386
+ const MINUTES_PER_HOUR = 60;
387
+ const HOURS_PER_DAY = 24;
388
+ const DAY_VIEW_STEP = 1;
389
+ const WEEK_VIEW_STEP = 7;
390
+ const PAGE_WINDOW = 180;
391
+ const PAGE_VIEWABILITY = { itemVisiblePercentThreshold: 90 };
392
+ const DEFAULT_HOUR_HEIGHT = 64;
393
+ const DEFAULT_MIN_HOUR_HEIGHT = 32;
394
+ const DEFAULT_MAX_HOUR_HEIGHT = 160;
395
+ const DEFAULT_HOUR_COLUMN_WIDTH = 50;
396
+ const MIN_EVENT_HEIGHT = 32;
397
+ const HOUR_LABEL_TOP_INSET = 12;
398
+ const NOW_TICK_MS = 6e4;
399
+ function useNow(enabled) {
400
+ const [now, setNow] = useState(() => /* @__PURE__ */ new Date());
401
+ useEffect(() => {
402
+ if (!enabled) return;
403
+ setNow(/* @__PURE__ */ new Date());
404
+ const id = setInterval(() => setNow(/* @__PURE__ */ new Date()), NOW_TICK_MS);
405
+ return () => clearInterval(id);
406
+ }, [enabled]);
407
+ return now;
408
+ }
409
+ function formatHourLabel(hour, ampm) {
410
+ if (!ampm) return String(hour);
411
+ const period = hour < 12 ? "AM" : "PM";
412
+ return `${hour % 12 === 0 ? 12 : hour % 12} ${period}`;
413
+ }
414
+ function AnimatedEventBox({ positioned, cellHeight, minHour, left, width, mode, renderEvent, onPress }) {
415
+ const RenderEventComponent = renderEvent;
416
+ const boxHeight = useDerivedValue(() => Math.max(positioned.durationHours * cellHeight.value, MIN_EVENT_HEIGHT), [positioned.durationHours]);
417
+ const boxStyle = useAnimatedStyle(() => ({
418
+ top: (positioned.startHours - minHour) * cellHeight.value,
419
+ height: boxHeight.value
420
+ }), [
421
+ positioned.startHours,
422
+ positioned.durationHours,
423
+ minHour
424
+ ]);
425
+ const handlePress = () => onPress(positioned.event);
426
+ return /* @__PURE__ */ jsx(Animated.View, {
427
+ style: [
428
+ styles.eventBox,
429
+ {
430
+ left,
431
+ width
432
+ },
433
+ boxStyle
434
+ ],
435
+ children: /* @__PURE__ */ jsx(RenderEventComponent, {
436
+ event: positioned.event,
437
+ mode,
438
+ boxHeight,
439
+ continuesBefore: positioned.continuesBefore,
440
+ continuesAfter: positioned.continuesAfter,
441
+ onPress: handlePress
442
+ })
443
+ });
444
+ }
445
+ const HourRow = ({ hour, minHour, cellHeight, hourColumnWidth, label }) => {
446
+ const theme = useCalendarTheme();
447
+ const animatedStyle = useAnimatedStyle(() => ({ top: (hour - minHour) * cellHeight.value }), [hour, minHour]);
448
+ return /* @__PURE__ */ jsxs(Animated.View, {
449
+ style: [styles.hourRow, animatedStyle],
450
+ pointerEvents: "none",
451
+ children: [/* @__PURE__ */ jsx(Text, {
452
+ style: [
453
+ theme.text.hourLabel,
454
+ styles.hourLabel,
455
+ {
456
+ width: hourColumnWidth,
457
+ color: theme.colors.textMuted
458
+ }
459
+ ],
460
+ allowFontScaling: false,
461
+ children: label
462
+ }), /* @__PURE__ */ jsx(View, { style: [styles.hourLine, { backgroundColor: theme.colors.gridLine }] })]
463
+ });
464
+ };
465
+ const NowIndicator = ({ cellHeight, nowHours, minHour, left, color }) => {
466
+ const animatedStyle = useAnimatedStyle(() => ({ top: (nowHours - minHour) * cellHeight.value }), [nowHours, minHour]);
467
+ return /* @__PURE__ */ jsx(Animated.View, {
468
+ style: [
469
+ styles.nowIndicator,
470
+ {
471
+ left,
472
+ backgroundColor: color
473
+ },
474
+ animatedStyle
475
+ ],
476
+ pointerEvents: "none"
477
+ });
478
+ };
479
+ function TimetablePageInner({ mode, date, events, cellHeight, hourHeight, committedCellHeight, scrollY, isActive, scrollOffsetMinutes, weekStartsOn, hourColumnWidth, minHour, maxHour, ampm, minHourHeight, maxHourHeight, showNowIndicator, renderEvent, keyExtractor, onPressEvent, onPressCell }) {
480
+ const theme = useCalendarTheme();
481
+ const { width } = useWindowDimensions();
482
+ const scrollRef = useAnimatedRef();
483
+ const heightSource = isActive ? cellHeight : committedCellHeight;
484
+ const scrollHandler = useAnimatedScrollHandler((event) => {
485
+ if (isActive) scrollY.value = event.contentOffset.y;
486
+ });
487
+ useAnimatedReaction(() => scrollY.value, (current, previous) => {
488
+ if (!isActive && current !== previous) scrollTo(scrollRef, 0, current, false);
489
+ });
490
+ const days = useMemo(() => mode === "week" ? getWeekDays(date, weekStartsOn) : [date], [
491
+ mode,
492
+ date,
493
+ weekStartsOn
494
+ ]);
495
+ const dayWidth = (width - hourColumnWidth) / days.length;
496
+ const dayLeft = (dayIndex) => hourColumnWidth + dayIndex * dayWidth;
497
+ const dayLayouts = useMemo(() => days.map((day) => layoutDayEvents(events, day)), [days, events]);
498
+ const handleBackgroundPress = (event) => {
499
+ if (!onPressCell) return;
500
+ const { locationX, locationY } = event.nativeEvent;
501
+ const day = days[days.length === 1 ? 0 : Math.floor(locationX / dayWidth)];
502
+ if (!day) return;
503
+ const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
504
+ const pressed = new Date(day);
505
+ pressed.setHours(0, 0, 0, 0);
506
+ pressed.setMinutes(minutes);
507
+ onPressCell(pressed);
508
+ };
509
+ const hoursRange = useMemo(() => Array.from({ length: maxHour - minHour }, (_, index) => minHour + index), [minHour, maxHour]);
510
+ const now = useNow(showNowIndicator && isActive);
511
+ const nowDayIndex = days.findIndex((day) => getIsToday(day));
512
+ const nowHours = (getHours(now) * MINUTES_PER_HOUR + getMinutes(now)) / MINUTES_PER_HOUR;
513
+ const nowInWindow = nowHours >= minHour && nowHours <= maxHour;
514
+ const fullHeightStyle = useAnimatedStyle(() => ({ height: (maxHour - minHour) * heightSource.value }), [
515
+ minHour,
516
+ maxHour,
517
+ heightSource
518
+ ]);
519
+ const pinchStartCellHeight = useSharedValue(hourHeight);
520
+ const zoomGesture = useMemo(() => {
521
+ const pinch = Gesture.Pinch().onStart(() => {
522
+ pinchStartCellHeight.value = cellHeight.value;
523
+ }).onUpdate((event) => {
524
+ cellHeight.value = Math.min(maxHourHeight, Math.max(minHourHeight, pinchStartCellHeight.value * event.scale));
525
+ }).onEnd(() => {
526
+ committedCellHeight.value = cellHeight.value;
527
+ });
528
+ return Gesture.Simultaneous(pinch, Gesture.Native());
529
+ }, [
530
+ cellHeight,
531
+ committedCellHeight,
532
+ pinchStartCellHeight,
533
+ minHourHeight,
534
+ maxHourHeight
535
+ ]);
536
+ return /* @__PURE__ */ jsx(View, {
537
+ style: styles.container,
538
+ children: /* @__PURE__ */ jsx(GestureDetector, {
539
+ gesture: zoomGesture,
540
+ children: /* @__PURE__ */ jsx(Animated.ScrollView, {
541
+ ref: scrollRef,
542
+ showsVerticalScrollIndicator: true,
543
+ onScroll: scrollHandler,
544
+ scrollEventThrottle: 16,
545
+ contentContainerStyle: { paddingTop: HOUR_LABEL_TOP_INSET },
546
+ contentOffset: {
547
+ x: 0,
548
+ y: Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - minHour) * hourHeight
549
+ },
550
+ children: /* @__PURE__ */ jsxs(Animated.View, {
551
+ style: [styles.content, fullHeightStyle],
552
+ children: [
553
+ onPressCell ? /* @__PURE__ */ jsx(Pressable, {
554
+ style: [styles.cellPressLayer, { left: hourColumnWidth }],
555
+ onPress: handleBackgroundPress,
556
+ importantForAccessibility: "no",
557
+ accessibilityElementsHidden: true
558
+ }) : null,
559
+ days.map((day, dayIndex) => isWeekend(day) ? /* @__PURE__ */ jsx(Animated.View, {
560
+ style: [
561
+ styles.weekendColumn,
562
+ { backgroundColor: theme.colors.weekendBackground },
563
+ {
564
+ left: dayLeft(dayIndex),
565
+ width: dayWidth
566
+ },
567
+ fullHeightStyle
568
+ ],
569
+ pointerEvents: "none"
570
+ }, `weekend-${day.toISOString()}`) : null),
571
+ days.map((day, dayIndex) => /* @__PURE__ */ jsx(Animated.View, {
572
+ style: [
573
+ styles.daySeparator,
574
+ { backgroundColor: theme.colors.gridLine },
575
+ { left: dayLeft(dayIndex) },
576
+ fullHeightStyle
577
+ ],
578
+ pointerEvents: "none"
579
+ }, `separator-${day.toISOString()}`)),
580
+ hoursRange.map((hour) => /* @__PURE__ */ jsx(HourRow, {
581
+ hour,
582
+ minHour,
583
+ cellHeight: heightSource,
584
+ hourColumnWidth,
585
+ label: formatHourLabel(hour, ampm)
586
+ }, hour)),
587
+ dayLayouts.flatMap((layout, dayIndex) => layout.filter((p) => p.startHours < maxHour && p.startHours + p.durationHours > minHour).map((positioned, eventIndex) => {
588
+ const columnWidth = dayWidth / positioned.columns;
589
+ return /* @__PURE__ */ jsx(AnimatedEventBox, {
590
+ positioned,
591
+ cellHeight: heightSource,
592
+ minHour,
593
+ left: dayLeft(dayIndex) + positioned.column * columnWidth,
594
+ width: columnWidth,
595
+ mode,
596
+ renderEvent,
597
+ onPress: onPressEvent
598
+ }, keyExtractor(positioned.event, eventIndex));
599
+ })),
600
+ showNowIndicator && nowDayIndex >= 0 && nowInWindow ? /* @__PURE__ */ jsx(NowIndicator, {
601
+ cellHeight: heightSource,
602
+ nowHours,
603
+ minHour,
604
+ left: dayLeft(nowDayIndex),
605
+ color: theme.colors.nowIndicator
606
+ }) : null
607
+ ]
608
+ })
609
+ })
610
+ })
611
+ });
612
+ }
613
+ const TimetablePage = memo(TimetablePageInner);
614
+ function TimeGridInner({ mode, date, events, cellHeight, hourHeight = 64, weekStartsOn, renderEvent, keyExtractor, scrollOffsetMinutes = 0, hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH, minHour = 0, maxHour = HOURS_PER_DAY, ampm = false, minHourHeight = DEFAULT_MIN_HOUR_HEIGHT, maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT, showNowIndicator = true, locale, freeSwipe = false, onPressEvent, onPressCell, onChangeDate, renderHeader }) {
615
+ const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
616
+ const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
617
+ const { width, height } = useWindowDimensions();
618
+ const listRef = useRef(null);
619
+ const [pageHeight, setPageHeight] = useState(height);
620
+ const step = mode === "week" ? WEEK_VIEW_STEP : DAY_VIEW_STEP;
621
+ const scrollY = useSharedValue(Math.max(0, scrollOffsetMinutes / MINUTES_PER_HOUR - clampedMinHour) * hourHeight);
622
+ const committedCellHeight = useSharedValue(hourHeight);
623
+ const [anchorDate] = useState(date);
624
+ const anchor = useMemo(() => mode === "week" ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate), [
625
+ mode,
626
+ anchorDate,
627
+ weekStartsOn
628
+ ]);
629
+ const pageDates = useMemo(() => Array.from({ length: 361 }, (_, i) => addDays(anchor, (i - PAGE_WINDOW) * step)), [anchor, step]);
630
+ const activeIndex = useCallback((target) => {
631
+ const aligned = mode === "week" ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
632
+ return Math.round(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
633
+ }, [
634
+ anchor,
635
+ mode,
636
+ step,
637
+ weekStartsOn
638
+ ])(date);
639
+ const viewedIndexRef = useRef(activeIndex);
640
+ const headerDays = useMemo(() => mode === "week" ? getWeekDays(date, weekStartsOn) : [date], [
641
+ mode,
642
+ date,
643
+ weekStartsOn
644
+ ]);
645
+ const handleViewableItemsChanged = useCallback((info) => {
646
+ const settled = info.viewableItems.find((token) => token.isViewable);
647
+ if (settled?.index == null || settled.index === viewedIndexRef.current) return;
648
+ viewedIndexRef.current = settled.index;
649
+ if (settled.item) onChangeDate(settled.item);
650
+ }, [onChangeDate]);
651
+ useEffect(() => {
652
+ if (activeIndex === viewedIndexRef.current) return;
653
+ viewedIndexRef.current = activeIndex;
654
+ listRef.current?.scrollToIndex({
655
+ index: activeIndex,
656
+ animated: false
657
+ });
658
+ }, [activeIndex]);
659
+ const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
660
+ const keyExtractorList = useCallback((item) => item.toISOString(), []);
661
+ const getFixedItemSize = useCallback(() => width, [width]);
662
+ const renderItem = useCallback(({ item, index }) => /* @__PURE__ */ jsx(View, {
663
+ style: {
664
+ width,
665
+ height: pageHeight
666
+ },
667
+ children: /* @__PURE__ */ jsx(TimetablePage, {
668
+ mode,
669
+ date: item,
670
+ events,
671
+ cellHeight,
672
+ hourHeight,
673
+ committedCellHeight,
674
+ scrollY,
675
+ isActive: index === activeIndex,
676
+ scrollOffsetMinutes,
677
+ weekStartsOn,
678
+ hourColumnWidth,
679
+ minHour: clampedMinHour,
680
+ maxHour: clampedMaxHour,
681
+ ampm,
682
+ minHourHeight,
683
+ maxHourHeight,
684
+ showNowIndicator,
685
+ renderEvent,
686
+ keyExtractor,
687
+ onPressEvent,
688
+ onPressCell
689
+ })
690
+ }), [
691
+ width,
692
+ pageHeight,
693
+ mode,
694
+ events,
695
+ cellHeight,
696
+ hourHeight,
697
+ committedCellHeight,
698
+ scrollY,
699
+ activeIndex,
700
+ scrollOffsetMinutes,
701
+ weekStartsOn,
702
+ hourColumnWidth,
703
+ clampedMinHour,
704
+ clampedMaxHour,
705
+ ampm,
706
+ minHourHeight,
707
+ maxHourHeight,
708
+ showNowIndicator,
709
+ renderEvent,
710
+ keyExtractor,
711
+ onPressEvent,
712
+ onPressCell
713
+ ]);
714
+ return /* @__PURE__ */ jsxs(View, {
715
+ style: styles.container,
716
+ children: [renderHeader ? renderHeader(headerDays) : /* @__PURE__ */ jsx(DefaultHeader, {
717
+ days: headerDays,
718
+ mode,
719
+ width,
720
+ hourColumnWidth,
721
+ locale
722
+ }), /* @__PURE__ */ jsx(View, {
723
+ style: styles.pager,
724
+ onLayout: (event) => setPageHeight(event.nativeEvent.layout.height),
725
+ children: /* @__PURE__ */ jsx(LegendList, {
726
+ ref: listRef,
727
+ style: styles.pagerList,
728
+ data: pageDates,
729
+ horizontal: true,
730
+ recycleItems: false,
731
+ keyExtractor: keyExtractorList,
732
+ getFixedItemSize,
733
+ pagingEnabled: !freeSwipe,
734
+ snapToIndices: freeSwipe ? snapToIndices : void 0,
735
+ initialScrollIndex: activeIndex,
736
+ showsHorizontalScrollIndicator: false,
737
+ viewabilityConfig: PAGE_VIEWABILITY,
738
+ onViewableItemsChanged: handleViewableItemsChanged,
739
+ renderItem
740
+ }, pageHeight)
741
+ })]
742
+ });
743
+ }
744
+ const TimeGrid = memo(TimeGridInner);
745
+ const DefaultHeader = ({ days, mode, width, hourColumnWidth, locale }) => {
746
+ const dayWidth = mode === "week" ? (width - hourColumnWidth) / days.length : width;
747
+ return /* @__PURE__ */ jsxs(View, {
748
+ style: styles.headerRow,
749
+ children: [mode === "week" ? /* @__PURE__ */ jsx(View, { style: { width: hourColumnWidth } }) : null, days.map((day) => /* @__PURE__ */ jsx(DayHeader, {
750
+ day,
751
+ mode,
752
+ width: dayWidth,
753
+ locale
754
+ }, day.toISOString()))]
755
+ });
756
+ };
757
+ const DayHeader = ({ day, mode, width, locale }) => {
758
+ const theme = useCalendarTheme();
759
+ const isToday = getIsToday(day);
760
+ const badgeSize = mode === "day" ? 44 : 32;
761
+ return /* @__PURE__ */ jsxs(View, {
762
+ style: [styles.dayHeader, {
763
+ width,
764
+ gap: mode === "day" ? 4 : 2
765
+ }],
766
+ children: [/* @__PURE__ */ jsx(View, {
767
+ style: [styles.dayHeaderBadge, isToday && {
768
+ backgroundColor: theme.colors.todayBackground,
769
+ borderRadius: 999,
770
+ width: badgeSize,
771
+ height: badgeSize
772
+ }],
773
+ children: /* @__PURE__ */ jsx(Text, {
774
+ style: [theme.text.dayNumber, { color: isToday ? theme.colors.todayText : theme.colors.text }],
775
+ allowFontScaling: false,
776
+ ...isToday && { accessibilityLabel: `Today, ${day.getDate()}` },
777
+ children: day.getDate()
778
+ })
779
+ }), /* @__PURE__ */ jsx(Text, {
780
+ style: [theme.text.weekday, { color: theme.colors.text }],
781
+ allowFontScaling: false,
782
+ children: day.toLocaleDateString(locale, { weekday: "short" })
783
+ })]
784
+ });
785
+ };
786
+ const styles = StyleSheet.create({
787
+ pager: { flex: 1 },
788
+ pagerList: { flex: 1 },
789
+ container: { flex: 1 },
790
+ headerRow: {
791
+ flexDirection: "row",
792
+ alignItems: "center",
793
+ paddingBottom: 8
794
+ },
795
+ dayHeader: { alignItems: "center" },
796
+ dayHeaderBadge: {
797
+ justifyContent: "center",
798
+ alignItems: "center"
799
+ },
800
+ content: {
801
+ width: "100%",
802
+ position: "relative"
803
+ },
804
+ cellPressLayer: {
805
+ position: "absolute",
806
+ top: 0,
807
+ bottom: 0,
808
+ right: 0
809
+ },
810
+ weekendColumn: {
811
+ position: "absolute",
812
+ top: 0
813
+ },
814
+ daySeparator: {
815
+ position: "absolute",
816
+ top: 0,
817
+ width: StyleSheet.hairlineWidth
818
+ },
819
+ hourRow: {
820
+ position: "absolute",
821
+ left: 0,
822
+ right: 0,
823
+ flexDirection: "row",
824
+ alignItems: "flex-start"
825
+ },
826
+ hourLabel: {
827
+ marginTop: -8,
828
+ textAlign: "center"
829
+ },
830
+ hourLine: {
831
+ flex: 1,
832
+ height: StyleSheet.hairlineWidth
833
+ },
834
+ eventBox: {
835
+ position: "absolute",
836
+ overflow: "hidden"
837
+ },
838
+ nowIndicator: {
839
+ position: "absolute",
840
+ right: 0,
841
+ height: 2
842
+ }
843
+ });
844
+ //#endregion
845
+ //#region src/components/Calendar.tsx
846
+ const defaultKeyExtractor = (event) => `${event.start.toISOString()}|${event.end.toISOString()}|${event.title ?? ""}`;
847
+ function Calendar({ events, mode, date, onChangeDate, onPressEvent, onPressDay, onPressMore, onPressCell, maxVisibleEventCount = 2, weekStartsOn = 0, renderEvent = DefaultEvent, keyExtractor = defaultKeyExtractor, theme, cellHeight: cellHeightProp, hourHeight = 64, minHourHeight, maxHourHeight, hourColumnWidth, minHour, maxHour, ampm, scrollOffsetMinutes, showNowIndicator, locale, freeSwipe, renderTimeGridHeader }) {
848
+ const mergedTheme = useMemo(() => mergeTheme(theme), [theme]);
849
+ const internalCellHeight = useSharedValue(hourHeight);
850
+ return /* @__PURE__ */ jsx(CalendarThemeProvider, {
851
+ value: mergedTheme,
852
+ children: mode === "month" ? /* @__PURE__ */ jsx(MonthPager, {
853
+ date,
854
+ events,
855
+ maxVisibleEventCount,
856
+ weekStartsOn,
857
+ renderEvent,
858
+ keyExtractor,
859
+ onPressDay,
860
+ onPressEvent,
861
+ onPressMore,
862
+ onChangeDate,
863
+ freeSwipe
864
+ }) : /* @__PURE__ */ jsx(TimeGrid, {
865
+ mode,
866
+ date,
867
+ events,
868
+ cellHeight: cellHeightProp ?? internalCellHeight,
869
+ hourHeight,
870
+ weekStartsOn,
871
+ renderEvent,
872
+ keyExtractor,
873
+ scrollOffsetMinutes,
874
+ hourColumnWidth,
875
+ minHour,
876
+ maxHour,
877
+ ampm,
878
+ minHourHeight,
879
+ maxHourHeight,
880
+ showNowIndicator,
881
+ locale,
882
+ freeSwipe,
883
+ onPressEvent,
884
+ onPressCell,
885
+ onChangeDate,
886
+ renderHeader: renderTimeGridHeader
887
+ })
888
+ });
889
+ }
890
+ //#endregion
891
+ export { Calendar, CalendarThemeProvider, DEFAULT_HOUR_HEIGHT, DefaultEvent, MonthPager, MonthView, TimeGrid, defaultTheme, getIsToday, getWeekDays, isSameCalendarDay, isWeekend, layoutDayEvents, mergeTheme, minutesIntoDay, useCalendarTheme };