react-native-ll-calendar 0.16.0 → 0.17.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,562 @@
1
+ import React, { useMemo, type ReactNode } from 'react';
2
+ import {
3
+ Platform,
4
+ RefreshControl,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ type TextStyle,
11
+ type ViewStyle,
12
+ } from 'react-native';
13
+ import dayjs from 'dayjs';
14
+ import type {
15
+ CalendarResource,
16
+ CalendarEvent,
17
+ } from '../../types/resources-calendar';
18
+ import ResourcesCalendarEventPosition from '../../utils/resources-calendar-event-position';
19
+ import { EVENT_GAP } from '../../constants/size';
20
+
21
+ export const CELL_BORDER_WIDTH = 0.5;
22
+ export const BORDER_COLOR = 'lightslategrey';
23
+ const DEFAULT_EVENT_HEIGHT = 22;
24
+
25
+ export type WeekPanelProps = {
26
+ weekKey: string;
27
+ width: number;
28
+ resources: CalendarResource[];
29
+ events: CalendarEvent[];
30
+ eventHeight?: number;
31
+ onPressCell?: (resource: CalendarResource, date: Date) => void;
32
+ onLongPressCell?: (resource: CalendarResource, date: Date) => void;
33
+ delayLongPressCell?: number;
34
+ onPressEvent?: (event: CalendarEvent) => void;
35
+ onLongPressEvent?: (event: CalendarEvent) => void;
36
+ delayLongPressEvent?: number;
37
+ prioritizeCellInteraction?: boolean;
38
+ eventTextStyle?: (event: CalendarEvent) => TextStyle;
39
+ eventEllipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
40
+ allowFontScaling?: boolean;
41
+ renderEventOverlay?: (event: CalendarEvent) => ReactNode;
42
+ dateCellContainerStyle?: (date: Date) => ViewStyle;
43
+ cellContainerStyle?: (resource: CalendarResource, date: Date) => ViewStyle;
44
+ renderDateLabel?: (date: Date) => React.JSX.Element;
45
+ renderResourceNameLabel?: (resource: CalendarResource) => React.JSX.Element;
46
+ onRefresh?: () => void;
47
+ refreshing?: boolean;
48
+ bottomSpacing?: number;
49
+ fixedRowCount?: number;
50
+ };
51
+
52
+ type DayCellProps = {
53
+ resource: CalendarResource;
54
+ date: dayjs.Dayjs;
55
+ dateIndex: number;
56
+ columnWidth: number;
57
+ eventHeight: number;
58
+ eventPosition: ResourcesCalendarEventPosition;
59
+ eventsByResourceId: Map<string, CalendarEvent[]>;
60
+ onPressCell?: (resource: CalendarResource, date: Date) => void;
61
+ onLongPressCell?: (resource: CalendarResource, date: Date) => void;
62
+ delayLongPressCell?: number;
63
+ onPressEvent?: (event: CalendarEvent) => void;
64
+ onLongPressEvent?: (event: CalendarEvent) => void;
65
+ delayLongPressEvent?: number;
66
+ prioritizeCellInteraction?: boolean;
67
+ eventTextStyle?: (event: CalendarEvent) => TextStyle;
68
+ eventEllipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
69
+ allowFontScaling?: boolean;
70
+ renderEventOverlay?: (event: CalendarEvent) => ReactNode;
71
+ cellContainerStyle?: (resource: CalendarResource, date: Date) => ViewStyle;
72
+ };
73
+
74
+ function DayCell({
75
+ resource,
76
+ date,
77
+ dateIndex,
78
+ columnWidth,
79
+ eventHeight,
80
+ eventPosition,
81
+ eventsByResourceId,
82
+ onPressCell,
83
+ onLongPressCell,
84
+ delayLongPressCell,
85
+ onPressEvent,
86
+ onLongPressEvent,
87
+ delayLongPressEvent,
88
+ prioritizeCellInteraction,
89
+ eventTextStyle,
90
+ eventEllipsizeMode,
91
+ allowFontScaling,
92
+ renderEventOverlay,
93
+ cellContainerStyle,
94
+ }: DayCellProps) {
95
+ const resourceEvents = eventsByResourceId.get(resource.id) ?? [];
96
+
97
+ const filteredEvents = resourceEvents
98
+ .filter((event) => {
99
+ const startDjs = dayjs(event.start);
100
+ const endDjs = dayjs(event.end);
101
+ return (
102
+ startDjs.format('YYYY-MM-DD') === date.format('YYYY-MM-DD') ||
103
+ (dateIndex === 0 &&
104
+ startDjs.isBefore(date) &&
105
+ !endDjs.startOf('day').isBefore(date.startOf('day')))
106
+ );
107
+ })
108
+ .sort((a, b) => {
109
+ const aStartDjs = dateIndex === 0 ? date : dayjs(a.start);
110
+ const bStartDjs = dateIndex === 0 ? date : dayjs(b.start);
111
+ const aDiffDays = dayjs(a.end)
112
+ .startOf('day')
113
+ .diff(aStartDjs.startOf('day'), 'day');
114
+ const bDiffDays = dayjs(b.end)
115
+ .startOf('day')
116
+ .diff(bStartDjs.startOf('day'), 'day');
117
+ if (aDiffDays !== bDiffDays) {
118
+ return bDiffDays - aDiffDays;
119
+ }
120
+ return dayjs(a.start).diff(dayjs(b.start));
121
+ });
122
+
123
+ const rowNums = eventPosition.getRowNums({
124
+ resourceId: resource.id,
125
+ date: date.toDate(),
126
+ });
127
+ const cellEvents: (CalendarEvent | number)[] = [];
128
+ const rowsLength = rowNums.length + filteredEvents.length;
129
+ let eventIndex = 0;
130
+ for (let ii = 1; ii <= rowsLength; ii++) {
131
+ if (rowNums.includes(ii)) {
132
+ cellEvents.push(ii);
133
+ } else {
134
+ const event = filteredEvents[eventIndex];
135
+ if (event) {
136
+ cellEvents.push(event);
137
+ }
138
+ eventIndex++;
139
+ }
140
+ }
141
+
142
+ const showPrioritizedCellOverlay =
143
+ prioritizeCellInteraction === true &&
144
+ (onPressCell != null || onLongPressCell != null);
145
+
146
+ const cellWrapperStyle = [
147
+ styles.dayCell,
148
+ { width: columnWidth, zIndex: 7 - dateIndex },
149
+ ];
150
+
151
+ const cellInner = (
152
+ <>
153
+ <View
154
+ style={[
155
+ styles.dayCellBackground,
156
+ cellContainerStyle?.(resource, date.toDate()),
157
+ ]}
158
+ />
159
+ {cellEvents.map((event, rowIndex) => {
160
+ if (typeof event === 'number') {
161
+ return (
162
+ <View
163
+ key={`spacer-${rowIndex}`}
164
+ style={{ height: eventHeight, marginBottom: EVENT_GAP }}
165
+ />
166
+ );
167
+ }
168
+
169
+ const rawStartDjs = dayjs(event.start);
170
+ const startDjs = dateIndex === 0 ? date : rawStartDjs;
171
+ const endDjs = dayjs(event.end);
172
+ const diffDays = endDjs
173
+ .startOf('day')
174
+ .diff(startDjs.startOf('day'), 'day');
175
+ const isPrevDateEvent = dateIndex === 0 && rawStartDjs.isBefore(date);
176
+ const remainingDaysInWeek = 6 - dateIndex;
177
+ const isNextWeekEvent = diffDays > remainingDaysInWeek;
178
+ const effectiveDiffDays = isNextWeekEvent
179
+ ? remainingDaysInWeek
180
+ : diffDays;
181
+
182
+ let width =
183
+ (effectiveDiffDays + 1) * columnWidth -
184
+ EVENT_GAP * 2 -
185
+ CELL_BORDER_WIDTH * 2;
186
+ if (isPrevDateEvent) {
187
+ width += EVENT_GAP + 1;
188
+ }
189
+ if (isNextWeekEvent) {
190
+ width += EVENT_GAP + CELL_BORDER_WIDTH;
191
+ }
192
+
193
+ eventPosition.push({
194
+ resourceId: resource.id,
195
+ startDate: startDjs.toDate(),
196
+ days: effectiveDiffDays + 1,
197
+ rowNum: rowIndex + 1,
198
+ });
199
+
200
+ const eventOverlayNode = renderEventOverlay?.(event);
201
+ const showEventOverlay =
202
+ renderEventOverlay != null &&
203
+ eventOverlayNode != null &&
204
+ eventOverlayNode !== false;
205
+
206
+ return (
207
+ <View
208
+ key={event.id}
209
+ pointerEvents={showPrioritizedCellOverlay ? 'none' : 'auto'}
210
+ style={[
211
+ styles.eventOuter,
212
+ { width, height: eventHeight },
213
+ isPrevDateEvent && styles.prevDateEvent,
214
+ ]}
215
+ >
216
+ <TouchableOpacity
217
+ style={[
218
+ styles.event,
219
+ {
220
+ backgroundColor: event.backgroundColor,
221
+ borderColor: event.borderColor,
222
+ ...(event.borderStyle !== undefined && {
223
+ borderStyle: event.borderStyle,
224
+ }),
225
+ ...(event.borderWidth !== undefined && {
226
+ borderWidth: event.borderWidth,
227
+ }),
228
+ ...(event.borderRadius !== undefined && {
229
+ borderRadius: event.borderRadius,
230
+ }),
231
+ },
232
+ isPrevDateEvent && styles.prevDateEventInner,
233
+ isNextWeekEvent && styles.nextWeekEventInner,
234
+ ]}
235
+ activeOpacity={0.8}
236
+ onPress={() => onPressEvent?.(event)}
237
+ onLongPress={() => onLongPressEvent?.(event)}
238
+ delayLongPress={delayLongPressEvent}
239
+ >
240
+ <Text
241
+ numberOfLines={1}
242
+ ellipsizeMode={eventEllipsizeMode ?? 'tail'}
243
+ allowFontScaling={allowFontScaling}
244
+ style={[
245
+ styles.eventTitle,
246
+ { color: event.color },
247
+ eventTextStyle?.(event),
248
+ ]}
249
+ >
250
+ {event.title}
251
+ </Text>
252
+ </TouchableOpacity>
253
+ {showEventOverlay ? (
254
+ <View style={styles.eventOverlayHost} pointerEvents="box-none">
255
+ {eventOverlayNode}
256
+ </View>
257
+ ) : null}
258
+ </View>
259
+ );
260
+ })}
261
+ </>
262
+ );
263
+
264
+ return showPrioritizedCellOverlay ? (
265
+ <View style={cellWrapperStyle}>
266
+ {cellInner}
267
+ <TouchableOpacity
268
+ accessible={false}
269
+ style={styles.cellInteractionOverlay}
270
+ activeOpacity={1}
271
+ onPress={() => onPressCell?.(resource, date.toDate())}
272
+ onLongPress={() => onLongPressCell?.(resource, date.toDate())}
273
+ delayLongPress={delayLongPressCell}
274
+ />
275
+ </View>
276
+ ) : (
277
+ <TouchableOpacity
278
+ activeOpacity={1}
279
+ style={cellWrapperStyle}
280
+ onPress={() => onPressCell?.(resource, date.toDate())}
281
+ onLongPress={() => onLongPressCell?.(resource, date.toDate())}
282
+ delayLongPress={delayLongPressCell}
283
+ >
284
+ {cellInner}
285
+ </TouchableOpacity>
286
+ );
287
+ }
288
+
289
+ export function WeekPanel({
290
+ weekKey,
291
+ width,
292
+ resources,
293
+ events,
294
+ eventHeight,
295
+ onPressCell,
296
+ onLongPressCell,
297
+ delayLongPressCell,
298
+ onPressEvent,
299
+ onLongPressEvent,
300
+ delayLongPressEvent,
301
+ prioritizeCellInteraction,
302
+ eventTextStyle,
303
+ eventEllipsizeMode,
304
+ allowFontScaling,
305
+ renderEventOverlay,
306
+ dateCellContainerStyle,
307
+ cellContainerStyle,
308
+ renderDateLabel,
309
+ renderResourceNameLabel,
310
+ onRefresh,
311
+ refreshing,
312
+ bottomSpacing,
313
+ fixedRowCount,
314
+ }: WeekPanelProps) {
315
+ const columnWidth = width / 8;
316
+ const startDjs = dayjs(weekKey);
317
+ const days = Array.from({ length: 7 }, (_, i) => startDjs.add(i, 'day'));
318
+ const resolvedEventHeight = eventHeight ?? DEFAULT_EVENT_HEIGHT;
319
+ const resolvedFixedRowCount = fixedRowCount ?? 0;
320
+
321
+ const eventsByResourceId = useMemo(() => {
322
+ const deduped = new Map<string, CalendarEvent>();
323
+ for (const event of events) {
324
+ deduped.set(event.id, event);
325
+ }
326
+ const map = new Map<string, CalendarEvent[]>();
327
+ for (const event of deduped.values()) {
328
+ const list = map.get(event.resourceId) ?? [];
329
+ list.push(event);
330
+ map.set(event.resourceId, list);
331
+ }
332
+ return map;
333
+ }, [events]);
334
+
335
+ const fixedResources = useMemo(
336
+ () => resources.slice(0, resolvedFixedRowCount),
337
+ [resources, resolvedFixedRowCount]
338
+ );
339
+ const scrollableResources = useMemo(
340
+ () => resources.slice(resolvedFixedRowCount),
341
+ [resources, resolvedFixedRowCount]
342
+ );
343
+
344
+ const eventPosition = new ResourcesCalendarEventPosition();
345
+
346
+ const renderResourceRow = (
347
+ resource: CalendarResource,
348
+ showTopBorder: boolean
349
+ ) => (
350
+ <View
351
+ key={resource.id}
352
+ style={[styles.resourceRow, showTopBorder && styles.resourceRowFirst]}
353
+ >
354
+ <View style={[styles.resourceNameCell, { width: columnWidth }]}>
355
+ {renderResourceNameLabel ? (
356
+ renderResourceNameLabel(resource)
357
+ ) : (
358
+ <Text
359
+ style={styles.resourceNameText}
360
+ numberOfLines={2}
361
+ allowFontScaling={allowFontScaling}
362
+ >
363
+ {resource.name}
364
+ </Text>
365
+ )}
366
+ </View>
367
+ {days.map((day, dateIndex) => (
368
+ <DayCell
369
+ key={day.format('YYYY-MM-DD')}
370
+ resource={resource}
371
+ date={day}
372
+ dateIndex={dateIndex}
373
+ columnWidth={columnWidth}
374
+ eventHeight={resolvedEventHeight}
375
+ eventPosition={eventPosition}
376
+ eventsByResourceId={eventsByResourceId}
377
+ onPressCell={onPressCell}
378
+ onLongPressCell={onLongPressCell}
379
+ delayLongPressCell={delayLongPressCell}
380
+ onPressEvent={onPressEvent}
381
+ onLongPressEvent={onLongPressEvent}
382
+ delayLongPressEvent={delayLongPressEvent}
383
+ prioritizeCellInteraction={prioritizeCellInteraction}
384
+ eventTextStyle={eventTextStyle}
385
+ eventEllipsizeMode={eventEllipsizeMode}
386
+ allowFontScaling={allowFontScaling}
387
+ renderEventOverlay={renderEventOverlay}
388
+ cellContainerStyle={cellContainerStyle}
389
+ />
390
+ ))}
391
+ </View>
392
+ );
393
+
394
+ return (
395
+ <View style={[styles.panel, { width }]}>
396
+ <View style={styles.headerRow}>
397
+ <View
398
+ style={[
399
+ styles.headerCell,
400
+ styles.resourceNameHeaderCell,
401
+ { width: columnWidth },
402
+ ]}
403
+ />
404
+ {days.map((day) => (
405
+ <View
406
+ key={day.format('YYYY-MM-DD')}
407
+ style={[
408
+ styles.headerCell,
409
+ { width: columnWidth },
410
+ dateCellContainerStyle?.(day.toDate()),
411
+ ]}
412
+ >
413
+ {renderDateLabel ? (
414
+ renderDateLabel(day.toDate())
415
+ ) : (
416
+ <>
417
+ <Text style={styles.headerDayOfWeekText}>
418
+ {day.format('ddd')}
419
+ </Text>
420
+ <Text style={styles.headerDateText}>{day.format('M/D')}</Text>
421
+ </>
422
+ )}
423
+ </View>
424
+ ))}
425
+ </View>
426
+
427
+ {fixedResources.map((resource, index) =>
428
+ renderResourceRow(resource, index === 0)
429
+ )}
430
+
431
+ <ScrollView
432
+ showsVerticalScrollIndicator={false}
433
+ refreshControl={
434
+ <RefreshControl
435
+ refreshing={refreshing ?? false}
436
+ onRefresh={onRefresh}
437
+ />
438
+ }
439
+ >
440
+ {scrollableResources.map((resource, index) =>
441
+ renderResourceRow(
442
+ resource,
443
+ fixedResources.length === 0 && index === 0
444
+ )
445
+ )}
446
+ <View style={{ height: bottomSpacing }} />
447
+ </ScrollView>
448
+ </View>
449
+ );
450
+ }
451
+
452
+ const styles = StyleSheet.create({
453
+ panel: {
454
+ flex: 1,
455
+ overflow: 'hidden',
456
+ },
457
+ headerRow: {
458
+ flexDirection: 'row',
459
+ backgroundColor: 'white',
460
+ borderBottomWidth: CELL_BORDER_WIDTH,
461
+ borderBottomColor: BORDER_COLOR,
462
+ },
463
+ headerCell: {
464
+ alignItems: 'center',
465
+ justifyContent: 'center',
466
+ paddingVertical: 6,
467
+ borderRightWidth: CELL_BORDER_WIDTH,
468
+ borderRightColor: BORDER_COLOR,
469
+ },
470
+ resourceNameHeaderCell: {
471
+ borderRightWidth: CELL_BORDER_WIDTH,
472
+ borderRightColor: BORDER_COLOR,
473
+ },
474
+ headerDayOfWeekText: {
475
+ fontSize: 11,
476
+ color: '#666',
477
+ },
478
+ headerDateText: {
479
+ fontSize: 13,
480
+ fontWeight: '600',
481
+ color: '#222',
482
+ },
483
+ resourceRow: {
484
+ flexDirection: 'row',
485
+ minHeight: 30,
486
+ borderBottomWidth: CELL_BORDER_WIDTH,
487
+ borderBottomColor: BORDER_COLOR,
488
+ backgroundColor: 'white',
489
+ },
490
+ resourceRowFirst: {
491
+ borderTopWidth: CELL_BORDER_WIDTH,
492
+ borderTopColor: BORDER_COLOR,
493
+ },
494
+ resourceNameCell: {
495
+ justifyContent: 'center',
496
+ paddingHorizontal: 4,
497
+ borderRightWidth: CELL_BORDER_WIDTH,
498
+ borderRightColor: BORDER_COLOR,
499
+ backgroundColor: '#fafafa',
500
+ },
501
+ resourceNameText: {
502
+ fontSize: 11,
503
+ color: '#333',
504
+ },
505
+ dayCell: {
506
+ borderRightWidth: CELL_BORDER_WIDTH,
507
+ borderRightColor: BORDER_COLOR,
508
+ minHeight: 30,
509
+ paddingBottom: EVENT_GAP,
510
+ position: 'relative',
511
+ },
512
+ dayCellBackground: {
513
+ ...StyleSheet.absoluteFillObject,
514
+ },
515
+ cellInteractionOverlay: {
516
+ ...StyleSheet.absoluteFillObject,
517
+ zIndex: 1000,
518
+ backgroundColor: 'transparent',
519
+ ...Platform.select({
520
+ android: { elevation: 12 },
521
+ default: {},
522
+ }),
523
+ },
524
+ eventOuter: {
525
+ position: 'relative',
526
+ marginTop: EVENT_GAP,
527
+ marginLeft: EVENT_GAP,
528
+ },
529
+ prevDateEvent: {
530
+ marginLeft: -1,
531
+ },
532
+ prevDateEventInner: {
533
+ borderTopStartRadius: 0,
534
+ borderBottomStartRadius: 0,
535
+ },
536
+ nextWeekEventInner: {
537
+ borderTopEndRadius: 0,
538
+ borderBottomEndRadius: 0,
539
+ paddingRight: 0,
540
+ },
541
+ event: {
542
+ flex: 1,
543
+ width: '100%',
544
+ height: '100%',
545
+ borderWidth: 0.5,
546
+ borderRadius: 4,
547
+ paddingHorizontal: 4,
548
+ flexDirection: 'row',
549
+ alignItems: 'center',
550
+ ...Platform.select({
551
+ android: { elevation: 2 },
552
+ default: {},
553
+ }),
554
+ },
555
+ eventTitle: {
556
+ fontSize: 11,
557
+ },
558
+ eventOverlayHost: {
559
+ ...StyleSheet.absoluteFillObject,
560
+ pointerEvents: 'box-none' as const,
561
+ },
562
+ });