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