@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.
@@ -1,10 +1,21 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
2
  import { useCallback, useEffect, useMemo, useRef } from "react";
3
- import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
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
- /** Pixel width of the event bar. */
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 horizontal axis. */
59
+ /** The day to lay out along the time axis. */
40
60
  date: Date;
41
- /** The rows, top to bottom. */
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
- /** Height of each resource row in px (default 56). */
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
- function DefaultBar<T>({ event, width }: ResourceEventArgs<T>): ReactElement {
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 && width > 56 ? (
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
- left: number;
116
- width: number;
117
- laneHeight: number;
118
- hourWidth: number;
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 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.
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
- laneHeight,
134
- hourWidth,
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
- () => ({ transform: [{ translateX: moveX.value }], width: Math.max(width + resizeW.value, 2) }),
179
- [width],
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 delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
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) * hourWidth;
310
+ moveX.value = (delta / MINUTES_PER_HOUR) * hourSize;
197
311
  runOnJS(commit)(delta, delta);
198
312
  }),
199
- [draggable, hourWidth, snapMinutes, moveX, commit],
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 delta = snapDeltaMinutes(e.translationX, hourWidth, snapMinutes);
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) * hourWidth;
330
+ resizeW.value = (delta / MINUTES_PER_HOUR) * hourSize;
216
331
  runOnJS(commit)(0, delta);
217
332
  }),
218
- [draggable, hourWidth, snapMinutes, resizeW, commit],
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: pe.column * laneHeight, height: laneHeight, padding: 1 },
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 event={pe.event} width={width} onPress={onPress} />
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
- style={[styles.resizeGrip, { backgroundColor: theme.colors.eventText }]}
241
- accessibilityRole="adjustable"
242
- accessibilityLabel={`Resize ${pe.event.title}`}
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
- laneHeight={laneHeight}
380
- hourWidth={hourWidth}
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
  });