react-native-bigger-calendar 0.1.0 → 0.2.1
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 +82 -2
- package/dist/index.d.mts +289 -73
- package/dist/index.d.ts +289 -73
- package/dist/index.js +659 -150
- package/dist/index.mjs +658 -151
- package/package.json +2 -2
- package/src/components/Agenda.tsx +125 -0
- package/src/components/AllDayLane.tsx +83 -0
- package/src/components/Calendar.tsx +221 -11
- package/src/components/DefaultEvent.tsx +24 -5
- package/src/components/MonthPager.tsx +138 -26
- package/src/components/MonthView.tsx +82 -14
- package/src/components/TimeGrid.tsx +351 -62
- package/src/index.tsx +9 -2
- package/src/types.ts +23 -1
- package/src/utils/dates.ts +67 -2
- package/src/utils/layout.ts +18 -0
|
@@ -7,8 +7,11 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
addDays,
|
|
9
9
|
differenceInCalendarDays,
|
|
10
|
+
format,
|
|
10
11
|
getHours,
|
|
12
|
+
getISOWeek,
|
|
11
13
|
getMinutes,
|
|
14
|
+
type Locale,
|
|
12
15
|
startOfDay,
|
|
13
16
|
startOfWeek,
|
|
14
17
|
} from 'date-fns';
|
|
@@ -17,9 +20,11 @@ import {
|
|
|
17
20
|
type GestureResponderEvent,
|
|
18
21
|
Pressable,
|
|
19
22
|
StyleSheet,
|
|
23
|
+
type StyleProp,
|
|
20
24
|
Text,
|
|
21
25
|
useWindowDimensions,
|
|
22
26
|
View,
|
|
27
|
+
type ViewStyle,
|
|
23
28
|
} from 'react-native';
|
|
24
29
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
25
30
|
import Animated, {
|
|
@@ -38,16 +43,15 @@ import type {
|
|
|
38
43
|
CalendarMode,
|
|
39
44
|
EventKeyExtractor,
|
|
40
45
|
RenderEvent,
|
|
46
|
+
TimeGridMode,
|
|
41
47
|
WeekStartsOn,
|
|
42
48
|
} from '../types';
|
|
43
|
-
import { getIsToday,
|
|
49
|
+
import { getIsToday, getViewDays, isSameCalendarDay, isWeekend, viewDayCount } from '../utils/dates';
|
|
44
50
|
import { layoutDayEvents, type PositionedEvent } from '../utils/layout';
|
|
51
|
+
import { AllDayLane } from './AllDayLane';
|
|
45
52
|
|
|
46
53
|
const MINUTES_PER_HOUR = 60;
|
|
47
54
|
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
55
|
// Steps rendered either side of the current page. LegendList virtualises, so
|
|
52
56
|
// only a few mount at once; a wide window means the user effectively never runs
|
|
53
57
|
// out of pages to swipe. Items are keyed by date and never recycled.
|
|
@@ -99,6 +103,7 @@ type AnimatedEventBoxProps<T> = {
|
|
|
99
103
|
mode: CalendarMode;
|
|
100
104
|
renderEvent: RenderEvent<T>;
|
|
101
105
|
onPress: (event: CalendarEvent<T>) => void;
|
|
106
|
+
onLongPress?: (event: CalendarEvent<T>) => void;
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
function AnimatedEventBox<T>({
|
|
@@ -110,6 +115,7 @@ function AnimatedEventBox<T>({
|
|
|
110
115
|
mode,
|
|
111
116
|
renderEvent,
|
|
112
117
|
onPress,
|
|
118
|
+
onLongPress,
|
|
113
119
|
}: AnimatedEventBoxProps<T>) {
|
|
114
120
|
const RenderEventComponent = renderEvent;
|
|
115
121
|
// Live pixel height of the box, driven on the UI thread by the shared
|
|
@@ -130,6 +136,7 @@ function AnimatedEventBox<T>({
|
|
|
130
136
|
);
|
|
131
137
|
|
|
132
138
|
const handlePress = () => onPress(positioned.event);
|
|
139
|
+
const handleLongPress = onLongPress ? () => onLongPress(positioned.event) : undefined;
|
|
133
140
|
|
|
134
141
|
return (
|
|
135
142
|
<Animated.View style={[styles.eventBox, { left, width }, boxStyle]}>
|
|
@@ -140,20 +147,34 @@ function AnimatedEventBox<T>({
|
|
|
140
147
|
continuesBefore={positioned.continuesBefore}
|
|
141
148
|
continuesAfter={positioned.continuesAfter}
|
|
142
149
|
onPress={handlePress}
|
|
150
|
+
onLongPress={handleLongPress}
|
|
143
151
|
/>
|
|
144
152
|
</Animated.View>
|
|
145
153
|
);
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
|
|
157
|
+
export type HourRenderer = (hour: number, ampm: boolean) => React.ReactNode;
|
|
158
|
+
|
|
148
159
|
type HourRowProps = {
|
|
149
160
|
hour: number;
|
|
150
161
|
minHour: number;
|
|
151
162
|
cellHeight: SharedValue<number>;
|
|
152
163
|
hourColumnWidth: number;
|
|
153
164
|
label: string;
|
|
165
|
+
ampm: boolean;
|
|
166
|
+
hourComponent?: HourRenderer;
|
|
154
167
|
};
|
|
155
168
|
|
|
156
|
-
const HourRow = ({
|
|
169
|
+
const HourRow = ({
|
|
170
|
+
hour,
|
|
171
|
+
minHour,
|
|
172
|
+
cellHeight,
|
|
173
|
+
hourColumnWidth,
|
|
174
|
+
label,
|
|
175
|
+
ampm,
|
|
176
|
+
hourComponent,
|
|
177
|
+
}: HourRowProps) => {
|
|
157
178
|
const theme = useCalendarTheme();
|
|
158
179
|
// Position via `top` (a layout prop), not a transform. The per-row layout pass
|
|
159
180
|
// as cellHeight animates keeps the ScrollView's content size in sync while
|
|
@@ -165,21 +186,48 @@ const HourRow = ({ hour, minHour, cellHeight, hourColumnWidth, label }: HourRowP
|
|
|
165
186
|
|
|
166
187
|
return (
|
|
167
188
|
<Animated.View style={[styles.hourRow, animatedStyle]} pointerEvents="none">
|
|
168
|
-
|
|
169
|
-
style={
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
{hourComponent ? (
|
|
190
|
+
<View style={{ width: hourColumnWidth }}>{hourComponent(hour, ampm)}</View>
|
|
191
|
+
) : (
|
|
192
|
+
<Text
|
|
193
|
+
style={[
|
|
194
|
+
theme.text.hourLabel,
|
|
195
|
+
styles.hourLabel,
|
|
196
|
+
{ width: hourColumnWidth, color: theme.colors.textMuted },
|
|
197
|
+
]}
|
|
198
|
+
allowFontScaling={false}
|
|
199
|
+
>
|
|
200
|
+
{label}
|
|
201
|
+
</Text>
|
|
202
|
+
)}
|
|
178
203
|
<View style={[styles.hourLine, { backgroundColor: theme.colors.gridLine }]} />
|
|
179
204
|
</Animated.View>
|
|
180
205
|
);
|
|
181
206
|
};
|
|
182
207
|
|
|
208
|
+
type TimeslotLineProps = {
|
|
209
|
+
hour: number;
|
|
210
|
+
minHour: number;
|
|
211
|
+
fraction: number;
|
|
212
|
+
cellHeight: SharedValue<number>;
|
|
213
|
+
hourColumnWidth: number;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// A faint divider inside an hour row, marking a sub-hour slot (e.g. half hours).
|
|
217
|
+
const TimeslotLine = ({ hour, minHour, fraction, cellHeight, hourColumnWidth }: TimeslotLineProps) => {
|
|
218
|
+
const theme = useCalendarTheme();
|
|
219
|
+
const animatedStyle = useAnimatedStyle(
|
|
220
|
+
() => ({ top: (hour - minHour + fraction) * cellHeight.value }),
|
|
221
|
+
[hour, minHour, fraction],
|
|
222
|
+
);
|
|
223
|
+
return (
|
|
224
|
+
<Animated.View
|
|
225
|
+
style={[styles.timeslotLine, { left: hourColumnWidth, backgroundColor: theme.colors.gridLine }, animatedStyle]}
|
|
226
|
+
pointerEvents="none"
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
183
231
|
type NowIndicatorProps = {
|
|
184
232
|
cellHeight: SharedValue<number>;
|
|
185
233
|
nowHours: number;
|
|
@@ -203,7 +251,8 @@ const NowIndicator = ({ cellHeight, nowHours, minHour, left, color }: NowIndicat
|
|
|
203
251
|
};
|
|
204
252
|
|
|
205
253
|
type TimetablePageProps<T> = {
|
|
206
|
-
mode:
|
|
254
|
+
mode: TimeGridMode;
|
|
255
|
+
numberOfDays: number;
|
|
207
256
|
date: Date;
|
|
208
257
|
events: CalendarEvent<T>[];
|
|
209
258
|
cellHeight: SharedValue<number>;
|
|
@@ -216,17 +265,26 @@ type TimetablePageProps<T> = {
|
|
|
216
265
|
isActive: boolean;
|
|
217
266
|
scrollOffsetMinutes: number;
|
|
218
267
|
weekStartsOn: WeekStartsOn;
|
|
268
|
+
weekEndsOn?: WeekStartsOn;
|
|
219
269
|
hourColumnWidth: number;
|
|
220
270
|
minHour: number;
|
|
221
271
|
maxHour: number;
|
|
222
272
|
ampm: boolean;
|
|
273
|
+
timeslots: number;
|
|
274
|
+
isRTL: boolean;
|
|
275
|
+
showVerticalScrollIndicator: boolean;
|
|
276
|
+
verticalScrollEnabled: boolean;
|
|
277
|
+
hourComponent?: HourRenderer;
|
|
278
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
223
279
|
minHourHeight: number;
|
|
224
280
|
maxHourHeight: number;
|
|
225
281
|
showNowIndicator: boolean;
|
|
226
282
|
renderEvent: RenderEvent<T>;
|
|
227
283
|
keyExtractor: EventKeyExtractor<T>;
|
|
228
284
|
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
285
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
229
286
|
onPressCell?: (date: Date) => void;
|
|
287
|
+
onLongPressCell?: (date: Date) => void;
|
|
230
288
|
};
|
|
231
289
|
|
|
232
290
|
// A single date's grid: the pinch-zoomable, vertically-scrolling time column.
|
|
@@ -234,6 +292,7 @@ type TimetablePageProps<T> = {
|
|
|
234
292
|
// next dates are ready to drag into view.
|
|
235
293
|
function TimetablePageInner<T>({
|
|
236
294
|
mode,
|
|
295
|
+
numberOfDays,
|
|
237
296
|
date,
|
|
238
297
|
events,
|
|
239
298
|
cellHeight,
|
|
@@ -243,17 +302,26 @@ function TimetablePageInner<T>({
|
|
|
243
302
|
isActive,
|
|
244
303
|
scrollOffsetMinutes,
|
|
245
304
|
weekStartsOn,
|
|
305
|
+
weekEndsOn,
|
|
246
306
|
hourColumnWidth,
|
|
247
307
|
minHour,
|
|
248
308
|
maxHour,
|
|
249
309
|
ampm,
|
|
310
|
+
timeslots,
|
|
311
|
+
isRTL,
|
|
312
|
+
showVerticalScrollIndicator,
|
|
313
|
+
verticalScrollEnabled,
|
|
314
|
+
hourComponent,
|
|
315
|
+
calendarCellStyle,
|
|
250
316
|
minHourHeight,
|
|
251
317
|
maxHourHeight,
|
|
252
318
|
showNowIndicator,
|
|
253
319
|
renderEvent,
|
|
254
320
|
keyExtractor,
|
|
255
321
|
onPressEvent,
|
|
322
|
+
onLongPressEvent,
|
|
256
323
|
onPressCell,
|
|
324
|
+
onLongPressCell,
|
|
257
325
|
}: TimetablePageProps<T>) {
|
|
258
326
|
const theme = useCalendarTheme();
|
|
259
327
|
const { width } = useWindowDimensions();
|
|
@@ -282,8 +350,8 @@ function TimetablePageInner<T>({
|
|
|
282
350
|
);
|
|
283
351
|
|
|
284
352
|
const days = useMemo(
|
|
285
|
-
() => (mode
|
|
286
|
-
[mode, date, weekStartsOn],
|
|
353
|
+
() => getViewDays(mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
|
|
354
|
+
[mode, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
287
355
|
);
|
|
288
356
|
|
|
289
357
|
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
@@ -296,17 +364,24 @@ function TimetablePageInner<T>({
|
|
|
296
364
|
|
|
297
365
|
// Map a tap on empty grid space back to the date+time it represents. Reads the
|
|
298
366
|
// live row height on the JS thread to convert the touch Y into minutes.
|
|
299
|
-
const
|
|
300
|
-
if (!onPressCell) return;
|
|
367
|
+
const cellDateFromTouch = (event: GestureResponderEvent): Date | null => {
|
|
301
368
|
const { locationX, locationY } = event.nativeEvent;
|
|
302
369
|
const dayIndex = days.length === 1 ? 0 : Math.floor(locationX / dayWidth);
|
|
303
370
|
const day = days[dayIndex];
|
|
304
|
-
if (!day) return;
|
|
371
|
+
if (!day) return null;
|
|
305
372
|
const minutes = Math.round((minHour + locationY / heightSource.value) * MINUTES_PER_HOUR);
|
|
306
373
|
const pressed = new Date(day);
|
|
307
374
|
pressed.setHours(0, 0, 0, 0);
|
|
308
375
|
pressed.setMinutes(minutes);
|
|
309
|
-
|
|
376
|
+
return pressed;
|
|
377
|
+
};
|
|
378
|
+
const handleBackgroundPress = (event: GestureResponderEvent) => {
|
|
379
|
+
const date = onPressCell && cellDateFromTouch(event);
|
|
380
|
+
if (date) onPressCell?.(date);
|
|
381
|
+
};
|
|
382
|
+
const handleBackgroundLongPress = (event: GestureResponderEvent) => {
|
|
383
|
+
const date = onLongPressCell && cellDateFromTouch(event);
|
|
384
|
+
if (date) onLongPressCell?.(date);
|
|
310
385
|
};
|
|
311
386
|
|
|
312
387
|
// The hours (rows/labels) visible in the window [minHour, maxHour).
|
|
@@ -354,10 +429,22 @@ function TimetablePageInner<T>({
|
|
|
354
429
|
|
|
355
430
|
return (
|
|
356
431
|
<View style={styles.container}>
|
|
432
|
+
<AllDayLane
|
|
433
|
+
days={days}
|
|
434
|
+
events={events}
|
|
435
|
+
mode={mode}
|
|
436
|
+
hourColumnWidth={hourColumnWidth}
|
|
437
|
+
dayWidth={dayWidth}
|
|
438
|
+
renderEvent={renderEvent}
|
|
439
|
+
keyExtractor={keyExtractor}
|
|
440
|
+
onPressEvent={onPressEvent}
|
|
441
|
+
onLongPressEvent={onLongPressEvent}
|
|
442
|
+
/>
|
|
357
443
|
<GestureDetector gesture={zoomGesture}>
|
|
358
444
|
<Animated.ScrollView
|
|
359
445
|
ref={scrollRef}
|
|
360
|
-
showsVerticalScrollIndicator
|
|
446
|
+
showsVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
447
|
+
scrollEnabled={verticalScrollEnabled}
|
|
361
448
|
onScroll={scrollHandler}
|
|
362
449
|
scrollEventThrottle={16}
|
|
363
450
|
contentContainerStyle={{ paddingTop: HOUR_LABEL_TOP_INSET }}
|
|
@@ -367,13 +454,14 @@ function TimetablePageInner<T>({
|
|
|
367
454
|
}}
|
|
368
455
|
>
|
|
369
456
|
<Animated.View style={[styles.content, fullHeightStyle]}>
|
|
370
|
-
{onPressCell ? (
|
|
457
|
+
{onPressCell || onLongPressCell ? (
|
|
371
458
|
// Behind the events, so empty-space taps create while event taps
|
|
372
459
|
// still hit their box. Hidden from screen readers (a convenience
|
|
373
460
|
// gesture, not the primary create path).
|
|
374
461
|
<Pressable
|
|
375
462
|
style={[styles.cellPressLayer, { left: hourColumnWidth }]}
|
|
376
|
-
onPress={handleBackgroundPress}
|
|
463
|
+
onPress={onPressCell ? handleBackgroundPress : undefined}
|
|
464
|
+
onLongPress={onLongPressCell ? handleBackgroundLongPress : undefined}
|
|
377
465
|
importantForAccessibility="no"
|
|
378
466
|
accessibilityElementsHidden
|
|
379
467
|
/>
|
|
@@ -394,6 +482,24 @@ function TimetablePageInner<T>({
|
|
|
394
482
|
) : null,
|
|
395
483
|
)}
|
|
396
484
|
|
|
485
|
+
{calendarCellStyle
|
|
486
|
+
? days.map((day, dayIndex) => {
|
|
487
|
+
const cellStyle = calendarCellStyle(day);
|
|
488
|
+
return cellStyle ? (
|
|
489
|
+
<Animated.View
|
|
490
|
+
key={`cell-${day.toISOString()}`}
|
|
491
|
+
style={[
|
|
492
|
+
styles.weekendColumn,
|
|
493
|
+
{ left: dayLeft(dayIndex), width: dayWidth },
|
|
494
|
+
cellStyle,
|
|
495
|
+
fullHeightStyle,
|
|
496
|
+
]}
|
|
497
|
+
pointerEvents="none"
|
|
498
|
+
/>
|
|
499
|
+
) : null;
|
|
500
|
+
})
|
|
501
|
+
: null}
|
|
502
|
+
|
|
397
503
|
{days.map((day, dayIndex) => (
|
|
398
504
|
<Animated.View
|
|
399
505
|
key={`separator-${day.toISOString()}`}
|
|
@@ -415,9 +521,26 @@ function TimetablePageInner<T>({
|
|
|
415
521
|
cellHeight={heightSource}
|
|
416
522
|
hourColumnWidth={hourColumnWidth}
|
|
417
523
|
label={formatHourLabel(hour, ampm)}
|
|
524
|
+
ampm={ampm}
|
|
525
|
+
hourComponent={hourComponent}
|
|
418
526
|
/>
|
|
419
527
|
))}
|
|
420
528
|
|
|
529
|
+
{timeslots > 1
|
|
530
|
+
? hoursRange.flatMap((hour) =>
|
|
531
|
+
Array.from({ length: timeslots - 1 }, (_, i) => (
|
|
532
|
+
<TimeslotLine
|
|
533
|
+
key={`slot-${hour}-${i}`}
|
|
534
|
+
hour={hour}
|
|
535
|
+
minHour={minHour}
|
|
536
|
+
fraction={(i + 1) / timeslots}
|
|
537
|
+
cellHeight={heightSource}
|
|
538
|
+
hourColumnWidth={hourColumnWidth}
|
|
539
|
+
/>
|
|
540
|
+
)),
|
|
541
|
+
)
|
|
542
|
+
: null}
|
|
543
|
+
|
|
421
544
|
{dayLayouts.flatMap((layout, dayIndex) =>
|
|
422
545
|
layout
|
|
423
546
|
// Skip events that fall entirely outside the [minHour, maxHour) window.
|
|
@@ -428,7 +551,10 @@ function TimetablePageInner<T>({
|
|
|
428
551
|
const columnWidth = dayWidth / positioned.columns;
|
|
429
552
|
return (
|
|
430
553
|
<AnimatedEventBox
|
|
431
|
-
|
|
554
|
+
// Prefix with the day so a multi-day event's per-day segments
|
|
555
|
+
// (which share the same event key) stay unique across the
|
|
556
|
+
// flattened list of all days' boxes.
|
|
557
|
+
key={`${dayIndex}:${keyExtractor(positioned.event, eventIndex)}`}
|
|
432
558
|
positioned={positioned}
|
|
433
559
|
cellHeight={heightSource}
|
|
434
560
|
minHour={minHour}
|
|
@@ -437,6 +563,7 @@ function TimetablePageInner<T>({
|
|
|
437
563
|
mode={mode}
|
|
438
564
|
renderEvent={renderEvent}
|
|
439
565
|
onPress={onPressEvent}
|
|
566
|
+
onLongPress={onLongPressEvent}
|
|
440
567
|
/>
|
|
441
568
|
);
|
|
442
569
|
}),
|
|
@@ -461,7 +588,15 @@ function TimetablePageInner<T>({
|
|
|
461
588
|
const TimetablePage = memo(TimetablePageInner) as typeof TimetablePageInner;
|
|
462
589
|
|
|
463
590
|
export type TimeGridProps<T> = {
|
|
464
|
-
mode:
|
|
591
|
+
mode: TimeGridMode;
|
|
592
|
+
/** Day columns to show in `custom` mode. Ignored by day/3days/week. Default 1. */
|
|
593
|
+
numberOfDays?: number;
|
|
594
|
+
/**
|
|
595
|
+
* Last weekday of a `custom` partial-week view (0–6). When set, `custom` shows
|
|
596
|
+
* `weekStartsOn`…`weekEndsOn` of `date`'s week and pages by week, taking
|
|
597
|
+
* precedence over `numberOfDays`. Ignored by other modes.
|
|
598
|
+
*/
|
|
599
|
+
weekEndsOn?: WeekStartsOn;
|
|
465
600
|
date: Date;
|
|
466
601
|
events: CalendarEvent<T>[];
|
|
467
602
|
cellHeight: SharedValue<number>;
|
|
@@ -472,19 +607,49 @@ export type TimeGridProps<T> = {
|
|
|
472
607
|
keyExtractor: EventKeyExtractor<T>;
|
|
473
608
|
scrollOffsetMinutes?: number;
|
|
474
609
|
hourColumnWidth?: number;
|
|
610
|
+
/** Hide the left hour-axis column (lines stay, labels/gutter go). Default false. */
|
|
611
|
+
hideHours?: boolean;
|
|
612
|
+
/** Sub-hour divider lines per hour (e.g. 2 = half-hours). Default 1 (none). */
|
|
613
|
+
timeslots?: number;
|
|
614
|
+
/** Per-date style merged onto each day column. */
|
|
615
|
+
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
616
|
+
/** Show the ISO week number in the header gutter. Default false. */
|
|
617
|
+
showWeekNumber?: boolean;
|
|
618
|
+
/** Element rendered between the day header and the grid. */
|
|
619
|
+
headerComponent?: React.ReactNode;
|
|
475
620
|
/** First hour shown (0–23). Default 0. */
|
|
476
621
|
minHour?: number;
|
|
477
622
|
/** Last hour shown, exclusive (1–24). Default 24. */
|
|
478
623
|
maxHour?: number;
|
|
479
624
|
/** Show hour labels in 12-hour AM/PM form. Default false (24h). */
|
|
480
625
|
ampm?: boolean;
|
|
626
|
+
/** Reverse day-column order (right-to-left). Default false. */
|
|
627
|
+
isRTL?: boolean;
|
|
481
628
|
minHourHeight?: number;
|
|
482
629
|
maxHourHeight?: number;
|
|
483
630
|
showNowIndicator?: boolean;
|
|
484
|
-
locale?:
|
|
631
|
+
locale?: Locale;
|
|
485
632
|
freeSwipe?: boolean;
|
|
633
|
+
/** Allow swiping between pages. Default true. */
|
|
634
|
+
swipeEnabled?: boolean;
|
|
635
|
+
/** Show the vertical scroll indicator on the time grid. Default true. */
|
|
636
|
+
showVerticalScrollIndicator?: boolean;
|
|
637
|
+
/** Allow vertical scrolling of the time grid. Default true. */
|
|
638
|
+
verticalScrollEnabled?: boolean;
|
|
639
|
+
/** Prefix for the week-number label (e.g. "W"). Default "W". */
|
|
640
|
+
weekNumberPrefix?: string;
|
|
641
|
+
/** Replace the hour-axis label. Receives the hour (0–23) and `ampm`. */
|
|
642
|
+
hourComponent?: HourRenderer;
|
|
643
|
+
/** Highlight this date in the header instead of the real "today". */
|
|
644
|
+
activeDate?: Date;
|
|
645
|
+
/** After an empty-cell press, snap the pager back to the active page. Default false. */
|
|
646
|
+
resetPageOnPressCell?: boolean;
|
|
486
647
|
onPressEvent: (event: CalendarEvent<T>) => void;
|
|
648
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
487
649
|
onPressCell?: (date: Date) => void;
|
|
650
|
+
onLongPressCell?: (date: Date) => void;
|
|
651
|
+
/** Tap a day's column header (default header only). */
|
|
652
|
+
onPressDateHeader?: (date: Date) => void;
|
|
488
653
|
onChangeDate: (date: Date) => void;
|
|
489
654
|
/** Optional header above the grid (e.g. weekday labels). Rendered full-width. */
|
|
490
655
|
renderHeader?: (days: Date[]) => React.ReactNode;
|
|
@@ -492,6 +657,8 @@ export type TimeGridProps<T> = {
|
|
|
492
657
|
|
|
493
658
|
function TimeGridInner<T>({
|
|
494
659
|
mode,
|
|
660
|
+
numberOfDays = 1,
|
|
661
|
+
weekEndsOn,
|
|
495
662
|
date,
|
|
496
663
|
events,
|
|
497
664
|
cellHeight,
|
|
@@ -500,30 +667,57 @@ function TimeGridInner<T>({
|
|
|
500
667
|
renderEvent,
|
|
501
668
|
keyExtractor,
|
|
502
669
|
scrollOffsetMinutes = 0,
|
|
503
|
-
hourColumnWidth = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
670
|
+
hourColumnWidth: hourColumnWidthProp = DEFAULT_HOUR_COLUMN_WIDTH,
|
|
671
|
+
hideHours = false,
|
|
672
|
+
timeslots = 1,
|
|
673
|
+
calendarCellStyle,
|
|
674
|
+
showWeekNumber = false,
|
|
675
|
+
headerComponent,
|
|
504
676
|
minHour = 0,
|
|
505
677
|
maxHour = HOURS_PER_DAY,
|
|
506
678
|
ampm = false,
|
|
679
|
+
isRTL = false,
|
|
507
680
|
minHourHeight = DEFAULT_MIN_HOUR_HEIGHT,
|
|
508
681
|
maxHourHeight = DEFAULT_MAX_HOUR_HEIGHT,
|
|
509
682
|
showNowIndicator = true,
|
|
510
683
|
locale,
|
|
511
684
|
freeSwipe = false,
|
|
685
|
+
swipeEnabled = true,
|
|
686
|
+
showVerticalScrollIndicator = true,
|
|
687
|
+
verticalScrollEnabled = true,
|
|
688
|
+
weekNumberPrefix = 'W',
|
|
689
|
+
hourComponent,
|
|
690
|
+
activeDate,
|
|
691
|
+
resetPageOnPressCell = false,
|
|
512
692
|
onPressEvent,
|
|
693
|
+
onLongPressEvent,
|
|
513
694
|
onPressCell,
|
|
695
|
+
onLongPressCell,
|
|
696
|
+
onPressDateHeader,
|
|
514
697
|
onChangeDate,
|
|
515
698
|
renderHeader,
|
|
516
699
|
}: TimeGridProps<T>) {
|
|
517
700
|
// Guard against an inverted/out-of-range window so the grid never collapses.
|
|
518
701
|
const clampedMinHour = Math.max(0, Math.min(minHour, HOURS_PER_DAY - 1));
|
|
519
702
|
const clampedMaxHour = Math.max(clampedMinHour + 1, Math.min(maxHour, HOURS_PER_DAY));
|
|
703
|
+
// Collapse the hour gutter to zero when hours are hidden.
|
|
704
|
+
const hourColumnWidth = hideHours ? 0 : hourColumnWidthProp;
|
|
520
705
|
|
|
521
706
|
const { width, height } = useWindowDimensions();
|
|
522
707
|
const listRef = useRef<LegendListRef>(null);
|
|
523
708
|
// Horizontal list items need an explicit cross-axis height; seed it with the
|
|
524
709
|
// window height (so it renders immediately and in tests) and refine on layout.
|
|
525
710
|
const [pageHeight, setPageHeight] = useState(height);
|
|
526
|
-
|
|
711
|
+
// The list must remount exactly once — when the real height replaces the
|
|
712
|
+
// window-height seed — or it keeps the oversized seed and clips. It must NOT
|
|
713
|
+
// remount on later height changes (e.g. a taller day header vs a shorter week
|
|
714
|
+
// header on a mode switch): a remount blanks the visible page for a frame.
|
|
715
|
+
const [measured, setMeasured] = useState(false);
|
|
716
|
+
// Week-anchored modes page by a full week and align pages to the week start:
|
|
717
|
+
// `week`, and `custom` when a `weekEndsOn` defines a partial-week span.
|
|
718
|
+
const weekAnchored = mode === 'week' || (mode === 'custom' && weekEndsOn != null);
|
|
719
|
+
// Days advanced per page: a full week when week-anchored, else the column count.
|
|
720
|
+
const step = weekAnchored ? 7 : viewDayCount(mode, numberOfDays);
|
|
527
721
|
// Shared vertical scroll offset so every mounted page stays aligned. Seeded
|
|
528
722
|
// from the numeric hourHeight rather than reading cellHeight.value (which
|
|
529
723
|
// would warn about reading a shared value during render).
|
|
@@ -540,8 +734,8 @@ function TimeGridInner<T>({
|
|
|
540
734
|
const [anchorDate] = useState(date);
|
|
541
735
|
const anchor = useMemo(
|
|
542
736
|
() =>
|
|
543
|
-
|
|
544
|
-
[
|
|
737
|
+
weekAnchored ? startOfWeek(anchorDate, { weekStartsOn }) : startOfDay(anchorDate),
|
|
738
|
+
[weekAnchored, anchorDate, weekStartsOn],
|
|
545
739
|
);
|
|
546
740
|
const pageDates = useMemo(
|
|
547
741
|
() =>
|
|
@@ -553,10 +747,12 @@ function TimeGridInner<T>({
|
|
|
553
747
|
const indexOfDate = useCallback(
|
|
554
748
|
(target: Date) => {
|
|
555
749
|
const aligned =
|
|
556
|
-
|
|
557
|
-
|
|
750
|
+
weekAnchored ? startOfWeek(target, { weekStartsOn }) : startOfDay(target);
|
|
751
|
+
// Floor so an arbitrary date lands on the page whose range contains it
|
|
752
|
+
// (exact for day/week, where dates are already page-aligned).
|
|
753
|
+
return Math.floor(differenceInCalendarDays(aligned, anchor) / step) + PAGE_WINDOW;
|
|
558
754
|
},
|
|
559
|
-
[anchor,
|
|
755
|
+
[anchor, weekAnchored, step, weekStartsOn],
|
|
560
756
|
);
|
|
561
757
|
|
|
562
758
|
// The committed date's page is the centred/active one. `viewedIndexRef` tracks
|
|
@@ -564,11 +760,12 @@ function TimeGridInner<T>({
|
|
|
564
760
|
const activeIndex = indexOfDate(date);
|
|
565
761
|
const viewedIndexRef = useRef(activeIndex);
|
|
566
762
|
|
|
567
|
-
// Header days track the
|
|
568
|
-
// never flashes another day's label.
|
|
763
|
+
// Header days track the active page (page-aligned), so they always match the
|
|
764
|
+
// columns below and a swipe never flashes another day's label.
|
|
569
765
|
const headerDays = useMemo(
|
|
570
|
-
() =>
|
|
571
|
-
|
|
766
|
+
() =>
|
|
767
|
+
getViewDays(mode, pageDates[activeIndex] ?? date, weekStartsOn, numberOfDays, isRTL, weekEndsOn),
|
|
768
|
+
[mode, pageDates, activeIndex, date, weekStartsOn, numberOfDays, isRTL, weekEndsOn],
|
|
572
769
|
);
|
|
573
770
|
|
|
574
771
|
const handleViewableItemsChanged = useCallback(
|
|
@@ -589,6 +786,17 @@ function TimeGridInner<T>({
|
|
|
589
786
|
listRef.current?.scrollToIndex({ index: activeIndex, animated: false });
|
|
590
787
|
}, [activeIndex]);
|
|
591
788
|
|
|
789
|
+
// Optionally snap the pager back to the active page after an empty-cell press
|
|
790
|
+
// (so tapping a far-swiped page returns to the committed date).
|
|
791
|
+
const handlePressCell = useMemo(() => {
|
|
792
|
+
if (!onPressCell) return undefined;
|
|
793
|
+
if (!resetPageOnPressCell) return onPressCell;
|
|
794
|
+
return (cellDate: Date) => {
|
|
795
|
+
onPressCell(cellDate);
|
|
796
|
+
listRef.current?.scrollToIndex({ index: activeIndex, animated: true });
|
|
797
|
+
};
|
|
798
|
+
}, [onPressCell, resetPageOnPressCell, activeIndex]);
|
|
799
|
+
|
|
592
800
|
const snapToIndices = useMemo(() => pageDates.map((_, index) => index), [pageDates]);
|
|
593
801
|
const keyExtractorList = useCallback((item: Date) => item.toISOString(), []);
|
|
594
802
|
const getFixedItemSize = useCallback(() => width, [width]);
|
|
@@ -597,6 +805,7 @@ function TimeGridInner<T>({
|
|
|
597
805
|
<View style={{ width, height: pageHeight }}>
|
|
598
806
|
<TimetablePage
|
|
599
807
|
mode={mode}
|
|
808
|
+
numberOfDays={numberOfDays}
|
|
600
809
|
date={item}
|
|
601
810
|
events={events}
|
|
602
811
|
cellHeight={cellHeight}
|
|
@@ -606,17 +815,26 @@ function TimeGridInner<T>({
|
|
|
606
815
|
isActive={index === activeIndex}
|
|
607
816
|
scrollOffsetMinutes={scrollOffsetMinutes}
|
|
608
817
|
weekStartsOn={weekStartsOn}
|
|
818
|
+
weekEndsOn={weekEndsOn}
|
|
609
819
|
hourColumnWidth={hourColumnWidth}
|
|
610
820
|
minHour={clampedMinHour}
|
|
611
821
|
maxHour={clampedMaxHour}
|
|
612
822
|
ampm={ampm}
|
|
823
|
+
timeslots={timeslots}
|
|
824
|
+
isRTL={isRTL}
|
|
825
|
+
showVerticalScrollIndicator={showVerticalScrollIndicator}
|
|
826
|
+
verticalScrollEnabled={verticalScrollEnabled}
|
|
827
|
+
hourComponent={hourComponent}
|
|
828
|
+
calendarCellStyle={calendarCellStyle}
|
|
613
829
|
minHourHeight={minHourHeight}
|
|
614
830
|
maxHourHeight={maxHourHeight}
|
|
615
831
|
showNowIndicator={showNowIndicator}
|
|
616
832
|
renderEvent={renderEvent}
|
|
617
833
|
keyExtractor={keyExtractor}
|
|
618
834
|
onPressEvent={onPressEvent}
|
|
619
|
-
|
|
835
|
+
onLongPressEvent={onLongPressEvent}
|
|
836
|
+
onPressCell={handlePressCell}
|
|
837
|
+
onLongPressCell={onLongPressCell}
|
|
620
838
|
/>
|
|
621
839
|
</View>
|
|
622
840
|
),
|
|
@@ -624,6 +842,7 @@ function TimeGridInner<T>({
|
|
|
624
842
|
width,
|
|
625
843
|
pageHeight,
|
|
626
844
|
mode,
|
|
845
|
+
numberOfDays,
|
|
627
846
|
events,
|
|
628
847
|
cellHeight,
|
|
629
848
|
hourHeight,
|
|
@@ -632,17 +851,26 @@ function TimeGridInner<T>({
|
|
|
632
851
|
activeIndex,
|
|
633
852
|
scrollOffsetMinutes,
|
|
634
853
|
weekStartsOn,
|
|
854
|
+
weekEndsOn,
|
|
635
855
|
hourColumnWidth,
|
|
636
856
|
clampedMinHour,
|
|
637
857
|
clampedMaxHour,
|
|
638
858
|
ampm,
|
|
859
|
+
timeslots,
|
|
860
|
+
isRTL,
|
|
861
|
+
showVerticalScrollIndicator,
|
|
862
|
+
verticalScrollEnabled,
|
|
863
|
+
hourComponent,
|
|
864
|
+
calendarCellStyle,
|
|
639
865
|
minHourHeight,
|
|
640
866
|
maxHourHeight,
|
|
641
867
|
showNowIndicator,
|
|
642
868
|
renderEvent,
|
|
643
869
|
keyExtractor,
|
|
644
870
|
onPressEvent,
|
|
645
|
-
|
|
871
|
+
onLongPressEvent,
|
|
872
|
+
handlePressCell,
|
|
873
|
+
onLongPressCell,
|
|
646
874
|
],
|
|
647
875
|
);
|
|
648
876
|
|
|
@@ -656,18 +884,28 @@ function TimeGridInner<T>({
|
|
|
656
884
|
mode={mode}
|
|
657
885
|
width={width}
|
|
658
886
|
hourColumnWidth={hourColumnWidth}
|
|
887
|
+
showWeekNumber={showWeekNumber}
|
|
888
|
+
weekNumberPrefix={weekNumberPrefix}
|
|
659
889
|
locale={locale}
|
|
890
|
+
activeDate={activeDate}
|
|
891
|
+
onPressDateHeader={onPressDateHeader}
|
|
660
892
|
/>
|
|
661
893
|
)}
|
|
662
894
|
|
|
895
|
+
{headerComponent}
|
|
896
|
+
|
|
663
897
|
<View
|
|
664
898
|
style={styles.pager}
|
|
665
|
-
onLayout={(event) =>
|
|
899
|
+
onLayout={(event) => {
|
|
900
|
+
setPageHeight(event.nativeEvent.layout.height);
|
|
901
|
+
setMeasured(true);
|
|
902
|
+
}}
|
|
666
903
|
>
|
|
667
904
|
<LegendList
|
|
668
|
-
// Remount
|
|
669
|
-
//
|
|
670
|
-
|
|
905
|
+
// Remount only on the seed→measured transition (see `measured`), not on
|
|
906
|
+
// every height change, so a day↔week header-height difference resizes the
|
|
907
|
+
// items in place instead of remounting and blanking the page.
|
|
908
|
+
key={measured ? 'grid' : 'grid-seed'}
|
|
671
909
|
ref={listRef}
|
|
672
910
|
style={styles.pagerList}
|
|
673
911
|
data={pageDates}
|
|
@@ -675,6 +913,7 @@ function TimeGridInner<T>({
|
|
|
675
913
|
recycleItems={false}
|
|
676
914
|
keyExtractor={keyExtractorList}
|
|
677
915
|
getFixedItemSize={getFixedItemSize}
|
|
916
|
+
scrollEnabled={swipeEnabled}
|
|
678
917
|
// Default: native paging — each page is the viewport width, so a swipe
|
|
679
918
|
// hard-stops at the adjacent page and can't fling past it. With
|
|
680
919
|
// `freeSwipe`, momentum carries across pages and snaps to a boundary.
|
|
@@ -698,17 +937,50 @@ type DefaultHeaderProps = {
|
|
|
698
937
|
mode: CalendarMode;
|
|
699
938
|
width: number;
|
|
700
939
|
hourColumnWidth: number;
|
|
701
|
-
|
|
940
|
+
showWeekNumber?: boolean;
|
|
941
|
+
weekNumberPrefix?: string;
|
|
942
|
+
locale?: Locale;
|
|
943
|
+
activeDate?: Date;
|
|
944
|
+
onPressDateHeader?: (date: Date) => void;
|
|
702
945
|
};
|
|
703
946
|
|
|
704
|
-
const DefaultHeader = ({
|
|
705
|
-
|
|
947
|
+
const DefaultHeader = ({
|
|
948
|
+
days,
|
|
949
|
+
mode,
|
|
950
|
+
width,
|
|
951
|
+
hourColumnWidth,
|
|
952
|
+
showWeekNumber,
|
|
953
|
+
weekNumberPrefix = 'W',
|
|
954
|
+
locale,
|
|
955
|
+
activeDate,
|
|
956
|
+
onPressDateHeader,
|
|
957
|
+
}: DefaultHeaderProps) => {
|
|
958
|
+
const theme = useCalendarTheme();
|
|
959
|
+
// Match the grid below: an hour-column spacer, then one column per day.
|
|
960
|
+
const dayWidth = (width - hourColumnWidth) / days.length;
|
|
706
961
|
|
|
707
962
|
return (
|
|
708
963
|
<View style={styles.headerRow}>
|
|
709
|
-
|
|
964
|
+
<View style={[styles.weekNumberGutter, { width: hourColumnWidth }]}>
|
|
965
|
+
{showWeekNumber && hourColumnWidth > 0 && days[0] ? (
|
|
966
|
+
<Text
|
|
967
|
+
style={[theme.text.hourLabel, { color: theme.colors.textMuted }]}
|
|
968
|
+
allowFontScaling={false}
|
|
969
|
+
>
|
|
970
|
+
{`${weekNumberPrefix}${getISOWeek(days[0])}`}
|
|
971
|
+
</Text>
|
|
972
|
+
) : null}
|
|
973
|
+
</View>
|
|
710
974
|
{days.map((day) => (
|
|
711
|
-
<DayHeader
|
|
975
|
+
<DayHeader
|
|
976
|
+
key={day.toISOString()}
|
|
977
|
+
day={day}
|
|
978
|
+
mode={mode}
|
|
979
|
+
width={dayWidth}
|
|
980
|
+
locale={locale}
|
|
981
|
+
activeDate={activeDate}
|
|
982
|
+
onPressDateHeader={onPressDateHeader}
|
|
983
|
+
/>
|
|
712
984
|
))}
|
|
713
985
|
</View>
|
|
714
986
|
);
|
|
@@ -718,31 +990,38 @@ type DayHeaderProps = {
|
|
|
718
990
|
day: Date;
|
|
719
991
|
mode: CalendarMode;
|
|
720
992
|
width: number;
|
|
721
|
-
locale?:
|
|
993
|
+
locale?: Locale;
|
|
994
|
+
activeDate?: Date;
|
|
995
|
+
onPressDateHeader?: (date: Date) => void;
|
|
722
996
|
};
|
|
723
997
|
|
|
724
|
-
const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
998
|
+
const DayHeader = ({ day, mode, width, locale, activeDate, onPressDateHeader }: DayHeaderProps) => {
|
|
725
999
|
const theme = useCalendarTheme();
|
|
726
1000
|
const isToday = getIsToday(day);
|
|
1001
|
+
// Highlight the chosen `activeDate` when supplied, else the real today.
|
|
1002
|
+
const isHighlighted = activeDate ? isSameCalendarDay(day, activeDate) : isToday;
|
|
727
1003
|
const badgeSize = mode === 'day' ? 44 : 32;
|
|
728
1004
|
|
|
729
1005
|
return (
|
|
730
|
-
<
|
|
1006
|
+
<Pressable
|
|
1007
|
+
style={[styles.dayHeader, { width, gap: mode === 'day' ? 4 : 2 }]}
|
|
1008
|
+
onPress={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
|
|
1009
|
+
disabled={!onPressDateHeader}
|
|
1010
|
+
accessibilityRole={onPressDateHeader ? 'button' : undefined}
|
|
1011
|
+
>
|
|
731
1012
|
<View
|
|
732
1013
|
style={[
|
|
733
1014
|
styles.dayHeaderBadge,
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
height: badgeSize,
|
|
739
|
-
},
|
|
1015
|
+
// Reserve the badge's size on every day so the highlight circle doesn't
|
|
1016
|
+
// change the header's dimensions between today and other days (no shift).
|
|
1017
|
+
{ width: badgeSize, height: badgeSize, borderRadius: 999 },
|
|
1018
|
+
isHighlighted && { backgroundColor: theme.colors.todayBackground },
|
|
740
1019
|
]}
|
|
741
1020
|
>
|
|
742
1021
|
<Text
|
|
743
1022
|
style={[
|
|
744
1023
|
theme.text.dayNumber,
|
|
745
|
-
{ color:
|
|
1024
|
+
{ color: isHighlighted ? theme.colors.todayText : theme.colors.text },
|
|
746
1025
|
]}
|
|
747
1026
|
allowFontScaling={false}
|
|
748
1027
|
{...(isToday && { accessibilityLabel: `Today, ${day.getDate()}` })}
|
|
@@ -751,9 +1030,9 @@ const DayHeader = ({ day, mode, width, locale }: DayHeaderProps) => {
|
|
|
751
1030
|
</Text>
|
|
752
1031
|
</View>
|
|
753
1032
|
<Text style={[theme.text.weekday, { color: theme.colors.text }]} allowFontScaling={false}>
|
|
754
|
-
{day
|
|
1033
|
+
{format(day, 'EEE', { locale })}
|
|
755
1034
|
</Text>
|
|
756
|
-
</
|
|
1035
|
+
</Pressable>
|
|
757
1036
|
);
|
|
758
1037
|
};
|
|
759
1038
|
|
|
@@ -772,6 +1051,10 @@ const styles = StyleSheet.create({
|
|
|
772
1051
|
alignItems: 'center',
|
|
773
1052
|
paddingBottom: 8,
|
|
774
1053
|
},
|
|
1054
|
+
weekNumberGutter: {
|
|
1055
|
+
alignItems: 'center',
|
|
1056
|
+
justifyContent: 'flex-end',
|
|
1057
|
+
},
|
|
775
1058
|
dayHeader: {
|
|
776
1059
|
alignItems: 'center',
|
|
777
1060
|
},
|
|
@@ -813,6 +1096,12 @@ const styles = StyleSheet.create({
|
|
|
813
1096
|
flex: 1,
|
|
814
1097
|
height: StyleSheet.hairlineWidth,
|
|
815
1098
|
},
|
|
1099
|
+
timeslotLine: {
|
|
1100
|
+
position: 'absolute',
|
|
1101
|
+
right: 0,
|
|
1102
|
+
height: StyleSheet.hairlineWidth,
|
|
1103
|
+
opacity: 0.5,
|
|
1104
|
+
},
|
|
816
1105
|
eventBox: {
|
|
817
1106
|
position: 'absolute',
|
|
818
1107
|
overflow: 'hidden',
|