@super-calendar/native 2.1.5 → 2.3.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/{DefaultMonthEvent-C-BiOsXb.d.mts → DefaultMonthEvent-BW6qy50X.d.ts} +62 -8
- package/dist/{DefaultMonthEvent-CcZLyfps.d.ts → DefaultMonthEvent-BbCMc60z.d.mts} +62 -8
- package/dist/{MonthList-BKlLes2B.mjs → MonthList-BidXJMfm.mjs} +98 -32
- package/dist/{MonthList-ege6HsEP.js → MonthList-CFXm7BK_.js} +102 -30
- package/dist/index.d.mts +110 -6
- package/dist/index.d.ts +110 -6
- package/dist/index.js +480 -113
- package/dist/index.mjs +476 -116
- package/dist/picker.d.mts +1 -1
- package/dist/picker.d.ts +1 -1
- package/dist/picker.js +1 -1
- package/dist/picker.mjs +1 -1
- package/package.json +2 -2
- package/src/components/Agenda.tsx +2 -2
- package/src/components/AllDayLane.tsx +11 -2
- package/src/components/Calendar.tsx +37 -3
- package/src/components/DefaultEvent.tsx +68 -47
- package/src/components/DefaultMonthEvent.tsx +11 -8
- package/src/components/MonthList.tsx +15 -2
- package/src/components/MonthPager.tsx +18 -10
- package/src/components/MonthView.tsx +39 -10
- package/src/components/ResourceTimeline.tsx +441 -0
- package/src/components/TimeGrid.tsx +54 -17
- package/src/index.tsx +10 -0
- package/src/theme.ts +74 -4
- package/src/types.ts +9 -0
- package/src/utils/withEventAccessibilityLabel.tsx +30 -0
|
@@ -16,7 +16,15 @@ import {
|
|
|
16
16
|
const isWeb = Platform.OS === "web";
|
|
17
17
|
import { useCalendarTheme } from "../theme";
|
|
18
18
|
import type { CalendarEvent, EventKeyExtractor, RenderEvent, WeekStartsOn } from "../types";
|
|
19
|
-
import {
|
|
19
|
+
import { withEventAccessibilityLabel } from "../utils/withEventAccessibilityLabel";
|
|
20
|
+
import {
|
|
21
|
+
type DateRange,
|
|
22
|
+
type EventAccessibilityLabeler,
|
|
23
|
+
type WeekdayFormat,
|
|
24
|
+
daySelectionState,
|
|
25
|
+
useCalendarSelection,
|
|
26
|
+
weekdayFormatToken,
|
|
27
|
+
} from "@super-calendar/core";
|
|
20
28
|
import { dayBadgeKind, rangeBandKind } from "@super-calendar/core";
|
|
21
29
|
import {
|
|
22
30
|
buildMonthWeeks,
|
|
@@ -54,6 +62,8 @@ export type MonthViewProps<T> = {
|
|
|
54
62
|
*/
|
|
55
63
|
maxVisibleEventCount?: number;
|
|
56
64
|
weekStartsOn: WeekStartsOn;
|
|
65
|
+
/** Weekday header label width: `narrow` ("M"), `short` ("Mon", default), or `long` ("Monday"). */
|
|
66
|
+
weekdayFormat?: WeekdayFormat;
|
|
57
67
|
locale?: Locale;
|
|
58
68
|
/** Sort each day's events by start time before slicing. Default true. */
|
|
59
69
|
sortedMonthView?: boolean;
|
|
@@ -95,6 +105,12 @@ export type MonthViewProps<T> = {
|
|
|
95
105
|
/** Per-date style merged onto the day cell. */
|
|
96
106
|
calendarCellStyle?: (date: Date) => StyleProp<ViewStyle>;
|
|
97
107
|
renderEvent: RenderEvent<T>;
|
|
108
|
+
/**
|
|
109
|
+
* Override the screen-reader label for each event chip. Receives the event and a
|
|
110
|
+
* `{ mode: "month", isAllDay, ampm: false }` context; return the full text to
|
|
111
|
+
* announce. Defaults to the built-in title-and-time label.
|
|
112
|
+
*/
|
|
113
|
+
eventAccessibilityLabel?: EventAccessibilityLabeler<T>;
|
|
98
114
|
keyExtractor: EventKeyExtractor<T>;
|
|
99
115
|
onPressDay?: (date: Date) => void;
|
|
100
116
|
onLongPressDay?: (date: Date) => void;
|
|
@@ -113,6 +129,7 @@ function MonthViewInner<T>({
|
|
|
113
129
|
events,
|
|
114
130
|
maxVisibleEventCount,
|
|
115
131
|
weekStartsOn,
|
|
132
|
+
weekdayFormat = "short",
|
|
116
133
|
locale,
|
|
117
134
|
sortedMonthView = true,
|
|
118
135
|
moreLabel = "{moreCount} More",
|
|
@@ -131,6 +148,7 @@ function MonthViewInner<T>({
|
|
|
131
148
|
isDateDisabled: isDateDisabledProp,
|
|
132
149
|
calendarCellStyle,
|
|
133
150
|
renderEvent,
|
|
151
|
+
eventAccessibilityLabel,
|
|
134
152
|
keyExtractor,
|
|
135
153
|
onPressDay,
|
|
136
154
|
onLongPressDay,
|
|
@@ -150,7 +168,11 @@ function MonthViewInner<T>({
|
|
|
150
168
|
const minDate = minDateProp ?? selection.minDate;
|
|
151
169
|
const maxDate = maxDateProp ?? selection.maxDate;
|
|
152
170
|
const isDateDisabled = isDateDisabledProp ?? selection.isDateDisabled;
|
|
153
|
-
|
|
171
|
+
// Month cells never show a time, so the override context reports 24h (ampm:false).
|
|
172
|
+
const RenderEventComponent = useMemo(
|
|
173
|
+
() => withEventAccessibilityLabel(renderEvent, eventAccessibilityLabel, false),
|
|
174
|
+
[renderEvent, eventAccessibilityLabel],
|
|
175
|
+
);
|
|
154
176
|
// Measured grid height, used to auto-fit the event chips per cell.
|
|
155
177
|
const [gridHeight, setGridHeight] = useState(0);
|
|
156
178
|
// Web-only hover highlight on the day badge (mouse pointers); stays null on
|
|
@@ -225,6 +247,7 @@ function MonthViewInner<T>({
|
|
|
225
247
|
},
|
|
226
248
|
// No weekend tint on blank placeholders, so the shading doesn't bleed
|
|
227
249
|
// into the empty cells of non-existent days.
|
|
250
|
+
theme.containers.dayCell,
|
|
228
251
|
]}
|
|
229
252
|
/>
|
|
230
253
|
);
|
|
@@ -291,6 +314,7 @@ function MonthViewInner<T>({
|
|
|
291
314
|
},
|
|
292
315
|
isWeekend(day) && { backgroundColor: theme.colors.weekendBackground },
|
|
293
316
|
calendarCellStyle?.(day),
|
|
317
|
+
theme.containers.dayCell,
|
|
294
318
|
]}
|
|
295
319
|
// In the picker, don't dim the whole cell on press: the tap should show on
|
|
296
320
|
// the badge (the circle) only, not the cell background. `onPressIn/Out` drive
|
|
@@ -382,6 +406,7 @@ function MonthViewInner<T>({
|
|
|
382
406
|
},
|
|
383
407
|
// Tap feedback lives on the badge, not the cell (picker only).
|
|
384
408
|
!showGrid && pressedKey === dayKey && { opacity: 0.2 },
|
|
409
|
+
theme.containers.dayBadge,
|
|
385
410
|
]}
|
|
386
411
|
>
|
|
387
412
|
<Text style={[theme.text.dateCell, { color: dateColor }]} allowFontScaling={false}>
|
|
@@ -390,7 +415,10 @@ function MonthViewInner<T>({
|
|
|
390
415
|
</View>
|
|
391
416
|
)}
|
|
392
417
|
{dayEvents.slice(0, visibleCount).map((event, index) => (
|
|
393
|
-
<View
|
|
418
|
+
<View
|
|
419
|
+
key={keyExtractor(event, index)}
|
|
420
|
+
style={[styles.monthEvent, theme.containers.monthEvent]}
|
|
421
|
+
>
|
|
394
422
|
<RenderEventComponent
|
|
395
423
|
event={event}
|
|
396
424
|
mode="month"
|
|
@@ -427,26 +455,29 @@ function MonthViewInner<T>({
|
|
|
427
455
|
return (
|
|
428
456
|
<View style={styles.root}>
|
|
429
457
|
{showTitle ? (
|
|
430
|
-
<Text
|
|
458
|
+
<Text
|
|
459
|
+
style={[styles.title, theme.text.monthTitle, { color: theme.colors.text }]}
|
|
460
|
+
allowFontScaling={false}
|
|
461
|
+
>
|
|
431
462
|
{format(date, "MMMM yyyy", locale ? { locale } : undefined)}
|
|
432
463
|
</Text>
|
|
433
464
|
) : null}
|
|
434
465
|
{showWeekdays ? (
|
|
435
|
-
<View style={styles.weekdayHeader}>
|
|
466
|
+
<View style={[styles.weekdayHeader, theme.containers.weekdayHeader]}>
|
|
436
467
|
{weekdayLabels.map((day) => (
|
|
437
468
|
<Text
|
|
438
469
|
key={day.toISOString()}
|
|
439
470
|
style={[theme.text.weekday, styles.weekdayLabel, { color: theme.colors.textMuted }]}
|
|
440
471
|
allowFontScaling={false}
|
|
441
472
|
>
|
|
442
|
-
{format(day,
|
|
473
|
+
{format(day, weekdayFormatToken(weekdayFormat), { locale })}
|
|
443
474
|
</Text>
|
|
444
475
|
))}
|
|
445
476
|
</View>
|
|
446
477
|
) : null}
|
|
447
478
|
<View style={styles.container} onLayout={handleLayout}>
|
|
448
479
|
{weeks.map((week) => (
|
|
449
|
-
<View style={styles.weekRow} key={week[0].toISOString()}>
|
|
480
|
+
<View style={[styles.weekRow, theme.containers.weekRow]} key={week[0].toISOString()}>
|
|
450
481
|
{week.map((day) => renderDay(day))}
|
|
451
482
|
</View>
|
|
452
483
|
))}
|
|
@@ -477,10 +508,8 @@ const styles = StyleSheet.create({
|
|
|
477
508
|
root: {
|
|
478
509
|
flex: 1,
|
|
479
510
|
},
|
|
480
|
-
//
|
|
511
|
+
// Layout only; the font is themeable via `theme.text.monthTitle`.
|
|
481
512
|
title: {
|
|
482
|
-
fontSize: 17,
|
|
483
|
-
fontWeight: "700",
|
|
484
513
|
paddingTop: 10,
|
|
485
514
|
paddingHorizontal: 14,
|
|
486
515
|
paddingBottom: 6,
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
5
|
+
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
|
6
|
+
import {
|
|
7
|
+
type CalendarEvent,
|
|
8
|
+
eventTimeLabel,
|
|
9
|
+
formatHour,
|
|
10
|
+
layoutDayEvents,
|
|
11
|
+
type PositionedEvent,
|
|
12
|
+
resolveDraggedBounds,
|
|
13
|
+
snapDeltaMinutes,
|
|
14
|
+
} from "@super-calendar/core";
|
|
15
|
+
import { useCalendarTheme } from "../theme";
|
|
16
|
+
|
|
17
|
+
// Native long-press duration (ms) before a bar is picked up to move.
|
|
18
|
+
const MOVE_ACTIVATE_MS = 250;
|
|
19
|
+
const MINUTES_PER_HOUR = 60;
|
|
20
|
+
|
|
21
|
+
/** A schedulable lane (room, person, machine) events are grouped under. */
|
|
22
|
+
export interface Resource {
|
|
23
|
+
/** Stable id events reference via their `resourceId`. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Row label; falls back to the id. */
|
|
26
|
+
title?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Props passed to a custom resource-timeline event renderer. */
|
|
30
|
+
export interface ResourceEventArgs<T = unknown> {
|
|
31
|
+
event: CalendarEvent<T>;
|
|
32
|
+
/** Pixel width of the event bar. */
|
|
33
|
+
width: number;
|
|
34
|
+
onPress: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Props for {@link ResourceTimeline}. */
|
|
38
|
+
export interface ResourceTimelineProps<T = unknown> {
|
|
39
|
+
/** The day to lay out along the horizontal axis. */
|
|
40
|
+
date: Date;
|
|
41
|
+
/** The rows, top to bottom. */
|
|
42
|
+
resources: Resource[];
|
|
43
|
+
/** Events; each is placed in the row named by `resourceId(event)`. */
|
|
44
|
+
events: CalendarEvent<T>[];
|
|
45
|
+
/** Read an event's resource id. Default: `event.resourceId`. */
|
|
46
|
+
resourceId?: (event: CalendarEvent<T>) => string | undefined;
|
|
47
|
+
/** First hour shown (default 0). */
|
|
48
|
+
startHour?: number;
|
|
49
|
+
/** Last hour shown, exclusive (default 24). */
|
|
50
|
+
endHour?: number;
|
|
51
|
+
/** Pixels per hour along the axis (default 80). */
|
|
52
|
+
hourWidth?: number;
|
|
53
|
+
/** Height of each resource row in px (default 56). */
|
|
54
|
+
rowHeight?: number;
|
|
55
|
+
/** Width of the left resource-label column in px (default 140). */
|
|
56
|
+
labelWidth?: number;
|
|
57
|
+
/** 12-hour AM/PM axis labels (default false). */
|
|
58
|
+
ampm?: boolean;
|
|
59
|
+
/** Custom event renderer; falls back to the built-in bar. */
|
|
60
|
+
renderEvent?: ComponentType<ResourceEventArgs<T>>;
|
|
61
|
+
/** Tap an event. */
|
|
62
|
+
onPressEvent?: (event: CalendarEvent<T>) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Enables drag-to-move and edge-resize along the time axis: long-press a bar to
|
|
65
|
+
* move it, or drag its right edge to resize. Called with the proposed new
|
|
66
|
+
* start/end; return `false` to reject the drop (it snaps back).
|
|
67
|
+
*/
|
|
68
|
+
onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
|
|
69
|
+
/** Snap dragged events to this many minutes (default 15). */
|
|
70
|
+
dragStepMinutes?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
74
|
+
|
|
75
|
+
function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
|
|
76
|
+
const theme = useCalendarTheme();
|
|
77
|
+
const time = eventTimeLabel({
|
|
78
|
+
mode: "day",
|
|
79
|
+
isAllDay: false,
|
|
80
|
+
start: event.start,
|
|
81
|
+
end: event.end,
|
|
82
|
+
ampm: false,
|
|
83
|
+
showTime: true,
|
|
84
|
+
});
|
|
85
|
+
return (
|
|
86
|
+
<View
|
|
87
|
+
style={[
|
|
88
|
+
styles.bar,
|
|
89
|
+
{ backgroundColor: theme.colors.eventBackground },
|
|
90
|
+
theme.containers.timeGridEvent,
|
|
91
|
+
]}
|
|
92
|
+
>
|
|
93
|
+
<Text
|
|
94
|
+
numberOfLines={1}
|
|
95
|
+
style={[theme.text.eventTitle, { color: theme.colors.eventText }]}
|
|
96
|
+
allowFontScaling={false}
|
|
97
|
+
>
|
|
98
|
+
{event.title}
|
|
99
|
+
</Text>
|
|
100
|
+
{time && width > 56 ? (
|
|
101
|
+
<Text
|
|
102
|
+
numberOfLines={1}
|
|
103
|
+
style={[styles.barTime, { color: theme.colors.eventText }]}
|
|
104
|
+
allowFontScaling={false}
|
|
105
|
+
>
|
|
106
|
+
{time}
|
|
107
|
+
</Text>
|
|
108
|
+
) : null}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type ResourceBarProps<T> = {
|
|
114
|
+
pe: PositionedEvent<T>;
|
|
115
|
+
left: number;
|
|
116
|
+
width: number;
|
|
117
|
+
laneHeight: number;
|
|
118
|
+
hourWidth: number;
|
|
119
|
+
snapMinutes: number;
|
|
120
|
+
Renderer: ComponentType<ResourceEventArgs<T>>;
|
|
121
|
+
onPress: () => void;
|
|
122
|
+
onDragEvent: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
|
|
123
|
+
theme: ReturnType<typeof useCalendarTheme>;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A bar with drag-to-move (long-press) and right-edge resize along the time axis.
|
|
127
|
+
// The pure snap/commit math is shared with the time grid (`snapDeltaMinutes`,
|
|
128
|
+
// `resolveDraggedBounds`); this only wires the horizontal gestures.
|
|
129
|
+
function ResourceBar<T>({
|
|
130
|
+
pe,
|
|
131
|
+
left,
|
|
132
|
+
width,
|
|
133
|
+
laneHeight,
|
|
134
|
+
hourWidth,
|
|
135
|
+
snapMinutes,
|
|
136
|
+
Renderer,
|
|
137
|
+
onPress,
|
|
138
|
+
onDragEvent,
|
|
139
|
+
theme,
|
|
140
|
+
}: ResourceBarProps<T>): ReactElement {
|
|
141
|
+
const moveX = useSharedValue(0);
|
|
142
|
+
const resizeW = useSharedValue(0);
|
|
143
|
+
const latest = useRef({ event: pe.event, onDragEvent });
|
|
144
|
+
latest.current = { event: pe.event, onDragEvent };
|
|
145
|
+
const draggable = !(pe.event as { disabled?: boolean }).disabled;
|
|
146
|
+
|
|
147
|
+
// Clear the live preview once the committed change re-renders the bar.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
moveX.value = 0;
|
|
150
|
+
resizeW.value = 0;
|
|
151
|
+
}, [pe.startHours, pe.durationHours, moveX, resizeW]);
|
|
152
|
+
|
|
153
|
+
const snapBack = useCallback(() => {
|
|
154
|
+
moveX.value = 0;
|
|
155
|
+
resizeW.value = 0;
|
|
156
|
+
}, [moveX, resizeW]);
|
|
157
|
+
|
|
158
|
+
const commit = useCallback(
|
|
159
|
+
(deltaStartMin: number, deltaEndMin: number) => {
|
|
160
|
+
const { event, onDragEvent: handler } = latest.current;
|
|
161
|
+
const next = resolveDraggedBounds(
|
|
162
|
+
event.start,
|
|
163
|
+
event.end,
|
|
164
|
+
deltaStartMin,
|
|
165
|
+
deltaEndMin,
|
|
166
|
+
snapMinutes,
|
|
167
|
+
);
|
|
168
|
+
if (!next) {
|
|
169
|
+
snapBack();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (handler(event, next.start, next.end) === false) snapBack();
|
|
173
|
+
},
|
|
174
|
+
[snapMinutes, snapBack],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const barStyle = useAnimatedStyle(
|
|
178
|
+
() => ({ transform: [{ translateX: moveX.value }], width: Math.max(width + resizeW.value, 2) }),
|
|
179
|
+
[width],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const moveGesture = useMemo(
|
|
183
|
+
() =>
|
|
184
|
+
Gesture.Pan()
|
|
185
|
+
.enabled(draggable)
|
|
186
|
+
.activateAfterLongPress(MOVE_ACTIVATE_MS)
|
|
187
|
+
.onUpdate((e) => {
|
|
188
|
+
moveX.value = e.translationX;
|
|
189
|
+
})
|
|
190
|
+
.onEnd((e) => {
|
|
191
|
+
const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
|
|
192
|
+
if (delta === 0) {
|
|
193
|
+
moveX.value = 0;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
moveX.value = (delta / MINUTES_PER_HOUR) * hourWidth;
|
|
197
|
+
runOnJS(commit)(delta, delta);
|
|
198
|
+
}),
|
|
199
|
+
[draggable, hourWidth, snapMinutes, moveX, commit],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const resizeGesture = useMemo(
|
|
203
|
+
() =>
|
|
204
|
+
Gesture.Pan()
|
|
205
|
+
.enabled(draggable)
|
|
206
|
+
.onUpdate((e) => {
|
|
207
|
+
resizeW.value = e.translationX;
|
|
208
|
+
})
|
|
209
|
+
.onEnd((e) => {
|
|
210
|
+
const delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
|
|
211
|
+
if (delta === 0) {
|
|
212
|
+
resizeW.value = 0;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
resizeW.value = (delta / MINUTES_PER_HOUR) * hourWidth;
|
|
216
|
+
runOnJS(commit)(0, delta);
|
|
217
|
+
}),
|
|
218
|
+
[draggable, hourWidth, snapMinutes, resizeW, commit],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Animated.View
|
|
223
|
+
style={[
|
|
224
|
+
{ position: "absolute", left, top: pe.column * laneHeight, height: laneHeight, padding: 1 },
|
|
225
|
+
barStyle,
|
|
226
|
+
]}
|
|
227
|
+
>
|
|
228
|
+
<GestureDetector gesture={moveGesture}>
|
|
229
|
+
<Pressable
|
|
230
|
+
onPress={onPress}
|
|
231
|
+
accessibilityRole="button"
|
|
232
|
+
accessibilityLabel={pe.event.title}
|
|
233
|
+
style={styles.fill}
|
|
234
|
+
>
|
|
235
|
+
<Renderer event={pe.event} width={width} onPress={onPress} />
|
|
236
|
+
</Pressable>
|
|
237
|
+
</GestureDetector>
|
|
238
|
+
<GestureDetector gesture={resizeGesture}>
|
|
239
|
+
<View
|
|
240
|
+
style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]}
|
|
241
|
+
accessibilityRole="adjustable"
|
|
242
|
+
accessibilityLabel={`Resize ${pe.event.title}`}
|
|
243
|
+
/>
|
|
244
|
+
</GestureDetector>
|
|
245
|
+
</Animated.View>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A horizontal resource timeline: rows are resources (rooms, people, machines)
|
|
251
|
+
* and the x-axis is one day's hours. Events sit in their resource's row, and
|
|
252
|
+
* overlapping events in the same row stack into sub-lanes (via the shared
|
|
253
|
+
* `layoutDayEvents`). The grid scrolls horizontally when the axis is wider than
|
|
254
|
+
* the screen. Pass `onDragEvent` to enable long-press drag-to-move and edge
|
|
255
|
+
* resize along the time axis. Read the theme from context — wrap in
|
|
256
|
+
* `CalendarThemeProvider` (or render inside `<Calendar>`) to restyle.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```tsx
|
|
260
|
+
* <ResourceTimeline
|
|
261
|
+
* date={new Date()}
|
|
262
|
+
* resources={[{ id: "a", title: "Room A" }, { id: "b", title: "Room B" }]}
|
|
263
|
+
* events={events} // each event carries a `resourceId`
|
|
264
|
+
* />
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
export function ResourceTimeline<T = unknown>({
|
|
268
|
+
date,
|
|
269
|
+
resources,
|
|
270
|
+
events,
|
|
271
|
+
resourceId = (event) => (event as { resourceId?: string }).resourceId,
|
|
272
|
+
startHour = 0,
|
|
273
|
+
endHour = 24,
|
|
274
|
+
hourWidth = 80,
|
|
275
|
+
rowHeight = 56,
|
|
276
|
+
labelWidth = 140,
|
|
277
|
+
ampm = false,
|
|
278
|
+
renderEvent,
|
|
279
|
+
onPressEvent,
|
|
280
|
+
onDragEvent,
|
|
281
|
+
dragStepMinutes = 15,
|
|
282
|
+
}: ResourceTimelineProps<T>): ReactElement {
|
|
283
|
+
const theme = useCalendarTheme();
|
|
284
|
+
const Renderer = renderEvent ?? DefaultBar;
|
|
285
|
+
|
|
286
|
+
const hours = useMemo(
|
|
287
|
+
() => Array.from({ length: Math.max(0, endHour - startHour) }, (_, i) => startHour + i),
|
|
288
|
+
[startHour, endHour],
|
|
289
|
+
);
|
|
290
|
+
const trackWidth = (endHour - startHour) * hourWidth;
|
|
291
|
+
|
|
292
|
+
// Group the overlap-resolved events by resource, once.
|
|
293
|
+
const byResource = useMemo(() => {
|
|
294
|
+
const map = new Map<string, ReturnType<typeof layoutDayEvents<T>>>();
|
|
295
|
+
for (const resource of resources) {
|
|
296
|
+
const own = events.filter((event) => resourceId(event) === resource.id);
|
|
297
|
+
map.set(resource.id, layoutDayEvents(own, date));
|
|
298
|
+
}
|
|
299
|
+
return map;
|
|
300
|
+
}, [resources, events, resourceId, date]);
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<ScrollView horizontal showsHorizontalScrollIndicator style={styles.root}>
|
|
304
|
+
<View style={{ width: labelWidth + trackWidth }}>
|
|
305
|
+
{/* Header: corner + hour axis */}
|
|
306
|
+
<View style={[styles.header, { borderBottomColor: theme.colors.gridLine }]}>
|
|
307
|
+
<View style={{ width: labelWidth }} />
|
|
308
|
+
<View style={{ width: trackWidth, height: 24 }}>
|
|
309
|
+
{hours.map((h) => (
|
|
310
|
+
<Text
|
|
311
|
+
key={h}
|
|
312
|
+
allowFontScaling={false}
|
|
313
|
+
style={[
|
|
314
|
+
styles.hourTick,
|
|
315
|
+
{ left: (h - startHour) * hourWidth, color: theme.colors.textMuted },
|
|
316
|
+
]}
|
|
317
|
+
>
|
|
318
|
+
{formatHour(h, { ampm })}
|
|
319
|
+
</Text>
|
|
320
|
+
))}
|
|
321
|
+
</View>
|
|
322
|
+
</View>
|
|
323
|
+
|
|
324
|
+
{/* Resource rows */}
|
|
325
|
+
{resources.map((resource) => {
|
|
326
|
+
const positioned = byResource.get(resource.id) ?? [];
|
|
327
|
+
return (
|
|
328
|
+
<View
|
|
329
|
+
key={resource.id}
|
|
330
|
+
style={[
|
|
331
|
+
styles.row,
|
|
332
|
+
{ height: rowHeight, borderBottomColor: theme.colors.gridLine },
|
|
333
|
+
theme.containers.resourceRow,
|
|
334
|
+
]}
|
|
335
|
+
>
|
|
336
|
+
<View
|
|
337
|
+
style={[
|
|
338
|
+
styles.label,
|
|
339
|
+
{ width: labelWidth, borderRightColor: theme.colors.gridLine },
|
|
340
|
+
theme.containers.resourceLabel,
|
|
341
|
+
]}
|
|
342
|
+
>
|
|
343
|
+
<Text
|
|
344
|
+
numberOfLines={1}
|
|
345
|
+
style={{ color: theme.colors.text }}
|
|
346
|
+
allowFontScaling={false}
|
|
347
|
+
>
|
|
348
|
+
{resource.title ?? resource.id}
|
|
349
|
+
</Text>
|
|
350
|
+
</View>
|
|
351
|
+
<View style={{ width: trackWidth }}>
|
|
352
|
+
{/* Hour grid lines */}
|
|
353
|
+
{hours.slice(1).map((h) => (
|
|
354
|
+
<View
|
|
355
|
+
key={h}
|
|
356
|
+
pointerEvents="none"
|
|
357
|
+
style={[
|
|
358
|
+
styles.gridLine,
|
|
359
|
+
{ left: (h - startHour) * hourWidth, backgroundColor: theme.colors.gridLine },
|
|
360
|
+
]}
|
|
361
|
+
/>
|
|
362
|
+
))}
|
|
363
|
+
{positioned.map((pe, idx) => {
|
|
364
|
+
const left = clamp(pe.startHours - startHour, 0, endHour - startHour) * hourWidth;
|
|
365
|
+
const right =
|
|
366
|
+
clamp(pe.startHours + pe.durationHours - startHour, 0, endHour - startHour) *
|
|
367
|
+
hourWidth;
|
|
368
|
+
const width = Math.max(right - left, 2);
|
|
369
|
+
// Overlapping events share the row height as stacked sub-lanes.
|
|
370
|
+
const laneHeight = rowHeight / pe.columns;
|
|
371
|
+
const onPress = () => onPressEvent?.(pe.event);
|
|
372
|
+
if (onDragEvent) {
|
|
373
|
+
return (
|
|
374
|
+
<ResourceBar
|
|
375
|
+
key={idx}
|
|
376
|
+
pe={pe}
|
|
377
|
+
left={left}
|
|
378
|
+
width={width}
|
|
379
|
+
laneHeight={laneHeight}
|
|
380
|
+
hourWidth={hourWidth}
|
|
381
|
+
snapMinutes={dragStepMinutes}
|
|
382
|
+
Renderer={Renderer}
|
|
383
|
+
onPress={onPress}
|
|
384
|
+
onDragEvent={onDragEvent}
|
|
385
|
+
theme={theme}
|
|
386
|
+
/>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return (
|
|
390
|
+
<Pressable
|
|
391
|
+
key={idx}
|
|
392
|
+
onPress={onPress}
|
|
393
|
+
accessibilityRole="button"
|
|
394
|
+
accessibilityLabel={pe.event.title}
|
|
395
|
+
style={{
|
|
396
|
+
position: "absolute",
|
|
397
|
+
left,
|
|
398
|
+
width,
|
|
399
|
+
top: pe.column * laneHeight,
|
|
400
|
+
height: laneHeight,
|
|
401
|
+
padding: 1,
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<Renderer event={pe.event} width={width} onPress={onPress} />
|
|
405
|
+
</Pressable>
|
|
406
|
+
);
|
|
407
|
+
})}
|
|
408
|
+
</View>
|
|
409
|
+
</View>
|
|
410
|
+
);
|
|
411
|
+
})}
|
|
412
|
+
</View>
|
|
413
|
+
</ScrollView>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const styles = StyleSheet.create({
|
|
418
|
+
root: { flexGrow: 0 },
|
|
419
|
+
header: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth },
|
|
420
|
+
hourTick: { position: "absolute", top: 4, fontSize: 10 },
|
|
421
|
+
row: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth },
|
|
422
|
+
label: {
|
|
423
|
+
justifyContent: "center",
|
|
424
|
+
paddingHorizontal: 10,
|
|
425
|
+
borderRightWidth: StyleSheet.hairlineWidth,
|
|
426
|
+
},
|
|
427
|
+
gridLine: { position: "absolute", top: 0, bottom: 0, width: StyleSheet.hairlineWidth },
|
|
428
|
+
bar: { flex: 1, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2, overflow: "hidden" },
|
|
429
|
+
barTime: { fontSize: 11, opacity: 0.75 },
|
|
430
|
+
fill: { flex: 1 },
|
|
431
|
+
// A slim right-edge handle for resizing along the time axis.
|
|
432
|
+
resizeGrip: {
|
|
433
|
+
position: "absolute",
|
|
434
|
+
right: 1,
|
|
435
|
+
top: "30%",
|
|
436
|
+
bottom: "30%",
|
|
437
|
+
width: 4,
|
|
438
|
+
borderRadius: 2,
|
|
439
|
+
opacity: 0.5,
|
|
440
|
+
},
|
|
441
|
+
});
|