@super-calendar/dom 2.1.5 → 2.2.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.
@@ -0,0 +1,431 @@
1
+ import { addMinutes, startOfDay } from "date-fns";
2
+ import type {
3
+ ComponentType,
4
+ CSSProperties,
5
+ PointerEvent as ReactPointerEvent,
6
+ ReactElement,
7
+ } from "react";
8
+ import { useMemo, useRef, useState } from "react";
9
+ import {
10
+ type CalendarEvent,
11
+ eventTimeLabel,
12
+ formatHour,
13
+ layoutDayEvents,
14
+ } from "@super-calendar/core";
15
+ import { createSlots, dataState, type ResolvedSlot, type SlotStyleProps } from "./slots";
16
+ import { type DomCalendarTheme, mergeDomTheme } from "./theme";
17
+
18
+ /**
19
+ * Styleable parts of {@link ResourceTimeline}. Pass a class or inline style per
20
+ * slot via the `classNames` / `styles` props.
21
+ */
22
+ export type ResourceTimelineSlot =
23
+ | "header"
24
+ | "corner"
25
+ | "timeAxis"
26
+ | "hourTick"
27
+ | "resourceLabel"
28
+ | "row"
29
+ | "track"
30
+ | "gridLines"
31
+ | "event"
32
+ | "eventBox";
33
+
34
+ /** A schedulable lane (room, person, machine) that events are grouped under. */
35
+ export interface Resource {
36
+ /** Stable id events reference via their `resourceId`. */
37
+ id: string;
38
+ /** Row label; falls back to the id. */
39
+ title?: string;
40
+ }
41
+
42
+ /** Props passed to a custom resource-timeline event renderer. */
43
+ export interface ResourceEventArgs<T = unknown> {
44
+ event: CalendarEvent<T>;
45
+ /** Pixel width of the event bar. */
46
+ width: number;
47
+ onPress: () => void;
48
+ }
49
+
50
+ /** Props for {@link ResourceTimeline}. */
51
+ export interface ResourceTimelineProps<T = unknown> extends SlotStyleProps<ResourceTimelineSlot> {
52
+ /** The day to lay out along the horizontal axis. */
53
+ date: Date;
54
+ /** The rows, top to bottom. */
55
+ resources: Resource[];
56
+ /** Events; each is placed in the row named by `resourceId(event)`. */
57
+ events: CalendarEvent<T>[];
58
+ /** Read an event's resource id. Default: `event.resourceId`. */
59
+ resourceId?: (event: CalendarEvent<T>) => string | undefined;
60
+ /** First hour shown (default 0). */
61
+ startHour?: number;
62
+ /** Last hour shown, exclusive (default 24). */
63
+ endHour?: number;
64
+ /** Pixels per hour along the axis (default 80). */
65
+ hourWidth?: number;
66
+ /** Height of each resource row in px (default 56). */
67
+ rowHeight?: number;
68
+ /** Width of the left resource-label column in px (default 140). */
69
+ labelWidth?: number;
70
+ /** 12-hour AM/PM axis labels (default false). */
71
+ ampm?: boolean;
72
+ /** Theme overrides; falls back to the default light theme. */
73
+ theme?: Partial<DomCalendarTheme>;
74
+ /** Custom event renderer; falls back to the built-in bar. */
75
+ renderEvent?: ComponentType<ResourceEventArgs<T>>;
76
+ /** Tap an event. */
77
+ onPressEvent?: (event: CalendarEvent<T>) => void;
78
+ /**
79
+ * Enables drag-to-move and edge-resize along the time axis; called with the
80
+ * proposed new start/end. Return `false` to reject the drop (it snaps back).
81
+ */
82
+ onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
83
+ /** Snap dragged events to this many minutes (default 15). */
84
+ dragStepMinutes?: number;
85
+ /** Class applied to the root element. */
86
+ className?: string;
87
+ /** Inline styles applied to the root element. */
88
+ style?: CSSProperties;
89
+ }
90
+
91
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
92
+
93
+ type DragState = {
94
+ key: string;
95
+ kind: "move" | "resize";
96
+ startHours: number;
97
+ durationHours: number;
98
+ moved: boolean;
99
+ };
100
+
101
+ function DefaultBar<T>({
102
+ event,
103
+ boxProps,
104
+ theme,
105
+ }: ResourceEventArgs<T> & { boxProps: ResolvedSlot; theme: DomCalendarTheme }) {
106
+ const time = eventTimeLabel({
107
+ mode: "day",
108
+ isAllDay: false,
109
+ start: event.start,
110
+ end: event.end,
111
+ ampm: false,
112
+ showTime: true,
113
+ });
114
+ const base: CSSProperties = { height: "100%", boxSizing: "border-box", overflow: "hidden" };
115
+ const themed: CSSProperties = {
116
+ padding: "2px 6px",
117
+ borderRadius: 6,
118
+ background: theme.eventBackground,
119
+ color: theme.eventText,
120
+ fontSize: 12,
121
+ };
122
+ return (
123
+ <div
124
+ className={boxProps.className}
125
+ data-slot={boxProps["data-slot"]}
126
+ style={
127
+ boxProps.className
128
+ ? { ...base, ...boxProps.style }
129
+ : { ...base, ...themed, ...boxProps.style }
130
+ }
131
+ >
132
+ <div
133
+ style={{
134
+ fontWeight: 600,
135
+ whiteSpace: "nowrap",
136
+ overflow: "hidden",
137
+ textOverflow: "ellipsis",
138
+ }}
139
+ >
140
+ {event.title}
141
+ </div>
142
+ {time ? <div style={{ opacity: 0.75, fontSize: 11 }}>{time}</div> : null}
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * A horizontal resource timeline: rows are resources (rooms, people, machines)
149
+ * and the x-axis is one day's hours. Events sit in their resource's row, and
150
+ * overlapping events in the same row stack into sub-lanes (via the shared
151
+ * `layoutDayEvents`). Pass `onDragEvent` to enable drag-to-move and edge-resize
152
+ * along the axis.
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * <ResourceTimeline
157
+ * date={new Date()}
158
+ * resources={[{ id: "a", title: "Room A" }, { id: "b", title: "Room B" }]}
159
+ * events={events} // each event carries a `resourceId`
160
+ * />
161
+ * ```
162
+ */
163
+ export function ResourceTimeline<T = unknown>({
164
+ date,
165
+ resources,
166
+ events,
167
+ resourceId = (event) => (event as { resourceId?: string }).resourceId,
168
+ startHour = 0,
169
+ endHour = 24,
170
+ hourWidth = 80,
171
+ rowHeight = 56,
172
+ labelWidth = 140,
173
+ ampm = false,
174
+ theme: themeOverrides,
175
+ renderEvent,
176
+ onPressEvent,
177
+ onDragEvent,
178
+ dragStepMinutes = 15,
179
+ className,
180
+ style,
181
+ classNames,
182
+ styles,
183
+ }: ResourceTimelineProps<T>): ReactElement {
184
+ const theme = useMemo(() => mergeDomTheme(themeOverrides), [themeOverrides]);
185
+ const slot = createSlots<ResourceTimelineSlot>({ classNames, styles });
186
+ const Renderer = renderEvent;
187
+ const snapHours = dragStepMinutes / 60;
188
+
189
+ // `drag` drives the visual; `dragRef` is the source of truth the pointer
190
+ // handlers read so they never see a stale closure between events.
191
+ const [drag, setDrag] = useState<DragState | null>(null);
192
+ const dragRef = useRef<DragState | null>(null);
193
+ const origin = useRef<{ x: number; startHours: number; durationHours: number } | null>(null);
194
+ const applyDrag = (next: DragState | null) => {
195
+ dragRef.current = next;
196
+ setDrag(next);
197
+ };
198
+
199
+ const beginDrag = (
200
+ e: ReactPointerEvent,
201
+ key: string,
202
+ kind: "move" | "resize",
203
+ startHours: number,
204
+ durationHours: number,
205
+ ) => {
206
+ if (!onDragEvent) return;
207
+ e.stopPropagation();
208
+ try {
209
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
210
+ } catch {
211
+ // Pointer capture is best-effort.
212
+ }
213
+ origin.current = { x: e.clientX, startHours, durationHours };
214
+ applyDrag({ key, kind, startHours, durationHours, moved: false });
215
+ };
216
+ const moveDrag = (e: ReactPointerEvent) => {
217
+ const d = dragRef.current;
218
+ if (!d || !origin.current) return;
219
+ const dHours = (e.clientX - origin.current.x) / hourWidth;
220
+ const snap = (v: number) => Math.round(v / snapHours) * snapHours;
221
+ if (d.kind === "move") {
222
+ const startHours = clamp(
223
+ snap(origin.current.startHours + dHours),
224
+ startHour,
225
+ endHour - d.durationHours,
226
+ );
227
+ applyDrag({ ...d, startHours, moved: true });
228
+ } else {
229
+ const durationHours = clamp(
230
+ snap(origin.current.durationHours + dHours),
231
+ snapHours,
232
+ endHour - d.startHours,
233
+ );
234
+ applyDrag({ ...d, durationHours, moved: true });
235
+ }
236
+ };
237
+ const endDrag = (e: ReactPointerEvent, event: CalendarEvent<T>, onPress: () => void) => {
238
+ const d = dragRef.current;
239
+ if (!d) return;
240
+ try {
241
+ (e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
242
+ } catch {
243
+ // Best-effort release.
244
+ }
245
+ if (!d.moved) {
246
+ applyDrag(null);
247
+ onPress();
248
+ return;
249
+ }
250
+ const base = startOfDay(date);
251
+ const start = addMinutes(base, Math.round(d.startHours * 60));
252
+ const end = addMinutes(base, Math.round((d.startHours + d.durationHours) * 60));
253
+ onDragEvent?.(event, start, end);
254
+ applyDrag(null);
255
+ origin.current = null;
256
+ };
257
+ const cancelDrag = () => {
258
+ applyDrag(null);
259
+ origin.current = null;
260
+ };
261
+
262
+ const hours = useMemo(
263
+ () => Array.from({ length: Math.max(0, endHour - startHour) }, (_, i) => startHour + i),
264
+ [startHour, endHour],
265
+ );
266
+ const trackWidth = (endHour - startHour) * hourWidth;
267
+
268
+ // Group the positioned (overlap-resolved) events by resource, once.
269
+ const byResource = useMemo(() => {
270
+ const map = new Map<string, ReturnType<typeof layoutDayEvents<T>>>();
271
+ for (const resource of resources) {
272
+ const own = events.filter((e) => resourceId(e) === resource.id);
273
+ map.set(resource.id, layoutDayEvents(own, date));
274
+ }
275
+ return map;
276
+ }, [resources, events, resourceId, date]);
277
+
278
+ const gridLines = `repeating-linear-gradient(to right, transparent 0, transparent ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth - 1}px, ${theme.gridLine} ${hourWidth}px)`;
279
+
280
+ return (
281
+ <div
282
+ className={className}
283
+ style={{ fontFamily: theme.fontFamily, color: theme.text, overflowX: "auto", ...style }}
284
+ >
285
+ <div style={{ minWidth: labelWidth + trackWidth }}>
286
+ {/* Header: corner + hour axis */}
287
+ <div
288
+ {...slot("header", {
289
+ base: { display: "flex" },
290
+ themed: { borderBottom: `1px solid ${theme.gridLine}` },
291
+ })}
292
+ >
293
+ <div {...slot("corner", { base: { width: labelWidth, flex: "none" } })} />
294
+ <div
295
+ {...slot("timeAxis", { base: { position: "relative", height: 24, width: trackWidth } })}
296
+ >
297
+ {hours.map((h) => (
298
+ <div
299
+ key={h}
300
+ {...slot("hourTick", {
301
+ base: { position: "absolute", left: (h - startHour) * hourWidth, top: 4 },
302
+ themed: { fontSize: 10, color: theme.textMuted },
303
+ })}
304
+ >
305
+ {formatHour(h, { ampm })}
306
+ </div>
307
+ ))}
308
+ </div>
309
+ </div>
310
+
311
+ {/* Resource rows */}
312
+ {resources.map((resource) => {
313
+ const positioned = byResource.get(resource.id) ?? [];
314
+ return (
315
+ <div
316
+ key={resource.id}
317
+ {...slot("row", {
318
+ base: { display: "flex", height: rowHeight },
319
+ themed: { borderBottom: `1px solid ${theme.gridLine}` },
320
+ })}
321
+ >
322
+ <div
323
+ {...slot("resourceLabel", {
324
+ base: {
325
+ width: labelWidth,
326
+ flex: "none",
327
+ display: "flex",
328
+ alignItems: "center",
329
+ padding: "0 10px",
330
+ boxSizing: "border-box",
331
+ },
332
+ themed: {
333
+ fontSize: 13,
334
+ fontWeight: 600,
335
+ borderRight: `1px solid ${theme.gridLine}`,
336
+ },
337
+ })}
338
+ >
339
+ {resource.title ?? resource.id}
340
+ </div>
341
+ <div {...slot("track", { base: { position: "relative", width: trackWidth } })}>
342
+ <div
343
+ aria-hidden
344
+ {...slot("gridLines", {
345
+ base: { position: "absolute", inset: 0, pointerEvents: "none" },
346
+ themed: { backgroundImage: gridLines },
347
+ })}
348
+ />
349
+ {positioned.map((pe, idx) => {
350
+ const key = `${resource.id}:${idx}`;
351
+ const active = drag?.key === key ? drag : null;
352
+ const startH = active ? active.startHours : pe.startHours;
353
+ const durH = active ? active.durationHours : pe.durationHours;
354
+ const left = clamp(startH - startHour, 0, endHour - startHour) * hourWidth;
355
+ const right =
356
+ clamp(startH + durH - startHour, 0, endHour - startHour) * hourWidth;
357
+ const width = Math.max(right - left, 2);
358
+ // Overlapping events in a row share the height as stacked sub-lanes.
359
+ const laneHeight = rowHeight / pe.columns;
360
+ const onPress = () => onPressEvent?.(pe.event);
361
+ const args: ResourceEventArgs<T> = { event: pe.event, width, onPress };
362
+ const draggable = !!onDragEvent;
363
+ return (
364
+ <button
365
+ key={idx}
366
+ type="button"
367
+ onClick={draggable ? undefined : onPress}
368
+ aria-label={pe.event.title}
369
+ {...dataState({ "data-dragging": !!active })}
370
+ onPointerDown={
371
+ draggable
372
+ ? (e) => beginDrag(e, key, "move", pe.startHours, pe.durationHours)
373
+ : undefined
374
+ }
375
+ onPointerMove={draggable ? moveDrag : undefined}
376
+ onPointerUp={draggable ? (e) => endDrag(e, pe.event, onPress) : undefined}
377
+ onPointerCancel={draggable ? cancelDrag : undefined}
378
+ {...slot("event", {
379
+ base: {
380
+ position: "absolute",
381
+ left,
382
+ width,
383
+ top: pe.column * laneHeight,
384
+ height: laneHeight,
385
+ padding: 1,
386
+ border: "none",
387
+ background: "transparent",
388
+ cursor: draggable ? "grab" : "pointer",
389
+ touchAction: draggable ? "none" : "auto",
390
+ font: "inherit",
391
+ textAlign: "left",
392
+ boxSizing: "border-box",
393
+ zIndex: active ? 3 : 1,
394
+ opacity: active ? 0.85 : 1,
395
+ },
396
+ })}
397
+ >
398
+ {Renderer ? (
399
+ <Renderer {...args} />
400
+ ) : (
401
+ <DefaultBar {...args} theme={theme} boxProps={slot("eventBox")} />
402
+ )}
403
+ {draggable ? (
404
+ <span
405
+ aria-hidden
406
+ onPointerDown={(e) => {
407
+ e.stopPropagation();
408
+ beginDrag(e, key, "resize", pe.startHours, pe.durationHours);
409
+ }}
410
+ style={{
411
+ position: "absolute",
412
+ top: 0,
413
+ bottom: 0,
414
+ right: 0,
415
+ width: 8,
416
+ cursor: "ew-resize",
417
+ touchAction: "none",
418
+ }}
419
+ />
420
+ ) : null}
421
+ </button>
422
+ );
423
+ })}
424
+ </div>
425
+ </div>
426
+ );
427
+ })}
428
+ </div>
429
+ </div>
430
+ );
431
+ }