@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.
- package/LICENSE +21 -0
- package/dist/index.d.mts +463 -0
- package/dist/index.d.ts +463 -0
- package/dist/index.js +1393 -0
- package/dist/index.mjs +1319 -0
- package/package.json +62 -0
- package/src/Agenda.tsx +188 -0
- package/src/Calendar.tsx +230 -0
- package/src/MonthList.tsx +176 -0
- package/src/MonthView.tsx +546 -0
- package/src/TimeGrid.tsx +833 -0
- package/src/index.ts +49 -0
- package/src/theme.ts +38 -0
package/src/TimeGrid.tsx
ADDED
|
@@ -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
|
+
}
|