@super-calendar/native 2.3.2 → 2.5.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/README.md +21 -0
- package/dist/{DefaultMonthEvent-BW6qy50X.d.ts → DefaultMonthEvent-BSvuJUFk.d.ts} +174 -173
- package/dist/{DefaultMonthEvent-BbCMc60z.d.mts → DefaultMonthEvent-zDj9dHEv.d.mts} +174 -173
- package/dist/{MonthList-CFXm7BK_.js → MonthList-Aczb2RSY.js} +219 -108
- package/dist/{MonthList-BidXJMfm.mjs → MonthList-zPLklHAY.mjs} +203 -110
- package/dist/index.d.mts +247 -263
- package/dist/index.d.ts +247 -263
- package/dist/index.js +793 -259
- package/dist/index.mjs +786 -264
- 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 +52 -11
- package/src/components/AllDayLane.tsx +22 -8
- package/src/components/Calendar.tsx +84 -5
- package/src/components/DefaultEvent.tsx +4 -0
- package/src/components/MonthList.tsx +52 -10
- package/src/components/MonthPager.tsx +45 -6
- package/src/components/MonthView.tsx +133 -74
- package/src/components/ResourceTimeline.tsx +581 -35
- package/src/components/TimeGrid.tsx +282 -162
- package/src/index.tsx +12 -4
- package/src/types.ts +16 -1
- package/src/utils/slots.ts +83 -0
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AccessibilityActionEvent,
|
|
5
|
+
type GestureResponderEvent,
|
|
6
|
+
Platform,
|
|
7
|
+
Pressable,
|
|
8
|
+
ScrollView,
|
|
9
|
+
StyleSheet,
|
|
10
|
+
Text,
|
|
11
|
+
View,
|
|
12
|
+
} from "react-native";
|
|
4
13
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
5
14
|
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
|
6
15
|
import {
|
|
7
16
|
type CalendarEvent,
|
|
17
|
+
cellRangeFromDrag,
|
|
18
|
+
closedHourBands,
|
|
8
19
|
eventTimeLabel,
|
|
9
20
|
formatHour,
|
|
10
21
|
layoutDayEvents,
|
|
@@ -16,7 +27,11 @@ import { useCalendarTheme } from "../theme";
|
|
|
16
27
|
|
|
17
28
|
// Native long-press duration (ms) before a bar is picked up to move.
|
|
18
29
|
const MOVE_ACTIVATE_MS = 250;
|
|
30
|
+
// Web: activate the create sweep past a small drag instead of a long-press, so
|
|
31
|
+
// a plain mouse drag creates (mirrors the time grid).
|
|
32
|
+
const DRAG_ACTIVATE_PX = 8;
|
|
19
33
|
const MINUTES_PER_HOUR = 60;
|
|
34
|
+
const isWeb = Platform.OS === "web";
|
|
20
35
|
|
|
21
36
|
/** A schedulable lane (room, person, machine) events are grouped under. */
|
|
22
37
|
export interface Resource {
|
|
@@ -29,16 +44,28 @@ export interface Resource {
|
|
|
29
44
|
/** Props passed to a custom resource-timeline event renderer. */
|
|
30
45
|
export interface ResourceEventArgs<T = unknown> {
|
|
31
46
|
event: CalendarEvent<T>;
|
|
32
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Pixel width of the event bar. In the vertical orientation the columns flex
|
|
49
|
+
* to the container, so this is 0 there; size against `height` instead.
|
|
50
|
+
*/
|
|
33
51
|
width: number;
|
|
52
|
+
/** Pixel height of the event bar; set in the vertical orientation. */
|
|
53
|
+
height?: number;
|
|
34
54
|
onPress: () => void;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/** Props for {@link ResourceTimeline}. */
|
|
38
58
|
export interface ResourceTimelineProps<T = unknown> {
|
|
39
|
-
/** The day to lay out along the
|
|
59
|
+
/** The day to lay out along the time axis. */
|
|
40
60
|
date: Date;
|
|
41
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Lay the day out along the horizontal axis with resources as rows (the
|
|
63
|
+
* default), or down the vertical axis with resources as columns, like the
|
|
64
|
+
* time grid. Vertical reads better on narrow screens: the columns share the
|
|
65
|
+
* width instead of the axis scrolling sideways.
|
|
66
|
+
*/
|
|
67
|
+
orientation?: "horizontal" | "vertical";
|
|
68
|
+
/** The resource lanes: rows when horizontal, columns when vertical. */
|
|
42
69
|
resources: Resource[];
|
|
43
70
|
/** Events; each is placed in the row named by `resourceId(event)`. */
|
|
44
71
|
events: CalendarEvent<T>[];
|
|
@@ -48,11 +75,13 @@ export interface ResourceTimelineProps<T = unknown> {
|
|
|
48
75
|
startHour?: number;
|
|
49
76
|
/** Last hour shown, exclusive (default 24). */
|
|
50
77
|
endHour?: number;
|
|
51
|
-
/** Pixels per hour along the axis (default 80). */
|
|
78
|
+
/** Pixels per hour along the horizontal axis (default 80). Horizontal only. */
|
|
52
79
|
hourWidth?: number;
|
|
53
|
-
/**
|
|
80
|
+
/** Pixels per hour down the vertical axis (default 48). Vertical only. */
|
|
81
|
+
hourHeight?: number;
|
|
82
|
+
/** Height of each resource row in px (default 56). Horizontal only. */
|
|
54
83
|
rowHeight?: number;
|
|
55
|
-
/** Width of the left resource-label column in px (default 140). */
|
|
84
|
+
/** Width of the left resource-label column in px (default 140). Horizontal only. */
|
|
56
85
|
labelWidth?: number;
|
|
57
86
|
/** 12-hour AM/PM axis labels (default false). */
|
|
58
87
|
ampm?: boolean;
|
|
@@ -60,20 +89,48 @@ export interface ResourceTimelineProps<T = unknown> {
|
|
|
60
89
|
renderEvent?: ComponentType<ResourceEventArgs<T>>;
|
|
61
90
|
/** Tap an event. */
|
|
62
91
|
onPressEvent?: (event: CalendarEvent<T>) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Long-press an event. When `onDragEvent` is also set, a long-press picks the
|
|
94
|
+
* bar up to move instead, so this fires only for non-draggable bars.
|
|
95
|
+
*/
|
|
96
|
+
onLongPressEvent?: (event: CalendarEvent<T>) => void;
|
|
63
97
|
/**
|
|
64
98
|
* Enables drag-to-move and edge-resize along the time axis: long-press a bar to
|
|
65
99
|
* move it, or drag its right edge to resize. Called with the proposed new
|
|
66
100
|
* start/end; return `false` to reject the drop (it snaps back).
|
|
67
101
|
*/
|
|
68
102
|
onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
|
|
103
|
+
/** Tap empty lane space; called with the snapped time and the lane's resource. */
|
|
104
|
+
onPressCell?: (at: Date, resource: Resource) => void;
|
|
105
|
+
/**
|
|
106
|
+
* Long-press empty lane space. When `onCreateEvent` is also set, the long-press
|
|
107
|
+
* starts the create drag instead, so this fires only without it.
|
|
108
|
+
*/
|
|
109
|
+
onLongPressCell?: (at: Date, resource: Resource) => void;
|
|
110
|
+
/**
|
|
111
|
+
* Long-press empty lane space, then drag along the time axis to sweep out a new
|
|
112
|
+
* event; called with the swept start/end and the lane's resource.
|
|
113
|
+
*/
|
|
114
|
+
onCreateEvent?: (start: Date, end: Date, resource: Resource) => void;
|
|
115
|
+
/**
|
|
116
|
+
* Shade the hours outside this window, per lane. Same shape as the Calendar's
|
|
117
|
+
* `businessHours` plus the lane's resource, so per-resource opening hours work
|
|
118
|
+
* (return `null` for a fully closed lane). A date-only function is accepted.
|
|
119
|
+
*/
|
|
120
|
+
businessHours?: (date: Date, resource: Resource) => { start: number; end: number } | null;
|
|
69
121
|
/** Snap dragged events to this many minutes (default 15). */
|
|
70
122
|
dragStepMinutes?: number;
|
|
71
123
|
}
|
|
72
124
|
|
|
73
125
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
74
126
|
|
|
75
|
-
|
|
127
|
+
// Hour-gutter width in the vertical orientation, matching the time grid's axis.
|
|
128
|
+
const GUTTER_WIDTH = 56;
|
|
129
|
+
|
|
130
|
+
function DefaultBar<T>({ event, width, height }: ResourceEventArgs<T>): ReactElement {
|
|
76
131
|
const theme = useCalendarTheme();
|
|
132
|
+
// Show the time line only when the bar has room for it along its long axis.
|
|
133
|
+
const showTime = height != null ? height > 34 : width > 56;
|
|
77
134
|
const time = eventTimeLabel({
|
|
78
135
|
mode: "day",
|
|
79
136
|
isAllDay: false,
|
|
@@ -97,7 +154,7 @@ function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
|
|
|
97
154
|
>
|
|
98
155
|
{event.title}
|
|
99
156
|
</Text>
|
|
100
|
-
{time &&
|
|
157
|
+
{time && showTime ? (
|
|
101
158
|
<Text
|
|
102
159
|
numberOfLines={1}
|
|
103
160
|
style={[styles.barTime, { color: theme.colors.eventText }]}
|
|
@@ -112,29 +169,44 @@ function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
|
|
|
112
169
|
|
|
113
170
|
type ResourceBarProps<T> = {
|
|
114
171
|
pe: PositionedEvent<T>;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
172
|
+
/** Time flows down instead of across; gestures and resize follow. */
|
|
173
|
+
vertical: boolean;
|
|
174
|
+
/** Pixels per hour along the time axis. */
|
|
175
|
+
hourSize: number;
|
|
176
|
+
/** Cross-axis lane offset: px when horizontal, a percent string when vertical. */
|
|
177
|
+
left: number | `${number}%`;
|
|
178
|
+
top: number;
|
|
179
|
+
/** Along-axis size when horizontal (px); lane width (percent) when vertical. */
|
|
180
|
+
width: number | `${number}%`;
|
|
181
|
+
/** Lane height when horizontal; along-axis size when vertical (px). */
|
|
182
|
+
height: number;
|
|
183
|
+
/** What the custom renderer is told about the bar's box. */
|
|
184
|
+
rendererSize: { width: number; height?: number };
|
|
119
185
|
snapMinutes: number;
|
|
120
186
|
Renderer: ComponentType<ResourceEventArgs<T>>;
|
|
121
187
|
onPress: () => void;
|
|
188
|
+
onLongPress?: () => void;
|
|
122
189
|
onDragEvent: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
|
|
123
190
|
theme: ReturnType<typeof useCalendarTheme>;
|
|
124
191
|
};
|
|
125
192
|
|
|
126
|
-
// A bar with drag-to-move (long-press) and
|
|
127
|
-
//
|
|
128
|
-
//
|
|
193
|
+
// A bar with drag-to-move (long-press) and edge resize along the time axis
|
|
194
|
+
// (right edge when horizontal, bottom edge when vertical). The pure snap/commit
|
|
195
|
+
// math is shared with the time grid (`snapDeltaMinutes`, `resolveDraggedBounds`);
|
|
196
|
+
// this only wires the gestures.
|
|
129
197
|
function ResourceBar<T>({
|
|
130
198
|
pe,
|
|
199
|
+
vertical,
|
|
200
|
+
hourSize,
|
|
131
201
|
left,
|
|
202
|
+
top,
|
|
132
203
|
width,
|
|
133
|
-
|
|
134
|
-
|
|
204
|
+
height,
|
|
205
|
+
rendererSize,
|
|
135
206
|
snapMinutes,
|
|
136
207
|
Renderer,
|
|
137
208
|
onPress,
|
|
209
|
+
onLongPress,
|
|
138
210
|
onDragEvent,
|
|
139
211
|
theme,
|
|
140
212
|
}: ResourceBarProps<T>): ReactElement {
|
|
@@ -174,9 +246,50 @@ function ResourceBar<T>({
|
|
|
174
246
|
[snapMinutes, snapBack],
|
|
175
247
|
);
|
|
176
248
|
|
|
249
|
+
// Dragging is gesture-only, so mirror move AND resize as screen-reader actions on
|
|
250
|
+
// the same `commit` path. They all live on the bar's accessible Pressable (the
|
|
251
|
+
// resize grip is a non-focusable visual/gesture affordance, so an `adjustable`
|
|
252
|
+
// role there would never receive focus on a real device).
|
|
253
|
+
const unit = (n: number) => `${n} minute${n === 1 ? "" : "s"}`;
|
|
254
|
+
const barActions = draggable
|
|
255
|
+
? [
|
|
256
|
+
{ name: "move-later", label: `Move ${unit(snapMinutes)} later` },
|
|
257
|
+
{ name: "move-earlier", label: `Move ${unit(snapMinutes)} earlier` },
|
|
258
|
+
{ name: "extend", label: `Extend by ${unit(snapMinutes)}` },
|
|
259
|
+
{ name: "shrink", label: `Shorten by ${unit(snapMinutes)}` },
|
|
260
|
+
]
|
|
261
|
+
: undefined;
|
|
262
|
+
const onBarAction = draggable
|
|
263
|
+
? (e: AccessibilityActionEvent) => {
|
|
264
|
+
switch (e.nativeEvent.actionName) {
|
|
265
|
+
case "move-later":
|
|
266
|
+
commit(snapMinutes, snapMinutes);
|
|
267
|
+
break;
|
|
268
|
+
case "move-earlier":
|
|
269
|
+
commit(-snapMinutes, -snapMinutes);
|
|
270
|
+
break;
|
|
271
|
+
case "extend":
|
|
272
|
+
commit(0, snapMinutes);
|
|
273
|
+
break;
|
|
274
|
+
case "shrink":
|
|
275
|
+
commit(0, -snapMinutes);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
: undefined;
|
|
280
|
+
|
|
177
281
|
const barStyle = useAnimatedStyle(
|
|
178
|
-
() =>
|
|
179
|
-
|
|
282
|
+
() =>
|
|
283
|
+
vertical
|
|
284
|
+
? {
|
|
285
|
+
transform: [{ translateY: moveX.value }],
|
|
286
|
+
height: Math.max(height + resizeW.value, 2),
|
|
287
|
+
}
|
|
288
|
+
: {
|
|
289
|
+
transform: [{ translateX: moveX.value }],
|
|
290
|
+
width: Math.max((width as number) + resizeW.value, 2),
|
|
291
|
+
},
|
|
292
|
+
[vertical, width, height],
|
|
180
293
|
);
|
|
181
294
|
|
|
182
295
|
const moveGesture = useMemo(
|
|
@@ -185,18 +298,19 @@ function ResourceBar<T>({
|
|
|
185
298
|
.enabled(draggable)
|
|
186
299
|
.activateAfterLongPress(MOVE_ACTIVATE_MS)
|
|
187
300
|
.onUpdate((e) => {
|
|
188
|
-
moveX.value = e.translationX;
|
|
301
|
+
moveX.value = vertical ? e.translationY : e.translationX;
|
|
189
302
|
})
|
|
190
303
|
.onEnd((e) => {
|
|
191
|
-
const
|
|
304
|
+
const translation = vertical ? e.translationY : e.translationX;
|
|
305
|
+
const delta = snapDeltaMinutes(translation, hourSize, snapMinutes);
|
|
192
306
|
if (delta === 0) {
|
|
193
307
|
moveX.value = 0;
|
|
194
308
|
return;
|
|
195
309
|
}
|
|
196
|
-
moveX.value = (delta / MINUTES_PER_HOUR) *
|
|
310
|
+
moveX.value = (delta / MINUTES_PER_HOUR) * hourSize;
|
|
197
311
|
runOnJS(commit)(delta, delta);
|
|
198
312
|
}),
|
|
199
|
-
[draggable,
|
|
313
|
+
[draggable, vertical, hourSize, snapMinutes, moveX, commit],
|
|
200
314
|
);
|
|
201
315
|
|
|
202
316
|
const resizeGesture = useMemo(
|
|
@@ -204,48 +318,231 @@ function ResourceBar<T>({
|
|
|
204
318
|
Gesture.Pan()
|
|
205
319
|
.enabled(draggable)
|
|
206
320
|
.onUpdate((e) => {
|
|
207
|
-
resizeW.value = e.translationX;
|
|
321
|
+
resizeW.value = vertical ? e.translationY : e.translationX;
|
|
208
322
|
})
|
|
209
323
|
.onEnd((e) => {
|
|
210
|
-
const
|
|
324
|
+
const translation = vertical ? e.translationY : e.translationX;
|
|
325
|
+
const delta = snapDeltaMinutes(translation, hourSize, snapMinutes);
|
|
211
326
|
if (delta === 0) {
|
|
212
327
|
resizeW.value = 0;
|
|
213
328
|
return;
|
|
214
329
|
}
|
|
215
|
-
resizeW.value = (delta / MINUTES_PER_HOUR) *
|
|
330
|
+
resizeW.value = (delta / MINUTES_PER_HOUR) * hourSize;
|
|
216
331
|
runOnJS(commit)(0, delta);
|
|
217
332
|
}),
|
|
218
|
-
[draggable,
|
|
333
|
+
[draggable, vertical, hourSize, snapMinutes, resizeW, commit],
|
|
219
334
|
);
|
|
220
335
|
|
|
221
336
|
return (
|
|
222
337
|
<Animated.View
|
|
223
338
|
style={[
|
|
224
|
-
{ position: "absolute", left, top
|
|
339
|
+
{ position: "absolute", left, top, padding: 1 },
|
|
340
|
+
vertical ? { width } : { height },
|
|
225
341
|
barStyle,
|
|
226
342
|
]}
|
|
227
343
|
>
|
|
228
344
|
<GestureDetector gesture={moveGesture}>
|
|
229
345
|
<Pressable
|
|
230
346
|
onPress={onPress}
|
|
347
|
+
// When the bar is draggable, the move gesture claims the long-press
|
|
348
|
+
// first; this fires only for non-draggable (disabled) bars.
|
|
349
|
+
onLongPress={onLongPress}
|
|
231
350
|
accessibilityRole="button"
|
|
232
351
|
accessibilityLabel={pe.event.title}
|
|
352
|
+
accessibilityActions={barActions}
|
|
353
|
+
onAccessibilityAction={onBarAction}
|
|
233
354
|
style={styles.fill}
|
|
234
355
|
>
|
|
235
|
-
<Renderer
|
|
356
|
+
<Renderer
|
|
357
|
+
event={pe.event}
|
|
358
|
+
width={rendererSize.width}
|
|
359
|
+
height={rendererSize.height}
|
|
360
|
+
onPress={onPress}
|
|
361
|
+
/>
|
|
236
362
|
</Pressable>
|
|
237
363
|
</GestureDetector>
|
|
238
364
|
<GestureDetector gesture={resizeGesture}>
|
|
365
|
+
{/* Visual + gesture resize affordance only; screen readers use the bar's
|
|
366
|
+
extend/shorten actions above (a non-focusable View can't hold them). */}
|
|
239
367
|
<View
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
368
|
+
testID="resource-resize-grip"
|
|
369
|
+
style={[
|
|
370
|
+
vertical ? styles.resizeGripBottom : styles.resizeGrip,
|
|
371
|
+
{ backgroundColor: theme.colors.eventText },
|
|
372
|
+
]}
|
|
373
|
+
accessibilityElementsHidden
|
|
374
|
+
importantForAccessibility="no"
|
|
243
375
|
/>
|
|
244
376
|
</GestureDetector>
|
|
245
377
|
</Animated.View>
|
|
246
378
|
);
|
|
247
379
|
}
|
|
248
380
|
|
|
381
|
+
type LaneInteractionLayerProps = {
|
|
382
|
+
resource: Resource;
|
|
383
|
+
/** Time flows down instead of across. */
|
|
384
|
+
vertical: boolean;
|
|
385
|
+
/** Pixels per hour along the time axis. */
|
|
386
|
+
hourSize: number;
|
|
387
|
+
startHour: number;
|
|
388
|
+
date: Date;
|
|
389
|
+
snapMinutes: number;
|
|
390
|
+
onPressCell?: (at: Date, resource: Resource) => void;
|
|
391
|
+
onLongPressCell?: (at: Date, resource: Resource) => void;
|
|
392
|
+
onCreateEvent?: (start: Date, end: Date, resource: Resource) => void;
|
|
393
|
+
theme: ReturnType<typeof useCalendarTheme>;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The tap/long-press/create surface behind a lane's bars, mirroring the time
|
|
397
|
+
// grid's cell layer: taps snap to `snapMinutes`, and (when `onCreateEvent` is
|
|
398
|
+
// set) a long-press starts a drag that sweeps out a ghost along the time axis.
|
|
399
|
+
// Hidden from screen readers — it's a pointer convenience, not the accessible
|
|
400
|
+
// path.
|
|
401
|
+
function LaneInteractionLayer({
|
|
402
|
+
resource,
|
|
403
|
+
vertical,
|
|
404
|
+
hourSize,
|
|
405
|
+
startHour,
|
|
406
|
+
date,
|
|
407
|
+
snapMinutes,
|
|
408
|
+
onPressCell,
|
|
409
|
+
onLongPressCell,
|
|
410
|
+
onCreateEvent,
|
|
411
|
+
theme,
|
|
412
|
+
}: LaneInteractionLayerProps): ReactElement {
|
|
413
|
+
const ghostStart = useSharedValue(0);
|
|
414
|
+
const ghostSize = useSharedValue(0);
|
|
415
|
+
const ghostVisible = useSharedValue(0);
|
|
416
|
+
|
|
417
|
+
const timeAt = useCallback(
|
|
418
|
+
(px: number) => cellRangeFromDrag(date, px, px, hourSize, startHour, snapMinutes)?.start,
|
|
419
|
+
[date, hourSize, startHour, snapMinutes],
|
|
420
|
+
);
|
|
421
|
+
const commitCreate = useCallback(
|
|
422
|
+
(fromPx: number, toPx: number) => {
|
|
423
|
+
const range = cellRangeFromDrag(date, fromPx, toPx, hourSize, startHour, snapMinutes);
|
|
424
|
+
if (range) onCreateEvent?.(range.start, range.end, resource);
|
|
425
|
+
},
|
|
426
|
+
[date, hourSize, startHour, snapMinutes, onCreateEvent, resource],
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const pressAt = useCallback(
|
|
430
|
+
(px: number) => {
|
|
431
|
+
const at = timeAt(px);
|
|
432
|
+
if (at) onPressCell?.(at, resource);
|
|
433
|
+
},
|
|
434
|
+
[timeAt, onPressCell, resource],
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Web: a GestureDetector swallows the Pressable's presses on react-native-web,
|
|
438
|
+
// so taps come back through a Tap gesture, and the create sweep activates past
|
|
439
|
+
// a small drag instead of a long-press so a plain mouse drag creates. Both
|
|
440
|
+
// mirror the time grid's cell layer.
|
|
441
|
+
const laneGesture = useMemo(() => {
|
|
442
|
+
const pan = onCreateEvent
|
|
443
|
+
? Gesture.Pan()
|
|
444
|
+
.onStart((e) => {
|
|
445
|
+
ghostStart.value = vertical ? e.y : e.x;
|
|
446
|
+
ghostSize.value = 0;
|
|
447
|
+
ghostVisible.value = 1;
|
|
448
|
+
})
|
|
449
|
+
.onUpdate((e) => {
|
|
450
|
+
ghostSize.value = (vertical ? e.y : e.x) - ghostStart.value;
|
|
451
|
+
})
|
|
452
|
+
// `success` is false when the gesture is cancelled/failed after it
|
|
453
|
+
// activated (RNGH still calls onEnd); only a real end should create.
|
|
454
|
+
.onEnd((e, success) => {
|
|
455
|
+
ghostVisible.value = 0;
|
|
456
|
+
if (success) runOnJS(commitCreate)(ghostStart.value, vertical ? e.y : e.x);
|
|
457
|
+
})
|
|
458
|
+
.onFinalize(() => {
|
|
459
|
+
ghostVisible.value = 0;
|
|
460
|
+
})
|
|
461
|
+
: null;
|
|
462
|
+
const activatedPan = pan
|
|
463
|
+
? isWeb
|
|
464
|
+
? vertical
|
|
465
|
+
? pan.activeOffsetY([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
|
|
466
|
+
: pan.activeOffsetX([-DRAG_ACTIVATE_PX, DRAG_ACTIVATE_PX])
|
|
467
|
+
: pan.activateAfterLongPress(MOVE_ACTIVATE_MS)
|
|
468
|
+
: null;
|
|
469
|
+
const tap =
|
|
470
|
+
isWeb && onPressCell
|
|
471
|
+
? Gesture.Tap().onEnd((e) => {
|
|
472
|
+
runOnJS(pressAt)(vertical ? e.y : e.x);
|
|
473
|
+
})
|
|
474
|
+
: null;
|
|
475
|
+
if (activatedPan && tap) return Gesture.Exclusive(activatedPan, tap);
|
|
476
|
+
return activatedPan ?? tap;
|
|
477
|
+
}, [
|
|
478
|
+
onCreateEvent,
|
|
479
|
+
onPressCell,
|
|
480
|
+
vertical,
|
|
481
|
+
commitCreate,
|
|
482
|
+
pressAt,
|
|
483
|
+
ghostStart,
|
|
484
|
+
ghostSize,
|
|
485
|
+
ghostVisible,
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
const ghostStyle = useAnimatedStyle(() => {
|
|
489
|
+
// Snap the preview to the same grid the commit resolves to, so what you see
|
|
490
|
+
// is what cellRangeFromDrag will create.
|
|
491
|
+
const stepPx = (snapMinutes / MINUTES_PER_HOUR) * hourSize;
|
|
492
|
+
const snap = (v: number) => (stepPx > 0 ? Math.round(v / stepPx) * stepPx : v);
|
|
493
|
+
const a = snap(ghostStart.value);
|
|
494
|
+
const b = snap(ghostStart.value + ghostSize.value);
|
|
495
|
+
const from = Math.min(a, b);
|
|
496
|
+
const size = Math.max(Math.abs(b - a), 2);
|
|
497
|
+
return vertical
|
|
498
|
+
? { opacity: ghostVisible.value, top: from, height: size }
|
|
499
|
+
: { opacity: ghostVisible.value, left: from, width: size };
|
|
500
|
+
}, [vertical, hourSize, snapMinutes]);
|
|
501
|
+
|
|
502
|
+
const pxOf = (e: GestureResponderEvent) =>
|
|
503
|
+
vertical ? e.nativeEvent.locationY : e.nativeEvent.locationX;
|
|
504
|
+
const handlePress = onPressCell ? (e: GestureResponderEvent) => pressAt(pxOf(e)) : undefined;
|
|
505
|
+
// When create is on, a long-press starts the create drag, so don't also fire
|
|
506
|
+
// the consumer's long-press handler (mirrors the time grid).
|
|
507
|
+
const handleLongPress =
|
|
508
|
+
onLongPressCell && !onCreateEvent
|
|
509
|
+
? (e: GestureResponderEvent) => {
|
|
510
|
+
const at = timeAt(pxOf(e));
|
|
511
|
+
if (at) onLongPressCell(at, resource);
|
|
512
|
+
}
|
|
513
|
+
: undefined;
|
|
514
|
+
|
|
515
|
+
const layer = (
|
|
516
|
+
<Pressable
|
|
517
|
+
testID="resource-cell-layer"
|
|
518
|
+
style={StyleSheet.absoluteFill}
|
|
519
|
+
onPress={handlePress}
|
|
520
|
+
onLongPress={handleLongPress}
|
|
521
|
+
accessibilityElementsHidden
|
|
522
|
+
importantForAccessibility="no"
|
|
523
|
+
/>
|
|
524
|
+
);
|
|
525
|
+
return (
|
|
526
|
+
<>
|
|
527
|
+
{laneGesture ? <GestureDetector gesture={laneGesture}>{layer}</GestureDetector> : layer}
|
|
528
|
+
{onCreateEvent ? (
|
|
529
|
+
<Animated.View
|
|
530
|
+
testID="resource-create-ghost"
|
|
531
|
+
pointerEvents="none"
|
|
532
|
+
style={[
|
|
533
|
+
vertical ? styles.vcreateGhost : styles.hcreateGhost,
|
|
534
|
+
{
|
|
535
|
+
backgroundColor: theme.colors.eventBackground,
|
|
536
|
+
borderColor: theme.colors.todayBackground,
|
|
537
|
+
},
|
|
538
|
+
ghostStyle,
|
|
539
|
+
]}
|
|
540
|
+
/>
|
|
541
|
+
) : null}
|
|
542
|
+
</>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
249
546
|
/**
|
|
250
547
|
* A horizontal resource timeline: rows are resources (rooms, people, machines)
|
|
251
548
|
* and the x-axis is one day's hours. Events sit in their resource's row, and
|
|
@@ -266,22 +563,35 @@ function ResourceBar<T>({
|
|
|
266
563
|
*/
|
|
267
564
|
export function ResourceTimeline<T = unknown>({
|
|
268
565
|
date,
|
|
566
|
+
orientation = "horizontal",
|
|
269
567
|
resources,
|
|
270
568
|
events,
|
|
271
569
|
resourceId = (event) => (event as { resourceId?: string }).resourceId,
|
|
272
570
|
startHour = 0,
|
|
273
571
|
endHour = 24,
|
|
274
572
|
hourWidth = 80,
|
|
573
|
+
hourHeight = 48,
|
|
275
574
|
rowHeight = 56,
|
|
276
575
|
labelWidth = 140,
|
|
277
576
|
ampm = false,
|
|
278
577
|
renderEvent,
|
|
279
578
|
onPressEvent,
|
|
579
|
+
onLongPressEvent,
|
|
280
580
|
onDragEvent,
|
|
581
|
+
onPressCell,
|
|
582
|
+
onLongPressCell,
|
|
583
|
+
onCreateEvent,
|
|
584
|
+
businessHours,
|
|
281
585
|
dragStepMinutes = 15,
|
|
282
586
|
}: ResourceTimelineProps<T>): ReactElement {
|
|
283
587
|
const theme = useCalendarTheme();
|
|
284
588
|
const Renderer = renderEvent ?? DefaultBar;
|
|
589
|
+
const cellEnabled = !!onPressCell || !!onLongPressCell || !!onCreateEvent;
|
|
590
|
+
// The closed-hours bands to shade in a lane, resolved per resource.
|
|
591
|
+
const bandsFor = (resource: Resource) =>
|
|
592
|
+
businessHours
|
|
593
|
+
? closedHourBands(date, (d) => businessHours(d, resource), startHour, endHour)
|
|
594
|
+
: [];
|
|
285
595
|
|
|
286
596
|
const hours = useMemo(
|
|
287
597
|
() => Array.from({ length: Math.max(0, endHour - startHour) }, (_, i) => startHour + i),
|
|
@@ -299,6 +609,159 @@ export function ResourceTimeline<T = unknown>({
|
|
|
299
609
|
return map;
|
|
300
610
|
}, [resources, events, resourceId, date]);
|
|
301
611
|
|
|
612
|
+
if (orientation === "vertical") {
|
|
613
|
+
// Time flows down like the time grid: hour gutter on the left and one
|
|
614
|
+
// flexed column per resource, so narrow screens share the width instead of
|
|
615
|
+
// scrolling sideways. The resource headers stay fixed above the scroll.
|
|
616
|
+
const trackHeight = (endHour - startHour) * hourHeight;
|
|
617
|
+
return (
|
|
618
|
+
<View style={styles.vroot}>
|
|
619
|
+
<View style={[styles.header, { borderBottomColor: theme.colors.gridLine }]}>
|
|
620
|
+
<View style={{ width: GUTTER_WIDTH }} />
|
|
621
|
+
{resources.map((resource) => (
|
|
622
|
+
<View key={resource.id} style={[styles.vheaderCell, theme.containers.resourceLabel]}>
|
|
623
|
+
<Text
|
|
624
|
+
numberOfLines={1}
|
|
625
|
+
style={[styles.vheaderText, { color: theme.colors.text }]}
|
|
626
|
+
allowFontScaling={false}
|
|
627
|
+
>
|
|
628
|
+
{resource.title ?? resource.id}
|
|
629
|
+
</Text>
|
|
630
|
+
</View>
|
|
631
|
+
))}
|
|
632
|
+
</View>
|
|
633
|
+
<ScrollView showsVerticalScrollIndicator>
|
|
634
|
+
<View style={[styles.vbody, { height: trackHeight }]}>
|
|
635
|
+
<View style={{ width: GUTTER_WIDTH }}>
|
|
636
|
+
{hours.map((h) => (
|
|
637
|
+
<Text
|
|
638
|
+
key={h}
|
|
639
|
+
allowFontScaling={false}
|
|
640
|
+
style={[
|
|
641
|
+
styles.vhourLabel,
|
|
642
|
+
{
|
|
643
|
+
top: Math.max(0, (h - startHour) * hourHeight - 6),
|
|
644
|
+
color: theme.colors.textMuted,
|
|
645
|
+
},
|
|
646
|
+
]}
|
|
647
|
+
>
|
|
648
|
+
{formatHour(h, { ampm })}
|
|
649
|
+
</Text>
|
|
650
|
+
))}
|
|
651
|
+
</View>
|
|
652
|
+
{resources.map((resource) => {
|
|
653
|
+
const positioned = byResource.get(resource.id) ?? [];
|
|
654
|
+
return (
|
|
655
|
+
<View
|
|
656
|
+
key={resource.id}
|
|
657
|
+
style={[
|
|
658
|
+
styles.vcolumn,
|
|
659
|
+
{ borderLeftColor: theme.colors.gridLine },
|
|
660
|
+
theme.containers.resourceRow,
|
|
661
|
+
]}
|
|
662
|
+
>
|
|
663
|
+
{/* Closed-hours shade, behind the grid lines and bars. */}
|
|
664
|
+
{bandsFor(resource).map((b) => (
|
|
665
|
+
<View
|
|
666
|
+
key={`shade-${b.start}`}
|
|
667
|
+
pointerEvents="none"
|
|
668
|
+
testID="resource-hours-shade"
|
|
669
|
+
style={[
|
|
670
|
+
styles.vshadeBand,
|
|
671
|
+
{
|
|
672
|
+
top: (b.start - startHour) * hourHeight,
|
|
673
|
+
height: (b.end - b.start) * hourHeight,
|
|
674
|
+
backgroundColor: theme.colors.outsideHoursBackground,
|
|
675
|
+
},
|
|
676
|
+
]}
|
|
677
|
+
/>
|
|
678
|
+
))}
|
|
679
|
+
{hours.slice(1).map((h) => (
|
|
680
|
+
<View
|
|
681
|
+
key={h}
|
|
682
|
+
pointerEvents="none"
|
|
683
|
+
style={[
|
|
684
|
+
styles.vgridLine,
|
|
685
|
+
{
|
|
686
|
+
top: (h - startHour) * hourHeight,
|
|
687
|
+
backgroundColor: theme.colors.gridLine,
|
|
688
|
+
},
|
|
689
|
+
]}
|
|
690
|
+
/>
|
|
691
|
+
))}
|
|
692
|
+
{cellEnabled ? (
|
|
693
|
+
<LaneInteractionLayer
|
|
694
|
+
resource={resource}
|
|
695
|
+
vertical
|
|
696
|
+
hourSize={hourHeight}
|
|
697
|
+
startHour={startHour}
|
|
698
|
+
date={date}
|
|
699
|
+
snapMinutes={dragStepMinutes}
|
|
700
|
+
onPressCell={onPressCell}
|
|
701
|
+
onLongPressCell={onLongPressCell}
|
|
702
|
+
onCreateEvent={onCreateEvent}
|
|
703
|
+
theme={theme}
|
|
704
|
+
/>
|
|
705
|
+
) : null}
|
|
706
|
+
{positioned.map((pe, idx) => {
|
|
707
|
+
const top =
|
|
708
|
+
clamp(pe.startHours - startHour, 0, endHour - startHour) * hourHeight;
|
|
709
|
+
const bottomPx =
|
|
710
|
+
clamp(pe.startHours + pe.durationHours - startHour, 0, endHour - startHour) *
|
|
711
|
+
hourHeight;
|
|
712
|
+
const height = Math.max(bottomPx - top, 2);
|
|
713
|
+
// Overlapping events share the column as side-by-side sub-lanes.
|
|
714
|
+
const lanePct = 100 / pe.columns;
|
|
715
|
+
const left = `${pe.column * lanePct}%` as const;
|
|
716
|
+
const width = `${lanePct}%` as const;
|
|
717
|
+
const onPress = () => onPressEvent?.(pe.event);
|
|
718
|
+
if (onDragEvent) {
|
|
719
|
+
return (
|
|
720
|
+
<ResourceBar
|
|
721
|
+
key={idx}
|
|
722
|
+
pe={pe}
|
|
723
|
+
vertical
|
|
724
|
+
hourSize={hourHeight}
|
|
725
|
+
left={left}
|
|
726
|
+
top={top}
|
|
727
|
+
width={width}
|
|
728
|
+
height={height}
|
|
729
|
+
rendererSize={{ width: 0, height }}
|
|
730
|
+
snapMinutes={dragStepMinutes}
|
|
731
|
+
Renderer={Renderer}
|
|
732
|
+
onPress={onPress}
|
|
733
|
+
onLongPress={
|
|
734
|
+
onLongPressEvent ? () => onLongPressEvent(pe.event) : undefined
|
|
735
|
+
}
|
|
736
|
+
onDragEvent={onDragEvent}
|
|
737
|
+
theme={theme}
|
|
738
|
+
/>
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return (
|
|
742
|
+
<Pressable
|
|
743
|
+
key={idx}
|
|
744
|
+
onPress={onPress}
|
|
745
|
+
onLongPress={
|
|
746
|
+
onLongPressEvent ? () => onLongPressEvent(pe.event) : undefined
|
|
747
|
+
}
|
|
748
|
+
accessibilityRole="button"
|
|
749
|
+
accessibilityLabel={pe.event.title}
|
|
750
|
+
style={{ position: "absolute", left, width, top, height, padding: 1 }}
|
|
751
|
+
>
|
|
752
|
+
<Renderer event={pe.event} width={0} height={height} onPress={onPress} />
|
|
753
|
+
</Pressable>
|
|
754
|
+
);
|
|
755
|
+
})}
|
|
756
|
+
</View>
|
|
757
|
+
);
|
|
758
|
+
})}
|
|
759
|
+
</View>
|
|
760
|
+
</ScrollView>
|
|
761
|
+
</View>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
302
765
|
return (
|
|
303
766
|
<ScrollView horizontal showsHorizontalScrollIndicator style={styles.root}>
|
|
304
767
|
<View style={{ width: labelWidth + trackWidth }}>
|
|
@@ -349,6 +812,22 @@ export function ResourceTimeline<T = unknown>({
|
|
|
349
812
|
</Text>
|
|
350
813
|
</View>
|
|
351
814
|
<View style={{ width: trackWidth }}>
|
|
815
|
+
{/* Closed-hours shade, behind the grid lines and bars. */}
|
|
816
|
+
{bandsFor(resource).map((b) => (
|
|
817
|
+
<View
|
|
818
|
+
key={`shade-${b.start}`}
|
|
819
|
+
pointerEvents="none"
|
|
820
|
+
testID="resource-hours-shade"
|
|
821
|
+
style={[
|
|
822
|
+
styles.hshadeBand,
|
|
823
|
+
{
|
|
824
|
+
left: (b.start - startHour) * hourWidth,
|
|
825
|
+
width: (b.end - b.start) * hourWidth,
|
|
826
|
+
backgroundColor: theme.colors.outsideHoursBackground,
|
|
827
|
+
},
|
|
828
|
+
]}
|
|
829
|
+
/>
|
|
830
|
+
))}
|
|
352
831
|
{/* Hour grid lines */}
|
|
353
832
|
{hours.slice(1).map((h) => (
|
|
354
833
|
<View
|
|
@@ -360,6 +839,20 @@ export function ResourceTimeline<T = unknown>({
|
|
|
360
839
|
]}
|
|
361
840
|
/>
|
|
362
841
|
))}
|
|
842
|
+
{cellEnabled ? (
|
|
843
|
+
<LaneInteractionLayer
|
|
844
|
+
resource={resource}
|
|
845
|
+
vertical={false}
|
|
846
|
+
hourSize={hourWidth}
|
|
847
|
+
startHour={startHour}
|
|
848
|
+
date={date}
|
|
849
|
+
snapMinutes={dragStepMinutes}
|
|
850
|
+
onPressCell={onPressCell}
|
|
851
|
+
onLongPressCell={onLongPressCell}
|
|
852
|
+
onCreateEvent={onCreateEvent}
|
|
853
|
+
theme={theme}
|
|
854
|
+
/>
|
|
855
|
+
) : null}
|
|
363
856
|
{positioned.map((pe, idx) => {
|
|
364
857
|
const left = clamp(pe.startHours - startHour, 0, endHour - startHour) * hourWidth;
|
|
365
858
|
const right =
|
|
@@ -374,13 +867,19 @@ export function ResourceTimeline<T = unknown>({
|
|
|
374
867
|
<ResourceBar
|
|
375
868
|
key={idx}
|
|
376
869
|
pe={pe}
|
|
870
|
+
vertical={false}
|
|
871
|
+
hourSize={hourWidth}
|
|
377
872
|
left={left}
|
|
873
|
+
top={pe.column * laneHeight}
|
|
378
874
|
width={width}
|
|
379
|
-
|
|
380
|
-
|
|
875
|
+
height={laneHeight}
|
|
876
|
+
rendererSize={{ width }}
|
|
381
877
|
snapMinutes={dragStepMinutes}
|
|
382
878
|
Renderer={Renderer}
|
|
383
879
|
onPress={onPress}
|
|
880
|
+
onLongPress={
|
|
881
|
+
onLongPressEvent ? () => onLongPressEvent(pe.event) : undefined
|
|
882
|
+
}
|
|
384
883
|
onDragEvent={onDragEvent}
|
|
385
884
|
theme={theme}
|
|
386
885
|
/>
|
|
@@ -390,6 +889,7 @@ export function ResourceTimeline<T = unknown>({
|
|
|
390
889
|
<Pressable
|
|
391
890
|
key={idx}
|
|
392
891
|
onPress={onPress}
|
|
892
|
+
onLongPress={onLongPressEvent ? () => onLongPressEvent(pe.event) : undefined}
|
|
393
893
|
accessibilityRole="button"
|
|
394
894
|
accessibilityLabel={pe.event.title}
|
|
395
895
|
style={{
|
|
@@ -438,4 +938,50 @@ const styles = StyleSheet.create({
|
|
|
438
938
|
borderRadius: 2,
|
|
439
939
|
opacity: 0.5,
|
|
440
940
|
},
|
|
941
|
+
// Its bottom-edge counterpart in the vertical orientation.
|
|
942
|
+
resizeGripBottom: {
|
|
943
|
+
position: "absolute",
|
|
944
|
+
bottom: 1,
|
|
945
|
+
left: "30%",
|
|
946
|
+
right: "30%",
|
|
947
|
+
height: 4,
|
|
948
|
+
borderRadius: 2,
|
|
949
|
+
opacity: 0.5,
|
|
950
|
+
},
|
|
951
|
+
vroot: { flex: 1 },
|
|
952
|
+
vheaderCell: {
|
|
953
|
+
flex: 1,
|
|
954
|
+
paddingVertical: 6,
|
|
955
|
+
paddingHorizontal: 4,
|
|
956
|
+
alignItems: "center",
|
|
957
|
+
borderLeftWidth: StyleSheet.hairlineWidth,
|
|
958
|
+
},
|
|
959
|
+
vheaderText: { fontSize: 13, fontWeight: "600" },
|
|
960
|
+
vbody: { flexDirection: "row" },
|
|
961
|
+
vcolumn: { flex: 1, borderLeftWidth: StyleSheet.hairlineWidth },
|
|
962
|
+
vhourLabel: { position: "absolute", right: 6, fontSize: 10 },
|
|
963
|
+
vgridLine: { position: "absolute", left: 0, right: 0, height: StyleSheet.hairlineWidth },
|
|
964
|
+
// The drag-to-create preview, sized along the time axis by the gesture. It
|
|
965
|
+
// sits above the bars (like the dom renderer and the time grid) so the sweep
|
|
966
|
+
// stays visible when it crosses an existing event.
|
|
967
|
+
vcreateGhost: {
|
|
968
|
+
position: "absolute",
|
|
969
|
+
left: 2,
|
|
970
|
+
right: 2,
|
|
971
|
+
borderWidth: 1,
|
|
972
|
+
borderRadius: 6,
|
|
973
|
+
opacity: 0,
|
|
974
|
+
zIndex: 2,
|
|
975
|
+
},
|
|
976
|
+
hcreateGhost: {
|
|
977
|
+
position: "absolute",
|
|
978
|
+
top: 2,
|
|
979
|
+
bottom: 2,
|
|
980
|
+
borderWidth: 1,
|
|
981
|
+
borderRadius: 6,
|
|
982
|
+
opacity: 0,
|
|
983
|
+
zIndex: 2,
|
|
984
|
+
},
|
|
985
|
+
hshadeBand: { position: "absolute", top: 0, bottom: 0 },
|
|
986
|
+
vshadeBand: { position: "absolute", left: 0, right: 0 },
|
|
441
987
|
});
|