@super-calendar/dom 2.0.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,833 @@
1
+ import { addDays, addMinutes, format, type Locale, startOfDay } from "date-fns";
2
+ import {
3
+ type ComponentType,
4
+ type CSSProperties,
5
+ type PointerEvent as ReactPointerEvent,
6
+ type ReactNode,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import {
13
+ type BusinessHours,
14
+ type CalendarEvent,
15
+ type CalendarMode,
16
+ cellRangeFromDrag,
17
+ closedHourBands,
18
+ eventAccessibilityLabel,
19
+ eventChipLayout,
20
+ eventTimeLabel,
21
+ formatHour,
22
+ getIsToday,
23
+ getViewDays,
24
+ isAllDayEvent,
25
+ isSameCalendarDay,
26
+ layoutDayEvents,
27
+ type TimeGridMode,
28
+ titleNumberOfLines,
29
+ type WeekStartsOn,
30
+ } from "@super-calendar/core";
31
+ import { type DomCalendarTheme, mergeDomTheme } from "./theme";
32
+
33
+ const HOURS = Array.from({ length: 24 }, (_, h) => h);
34
+ const GUTTER_WIDTH = 56;
35
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
36
+
37
+ /** Props passed to a custom time-grid event renderer. */
38
+ export interface DomRenderEventArgs<T = unknown> {
39
+ event: CalendarEvent<T>;
40
+ mode: CalendarMode;
41
+ isAllDay: boolean;
42
+ boxHeight?: number;
43
+ continuesBefore?: boolean;
44
+ continuesAfter?: boolean;
45
+ /** Show the time range in 12-hour AM/PM. */
46
+ ampm?: boolean;
47
+ onPress: () => void;
48
+ }
49
+
50
+ export type DomRenderEvent<T = unknown> = ComponentType<DomRenderEventArgs<T>>;
51
+
52
+ export interface TimeGridProps<T = unknown> {
53
+ date: Date;
54
+ events?: CalendarEvent<T>[];
55
+ /** "day" (default), "3days", "week", or "custom" (with `numberOfDays`). */
56
+ mode?: TimeGridMode;
57
+ numberOfDays?: number;
58
+ weekStartsOn?: WeekStartsOn;
59
+ /** Initial pixels per hour (default 48). */
60
+ hourHeight?: number;
61
+ /** Initial scroll position, in minutes from midnight (default 8:00). */
62
+ scrollOffsetMinutes?: number;
63
+ /** Pinch / Ctrl-⌘-scroll to zoom the grid (default true). */
64
+ zoomable?: boolean;
65
+ minHourHeight?: number;
66
+ maxHourHeight?: number;
67
+ /** Snap dragged events to this many minutes (default 15). */
68
+ dragStepMinutes?: number;
69
+ /** Render event time ranges in 12-hour AM/PM (default false, 24h). */
70
+ ampm?: boolean;
71
+ /** Sub-divisions per hour for the grid lines, e.g. 2 for half-hour (default 1). */
72
+ timeslots?: number;
73
+ /** Shade the hours outside business hours; `null` shades the whole day. */
74
+ businessHours?: BusinessHours;
75
+ /** Show the current-time indicator on today's column (default true). */
76
+ showNowIndicator?: boolean;
77
+ /** Show the all-day lane above the grid (default true). */
78
+ showAllDayEventCell?: boolean;
79
+ locale?: Locale;
80
+ theme?: Partial<DomCalendarTheme>;
81
+ height?: number | string;
82
+ renderEvent?: DomRenderEvent<T>;
83
+ /** Replace the hour-axis label. Receives the hour (0–23) and the `ampm` flag. */
84
+ hourComponent?: (hour: number, ampm: boolean) => ReactNode;
85
+ onPressEvent?: (event: CalendarEvent<T>) => void;
86
+ onPressDateHeader?: (day: Date) => void;
87
+ /** Tap empty grid space; called with the date and time at the press. */
88
+ onPressCell?: (date: Date) => void;
89
+ /** Drag empty grid space to create; called with the swept start/end. */
90
+ onCreateEvent?: (start: Date, end: Date) => void;
91
+ /** Fires when an event drag begins (e.g. to trigger haptics). */
92
+ onDragStart?: (event: CalendarEvent<T>) => void;
93
+ /**
94
+ * Enables drag-to-move and resize; called with the proposed new start/end.
95
+ * Return `false` to reject the drop (the event snaps back).
96
+ */
97
+ onDragEvent?: (event: CalendarEvent<T>, start: Date, end: Date) => void | boolean;
98
+ className?: string;
99
+ style?: CSSProperties;
100
+ }
101
+
102
+ type DragState = {
103
+ key: string;
104
+ kind: "move" | "resize";
105
+ startHours: number;
106
+ durationHours: number;
107
+ moved: boolean;
108
+ };
109
+
110
+ // Chip line metrics, matched to the box font below so the title clamp lands on a
111
+ // line boundary. 16px matches the native renderer's `eventTitle` line height, so
112
+ // the "does the time line still fit" decision flips at the same box height on
113
+ // both. The time reserves two lines (it can wrap on a narrow column).
114
+ const DOM_TITLE_LINE_HEIGHT = 16;
115
+ const DOM_TIME_LINE_HEIGHT = 30;
116
+ const DOM_BOX_PADDING_V = 2;
117
+
118
+ function DefaultDomEvent<T>({
119
+ event,
120
+ mode,
121
+ isAllDay,
122
+ boxHeight,
123
+ ampm = false,
124
+ theme,
125
+ }: DomRenderEventArgs<T> & { theme: DomCalendarTheme }) {
126
+ const timeLabel = eventTimeLabel({
127
+ mode,
128
+ isAllDay,
129
+ start: event.start,
130
+ end: event.end,
131
+ ampm,
132
+ showTime: true,
133
+ });
134
+ // The title fills the box in whole lines; the time is secondary and only shows
135
+ // once a full line is free beneath it. Mirrors the RN renderer via the same
136
+ // core helper, so a 30-minute slot shows just its title instead of clipping
137
+ // both lines, and the title never ends on a half-cut line.
138
+ const { titleMaxLines, showTime } = eventChipLayout({
139
+ boxHeightPx: boxHeight,
140
+ mode,
141
+ hasTime: !isAllDay && timeLabel != null,
142
+ titleLineHeightPx: DOM_TITLE_LINE_HEIGHT,
143
+ timeLineHeightPx: DOM_TIME_LINE_HEIGHT,
144
+ paddingYPx: DOM_BOX_PADDING_V,
145
+ });
146
+ const oneLine = titleNumberOfLines(mode, isAllDay) === 1;
147
+ return (
148
+ <div
149
+ style={{
150
+ height: "100%",
151
+ boxSizing: "border-box",
152
+ overflow: "hidden",
153
+ padding: `${DOM_BOX_PADDING_V}px 6px`,
154
+ borderRadius: 6,
155
+ background: theme.eventBackground,
156
+ color: theme.eventText,
157
+ fontSize: 12,
158
+ lineHeight: `${DOM_TITLE_LINE_HEIGHT}px`,
159
+ }}
160
+ >
161
+ <div
162
+ style={{
163
+ fontWeight: 600,
164
+ overflow: "hidden",
165
+ // Clip on a line boundary with no ellipsis. The all-day lane is a single
166
+ // line; timed events wrap to as many whole lines as the box allows.
167
+ ...(oneLine
168
+ ? { whiteSpace: "nowrap" }
169
+ : {
170
+ wordBreak: "break-word",
171
+ ...(titleMaxLines > 0
172
+ ? { maxHeight: titleMaxLines * DOM_TITLE_LINE_HEIGHT }
173
+ : null),
174
+ }),
175
+ }}
176
+ >
177
+ {event.title}
178
+ </div>
179
+ {showTime ? (
180
+ <div style={{ opacity: 0.75, overflow: "hidden", maxHeight: DOM_TIME_LINE_HEIGHT }}>
181
+ {timeLabel}
182
+ </div>
183
+ ) : null}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ /**
189
+ * A day / week / N-day time grid rendered with plain DOM elements. Events are
190
+ * positioned with the library's pure `layoutDayEvents`, so overlap columns and
191
+ * multi-day clipping match the React Native renderer. Supports Ctrl/⌘-scroll and
192
+ * two-finger pinch to zoom, and pointer drag to move / resize events.
193
+ */
194
+ export function TimeGrid<T = unknown>({
195
+ date,
196
+ events = [],
197
+ mode = "day",
198
+ numberOfDays = 1,
199
+ weekStartsOn = 0,
200
+ hourHeight: initialHourHeight = 48,
201
+ scrollOffsetMinutes = 8 * 60,
202
+ zoomable = true,
203
+ minHourHeight = 24,
204
+ maxHourHeight = 160,
205
+ dragStepMinutes = 15,
206
+ ampm = false,
207
+ timeslots = 1,
208
+ businessHours,
209
+ showNowIndicator = true,
210
+ showAllDayEventCell = true,
211
+ locale,
212
+ theme: themeOverrides,
213
+ height = 600,
214
+ renderEvent,
215
+ hourComponent,
216
+ onPressEvent,
217
+ onPressDateHeader,
218
+ onPressCell,
219
+ onCreateEvent,
220
+ onDragStart,
221
+ onDragEvent,
222
+ className,
223
+ style,
224
+ }: TimeGridProps<T>) {
225
+ const theme = useMemo(() => mergeDomTheme(themeOverrides), [themeOverrides]);
226
+ const scrollRef = useRef<HTMLDivElement>(null);
227
+ const dfns = locale ? { locale } : undefined;
228
+ const snapHours = dragStepMinutes / 60;
229
+
230
+ const [hourHeight, setHourHeight] = useState(initialHourHeight);
231
+ useEffect(() => setHourHeight(initialHourHeight), [initialHourHeight]);
232
+ const hourHeightRef = useRef(hourHeight);
233
+ hourHeightRef.current = hourHeight;
234
+
235
+ // `drag` drives the visual; `dragRef` is the source of truth the pointer
236
+ // handlers read, so they never see a stale state closure between events.
237
+ const [drag, setDrag] = useState<DragState | null>(null);
238
+ const dragRef = useRef<DragState | null>(null);
239
+ const dragOrigin = useRef<{ pointerY: number; startHours: number; durationHours: number } | null>(
240
+ null,
241
+ );
242
+ const applyDrag = (next: DragState | null) => {
243
+ dragRef.current = next;
244
+ setDrag(next);
245
+ };
246
+
247
+ // Create-by-drag / tap on empty grid space. Mouse and pen sweep out a new
248
+ // event; on touch the column scrolls instead, so a tap still hits onPressCell.
249
+ const [createBox, setCreateBox] = useState<{
250
+ dayIndex: number;
251
+ topPx: number;
252
+ heightPx: number;
253
+ } | null>(null);
254
+ const createOrigin = useRef<{ el: HTMLElement; dayIndex: number; startPx: number } | null>(null);
255
+ const cellEnabled = !!onPressCell || !!onCreateEvent;
256
+
257
+ const days = useMemo(
258
+ () => getViewDays(mode, date, weekStartsOn, numberOfDays),
259
+ [mode, date, weekStartsOn, numberOfDays],
260
+ );
261
+
262
+ const allDayByDay = useMemo(
263
+ // A multi-day all-day event shows in every column it overlaps (matching the
264
+ // native AllDayLane), not just its start day.
265
+ () =>
266
+ days.map((day) => {
267
+ const dayStart = startOfDay(day);
268
+ const dayEnd = addDays(dayStart, 1);
269
+ return events.filter((e) => isAllDayEvent(e) && e.start < dayEnd && e.end > dayStart);
270
+ }),
271
+ [days, events],
272
+ );
273
+ const hasAllDay = allDayByDay.some((list) => list.length > 0);
274
+
275
+ useEffect(() => {
276
+ // On mount / when the offset prop changes, not on every zoom (hence the ref).
277
+ if (scrollRef.current)
278
+ scrollRef.current.scrollTop = (scrollOffsetMinutes / 60) * hourHeightRef.current;
279
+ }, [scrollOffsetMinutes]);
280
+
281
+ // Zoom: Ctrl/⌘ + wheel (native listener so we can preventDefault), plus
282
+ // two-pointer pinch. Both scale hourHeight about the current view.
283
+ useEffect(() => {
284
+ const el = scrollRef.current;
285
+ if (!el || !zoomable) return;
286
+ const onWheel = (e: WheelEvent) => {
287
+ if (!e.ctrlKey && !e.metaKey) return;
288
+ e.preventDefault();
289
+ setHourHeight((h) => clamp(h * (1 - e.deltaY * 0.0015), minHourHeight, maxHourHeight));
290
+ };
291
+ el.addEventListener("wheel", onWheel, { passive: false });
292
+ return () => el.removeEventListener("wheel", onWheel);
293
+ }, [zoomable, minHourHeight, maxHourHeight]);
294
+
295
+ const pinch = useRef<Map<number, number>>(new Map());
296
+ const pinchBase = useRef<{ dist: number; height: number } | null>(null);
297
+ const onBodyPointerDown = (e: ReactPointerEvent) => {
298
+ if (!zoomable || e.pointerType !== "touch") return;
299
+ pinch.current.set(e.pointerId, e.clientY);
300
+ if (pinch.current.size === 2) {
301
+ const ys = [...pinch.current.values()];
302
+ pinchBase.current = { dist: Math.abs(ys[0] - ys[1]), height: hourHeight };
303
+ }
304
+ };
305
+ const onBodyPointerMove = (e: ReactPointerEvent) => {
306
+ if (!pinch.current.has(e.pointerId)) return;
307
+ pinch.current.set(e.pointerId, e.clientY);
308
+ if (pinch.current.size === 2 && pinchBase.current) {
309
+ const ys = [...pinch.current.values()];
310
+ const dist = Math.abs(ys[0] - ys[1]);
311
+ const ratio = dist / (pinchBase.current.dist || 1);
312
+ setHourHeight(clamp(pinchBase.current.height * ratio, minHourHeight, maxHourHeight));
313
+ }
314
+ };
315
+ const onBodyPointerUp = (e: ReactPointerEvent) => {
316
+ pinch.current.delete(e.pointerId);
317
+ if (pinch.current.size < 2) pinchBase.current = null;
318
+ };
319
+
320
+ const Renderer = renderEvent;
321
+ const totalHeight = 24 * hourHeight;
322
+
323
+ const beginDrag = (
324
+ e: ReactPointerEvent,
325
+ event: CalendarEvent<T>,
326
+ key: string,
327
+ kind: "move" | "resize",
328
+ startHours: number,
329
+ durationHours: number,
330
+ ) => {
331
+ if (!onDragEvent) return;
332
+ e.stopPropagation();
333
+ try {
334
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
335
+ } catch {
336
+ // Pointer capture is best-effort; some environments reject it.
337
+ }
338
+ dragOrigin.current = { pointerY: e.clientY, startHours, durationHours };
339
+ applyDrag({ key, kind, startHours, durationHours, moved: false });
340
+ onDragStart?.(event);
341
+ };
342
+ const cancelDrag = () => {
343
+ applyDrag(null);
344
+ dragOrigin.current = null;
345
+ };
346
+ const moveDrag = (e: ReactPointerEvent) => {
347
+ const d = dragRef.current;
348
+ if (!d || !dragOrigin.current) return;
349
+ // Read the live row height (ref, not the state closure) so a mid-drag zoom
350
+ // keeps the math aligned with what's on screen.
351
+ const dHours = (e.clientY - dragOrigin.current.pointerY) / hourHeightRef.current;
352
+ const snap = (v: number) => Math.round(v / snapHours) * snapHours;
353
+ if (d.kind === "move") {
354
+ const startHours = clamp(
355
+ snap(dragOrigin.current.startHours + dHours),
356
+ 0,
357
+ 24 - d.durationHours,
358
+ );
359
+ applyDrag({ ...d, startHours, moved: true });
360
+ } else {
361
+ const durationHours = clamp(
362
+ snap(dragOrigin.current.durationHours + dHours),
363
+ snapHours,
364
+ 24 - d.startHours,
365
+ );
366
+ applyDrag({ ...d, durationHours, moved: true });
367
+ }
368
+ };
369
+ const endDrag = (
370
+ e: ReactPointerEvent,
371
+ day: Date,
372
+ event: CalendarEvent<T>,
373
+ onPress: () => void,
374
+ ) => {
375
+ const d = dragRef.current;
376
+ if (!d) return;
377
+ try {
378
+ (e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
379
+ } catch {
380
+ // Best-effort release; ignore if the capture was never granted.
381
+ }
382
+ if (!d.moved) {
383
+ applyDrag(null);
384
+ onPress();
385
+ return;
386
+ }
387
+ const base = startOfDay(day);
388
+ const start = addMinutes(base, Math.round(d.startHours * 60));
389
+ const end = addMinutes(base, Math.round((d.startHours + d.durationHours) * 60));
390
+ onDragEvent?.(event, start, end);
391
+ applyDrag(null);
392
+ dragOrigin.current = null;
393
+ };
394
+
395
+ const pxFromTop = (el: HTMLElement, clientY: number) => clientY - el.getBoundingClientRect().top;
396
+ const beginCreate = (e: ReactPointerEvent, dayIndex: number) => {
397
+ // Mouse / pen only: on touch the column must stay free to scroll the grid.
398
+ // Only from the column background, primary button, never on an event.
399
+ if (!cellEnabled || e.pointerType === "touch") return;
400
+ if (e.target !== e.currentTarget || e.button > 0) return;
401
+ const el = e.currentTarget as HTMLElement;
402
+ const startPx = pxFromTop(el, e.clientY);
403
+ try {
404
+ el.setPointerCapture?.(e.pointerId);
405
+ } catch {
406
+ // Best-effort capture.
407
+ }
408
+ createOrigin.current = { el, dayIndex, startPx };
409
+ setCreateBox({ dayIndex, topPx: startPx, heightPx: 0 });
410
+ };
411
+ const moveCreate = (e: ReactPointerEvent) => {
412
+ const o = createOrigin.current;
413
+ if (!o) return;
414
+ const cur = pxFromTop(o.el, e.clientY);
415
+ setCreateBox({
416
+ dayIndex: o.dayIndex,
417
+ topPx: Math.min(o.startPx, cur),
418
+ heightPx: Math.abs(cur - o.startPx),
419
+ });
420
+ };
421
+ const endCreate = (e: ReactPointerEvent) => {
422
+ const o = createOrigin.current;
423
+ if (!o) return;
424
+ try {
425
+ o.el.releasePointerCapture?.(e.pointerId);
426
+ } catch {
427
+ // Best-effort release.
428
+ }
429
+ const endPx = pxFromTop(o.el, e.clientY);
430
+ const day = days[o.dayIndex];
431
+ const moved = Math.abs(endPx - o.startPx) > 4;
432
+ const h = hourHeightRef.current;
433
+ if (moved && onCreateEvent) {
434
+ const range = cellRangeFromDrag(day, o.startPx, endPx, h, 0, dragStepMinutes);
435
+ if (range) onCreateEvent(range.start, range.end);
436
+ } else if (onPressCell) {
437
+ const at = cellRangeFromDrag(day, o.startPx, o.startPx, h, 0, dragStepMinutes);
438
+ if (at) onPressCell(at.start);
439
+ }
440
+ createOrigin.current = null;
441
+ setCreateBox(null);
442
+ };
443
+ // A gesture the browser/OS cancels (scroll takeover, etc.) must not commit a
444
+ // create — just drop the in-progress state.
445
+ const cancelCreate = () => {
446
+ createOrigin.current = null;
447
+ setCreateBox(null);
448
+ };
449
+
450
+ // Per-day layout and shading are pure functions of days/events/businessHours,
451
+ // so memoize them — otherwise every drag pointermove (which calls setDrag)
452
+ // re-runs the full layout for each column.
453
+ const positionedByDay = useMemo(
454
+ () => days.map((day) => layoutDayEvents(events, day)),
455
+ [days, events],
456
+ );
457
+ // The dom grid always renders a full 0–24 window (no minHour/maxHour props), so
458
+ // closedHourBands uses its 0/24 defaults.
459
+ const bandsByDay = useMemo(
460
+ () => days.map((day) => closedHourBands(day, businessHours)),
461
+ [days, businessHours],
462
+ );
463
+ const gridLines = useMemo(() => {
464
+ const hourLines = `repeating-linear-gradient(to bottom, transparent 0, transparent ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight - 1}px, ${theme.gridLine} ${hourHeight}px)`;
465
+ if (timeslots <= 1) return hourLines;
466
+ const slotHeight = hourHeight / timeslots;
467
+ return `${hourLines}, repeating-linear-gradient(to bottom, transparent 0, transparent ${slotHeight - 1}px, ${theme.gridLine}80 ${slotHeight - 1}px, ${theme.gridLine}80 ${slotHeight}px)`;
468
+ }, [hourHeight, timeslots, theme.gridLine]);
469
+
470
+ return (
471
+ <div
472
+ className={className}
473
+ style={{
474
+ fontFamily: theme.fontFamily,
475
+ color: theme.text,
476
+ display: "flex",
477
+ flexDirection: "column",
478
+ ...style,
479
+ }}
480
+ >
481
+ {/* Header */}
482
+ <div style={{ display: "flex", borderBottom: `1px solid ${theme.gridLine}` }}>
483
+ <div style={{ width: GUTTER_WIDTH, flex: "none" }} />
484
+ {days.map((day) => {
485
+ const today = getIsToday(day);
486
+ return (
487
+ <button
488
+ key={day.toISOString()}
489
+ type="button"
490
+ tabIndex={onPressDateHeader ? 0 : -1}
491
+ aria-hidden={onPressDateHeader ? undefined : true}
492
+ onClick={onPressDateHeader ? () => onPressDateHeader(day) : undefined}
493
+ style={{
494
+ flex: 1,
495
+ border: "none",
496
+ background: "transparent",
497
+ font: "inherit",
498
+ color: theme.textMuted,
499
+ cursor: onPressDateHeader ? "pointer" : "default",
500
+ padding: "6px 0",
501
+ display: "flex",
502
+ flexDirection: "column",
503
+ alignItems: "center",
504
+ gap: 2,
505
+ }}
506
+ >
507
+ <span style={{ fontSize: 11, fontWeight: 600 }}>{format(day, "EEE", dfns)}</span>
508
+ <span
509
+ style={{
510
+ width: 28,
511
+ height: 28,
512
+ borderRadius: "50%",
513
+ display: "flex",
514
+ alignItems: "center",
515
+ justifyContent: "center",
516
+ fontSize: 15,
517
+ fontWeight: 600,
518
+ background: today ? theme.todayBackground : "transparent",
519
+ color: today ? theme.todayText : theme.text,
520
+ }}
521
+ >
522
+ {format(day, "d", dfns)}
523
+ </span>
524
+ </button>
525
+ );
526
+ })}
527
+ </div>
528
+
529
+ {/* All-day lane */}
530
+ {showAllDayEventCell && hasAllDay ? (
531
+ <div style={{ display: "flex", borderBottom: `1px solid ${theme.gridLine}` }}>
532
+ <div
533
+ style={{
534
+ width: GUTTER_WIDTH,
535
+ flex: "none",
536
+ fontSize: 10,
537
+ color: theme.textMuted,
538
+ textAlign: "right",
539
+ padding: "4px 6px 0 0",
540
+ }}
541
+ >
542
+ all-day
543
+ </div>
544
+ {allDayByDay.map((list, i) => {
545
+ const dayStart = startOfDay(days[i]);
546
+ const dayEnd = addDays(dayStart, 1);
547
+ return (
548
+ <div
549
+ key={days[i].toISOString()}
550
+ style={{
551
+ flex: 1,
552
+ minWidth: 0,
553
+ // Mirror the day column's geometry so the all-day chip lines up
554
+ // exactly with the timed events below it: a 1px left border (the
555
+ // grid line, transparent here) plus a 1px horizontal inset.
556
+ borderLeft: "1px solid transparent",
557
+ padding: "2px 1px",
558
+ display: "flex",
559
+ flexDirection: "column",
560
+ gap: 2,
561
+ }}
562
+ >
563
+ {list.map((event) => {
564
+ const args: DomRenderEventArgs<T> = {
565
+ event,
566
+ mode,
567
+ isAllDay: true,
568
+ // Whether this all-day event continues into the previous/next
569
+ // column, so custom renderers can draw continuation affordances.
570
+ continuesBefore: event.start < dayStart,
571
+ continuesAfter: event.end > dayEnd,
572
+ ampm,
573
+ onPress: () => onPressEvent?.(event),
574
+ };
575
+ return (
576
+ <button
577
+ key={`${event.start.toISOString()}:${event.title}`}
578
+ type="button"
579
+ onClick={() => onPressEvent?.(event)}
580
+ aria-label={eventAccessibilityLabel({
581
+ title: event.title,
582
+ isAllDay: true,
583
+ start: event.start,
584
+ end: event.end,
585
+ ampm,
586
+ })}
587
+ style={{
588
+ border: "none",
589
+ padding: 0,
590
+ background: "transparent",
591
+ cursor: "pointer",
592
+ textAlign: "left",
593
+ height: 22,
594
+ }}
595
+ >
596
+ {Renderer ? (
597
+ <Renderer {...args} />
598
+ ) : (
599
+ <DefaultDomEvent {...args} theme={theme} />
600
+ )}
601
+ </button>
602
+ );
603
+ })}
604
+ </div>
605
+ );
606
+ })}
607
+ </div>
608
+ ) : null}
609
+
610
+ {/* Scrollable body */}
611
+ <div
612
+ ref={scrollRef}
613
+ onPointerDown={onBodyPointerDown}
614
+ onPointerMove={onBodyPointerMove}
615
+ onPointerUp={onBodyPointerUp}
616
+ onPointerCancel={onBodyPointerUp}
617
+ style={{
618
+ overflowY: "auto",
619
+ height,
620
+ position: "relative",
621
+ touchAction: zoomable ? "pan-y" : "auto",
622
+ }}
623
+ >
624
+ <div style={{ display: "flex", height: totalHeight, position: "relative" }}>
625
+ {/* Hour gutter */}
626
+ <div style={{ width: GUTTER_WIDTH, flex: "none", position: "relative" }}>
627
+ {HOURS.map((h) => (
628
+ <div
629
+ key={h}
630
+ style={{
631
+ position: "absolute",
632
+ top: h * hourHeight - 6,
633
+ right: 6,
634
+ fontSize: 10,
635
+ color: theme.textMuted,
636
+ }}
637
+ >
638
+ {hourComponent ? hourComponent(h, ampm) : h === 0 ? "" : formatHour(h, { ampm })}
639
+ </div>
640
+ ))}
641
+ </div>
642
+
643
+ {/* Day columns */}
644
+ {days.map((day, dayIndex) => {
645
+ const positioned = positionedByDay[dayIndex];
646
+ const showNow = showNowIndicator && isSameCalendarDay(day, new Date());
647
+ const nowDate = new Date();
648
+ const nowTop = ((nowDate.getHours() * 60 + nowDate.getMinutes()) / 60) * hourHeight;
649
+ const bands = bandsByDay[dayIndex];
650
+ const ghost = createBox?.dayIndex === dayIndex ? createBox : null;
651
+ return (
652
+ <div
653
+ key={day.toISOString()}
654
+ // Empty columns are a pointer-only create surface: drag to sweep
655
+ // out an event. They are deliberately not tab stops, so keyboard
656
+ // focus moves through events only, not every empty day.
657
+ onPointerDown={cellEnabled ? (e) => beginCreate(e, dayIndex) : undefined}
658
+ onPointerMove={cellEnabled ? moveCreate : undefined}
659
+ onPointerUp={cellEnabled ? endCreate : undefined}
660
+ onPointerCancel={cellEnabled ? cancelCreate : undefined}
661
+ style={{
662
+ flex: 1,
663
+ position: "relative",
664
+ borderLeft: `1px solid ${theme.gridLine}`,
665
+ }}
666
+ >
667
+ {/* Business-hours shade, behind the grid lines and events. */}
668
+ {bands.map((b) => (
669
+ <div
670
+ key={b.start}
671
+ aria-hidden
672
+ style={{
673
+ position: "absolute",
674
+ left: 0,
675
+ right: 0,
676
+ top: b.start * hourHeight,
677
+ height: (b.end - b.start) * hourHeight,
678
+ background: theme.outsideHoursBackground,
679
+ pointerEvents: "none",
680
+ zIndex: 0,
681
+ }}
682
+ />
683
+ ))}
684
+ {/* Grid lines, painted over the shade so they stay visible. */}
685
+ <div
686
+ aria-hidden
687
+ style={{
688
+ position: "absolute",
689
+ inset: 0,
690
+ pointerEvents: "none",
691
+ zIndex: 0,
692
+ backgroundImage: gridLines,
693
+ }}
694
+ />
695
+ {positioned.map((pe, idx) => {
696
+ const key = `${dayIndex}:${idx}`;
697
+ const active = drag?.key === key ? drag : null;
698
+ const startHours = active ? active.startHours : pe.startHours;
699
+ const durationHours = active ? active.durationHours : pe.durationHours;
700
+ const top = startHours * hourHeight;
701
+ const boxHeight = Math.max(durationHours * hourHeight, 14);
702
+ const widthPct = 100 / pe.columns;
703
+ const onPress = () => onPressEvent?.(pe.event);
704
+ const args: DomRenderEventArgs<T> = {
705
+ event: pe.event,
706
+ mode,
707
+ isAllDay: false,
708
+ boxHeight,
709
+ continuesBefore: pe.continuesBefore,
710
+ continuesAfter: pe.continuesAfter,
711
+ ampm,
712
+ onPress,
713
+ };
714
+ const draggable = !!onDragEvent;
715
+ return (
716
+ <div
717
+ key={idx}
718
+ role="button"
719
+ tabIndex={0}
720
+ aria-label={eventAccessibilityLabel({
721
+ title: pe.event.title,
722
+ isAllDay: false,
723
+ start: pe.event.start,
724
+ end: pe.event.end,
725
+ ampm,
726
+ })}
727
+ onKeyDown={(e) => {
728
+ if (e.key === "Enter" || e.key === " ") {
729
+ e.preventDefault();
730
+ onPress();
731
+ }
732
+ }}
733
+ onPointerDown={
734
+ draggable
735
+ ? (e) =>
736
+ beginDrag(e, pe.event, key, "move", pe.startHours, pe.durationHours)
737
+ : undefined
738
+ }
739
+ onPointerMove={draggable ? moveDrag : undefined}
740
+ onPointerUp={
741
+ draggable ? (e) => endDrag(e, day, pe.event, onPress) : undefined
742
+ }
743
+ onPointerCancel={draggable ? cancelDrag : undefined}
744
+ onClick={draggable ? undefined : onPress}
745
+ style={{
746
+ position: "absolute",
747
+ top,
748
+ height: boxHeight,
749
+ left: `calc(${pe.column * widthPct}% + 1px)`,
750
+ width: `calc(${widthPct}% - 2px)`,
751
+ cursor: draggable ? "grab" : "pointer",
752
+ touchAction: draggable ? "none" : "auto",
753
+ zIndex: active ? 3 : 1,
754
+ opacity: active ? 0.85 : 1,
755
+ }}
756
+ >
757
+ {Renderer ? (
758
+ <Renderer {...args} />
759
+ ) : (
760
+ <DefaultDomEvent {...args} theme={theme} />
761
+ )}
762
+ {draggable ? (
763
+ <div
764
+ onPointerDown={(e) => {
765
+ e.stopPropagation();
766
+ beginDrag(e, pe.event, key, "resize", pe.startHours, pe.durationHours);
767
+ }}
768
+ style={{
769
+ position: "absolute",
770
+ left: 0,
771
+ right: 0,
772
+ bottom: 0,
773
+ height: 8,
774
+ cursor: "ns-resize",
775
+ touchAction: "none",
776
+ }}
777
+ />
778
+ ) : null}
779
+ </div>
780
+ );
781
+ })}
782
+ {showNow ? (
783
+ <div
784
+ style={{
785
+ position: "absolute",
786
+ top: nowTop,
787
+ left: 0,
788
+ right: 0,
789
+ height: 0,
790
+ zIndex: 2,
791
+ pointerEvents: "none",
792
+ }}
793
+ >
794
+ <div style={{ height: 2, background: theme.nowIndicator }} />
795
+ <div
796
+ style={{
797
+ position: "absolute",
798
+ left: -3,
799
+ top: -3,
800
+ width: 8,
801
+ height: 8,
802
+ borderRadius: "50%",
803
+ background: theme.nowIndicator,
804
+ }}
805
+ />
806
+ </div>
807
+ ) : null}
808
+ {ghost ? (
809
+ <div
810
+ aria-hidden
811
+ style={{
812
+ position: "absolute",
813
+ left: 1,
814
+ right: 1,
815
+ top: ghost.topPx,
816
+ height: Math.max(ghost.heightPx, 2),
817
+ background: theme.rangeBackground,
818
+ border: `1px solid ${theme.selectedBackground}`,
819
+ borderRadius: 6,
820
+ opacity: 0.7,
821
+ pointerEvents: "none",
822
+ zIndex: 2,
823
+ }}
824
+ />
825
+ ) : null}
826
+ </div>
827
+ );
828
+ })}
829
+ </div>
830
+ </div>
831
+ </div>
832
+ );
833
+ }