@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.
@@ -0,0 +1,518 @@
1
+ import { format, type Locale, isSameMonth, startOfDay } from "date-fns";
2
+ import { memo, useMemo, useState } from "react";
3
+ import {
4
+ type LayoutChangeEvent,
5
+ Platform,
6
+ StyleSheet,
7
+ type StyleProp,
8
+ Text,
9
+ TouchableOpacity,
10
+ View,
11
+ type ViewStyle,
12
+ } from "react-native";
13
+
14
+ // Web drag-to-select relays cell pointer events up to MonthList; native drag is
15
+ // driven by a list-level pan there instead.
16
+ const isWeb = Platform.OS === "web";
17
+ import { useCalendarTheme } from "../theme";
18
+ import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from "../types";
19
+ import { type DateRange, daySelectionState, useCalendarSelection } from "@super-calendar/core";
20
+ import { dayBadgeKind, rangeBandKind } from "@super-calendar/core";
21
+ import {
22
+ buildMonthWeeks,
23
+ getIsToday,
24
+ getWeekDays,
25
+ isSameCalendarDay,
26
+ isWeekend,
27
+ } from "@super-calendar/core";
28
+ import { monthEventCapacity, monthVisibleCount } from "@super-calendar/core";
29
+ import { compareDayEvents, groupEventsByDay, isAllDayEvent } from "@super-calendar/core";
30
+
31
+ // Day-cell metrics, mirrored from the styles below, used to estimate how many
32
+ // event chips fit when auto-fitting `maxVisibleEventCount`.
33
+ const DAY_CELL_PADDING_TOP = 4;
34
+ const DATE_BADGE_HEIGHT = 24;
35
+ // Vertical centre of the date badge, where the range band is centered.
36
+ const BAND_CENTER_Y = DAY_CELL_PADDING_TOP + DATE_BADGE_HEIGHT / 2;
37
+ const CELL_ROW_GAP = 2;
38
+ const CHIP_PADDING_V = 2;
39
+ // Pre-measure fallback so the first paint isn't empty or overflowing.
40
+ const FALLBACK_VISIBLE_COUNT = 3;
41
+
42
+ const numericStyle = (value: number | string | undefined, fallback: number) =>
43
+ typeof value === "number" ? value : fallback;
44
+
45
+ export type MonthViewProps<T> = {
46
+ date: Date;
47
+ events: CalendarEvent<T>[];
48
+ /**
49
+ * Max event chips per day cell. Omit to auto-fit as many as the cell height
50
+ * allows (the default); set a number for a fixed cap. Extra events collapse
51
+ * into a "+N more" label. Auto-fit assumes the built-in chip size — pass an
52
+ * explicit value when using a custom `renderEvent`.
53
+ */
54
+ maxVisibleEventCount?: number;
55
+ weekStartsOn: WeekStartsOn;
56
+ locale?: Locale;
57
+ /** Sort each day's events by start time before slicing. Default true. */
58
+ sortedMonthView?: boolean;
59
+ /** Template for the overflow label; `{moreCount}` is replaced. Default "{moreCount} More". */
60
+ moreLabel?: string;
61
+ /** Show dimmed days from adjacent months in the grid. Default true. */
62
+ showAdjacentMonths?: boolean;
63
+ /** Ignore taps on month-cell events (day-cell taps still fire). Default false. */
64
+ disableMonthEventCellPress?: boolean;
65
+ /** Reverse the day order within each week (right-to-left). Default false. */
66
+ isRTL?: boolean;
67
+ /** Always render six week rows, for a fixed-height grid. Default false. */
68
+ showSixWeeks?: boolean;
69
+ /** Render the "MMMM yyyy" title above the grid. Default true. */
70
+ showTitle?: boolean;
71
+ /** Render the weekday-label header row above the grid. Default true. */
72
+ showWeekdays?: boolean;
73
+ /** Highlight this date instead of the real "today". */
74
+ activeDate?: Date;
75
+ /** Days drawn as selected (a filled badge), in the month grid. */
76
+ selectedDates?: Date[];
77
+ /** A selected span: endpoints get a filled badge, the span gets the range band. */
78
+ selectedRange?: DateRange;
79
+ /**
80
+ * Fill the whole cell with the range band instead of the default centered
81
+ * rounded "pill" strip. Default false.
82
+ */
83
+ fillCellOnSelection?: boolean;
84
+ /** Earliest selectable day (inclusive); earlier days render disabled. */
85
+ minDate?: Date;
86
+ /** Latest selectable day (inclusive); later days render disabled. */
87
+ maxDate?: Date;
88
+ /** Return true to render a specific day disabled (dimmed, taps ignored). */
89
+ isDateDisabled?: (date: Date) => boolean;
90
+ /** Web drag-to-select relay: a pointer pressed down on this day's cell. */
91
+ onDayPointerDown?: (date: Date) => void;
92
+ /** Web drag-to-select relay: a pressed pointer entered this day's cell. */
93
+ onDayPointerEnter?: (date: Date) => void;
94
+ /** Per-date style merged onto the day cell. */
95
+ calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
96
+ renderEvent: RenderEvent<T>;
97
+ keyExtractor: EventKeyExtractor<T>;
98
+ onPressDay?: (date: Date) => void;
99
+ onLongPressDay?: (date: Date) => void;
100
+ onPressEvent: (event: CalendarEvent<T>) => void;
101
+ onLongPressEvent?: (event: CalendarEvent<T>) => void;
102
+ onPressMore?: (events: CalendarEvent<T>[], date: Date) => void;
103
+ /**
104
+ * Replace the default date badge in each day cell. Receives the day; return
105
+ * your own date label. Event chips and the "+N more" label still render below.
106
+ */
107
+ renderCustomDateForMonth?: (date: Date) => React.ReactNode;
108
+ };
109
+
110
+ function MonthViewInner<T>({
111
+ date,
112
+ events,
113
+ maxVisibleEventCount,
114
+ weekStartsOn,
115
+ locale,
116
+ sortedMonthView = true,
117
+ moreLabel = "{moreCount} More",
118
+ showAdjacentMonths = true,
119
+ disableMonthEventCellPress = false,
120
+ isRTL = false,
121
+ showSixWeeks = false,
122
+ showTitle = true,
123
+ showWeekdays = true,
124
+ activeDate,
125
+ selectedDates: selectedDatesProp,
126
+ selectedRange: selectedRangeProp,
127
+ fillCellOnSelection = false,
128
+ minDate: minDateProp,
129
+ maxDate: maxDateProp,
130
+ isDateDisabled: isDateDisabledProp,
131
+ calendarCellStyle,
132
+ renderEvent,
133
+ keyExtractor,
134
+ onPressDay,
135
+ onLongPressDay,
136
+ onPressEvent,
137
+ onLongPressEvent,
138
+ onPressMore,
139
+ renderCustomDateForMonth,
140
+ onDayPointerDown,
141
+ onDayPointerEnter,
142
+ }: MonthViewProps<T>) {
143
+ const theme = useCalendarTheme();
144
+ // Selection comes from context (so cached pages still repaint), but explicit
145
+ // props win for direct/standalone use of MonthView.
146
+ const selection = useCalendarSelection();
147
+ const selectedDates = selectedDatesProp ?? selection.selectedDates;
148
+ const selectedRange = selectedRangeProp ?? selection.selectedRange;
149
+ const minDate = minDateProp ?? selection.minDate;
150
+ const maxDate = maxDateProp ?? selection.maxDate;
151
+ const isDateDisabled = isDateDisabledProp ?? selection.isDateDisabled;
152
+ const RenderEventComponent = renderEvent;
153
+ // Measured grid height, used to auto-fit the event chips per cell.
154
+ const [gridHeight, setGridHeight] = useState(0);
155
+ // Web-only hover highlight on the day badge (mouse pointers); stays null on
156
+ // touch/native, so it never re-renders there. Mirrors the dom renderer.
157
+ const [hoveredKey, setHoveredKey] = useState<string | null>(null);
158
+ // Picker: which day is being pressed, so the tap dims only its badge (the circle),
159
+ // not the whole cell background. Stays null in the events calendar.
160
+ const [pressedKey, setPressedKey] = useState<string | null>(null);
161
+
162
+ const weeks = useMemo(
163
+ () => buildMonthWeeks(date, weekStartsOn, { showSixWeeks, isRTL }),
164
+ [date, weekStartsOn, isRTL, showSixWeeks],
165
+ );
166
+
167
+ // Weekday labels for the header row (any week works; reuse this month). Reversed
168
+ // in RTL so they line up with the mirrored day columns.
169
+ const weekdayLabels = useMemo(() => {
170
+ const days = getWeekDays(date, weekStartsOn);
171
+ return isRTL ? days.reverse() : days;
172
+ }, [date, weekStartsOn, isRTL]);
173
+
174
+ // How many chips fit per cell: a fixed cap when `maxVisibleEventCount` is set,
175
+ // else derived from the measured cell height and the (default) chip metrics.
176
+ const capacity = useMemo(() => {
177
+ if (maxVisibleEventCount != null) {
178
+ return { full: maxVisibleEventCount, withMore: maxVisibleEventCount };
179
+ }
180
+ if (gridHeight <= 0 || weeks.length === 0) {
181
+ return { full: FALLBACK_VISIBLE_COUNT, withMore: FALLBACK_VISIBLE_COUNT };
182
+ }
183
+ const rowHeight = gridHeight / weeks.length;
184
+ const fontSize = numericStyle(theme.text.eventTitle.fontSize, 12);
185
+ const lineHeight = numericStyle(theme.text.eventTitle.lineHeight, Math.ceil(fontSize * 1.3));
186
+ const chipRowHeight = lineHeight + CHIP_PADDING_V * 2 + CELL_ROW_GAP;
187
+ const moreFontSize = numericStyle(theme.text.more.fontSize, 11);
188
+ const moreRowHeight = Math.ceil(moreFontSize * 1.3) + CELL_ROW_GAP;
189
+ const available = rowHeight - DAY_CELL_PADDING_TOP - DATE_BADGE_HEIGHT;
190
+ return monthEventCapacity(available, chipRowHeight, moreRowHeight);
191
+ }, [maxVisibleEventCount, gridHeight, weeks.length, theme]);
192
+
193
+ // Group events by calendar day once per `events` change (shared with the dom
194
+ // renderer via core's `groupEventsByDay`), rather than scanning the whole list
195
+ // inside every one of the (up to) 42 day cells on each render. Multi-day events
196
+ // are indexed under every day they span.
197
+ const eventsByDay = useMemo(() => {
198
+ const map = groupEventsByDay(events);
199
+ if (sortedMonthView) {
200
+ // All-day events head the day, then timed events by start (shared with dom).
201
+ for (const list of map.values()) list.sort(compareDayEvents);
202
+ }
203
+ return map;
204
+ }, [events, sortedMonthView]);
205
+
206
+ // Draw the day-cell grid only for an events calendar; the events-free date
207
+ // picker reads cleaner without it (matching the dom renderer).
208
+ const showGrid = events.length > 0;
209
+
210
+ const renderDay = (day: Date) => {
211
+ const isCurrentMonth = isSameMonth(day, date);
212
+
213
+ // Blank out adjacent-month days when they're hidden, keeping the grid shape.
214
+ if (!isCurrentMonth && !showAdjacentMonths) {
215
+ return (
216
+ <View
217
+ key={day.toISOString()}
218
+ style={[
219
+ styles.dayCell,
220
+ showGrid && {
221
+ borderTopWidth: StyleSheet.hairlineWidth,
222
+ borderRightWidth: StyleSheet.hairlineWidth,
223
+ borderColor: theme.colors.gridLine,
224
+ },
225
+ // No weekend tint on blank placeholders, so the shading doesn't bleed
226
+ // into the empty cells of non-existent days.
227
+ ]}
228
+ />
229
+ );
230
+ }
231
+
232
+ const dayEvents = eventsByDay.get(startOfDay(day).toISOString()) ?? [];
233
+ const isToday = getIsToday(day);
234
+ // Highlight the chosen `activeDate` when supplied, else the real today.
235
+ const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
236
+ // Selection band wins over the weekend tint; the today badge shows unless the
237
+ // day is selected. Shared with the headless grid so they never diverge.
238
+ const { isDisabled, isSelected, isInRange, isRangeStart, isRangeEnd } = daySelectionState(
239
+ day,
240
+ { selectedDates, selectedRange },
241
+ { minDate, maxDate, isDateDisabled },
242
+ );
243
+ const visibleCount = monthVisibleCount(dayEvents.length, capacity);
244
+ const hiddenCount = dayEvents.length - visibleCount;
245
+
246
+ // The range shows as a band behind the days; endpoints and discrete selected
247
+ // days get a filled badge on top. Today's badge wins when it coincides. The
248
+ // band/badge decisions come from core so both renderers can't disagree.
249
+ const isFilledBadge = dayBadgeKind({ isSelected }, isHighlighted) !== "none";
250
+ const hasBand =
251
+ rangeBandKind({ isInRange, isRangeStart, isRangeEnd }, fillCellOnSelection) !== "none";
252
+ const dayKey = day.toISOString();
253
+ // A hovered, non-filled day gets the subtle badge highlight on the web, but only
254
+ // in the events-free picker. The events calendar (month and list views) has no
255
+ // hover, matching the dom renderer (its events-mode cell omits it).
256
+ const isHovered = isWeb && !isDisabled && !showGrid && hoveredKey === dayKey;
257
+ const dateColor = isDisabled
258
+ ? theme.colors.textDisabled
259
+ : isFilledBadge
260
+ ? isHighlighted
261
+ ? theme.colors.todayText
262
+ : theme.colors.selectedText
263
+ : isCurrentMonth
264
+ ? theme.colors.text
265
+ : theme.colors.textDisabled;
266
+
267
+ // Disabled days ignore taps; pass the guards through so a press never fires.
268
+ const handlePressDay = isDisabled || !onPressDay ? undefined : () => onPressDay(day);
269
+ const handleLongPressDay =
270
+ isDisabled || !onLongPressDay ? undefined : () => onLongPressDay(day);
271
+
272
+ // Summarise the cell for screen readers: full date, today marker, and how
273
+ // many events it holds (the chips inside are grouped under this cell).
274
+ const eventCount = dayEvents.length;
275
+ const accessibilityLabel = `${format(day, "EEEE, d LLLL yyyy", { locale })}${isToday ? ", today" : ""}${isSelected ? ", selected" : ""}${isDisabled ? ", unavailable" : ""}, ${eventCount} ${eventCount === 1 ? "event" : "events"}`;
276
+
277
+ return (
278
+ <TouchableOpacity
279
+ key={day.toISOString()}
280
+ style={[
281
+ styles.dayCell,
282
+ // Events mode mirrors the dom renderer: left-aligned cell content with
283
+ // the date badge in the top-right. The picker (no grid) stays centered
284
+ // so the selection range band lines up with the centered badge.
285
+ showGrid && styles.dayCellEvents,
286
+ showGrid && {
287
+ borderTopWidth: StyleSheet.hairlineWidth,
288
+ borderRightWidth: StyleSheet.hairlineWidth,
289
+ borderColor: theme.colors.gridLine,
290
+ },
291
+ isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
292
+ calendarCellStyle?.(day),
293
+ ]}
294
+ // In the picker, don't dim the whole cell on press: the tap should show on
295
+ // the badge (the circle) only, not the cell background. `onPressIn/Out` drive
296
+ // the badge's own opacity below. The events calendar keeps the default
297
+ // whole-cell press feedback (a tap there opens the day).
298
+ activeOpacity={showGrid ? 0.2 : 1}
299
+ {...(showGrid
300
+ ? null
301
+ : {
302
+ onPressIn: () => setPressedKey(dayKey),
303
+ onPressOut: () => setPressedKey((k) => (k === dayKey ? null : k)),
304
+ })}
305
+ onPress={handlePressDay}
306
+ onLongPress={handleLongPressDay}
307
+ disabled={isDisabled || (!onPressDay && !onLongPressDay)}
308
+ // Web only: track hover for the badge highlight, and (when drag-select is
309
+ // wired) relay pointer down/enter so MonthList can extend a range as the
310
+ // pressed pointer sweeps across cells. Native uses a pan, no hover.
311
+ {...(isWeb && !isDisabled
312
+ ? {
313
+ onPointerEnter: () => {
314
+ // Hover highlight is picker-only; the events calendar matches dom (none).
315
+ if (!showGrid) setHoveredKey(dayKey);
316
+ if (onDayPointerDown) onDayPointerEnter?.(day);
317
+ },
318
+ onPointerLeave: () => {
319
+ if (!showGrid) setHoveredKey((k) => (k === dayKey ? null : k));
320
+ },
321
+ ...(onDayPointerDown ? { onPointerDown: () => onDayPointerDown(day) } : {}),
322
+ }
323
+ : null)}
324
+ // A cell, not a button — it contains the event-chip buttons, and a nested
325
+ // <button> is invalid HTML on web. `cell` is also closer to the correct
326
+ // semantics for a calendar day than `button`.
327
+ role="cell"
328
+ // On web, the events calendar's day cells are not tab stops, so keyboard
329
+ // focus moves through the event chips (real buttons) only, not every empty
330
+ // day — matching the dom renderer. A pointer tap still opens the day. The
331
+ // events-free picker layout (no grid) stays keyboard-navigable for selection.
332
+ {...(isWeb && showGrid ? { focusable: false } : null)}
333
+ accessibilityLabel={accessibilityLabel}
334
+ >
335
+ {hasBand ? (
336
+ <View
337
+ testID="month-range-band"
338
+ style={[
339
+ styles.rangeBand,
340
+ { pointerEvents: "none" },
341
+ { backgroundColor: theme.colors.rangeBackground },
342
+ fillCellOnSelection
343
+ ? { top: 0, bottom: 0 }
344
+ : { top: BAND_CENTER_Y - theme.rangeBandHeight / 2, height: theme.rangeBandHeight },
345
+ // Cap the pill at the endpoint circle (half a badge in from centre)
346
+ // instead of spilling to the cell edge, so no band shows beside it.
347
+ !fillCellOnSelection &&
348
+ isRangeStart && {
349
+ left: "50%",
350
+ marginLeft: -DATE_BADGE_HEIGHT / 2,
351
+ borderTopLeftRadius: theme.rangeBandHeight / 2,
352
+ borderBottomLeftRadius: theme.rangeBandHeight / 2,
353
+ },
354
+ !fillCellOnSelection &&
355
+ isRangeEnd && {
356
+ right: "50%",
357
+ marginRight: -DATE_BADGE_HEIGHT / 2,
358
+ borderTopRightRadius: theme.rangeBandHeight / 2,
359
+ borderBottomRightRadius: theme.rangeBandHeight / 2,
360
+ },
361
+ ]}
362
+ />
363
+ ) : null}
364
+ {renderCustomDateForMonth ? (
365
+ renderCustomDateForMonth(day)
366
+ ) : (
367
+ <View
368
+ style={[
369
+ styles.dateBadge,
370
+ showGrid && styles.dateBadgeEvents,
371
+ isFilledBadge && {
372
+ backgroundColor: isHighlighted
373
+ ? theme.colors.todayBackground
374
+ : theme.colors.selectedBackground,
375
+ borderRadius: theme.todayBadgeRadius,
376
+ },
377
+ isHovered &&
378
+ !isFilledBadge && {
379
+ backgroundColor: theme.colors.hoverBackground,
380
+ borderRadius: theme.todayBadgeRadius,
381
+ },
382
+ // Tap feedback lives on the badge, not the cell (picker only).
383
+ !showGrid && pressedKey === dayKey && { opacity: 0.2 },
384
+ ]}
385
+ >
386
+ <Text style={[theme.text.dateCell, { color: dateColor }]} allowFontScaling={false}>
387
+ {format(day, "d")}
388
+ </Text>
389
+ </View>
390
+ )}
391
+ {dayEvents.slice(0, visibleCount).map((event, index) => (
392
+ <View key={keyExtractor(event, index)} style={styles.monthEvent}>
393
+ <RenderEventComponent
394
+ event={event}
395
+ mode="month"
396
+ isAllDay={isAllDayEvent(event)}
397
+ onPress={disableMonthEventCellPress ? () => {} : () => onPressEvent(event)}
398
+ onLongPress={
399
+ disableMonthEventCellPress || !onLongPressEvent
400
+ ? undefined
401
+ : () => onLongPressEvent(event)
402
+ }
403
+ />
404
+ </View>
405
+ ))}
406
+ {hiddenCount > 0 ? (
407
+ <Text
408
+ style={[theme.text.more, styles.moreLabel, { color: theme.colors.textMuted }]}
409
+ onPress={onPressMore ? () => onPressMore(dayEvents, day) : undefined}
410
+ accessibilityRole="button"
411
+ accessibilityLabel={`Show ${hiddenCount} more events`}
412
+ allowFontScaling={false}
413
+ >
414
+ {moreLabel.replace("{moreCount}", String(hiddenCount))}
415
+ </Text>
416
+ ) : null}
417
+ </TouchableOpacity>
418
+ );
419
+ };
420
+
421
+ const handleLayout = (event: LayoutChangeEvent) => {
422
+ const next = event.nativeEvent.layout.height;
423
+ setGridHeight((prev) => (prev === next ? prev : next));
424
+ };
425
+
426
+ return (
427
+ <View style={styles.root}>
428
+ {showTitle ? (
429
+ <Text style={[styles.title, { color: theme.colors.text }]} allowFontScaling={false}>
430
+ {format(date, "MMMM yyyy", locale ? { locale } : undefined)}
431
+ </Text>
432
+ ) : null}
433
+ {showWeekdays ? (
434
+ <View style={styles.weekdayHeader}>
435
+ {weekdayLabels.map((day) => (
436
+ <Text
437
+ key={day.toISOString()}
438
+ style={[theme.text.weekday, styles.weekdayLabel, { color: theme.colors.textMuted }]}
439
+ allowFontScaling={false}
440
+ >
441
+ {format(day, "EEE", { locale })}
442
+ </Text>
443
+ ))}
444
+ </View>
445
+ ) : null}
446
+ <View style={styles.container} onLayout={handleLayout}>
447
+ {weeks.map((week) => (
448
+ <View style={styles.weekRow} key={week[0].toISOString()}>
449
+ {week.map((day) => renderDay(day))}
450
+ </View>
451
+ ))}
452
+ </View>
453
+ </View>
454
+ );
455
+ }
456
+
457
+ export const MonthView = memo(MonthViewInner) as typeof MonthViewInner;
458
+
459
+ const styles = StyleSheet.create({
460
+ root: {
461
+ flex: 1,
462
+ },
463
+ // Matches the dom MonthView title: "MMMM yyyy" above the grid.
464
+ title: {
465
+ fontSize: 17,
466
+ fontWeight: "700",
467
+ paddingTop: 10,
468
+ paddingHorizontal: 14,
469
+ paddingBottom: 6,
470
+ },
471
+ weekdayHeader: {
472
+ flexDirection: "row",
473
+ paddingBottom: 4,
474
+ },
475
+ weekdayLabel: {
476
+ flex: 1,
477
+ textAlign: "center",
478
+ },
479
+ container: {
480
+ flex: 1,
481
+ },
482
+ weekRow: {
483
+ flex: 1,
484
+ flexDirection: "row",
485
+ },
486
+ dayCell: {
487
+ flex: 1,
488
+ alignItems: "center",
489
+ paddingTop: 4,
490
+ gap: 2,
491
+ overflow: "hidden",
492
+ },
493
+ dayCellEvents: {
494
+ alignItems: "stretch",
495
+ },
496
+ dateBadge: {
497
+ justifyContent: "center",
498
+ alignItems: "center",
499
+ height: 24,
500
+ width: 24,
501
+ },
502
+ dateBadgeEvents: {
503
+ alignSelf: "flex-end",
504
+ marginRight: 4,
505
+ },
506
+ rangeBand: {
507
+ position: "absolute",
508
+ left: 0,
509
+ right: 0,
510
+ },
511
+ monthEvent: {
512
+ marginHorizontal: 4,
513
+ },
514
+ moreLabel: {
515
+ marginTop: 2,
516
+ marginHorizontal: 4,
517
+ },
518
+ });