@super-calendar/dom 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.
@@ -8,6 +8,8 @@ import type {
8
8
  import { useMemo, useRef, useState } from "react";
9
9
  import {
10
10
  type CalendarEvent,
11
+ cellRangeFromDrag,
12
+ closedHourBands,
11
13
  eventTimeLabel,
12
14
  formatHour,
13
15
  layoutDayEvents,
@@ -28,8 +30,10 @@ export type ResourceTimelineSlot =
28
30
  | "row"
29
31
  | "track"
30
32
  | "gridLines"
33
+ | "businessHours"
31
34
  | "event"
32
- | "eventBox";
35
+ | "eventBox"
36
+ | "createGhost";
33
37
 
34
38
  /** A schedulable lane (room, person, machine) that events are grouped under. */
35
39
  export interface Resource {
@@ -42,16 +46,28 @@ export interface Resource {
42
46
  /** Props passed to a custom resource-timeline event renderer. */
43
47
  export interface ResourceEventArgs<T = unknown> {
44
48
  event: CalendarEvent<T>;
45
- /** Pixel width of the event bar. */
49
+ /**
50
+ * Pixel width of the event bar. In the vertical orientation the columns flex
51
+ * to the container, so this is 0 there; size against `height` instead.
52
+ */
46
53
  width: number;
54
+ /** Pixel height of the event bar; set in the vertical orientation. */
55
+ height?: number;
47
56
  onPress: () => void;
48
57
  }
49
58
 
50
59
  /** Props for {@link ResourceTimeline}. */
51
60
  export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<ResourceTimelineSlot> {
52
- /** The day to lay out along the horizontal axis. */
61
+ /** The day to lay out along the time axis. */
53
62
  date: Date;
54
- /** The rows, top to bottom. */
63
+ /**
64
+ * Lay the day out along the horizontal axis with resources as rows (the
65
+ * default), or down the vertical axis with resources as columns, like the
66
+ * time grid. Vertical reads better on narrow screens: the columns share the
67
+ * width instead of the axis scrolling sideways.
68
+ */
69
+ orientation?: "horizontal" | "vertical";
70
+ /** The resource lanes: rows when horizontal, columns when vertical. */
55
71
  resources: Resource[];
56
72
  /** Events; each is placed in the row named by `resourceId(event)`. */
57
73
  events: CalendarEvent<T>[];
@@ -61,11 +77,13 @@ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<Resou
61
77
  startHour?: number;
62
78
  /** Last hour shown, exclusive (default 24). */
63
79
  endHour?: number;
64
- /** Pixels per hour along the axis (default 80). */
80
+ /** Pixels per hour along the horizontal axis (default 80). Horizontal only. */
65
81
  hourWidth?: number;
66
- /** Height of each resource row in px (default 56). */
82
+ /** Pixels per hour down the vertical axis (default 48). Vertical only. */
83
+ hourHeight?: number;
84
+ /** Height of each resource row in px (default 56). Horizontal only. */
67
85
  rowHeight?: number;
68
- /** Width of the left resource-label column in px (default 140). */
86
+ /** Width of the left resource-label column in px (default 140). Horizontal only. */
69
87
  labelWidth?: number;
70
88
  /** 12-hour AM/PM axis labels (default false). */
71
89
  ampm?: boolean;
@@ -80,6 +98,16 @@ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<Resou
80
98
  * proposed new start/end. Return `false` to reject the drop (it snaps back).
81
99
  */
82
100
  onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
101
+ /** Tap empty lane space; called with the snapped time and the lane's resource. */
102
+ onPressCell?: (at: Date, resource: Resource) => void;
103
+ /** Drag empty lane space to create; called with the swept start/end and the lane's resource. */
104
+ onCreateEvent?: (start: Date, end: Date, resource: Resource) => void;
105
+ /**
106
+ * Shade the hours outside this window, per lane. Same shape as the Calendar's
107
+ * `businessHours` plus the lane's resource, so per-resource opening hours work
108
+ * (return `null` for a fully closed lane). A date-only function is accepted.
109
+ */
110
+ businessHours?: (date: Date, resource: Resource) => { start: number; end: number } | null;
83
111
  /** Snap dragged events to this many minutes (default 15). */
84
112
  dragStepMinutes?: number;
85
113
  /** Class applied to the root element. */
@@ -90,6 +118,9 @@ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<Resou
90
118
 
91
119
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
92
120
 
121
+ // Hour-gutter width in the vertical orientation, matching the time grid's axis.
122
+ const GUTTER_WIDTH = 56;
123
+
93
124
  type DragState = {
94
125
  key: string;
95
126
  kind: "move" | "resize";
@@ -100,9 +131,13 @@ type DragState = {
100
131
 
101
132
  function DefaultBar<T>({
102
133
  event,
134
+ height,
103
135
  boxProps,
104
136
  theme,
105
137
  }: ResourceEventArgs<T> & { boxProps: ResolvedSlot; theme: DomCalendarTheme }) {
138
+ // Vertical bars gate the time line on their height (short slots show just the
139
+ // title); horizontal bars keep it always, as before.
140
+ const showTime = height != null ? height > 34 : true;
106
141
  const time = eventTimeLabel({
107
142
  mode: "day",
108
143
  isAllDay: false,
@@ -139,7 +174,7 @@ function DefaultBar<T>({
139
174
  >
140
175
  {event.title}
141
176
  </div>
142
- {time ? <div style={{ opacity: 0.75, fontSize: 11 }}>{time}</div> : null}
177
+ {time && showTime ? <div style={{ opacity: 0.75, fontSize: 11 }}>{time}</div> : null}
143
178
  </div>
144
179
  );
145
180
  }
@@ -162,12 +197,14 @@ function DefaultBar<T>({
162
197
  */
163
198
  export function ResourceTimeline<T = unknown>({
164
199
  date,
200
+ orientation = "horizontal",
165
201
  resources,
166
202
  events,
167
203
  resourceId = (event) => (event as { resourceId?: string }).resourceId,
168
204
  startHour = 0,
169
205
  endHour = 24,
170
206
  hourWidth = 80,
207
+ hourHeight = 48,
171
208
  rowHeight = 56,
172
209
  labelWidth = 140,
173
210
  ampm = false,
@@ -175,6 +212,9 @@ export function ResourceTimeline<T = unknown>({
175
212
  renderEvent,
176
213
  onPressEvent,
177
214
  onDragEvent,
215
+ onPressCell,
216
+ onCreateEvent,
217
+ businessHours,
178
218
  dragStepMinutes = 15,
179
219
  className,
180
220
  style,
@@ -185,6 +225,9 @@ export function ResourceTimeline<T = unknown>({
185
225
  const slot = createSlots<ResourceTimelineSlot>({ classNames, styles });
186
226
  const Renderer = renderEvent;
187
227
  const snapHours = dragStepMinutes / 60;
228
+ const vertical = orientation === "vertical";
229
+ // Pixels per hour along whichever axis carries the time.
230
+ const hourSize = vertical ? hourHeight : hourWidth;
188
231
 
189
232
  // `drag` drives the visual; `dragRef` is the source of truth the pointer
190
233
  // handlers read so they never see a stale closure between events.
@@ -210,13 +253,13 @@ export function ResourceTimeline<T = unknown>({
210
253
  } catch {
211
254
  // Pointer capture is best-effort.
212
255
  }
213
- origin.current = { x: e.clientX, startHours, durationHours };
256
+ origin.current = { x: vertical ? e.clientY : e.clientX, startHours, durationHours };
214
257
  applyDrag({ key, kind, startHours, durationHours, moved: false });
215
258
  };
216
259
  const moveDrag = (e: ReactPointerEvent) => {
217
260
  const d = dragRef.current;
218
261
  if (!d || !origin.current) return;
219
- const dHours = (e.clientX - origin.current.x) / hourWidth;
262
+ const dHours = ((vertical ? e.clientY : e.clientX) - origin.current.x) / hourSize;
220
263
  const snap = (v: number) => Math.round(v / snapHours) * snapHours;
221
264
  if (d.kind === "move") {
222
265
  const startHours = clamp(
@@ -259,6 +302,90 @@ export function ResourceTimeline<T = unknown>({
259
302
  origin.current = null;
260
303
  };
261
304
 
305
+ // Create-by-drag / tap on empty lane space, mirroring the time grid: mouse and
306
+ // pen sweep out a new event, and a tap without movement fires onPressCell. Touch
307
+ // is left free to scroll the board, so cell taps are pointer (mouse/pen) only.
308
+ const [createBox, setCreateBox] = useState<{
309
+ resourceKey: string;
310
+ startPx: number;
311
+ curPx: number;
312
+ } | null>(null);
313
+ const createOrigin = useRef<{ el: HTMLElement; resource: Resource; startPx: number } | null>(
314
+ null,
315
+ );
316
+ const cellEnabled = !!onPressCell || !!onCreateEvent;
317
+ const pxAlongAxis = (el: HTMLElement, e: ReactPointerEvent) => {
318
+ const rect = el.getBoundingClientRect();
319
+ return vertical ? e.clientY - rect.top : e.clientX - rect.left;
320
+ };
321
+ const beginCreate = (e: ReactPointerEvent, resource: Resource) => {
322
+ // Only from the lane background, primary button, never on an event bar.
323
+ if (!cellEnabled || e.pointerType === "touch") return;
324
+ if (e.target !== e.currentTarget || e.button > 0) return;
325
+ const el = e.currentTarget as HTMLElement;
326
+ const startPx = pxAlongAxis(el, e);
327
+ try {
328
+ el.setPointerCapture?.(e.pointerId);
329
+ } catch {
330
+ // Best-effort capture.
331
+ }
332
+ createOrigin.current = { el, resource, startPx };
333
+ // Only preview a ghost when a create can happen; in press-only mode
334
+ // (onPressCell without onCreateEvent) the tap still commits via endCreate.
335
+ if (onCreateEvent) setCreateBox({ resourceKey: resource.id, startPx, curPx: startPx });
336
+ };
337
+ const moveCreate = (e: ReactPointerEvent) => {
338
+ const o = createOrigin.current;
339
+ if (!o || !onCreateEvent) return;
340
+ setCreateBox({ resourceKey: o.resource.id, startPx: o.startPx, curPx: pxAlongAxis(o.el, e) });
341
+ };
342
+ const endCreate = (e: ReactPointerEvent) => {
343
+ const o = createOrigin.current;
344
+ if (!o) return;
345
+ try {
346
+ o.el.releasePointerCapture?.(e.pointerId);
347
+ } catch {
348
+ // Best-effort release.
349
+ }
350
+ const endPx = pxAlongAxis(o.el, e);
351
+ const moved = Math.abs(endPx - o.startPx) > 4;
352
+ if (moved && onCreateEvent) {
353
+ const range = cellRangeFromDrag(date, o.startPx, endPx, hourSize, startHour, dragStepMinutes);
354
+ if (range) onCreateEvent(range.start, range.end, o.resource);
355
+ } else if (onPressCell) {
356
+ const at = cellRangeFromDrag(
357
+ date,
358
+ o.startPx,
359
+ o.startPx,
360
+ hourSize,
361
+ startHour,
362
+ dragStepMinutes,
363
+ );
364
+ if (at) onPressCell(at.start, o.resource);
365
+ }
366
+ createOrigin.current = null;
367
+ setCreateBox(null);
368
+ };
369
+ // A gesture the browser cancels (scroll takeover, etc.) must not commit.
370
+ const cancelCreate = () => {
371
+ createOrigin.current = null;
372
+ setCreateBox(null);
373
+ };
374
+ const trackInteractionProps = (resource: Resource) =>
375
+ cellEnabled
376
+ ? {
377
+ onPointerDown: (e: ReactPointerEvent) => beginCreate(e, resource),
378
+ onPointerMove: moveCreate,
379
+ onPointerUp: endCreate,
380
+ onPointerCancel: cancelCreate,
381
+ }
382
+ : null;
383
+ // The closed-hours bands to shade in a lane, resolved per resource.
384
+ const bandsFor = (resource: Resource) =>
385
+ businessHours
386
+ ? closedHourBands(date, (d) => businessHours(d, resource), startHour, endHour)
387
+ : [];
388
+
262
389
  const hours = useMemo(
263
390
  () => Array.from({ length: Math.max(0, endHour - startHour) }, (_, i) => startHour + i),
264
391
  [startHour, endHour],
@@ -275,6 +402,230 @@ export function ResourceTimeline<T = unknown>({
275
402
  return map;
276
403
  }, [resources, events, resourceId, date]);
277
404
 
405
+ if (vertical) {
406
+ // Time flows down like the time grid: hour gutter on the left, one flexed
407
+ // column per resource, so narrow screens share the width instead of
408
+ // scrolling sideways.
409
+ const trackHeight = (endHour - startHour) * hourHeight;
410
+ const vGridLines = `repeating-linear-gradient(to bottom, transparent 0, transparent ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight}px)`;
411
+ return (
412
+ <div
413
+ className={className}
414
+ style={{ fontFamily: theme.fontFamily, color: theme.text, overflowY: "auto", ...style }}
415
+ >
416
+ {/* Header: corner above the hour gutter + one label per resource column */}
417
+ <div
418
+ {...slot("header", {
419
+ // Sticky so the resource labels stay visible while the hours scroll
420
+ // (matching the native renderer's fixed header row).
421
+ base: { display: "flex", position: "sticky", top: 0, zIndex: 2 },
422
+ themed: { borderBottom: `1px solid ${theme.gridLine}`, background: theme.surface },
423
+ })}
424
+ >
425
+ <div {...slot("corner", { base: { width: GUTTER_WIDTH, flex: "none" } })} />
426
+ {resources.map((resource) => (
427
+ <div
428
+ key={resource.id}
429
+ {...slot("resourceLabel", {
430
+ base: {
431
+ flex: 1,
432
+ minWidth: 0,
433
+ padding: "6px 4px",
434
+ textAlign: "center",
435
+ overflow: "hidden",
436
+ textOverflow: "ellipsis",
437
+ whiteSpace: "nowrap",
438
+ boxSizing: "border-box",
439
+ },
440
+ themed: {
441
+ fontSize: 13,
442
+ fontWeight: 600,
443
+ borderLeft: `1px solid ${theme.gridLine}`,
444
+ },
445
+ })}
446
+ >
447
+ {resource.title ?? resource.id}
448
+ </div>
449
+ ))}
450
+ </div>
451
+ <div style={{ display: "flex" }}>
452
+ <div
453
+ {...slot("timeAxis", {
454
+ base: {
455
+ position: "relative",
456
+ width: GUTTER_WIDTH,
457
+ flex: "none",
458
+ height: trackHeight,
459
+ },
460
+ })}
461
+ >
462
+ {hours.map((h) => (
463
+ <div
464
+ key={h}
465
+ {...slot("hourTick", {
466
+ base: {
467
+ position: "absolute",
468
+ top: Math.max(0, (h - startHour) * hourHeight - 6),
469
+ right: 6,
470
+ },
471
+ themed: { fontSize: 10, color: theme.textMuted },
472
+ })}
473
+ >
474
+ {formatHour(h, { ampm })}
475
+ </div>
476
+ ))}
477
+ </div>
478
+ {resources.map((resource) => {
479
+ const positioned = byResource.get(resource.id) ?? [];
480
+ return (
481
+ <div
482
+ key={resource.id}
483
+ {...slot("row", {
484
+ base: { flex: 1, minWidth: 0 },
485
+ themed: { borderLeft: `1px solid ${theme.gridLine}` },
486
+ })}
487
+ >
488
+ <div
489
+ {...trackInteractionProps(resource)}
490
+ {...slot("track", { base: { position: "relative", height: trackHeight } })}
491
+ >
492
+ {/* Closed-hours shade, behind the grid lines and events. */}
493
+ {bandsFor(resource).map((b) => (
494
+ <div
495
+ key={b.start}
496
+ aria-hidden
497
+ {...slot("businessHours", {
498
+ base: {
499
+ position: "absolute",
500
+ left: 0,
501
+ right: 0,
502
+ top: (b.start - startHour) * hourHeight,
503
+ height: (b.end - b.start) * hourHeight,
504
+ pointerEvents: "none",
505
+ zIndex: 0,
506
+ },
507
+ themed: { background: theme.outsideHoursBackground },
508
+ })}
509
+ />
510
+ ))}
511
+ <div
512
+ aria-hidden
513
+ {...slot("gridLines", {
514
+ base: { position: "absolute", inset: 0, pointerEvents: "none" },
515
+ themed: { backgroundImage: vGridLines },
516
+ })}
517
+ />
518
+ {positioned.map((pe, idx) => {
519
+ const key = `${resource.id}:${idx}`;
520
+ const active = drag?.key === key ? drag : null;
521
+ const startH = active ? active.startHours : pe.startHours;
522
+ const durH = active ? active.durationHours : pe.durationHours;
523
+ const top = clamp(startH - startHour, 0, endHour - startHour) * hourHeight;
524
+ const bottom =
525
+ clamp(startH + durH - startHour, 0, endHour - startHour) * hourHeight;
526
+ const height = Math.max(bottom - top, 2);
527
+ // Overlapping events share the column as side-by-side sub-lanes.
528
+ const lanePct = 100 / pe.columns;
529
+ const onPress = () => onPressEvent?.(pe.event);
530
+ const args: ResourceEventArgs<T> = {
531
+ event: pe.event,
532
+ width: 0,
533
+ height,
534
+ onPress,
535
+ };
536
+ const draggable = !!onDragEvent;
537
+ return (
538
+ <button
539
+ key={idx}
540
+ type="button"
541
+ onClick={draggable ? undefined : onPress}
542
+ aria-label={pe.event.title}
543
+ {...dataState({ "data-dragging": !!active })}
544
+ onPointerDown={
545
+ draggable
546
+ ? (e) => beginDrag(e, key, "move", pe.startHours, pe.durationHours)
547
+ : undefined
548
+ }
549
+ onPointerMove={draggable ? moveDrag : undefined}
550
+ onPointerUp={draggable ? (e) => endDrag(e, pe.event, onPress) : undefined}
551
+ onPointerCancel={draggable ? cancelDrag : undefined}
552
+ {...slot("event", {
553
+ base: {
554
+ position: "absolute",
555
+ top,
556
+ height,
557
+ left: `${pe.column * lanePct}%`,
558
+ width: `${lanePct}%`,
559
+ padding: 1,
560
+ border: "none",
561
+ background: "transparent",
562
+ cursor: draggable ? "grab" : "pointer",
563
+ touchAction: draggable ? "none" : "auto",
564
+ font: "inherit",
565
+ textAlign: "left",
566
+ boxSizing: "border-box",
567
+ zIndex: active ? 3 : 1,
568
+ opacity: active ? 0.85 : 1,
569
+ },
570
+ })}
571
+ >
572
+ {Renderer ? (
573
+ <Renderer {...args} />
574
+ ) : (
575
+ <DefaultBar {...args} theme={theme} boxProps={slot("eventBox")} />
576
+ )}
577
+ {draggable ? (
578
+ <span
579
+ aria-hidden
580
+ onPointerDown={(e) => {
581
+ e.stopPropagation();
582
+ beginDrag(e, key, "resize", pe.startHours, pe.durationHours);
583
+ }}
584
+ style={{
585
+ position: "absolute",
586
+ left: 0,
587
+ right: 0,
588
+ bottom: 0,
589
+ height: 8,
590
+ cursor: "ns-resize",
591
+ touchAction: "none",
592
+ }}
593
+ />
594
+ ) : null}
595
+ </button>
596
+ );
597
+ })}
598
+ {createBox?.resourceKey === resource.id ? (
599
+ <div
600
+ aria-hidden
601
+ {...slot("createGhost", {
602
+ base: {
603
+ position: "absolute",
604
+ left: 1,
605
+ right: 1,
606
+ top: Math.min(createBox.startPx, createBox.curPx),
607
+ height: Math.max(Math.abs(createBox.curPx - createBox.startPx), 2),
608
+ opacity: 0.7,
609
+ pointerEvents: "none",
610
+ zIndex: 2,
611
+ },
612
+ themed: {
613
+ background: theme.rangeBackground,
614
+ border: `1px solid ${theme.selectedBackground}`,
615
+ borderRadius: 6,
616
+ },
617
+ })}
618
+ />
619
+ ) : null}
620
+ </div>
621
+ </div>
622
+ );
623
+ })}
624
+ </div>
625
+ </div>
626
+ );
627
+ }
628
+
278
629
  const gridLines = `repeating-linear-gradient(to right, transparent 0, transparent ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth}px)`;
279
630
 
280
631
  return (
@@ -338,7 +689,29 @@ export function ResourceTimeline<T = unknown>({
338
689
  >
339
690
  {resource.title ?? resource.id}
340
691
  </div>
341
- <div {...slot("track", { base: { position: "relative", width: trackWidth } })}>
692
+ <div
693
+ {...trackInteractionProps(resource)}
694
+ {...slot("track", { base: { position: "relative", width: trackWidth } })}
695
+ >
696
+ {/* Closed-hours shade, behind the grid lines and events. */}
697
+ {bandsFor(resource).map((b) => (
698
+ <div
699
+ key={b.start}
700
+ aria-hidden
701
+ {...slot("businessHours", {
702
+ base: {
703
+ position: "absolute",
704
+ top: 0,
705
+ bottom: 0,
706
+ left: (b.start - startHour) * hourWidth,
707
+ width: (b.end - b.start) * hourWidth,
708
+ pointerEvents: "none",
709
+ zIndex: 0,
710
+ },
711
+ themed: { background: theme.outsideHoursBackground },
712
+ })}
713
+ />
714
+ ))}
342
715
  <div
343
716
  aria-hidden
344
717
  {...slot("gridLines", {
@@ -421,6 +794,28 @@ export function ResourceTimeline<T = unknown>({
421
794
  </button>
422
795
  );
423
796
  })}
797
+ {createBox?.resourceKey === resource.id ? (
798
+ <div
799
+ aria-hidden
800
+ {...slot("createGhost", {
801
+ base: {
802
+ position: "absolute",
803
+ top: 1,
804
+ bottom: 1,
805
+ left: Math.min(createBox.startPx, createBox.curPx),
806
+ width: Math.max(Math.abs(createBox.curPx - createBox.startPx), 2),
807
+ opacity: 0.7,
808
+ pointerEvents: "none",
809
+ zIndex: 2,
810
+ },
811
+ themed: {
812
+ background: theme.rangeBackground,
813
+ border: `1px solid ${theme.selectedBackground}`,
814
+ borderRadius: 6,
815
+ },
816
+ })}
817
+ />
818
+ ) : null}
424
819
  </div>
425
820
  </div>
426
821
  );