@marco.colia/react-fast-scheduler 2.1.1 → 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.
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import { jsx, jsxs } from 'react/jsx-runtime';
1
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { format, startOfMonth, startOfWeek, endOfWeek, endOfMonth, addDays, addMonths, isSameDay, isToday, isSameMonth } from 'date-fns';
3
- import * as React5 from 'react';
3
+ import * as React2 from 'react';
4
4
 
5
5
  // src/components/defaultRenderAppointment/index.tsx
6
6
  function defaultRenderAppointment({
7
7
  appointment,
8
+ isDropInvalid,
8
9
  onPointerDown,
9
10
  onResizePointerDown,
10
11
  appointmentAppearance,
@@ -14,224 +15,37 @@ function defaultRenderAppointment({
14
15
  const description = typeof rawMaybe.description === "string" ? rawMaybe.description : void 0;
15
16
  const legacyClassName = typeof appointmentBackgroundColor === "string" ? appointmentBackgroundColor : void 0;
16
17
  const backgroundClassName = appointmentAppearance?.className ?? legacyClassName ?? "bg-muted/40 dark:bg-muted/30";
17
- const className = `relative h-full cursor-grab overflow-hidden rounded-md border border-border p-2 pb-5 text-foreground ${backgroundClassName}`;
18
- return /* @__PURE__ */ jsxs("div", { onPointerDown, className, children: [
19
- /* @__PURE__ */ jsx("div", { className: "text-xs font-semibold", children: appointment.title }),
20
- description ? /* @__PURE__ */ jsx("div", { className: "mt-1 text-[10px] font-medium text-muted-foreground", children: description }) : null,
21
- /* @__PURE__ */ jsx(
22
- "div",
23
- {
24
- role: "button",
25
- "aria-label": "Resize appointment",
26
- onPointerDown: (event) => {
27
- event.stopPropagation();
28
- onResizePointerDown(event);
29
- },
30
- className: "absolute inset-x-1 bottom-1 h-2 cursor-ns-resize rounded-full bg-border/90 transition-colors hover:bg-border"
31
- }
32
- )
33
- ] });
34
- }
35
-
36
- // src/lib/cn.ts
37
- function cn(...inputs) {
38
- return inputs.filter(Boolean).join(" ");
39
- }
40
- var variantClasses = {
41
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
42
- outline: "border border-border bg-card text-foreground hover:bg-muted",
43
- ghost: "text-foreground hover:bg-muted"
44
- };
45
- var sizeClasses = {
46
- default: "h-9 px-4 py-2",
47
- sm: "h-8 px-3 text-xs",
48
- icon: "h-8 w-8"
49
- };
50
- var Button = React5.forwardRef(function Button2({ className, variant = "default", size = "default", type = "button", ...props }, ref) {
51
- return /* @__PURE__ */ jsx(
52
- "button",
53
- {
54
- ref,
55
- type,
56
- className: cn(
57
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
58
- variantClasses[variant],
59
- sizeClasses[size],
60
- className
61
- ),
62
- ...props
63
- }
64
- );
65
- });
66
- function Calendar({
67
- selected,
68
- onSelect
69
- }) {
70
- const [month, setMonth] = React5.useState(selected ?? /* @__PURE__ */ new Date());
71
- React5.useEffect(() => {
72
- if (selected) {
73
- setMonth(selected);
74
- }
75
- }, [selected]);
76
- const monthStart = startOfMonth(month);
77
- const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
78
- const calendarEnd = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
79
- const days = [];
80
- for (let day = calendarStart; day <= calendarEnd; day = addDays(day, 1)) {
81
- days.push(day);
82
- }
83
- return /* @__PURE__ */ jsxs("div", { className: "w-72 rounded-md bg-popover", children: [
84
- /* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center justify-between", children: [
85
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Previous month", onClick: () => setMonth(addMonths(month, -1)), children: "<" }),
86
- /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: format(month, "MMMM yyyy") }),
87
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Next month", onClick: () => setMonth(addMonths(month, 1)), children: ">" })
88
- ] }),
89
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-1 text-center text-xs text-muted-foreground", children: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map((label) => /* @__PURE__ */ jsx("div", { className: "py-1", children: label }, label)) }),
90
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-1", children: days.map((day) => {
91
- const isSelected = selected ? isSameDay(day, selected) : false;
92
- return /* @__PURE__ */ jsx(
93
- Button,
94
- {
95
- variant: isSelected ? "default" : "ghost",
96
- size: "icon",
97
- className: cn(
98
- "text-xs",
99
- !isSameMonth(day, month) && "text-muted-foreground opacity-60",
100
- isToday(day) && !isSelected && "border border-border"
101
- ),
102
- onClick: () => onSelect?.(day),
103
- children: format(day, "d")
104
- },
105
- day.toISOString()
106
- );
107
- }) })
108
- ] });
109
- }
110
- var PopoverContext = React5.createContext(null);
111
- function usePopoverContext() {
112
- const context = React5.useContext(PopoverContext);
113
- if (!context) {
114
- throw new Error("Popover components must be used within Popover.");
115
- }
116
- return context;
117
- }
118
- function Popover({
119
- children,
120
- open: controlledOpen,
121
- onOpenChange
122
- }) {
123
- const [uncontrolledOpen, setUncontrolledOpen] = React5.useState(false);
124
- const triggerRef = React5.useRef(null);
125
- const contentRef = React5.useRef(null);
126
- const open = controlledOpen ?? uncontrolledOpen;
127
- const setOpen = React5.useCallback(
128
- (value) => {
129
- const nextValue = typeof value === "function" ? value(open) : value;
130
- if (controlledOpen === void 0) {
131
- setUncontrolledOpen(nextValue);
132
- }
133
- onOpenChange?.(nextValue);
134
- },
135
- [controlledOpen, onOpenChange, open]
136
- );
137
- React5.useEffect(() => {
138
- if (!open) {
139
- return void 0;
140
- }
141
- const handlePointerDown = (event) => {
142
- const target = event.target;
143
- if (triggerRef.current?.contains(target) || contentRef.current?.contains(target)) {
144
- return;
145
- }
146
- setOpen(false);
147
- };
148
- const handleKeyDown = (event) => {
149
- if (event.key === "Escape") {
150
- setOpen(false);
151
- }
152
- };
153
- document.addEventListener("mousedown", handlePointerDown);
154
- document.addEventListener("keydown", handleKeyDown);
155
- return () => {
156
- document.removeEventListener("mousedown", handlePointerDown);
157
- document.removeEventListener("keydown", handleKeyDown);
158
- };
159
- }, [open, setOpen]);
160
- return /* @__PURE__ */ jsx(PopoverContext.Provider, { value: { open, setOpen, triggerRef, contentRef }, children: /* @__PURE__ */ jsx("div", { className: "relative inline-flex", children }) });
161
- }
162
- function PopoverTrigger({
163
- children,
164
- asChild = false
165
- }) {
166
- const { open, setOpen, triggerRef } = usePopoverContext();
167
- if (asChild && React5.isValidElement(children)) {
168
- const child = children;
169
- return React5.cloneElement(child, {
170
- ref: triggerRef,
171
- "aria-expanded": open,
172
- onClick: (event) => {
173
- child.props.onClick?.(event);
174
- setOpen((current) => !current);
175
- }
176
- });
177
- }
178
- return /* @__PURE__ */ jsx(
179
- "button",
180
- {
181
- ref: triggerRef,
182
- type: "button",
183
- "aria-expanded": open,
184
- onClick: () => setOpen((current) => !current),
185
- children
186
- }
187
- );
188
- }
189
- function PopoverContent({
190
- children,
191
- className,
192
- align = "start"
193
- }) {
194
- const { open, contentRef } = usePopoverContext();
195
- if (!open) {
196
- return null;
197
- }
198
- return /* @__PURE__ */ jsx(
18
+ const invalidStateClassName = isDropInvalid ? "border-red-500 bg-red-100/80 text-red-950 ring-1 ring-red-500/60 dark:bg-red-950/40 dark:text-red-100" : "";
19
+ const visualStateClassName = appointment.visualState === "ghost" ? "cursor-grab border-dashed opacity-35" : appointment.visualState === "dragging" ? "cursor-grabbing opacity-55 shadow-xl ring-1 ring-border/70" : "cursor-grab";
20
+ const className = `relative h-full overflow-hidden rounded-md border border-border p-2 pb-5 text-foreground ${backgroundClassName} ${invalidStateClassName} ${visualStateClassName}`;
21
+ return /* @__PURE__ */ jsxs(
199
22
  "div",
200
23
  {
201
- ref: contentRef,
202
- className: cn(
203
- "absolute top-full z-50 mt-2 min-w-72 rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md outline-none",
204
- align === "start" && "left-0",
205
- align === "center" && "left-1/2 -translate-x-1/2",
206
- align === "end" && "right-0",
207
- className
208
- ),
209
- children
24
+ onPointerDown,
25
+ className,
26
+ children: [
27
+ isDropInvalid ? /* @__PURE__ */ jsx("div", { className: "absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white", children: "X" }) : null,
28
+ /* @__PURE__ */ jsx("div", { className: "text-xs font-semibold", children: appointment.title }),
29
+ description ? /* @__PURE__ */ jsx("div", { className: "mt-1 text-[10px] font-medium text-muted-foreground", children: description }) : null,
30
+ /* @__PURE__ */ jsx(
31
+ "div",
32
+ {
33
+ role: "button",
34
+ "aria-label": "Resize appointment",
35
+ onPointerDown: (event) => {
36
+ if (appointment.visualState === "ghost") {
37
+ return;
38
+ }
39
+ event.stopPropagation();
40
+ onResizePointerDown(event);
41
+ },
42
+ className: "absolute inset-x-1 bottom-1 h-2 cursor-ns-resize rounded-full bg-border/90 transition-colors hover:bg-border"
43
+ }
44
+ )
45
+ ]
210
46
  }
211
47
  );
212
48
  }
213
- function defaultRenderDatePicker({ selectedDate, onSelectedDateChange }) {
214
- return /* @__PURE__ */ jsxs(Popover, { children: [
215
- /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", className: "min-w-40 justify-start text-left font-normal", children: format(selectedDate, "PPP") }) }),
216
- /* @__PURE__ */ jsx(PopoverContent, { className: "w-auto p-2", align: "center", children: /* @__PURE__ */ jsx(Calendar, { selected: selectedDate, onSelect: onSelectedDateChange }) })
217
- ] });
218
- }
219
- function defaultRenderResourceHeader({ resource }) {
220
- return /* @__PURE__ */ jsx("strong", { children: resource.label });
221
- }
222
- function defaultRenderToolbar({
223
- goToPreviousDay,
224
- goToNextDay,
225
- defaultDatePicker,
226
- prevButtonLabel = "Prev",
227
- nextButtonLabel = "Next"
228
- }) {
229
- return /* @__PURE__ */ jsx("div", { className: "rfs-toolbar", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-border bg-card p-1 shadow-xs", children: [
230
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: goToPreviousDay, children: prevButtonLabel }),
231
- defaultDatePicker,
232
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: goToNextDay, children: nextButtonLabel })
233
- ] }) });
234
- }
235
49
 
236
50
  // src/components/constants.ts
237
51
  var STEP_MIN = 15;
@@ -390,17 +204,22 @@ function normalizeAppointments({
390
204
  end,
391
205
  startMin: clamp(minutesFromDayStart(dayStartAbs, start), 0, dayMinutes),
392
206
  endMin: clamp(minutesFromDayStart(dayStartAbs, end), 0, dayMinutes),
393
- title: adapter.getTitle(appointment)
207
+ title: adapter.getTitle(appointment),
208
+ renderKey: String(adapter.getId(appointment)),
209
+ visualState: "normal"
394
210
  };
395
211
  }).filter((appointment) => dayISO(appointment.start) === selectedISO);
396
212
  }
397
213
  function buildAppointmentMap(appointments) {
398
214
  return new Map(appointments.map((appointment) => [appointment.id, appointment]));
399
215
  }
400
- function applyDragToAppointments(appointments, drag, selectedDate, dayStartAbs) {
216
+ function buildPresentationAppointments(appointments, drag, selectedDate, dayStartAbs) {
401
217
  if (drag.kind === "none") {
402
218
  return appointments;
403
219
  }
220
+ if (drag.kind === "move") {
221
+ return appointments;
222
+ }
404
223
  return appointments.map((appointment) => {
405
224
  if (appointment.id !== drag.appointmentId) {
406
225
  return appointment;
@@ -411,7 +230,8 @@ function applyDragToAppointments(appointments, drag, selectedDate, dayStartAbs)
411
230
  startMin: drag.startMin,
412
231
  endMin: drag.endMin,
413
232
  start: dateAtMinute(selectedDate, dayStartAbs, drag.startMin),
414
- end: dateAtMinute(selectedDate, dayStartAbs, drag.endMin)
233
+ end: dateAtMinute(selectedDate, dayStartAbs, drag.endMin),
234
+ visualState: "dragging"
415
235
  };
416
236
  });
417
237
  }
@@ -424,10 +244,22 @@ function buildLaidOutByResource(resources, appointments) {
424
244
  }
425
245
  const result = /* @__PURE__ */ new Map();
426
246
  for (const resource of resources) {
427
- result.set(resource.id, layoutOverlaps(appointmentsByResource.get(resource.id) ?? []));
247
+ result.set(resource.id, buildLaidOutAppointments(appointmentsByResource.get(resource.id) ?? []));
428
248
  }
429
249
  return result;
430
250
  }
251
+ function buildLaidOutAppointments(appointments) {
252
+ return layoutOverlaps(
253
+ appointments.map((appointment) => ({
254
+ ...appointment,
255
+ appointmentId: appointment.id,
256
+ id: appointment.renderKey
257
+ }))
258
+ ).map(({ appointmentId, ...appointment }) => ({
259
+ ...appointment,
260
+ id: appointmentId
261
+ }));
262
+ }
431
263
  function buildAppointmentAppearanceByResource(resources, resourceAppointmentClassMap, getResourceAppointmentAppearance, appointmentColorTokenClassByResource, appointmentBgByResource) {
432
264
  const map = /* @__PURE__ */ new Map();
433
265
  for (const resource of resources) {
@@ -451,8 +283,6 @@ function buildAppointmentAppearanceByResource(resources, resourceAppointmentClas
451
283
  }
452
284
  return map;
453
285
  }
454
-
455
- // src/hooks/useSchedulerBaseData.ts
456
286
  function useSchedulerBaseData({
457
287
  adapter,
458
288
  appointments,
@@ -461,13 +291,22 @@ function useSchedulerBaseData({
461
291
  resources,
462
292
  selectedDate
463
293
  }) {
464
- const dayStartAbs = React5.useMemo(() => parseHHMM(dayStart), [dayStart]);
465
- const dayEndAbs = React5.useMemo(() => parseHHMM(dayEnd), [dayEnd]);
466
- const dayMinutes = React5.useMemo(() => Math.max(0, dayEndAbs - dayStartAbs), [dayEndAbs, dayStartAbs]);
467
- const gridHeight = React5.useMemo(() => TOP_PAD + dayMinutes * PX_PER_MIN + BOTTOM_PAD, [dayMinutes]);
468
- const gridMinWidth = React5.useMemo(() => GUTTER_W + Math.max(resources.length, 1) * RESOURCE_MIN_W, [resources.length]);
469
- const selectedISO = React5.useMemo(() => dayISO(selectedDate), [selectedDate]);
470
- const renderAppts = React5.useMemo(() => {
294
+ const dayStartAbs = React2.useMemo(() => parseHHMM(dayStart), [dayStart]);
295
+ const dayEndAbs = React2.useMemo(() => parseHHMM(dayEnd), [dayEnd]);
296
+ const dayMinutes = React2.useMemo(
297
+ () => Math.max(0, dayEndAbs - dayStartAbs),
298
+ [dayEndAbs, dayStartAbs]
299
+ );
300
+ const gridHeight = React2.useMemo(
301
+ () => TOP_PAD + dayMinutes * PX_PER_MIN + BOTTOM_PAD,
302
+ [dayMinutes]
303
+ );
304
+ const gridMinWidth = React2.useMemo(
305
+ () => GUTTER_W + Math.max(resources.length, 1) * RESOURCE_MIN_W,
306
+ [resources.length]
307
+ );
308
+ const selectedISO = React2.useMemo(() => dayISO(selectedDate), [selectedDate]);
309
+ const renderAppts = React2.useMemo(() => {
471
310
  return normalizeAppointments({
472
311
  adapter,
473
312
  appointments,
@@ -476,7 +315,7 @@ function useSchedulerBaseData({
476
315
  selectedISO
477
316
  });
478
317
  }, [adapter, appointments, dayMinutes, dayStartAbs, selectedISO]);
479
- const appointmentMap = React5.useMemo(() => buildAppointmentMap(renderAppts), [renderAppts]);
318
+ const appointmentMap = React2.useMemo(() => buildAppointmentMap(renderAppts), [renderAppts]);
480
319
  return {
481
320
  appointmentMap,
482
321
  dayEndAbs,
@@ -487,7 +326,157 @@ function useSchedulerBaseData({
487
326
  renderAppts
488
327
  };
489
328
  }
490
- function useSchedulerInteractions({
329
+
330
+ // src/hooks/useSchedulerInteractions.helpers.ts
331
+ var DRAG_START_THRESHOLD_PX = 4;
332
+ var updateMovePreviewPosition = (setMovePreview, pointer) => {
333
+ setMovePreview(
334
+ (current) => current ? {
335
+ ...current,
336
+ clientX: pointer.clientX,
337
+ clientY: pointer.clientY
338
+ } : current
339
+ );
340
+ };
341
+ var startPendingDragIfThresholdPassed = (args) => {
342
+ const { dragRef, pendingDragRef, pointer, setDrag, setMovePreview, suppressClickRef } = args;
343
+ const pending = pendingDragRef.current;
344
+ if (!pending || pending.drag.pointerId !== pointer.pointerId) {
345
+ return;
346
+ }
347
+ const deltaX = pointer.clientX - pending.startClientX;
348
+ const deltaY = pointer.clientY - pending.startClientY;
349
+ if (Math.hypot(deltaX, deltaY) < DRAG_START_THRESHOLD_PX) {
350
+ return;
351
+ }
352
+ suppressClickRef.current = true;
353
+ dragRef.current = pending.drag;
354
+ setDrag(pending.drag);
355
+ if (pending.drag.kind === "move") {
356
+ setMovePreview({
357
+ appointment: pending.appointment,
358
+ clientX: pointer.clientX,
359
+ clientY: pointer.clientY,
360
+ width: pending.width,
361
+ height: pending.height,
362
+ offsetX: pending.offsetX,
363
+ offsetY: pending.offsetY
364
+ });
365
+ }
366
+ pendingDragRef.current = null;
367
+ };
368
+ var buildNextMoveDrag = (drag, pointer, dayMinutes, pointerToResourceId, pointerToMin) => {
369
+ const nextResourceId = pointerToResourceId(pointer.clientX, drag.resourceId);
370
+ const pointerMin = pointerToMin(nextResourceId, pointer.clientY);
371
+ const rawStart = pointerMin - drag.offsetMin;
372
+ const nextStartMin = clamp(snap(rawStart), 0, dayMinutes - drag.durationMin);
373
+ const nextEndMin = nextStartMin + drag.durationMin;
374
+ if (nextStartMin === drag.startMin && nextEndMin === drag.endMin && nextResourceId === drag.resourceId) {
375
+ return null;
376
+ }
377
+ return {
378
+ ...drag,
379
+ resourceId: nextResourceId,
380
+ startMin: nextStartMin,
381
+ endMin: nextEndMin
382
+ };
383
+ };
384
+ var buildNextResizeDrag = (drag, pointer, dayMinutes, pointerToMin) => {
385
+ const pointerMin = pointerToMin(drag.resourceId, pointer.clientY);
386
+ const nextEndMin = clamp(snap(pointerMin), drag.startMin + MIN_EVENT_MIN, dayMinutes);
387
+ if (nextEndMin === drag.endMin) {
388
+ return null;
389
+ }
390
+ return {
391
+ ...drag,
392
+ endMin: nextEndMin
393
+ };
394
+ };
395
+ var processPointerFrame = (args) => {
396
+ const {
397
+ dayMinutes,
398
+ dragRef,
399
+ latestPointerRef,
400
+ pendingDragRef,
401
+ pointerToMin,
402
+ pointerToResourceId,
403
+ setDrag,
404
+ setMovePreview,
405
+ suppressClickRef
406
+ } = args;
407
+ const pointer = latestPointerRef.current;
408
+ if (!pointer) {
409
+ return;
410
+ }
411
+ const currentDrag = dragRef.current;
412
+ if (currentDrag.kind === "none" && pendingDragRef.current) {
413
+ startPendingDragIfThresholdPassed({
414
+ pointer,
415
+ pendingDragRef,
416
+ setDrag,
417
+ setMovePreview,
418
+ dragRef,
419
+ suppressClickRef
420
+ });
421
+ return;
422
+ }
423
+ if (currentDrag.kind === "none") {
424
+ return;
425
+ }
426
+ if (currentDrag.kind === "move") {
427
+ updateMovePreviewPosition(setMovePreview, pointer);
428
+ const nextDrag2 = buildNextMoveDrag(
429
+ currentDrag,
430
+ pointer,
431
+ dayMinutes,
432
+ pointerToResourceId,
433
+ pointerToMin
434
+ );
435
+ if (nextDrag2) {
436
+ suppressClickRef.current = true;
437
+ dragRef.current = nextDrag2;
438
+ setDrag(nextDrag2);
439
+ }
440
+ return;
441
+ }
442
+ const nextDrag = buildNextResizeDrag(currentDrag, pointer, dayMinutes, pointerToMin);
443
+ if (nextDrag) {
444
+ suppressClickRef.current = true;
445
+ dragRef.current = nextDrag;
446
+ setDrag(nextDrag);
447
+ }
448
+ };
449
+ var finishDrag = (args) => {
450
+ const {
451
+ dragRef,
452
+ frameRef,
453
+ latestPointerRef,
454
+ pendingDragRef,
455
+ persistDrag,
456
+ setDrag,
457
+ setMovePreview,
458
+ suppressClickRef
459
+ } = args;
460
+ if (frameRef.current !== null) {
461
+ window.cancelAnimationFrame(frameRef.current);
462
+ frameRef.current = null;
463
+ }
464
+ latestPointerRef.current = null;
465
+ pendingDragRef.current = null;
466
+ setMovePreview(null);
467
+ const current = dragRef.current;
468
+ if (current.kind === "none") {
469
+ return;
470
+ }
471
+ setDrag({ kind: "none" });
472
+ dragRef.current = { kind: "none" };
473
+ if (suppressClickRef.current) {
474
+ void persistDrag(current);
475
+ }
476
+ };
477
+
478
+ // src/hooks/useSchedulerInteractions.ts
479
+ var useSchedulerInteractions = ({
491
480
  appointmentMap,
492
481
  dayMinutes,
493
482
  dayStartAbs,
@@ -496,11 +485,28 @@ function useSchedulerInteractions({
496
485
  renderAppts,
497
486
  resources,
498
487
  selectedDate
499
- }) {
500
- const [drag, setDrag] = React5.useState({ kind: "none" });
501
- const colRefs = React5.useRef({});
502
- const suppressClickRef = React5.useRef(false);
503
- const pointerToMin = React5.useCallback(
488
+ }) => {
489
+ const [drag, setDrag] = React2.useState({ kind: "none" });
490
+ const [movePreview, setMovePreview] = React2.useState(
491
+ null
492
+ );
493
+ const colRefs = React2.useRef({});
494
+ const suppressClickRef = React2.useRef(false);
495
+ const dragRef = React2.useRef({ kind: "none" });
496
+ const pendingDragRef = React2.useRef(null);
497
+ const latestPointerRef = React2.useRef(null);
498
+ const frameRef = React2.useRef(null);
499
+ React2.useEffect(() => {
500
+ dragRef.current = drag;
501
+ }, [drag]);
502
+ React2.useEffect(() => {
503
+ return () => {
504
+ if (frameRef.current !== null) {
505
+ window.cancelAnimationFrame(frameRef.current);
506
+ }
507
+ };
508
+ }, []);
509
+ const pointerToMin = React2.useCallback(
504
510
  (resourceId, clientY) => {
505
511
  const col = colRefs.current[String(resourceId)];
506
512
  if (!col) {
@@ -511,7 +517,7 @@ function useSchedulerInteractions({
511
517
  },
512
518
  [dayMinutes]
513
519
  );
514
- const pointerToResourceId = React5.useCallback(
520
+ const pointerToResourceId = React2.useCallback(
515
521
  (clientX, fallbackResourceId) => {
516
522
  for (const resource of resources) {
517
523
  const col = colRefs.current[String(resource.id)];
@@ -527,14 +533,24 @@ function useSchedulerInteractions({
527
533
  },
528
534
  [resources]
529
535
  );
530
- const hasOverlap = React5.useCallback(
536
+ const hasOverlap = React2.useCallback(
531
537
  (appointmentId, resourceId, startMin, endMin) => {
532
- const events = renderAppts.filter((appointment) => appointment.resourceId === resourceId && appointment.id !== appointmentId);
533
- return events.some((appointment) => overlaps(appointment, { startMin, endMin }));
538
+ const events = renderAppts.filter(
539
+ (appointment) => appointment.resourceId === resourceId && appointment.id !== appointmentId
540
+ );
541
+ return events.some(
542
+ (appointment) => overlaps(appointment, { startMin, endMin })
543
+ );
534
544
  },
535
545
  [renderAppts]
536
546
  );
537
- const persistDrag = React5.useCallback(
547
+ const isDropInvalid = React2.useMemo(() => {
548
+ if (drag.kind === "none") {
549
+ return false;
550
+ }
551
+ return hasOverlap(drag.appointmentId, drag.resourceId, drag.startMin, drag.endMin);
552
+ }, [drag, hasOverlap]);
553
+ const persistDrag = React2.useCallback(
538
554
  async (state) => {
539
555
  const target = appointmentMap.get(state.appointmentId);
540
556
  if (!target) {
@@ -570,98 +586,124 @@ function useSchedulerInteractions({
570
586
  });
571
587
  }
572
588
  },
573
- [appointmentMap, dayStartAbs, hasOverlap, onAppointmentChange, onPersistMoveResize, selectedDate]
589
+ [
590
+ appointmentMap,
591
+ dayStartAbs,
592
+ hasOverlap,
593
+ onAppointmentChange,
594
+ onPersistMoveResize,
595
+ selectedDate
596
+ ]
574
597
  );
575
- const onApptPointerDown = React5.useCallback(
598
+ const onApptPointerDown = React2.useCallback(
576
599
  (event, appointment) => {
577
600
  if (event.button !== 0) {
578
601
  return;
579
602
  }
580
603
  event.stopPropagation();
581
604
  suppressClickRef.current = false;
605
+ const rect = event.currentTarget.getBoundingClientRect();
582
606
  const pointerMin = pointerToMin(appointment.resourceId, event.clientY);
583
- setDrag({
584
- kind: "move",
585
- appointmentId: appointment.id,
586
- pointerId: event.pointerId,
587
- resourceId: appointment.resourceId,
588
- durationMin: Math.max(MIN_EVENT_MIN, appointment.endMin - appointment.startMin),
589
- offsetMin: pointerMin - appointment.startMin,
590
- startMin: appointment.startMin,
591
- endMin: appointment.endMin
592
- });
607
+ pendingDragRef.current = {
608
+ drag: {
609
+ kind: "move",
610
+ appointmentId: appointment.id,
611
+ pointerId: event.pointerId,
612
+ resourceId: appointment.resourceId,
613
+ durationMin: Math.max(MIN_EVENT_MIN, appointment.endMin - appointment.startMin),
614
+ offsetMin: pointerMin - appointment.startMin,
615
+ startMin: appointment.startMin,
616
+ endMin: appointment.endMin
617
+ },
618
+ appointment,
619
+ startClientX: event.clientX,
620
+ startClientY: event.clientY,
621
+ width: rect.width,
622
+ height: rect.height,
623
+ offsetX: event.clientX - rect.left,
624
+ offsetY: event.clientY - rect.top
625
+ };
593
626
  },
594
627
  [pointerToMin]
595
628
  );
596
- const onResizePointerDown = React5.useCallback((event, appointment) => {
597
- if (event.button !== 0) {
598
- return;
599
- }
600
- event.stopPropagation();
601
- suppressClickRef.current = false;
602
- setDrag({
603
- kind: "resize",
604
- appointmentId: appointment.id,
605
- pointerId: event.pointerId,
606
- resourceId: appointment.resourceId,
607
- startMin: appointment.startMin,
608
- endMin: appointment.endMin
609
- });
610
- }, []);
611
- const onGlobalPointerMove = React5.useCallback(
612
- (event) => {
613
- if (drag.kind === "none") {
629
+ const onResizePointerDown = React2.useCallback(
630
+ (event, appointment) => {
631
+ if (event.button !== 0) {
614
632
  return;
615
633
  }
616
- if (drag.kind === "move") {
617
- const nextResourceId = pointerToResourceId(event.clientX, drag.resourceId);
618
- const pointerMin2 = pointerToMin(nextResourceId, event.clientY);
619
- const rawStart = pointerMin2 - drag.offsetMin;
620
- const nextStartMin = clamp(snap(rawStart), 0, dayMinutes - drag.durationMin);
621
- const nextEndMin2 = nextStartMin + drag.durationMin;
622
- if (nextStartMin !== drag.startMin || nextEndMin2 !== drag.endMin || nextResourceId !== drag.resourceId) {
623
- suppressClickRef.current = true;
624
- setDrag({
625
- ...drag,
626
- resourceId: nextResourceId,
627
- startMin: nextStartMin,
628
- endMin: nextEndMin2
629
- });
630
- }
634
+ event.stopPropagation();
635
+ suppressClickRef.current = false;
636
+ pendingDragRef.current = {
637
+ drag: {
638
+ kind: "resize",
639
+ appointmentId: appointment.id,
640
+ pointerId: event.pointerId,
641
+ resourceId: appointment.resourceId,
642
+ startMin: appointment.startMin,
643
+ endMin: appointment.endMin
644
+ },
645
+ appointment,
646
+ startClientX: event.clientX,
647
+ startClientY: event.clientY,
648
+ width: 0,
649
+ height: 0,
650
+ offsetX: 0,
651
+ offsetY: 0
652
+ };
653
+ },
654
+ []
655
+ );
656
+ const onGlobalPointerMove = React2.useCallback(
657
+ (event) => {
658
+ latestPointerRef.current = {
659
+ clientX: event.clientX,
660
+ clientY: event.clientY,
661
+ pointerId: event.pointerId
662
+ };
663
+ if (frameRef.current !== null) {
631
664
  return;
632
665
  }
633
- const pointerMin = pointerToMin(drag.resourceId, event.clientY);
634
- const nextEndMin = clamp(snap(pointerMin), drag.startMin + MIN_EVENT_MIN, dayMinutes);
635
- if (nextEndMin !== drag.endMin) {
636
- suppressClickRef.current = true;
637
- setDrag({
638
- ...drag,
639
- endMin: nextEndMin
666
+ frameRef.current = window.requestAnimationFrame(() => {
667
+ frameRef.current = null;
668
+ processPointerFrame({
669
+ dayMinutes,
670
+ dragRef,
671
+ latestPointerRef,
672
+ pendingDragRef,
673
+ pointerToMin,
674
+ pointerToResourceId,
675
+ setDrag,
676
+ setMovePreview,
677
+ suppressClickRef
640
678
  });
641
- }
679
+ });
642
680
  },
643
- [dayMinutes, drag, pointerToMin, pointerToResourceId]
681
+ [dayMinutes, pointerToMin, pointerToResourceId]
644
682
  );
645
- const onGlobalPointerUp = React5.useCallback(() => {
646
- if (drag.kind === "none") {
647
- return;
648
- }
649
- const current = drag;
650
- setDrag({ kind: "none" });
651
- if (suppressClickRef.current) {
652
- void persistDrag(current);
653
- }
654
- }, [drag, persistDrag]);
683
+ const onGlobalPointerUp = React2.useCallback(() => {
684
+ finishDrag({
685
+ dragRef,
686
+ frameRef,
687
+ latestPointerRef,
688
+ pendingDragRef,
689
+ persistDrag,
690
+ setDrag,
691
+ setMovePreview,
692
+ suppressClickRef
693
+ });
694
+ }, [persistDrag]);
655
695
  return {
656
696
  colRefs,
657
697
  drag,
698
+ isDropInvalid,
699
+ movePreview,
658
700
  onApptPointerDown,
659
701
  onGlobalPointerMove,
660
702
  onGlobalPointerUp,
661
703
  onResizePointerDown,
662
704
  suppressClickRef
663
705
  };
664
- }
706
+ };
665
707
  function useSchedulerPresentationData({
666
708
  appointmentColorTokenClassMap,
667
709
  dayStartAbs,
@@ -674,13 +716,24 @@ function useSchedulerPresentationData({
674
716
  resources,
675
717
  selectedDate
676
718
  }) {
677
- const effectiveAppts = React5.useMemo(() => {
678
- return applyDragToAppointments(renderAppts, drag, selectedDate, dayStartAbs);
719
+ const effectiveAppts = React2.useMemo(() => {
720
+ return buildPresentationAppointments(renderAppts, drag, selectedDate, dayStartAbs);
679
721
  }, [dayStartAbs, drag, renderAppts, selectedDate]);
680
- const laidOutByResource = React5.useMemo(() => {
681
- return buildLaidOutByResource(resources, effectiveAppts);
682
- }, [effectiveAppts, resources]);
683
- const appointmentBgByResource = React5.useMemo(() => {
722
+ const baseLaidOutByResource = React2.useMemo(() => {
723
+ return buildLaidOutByResource(resources, renderAppts);
724
+ }, [renderAppts, resources]);
725
+ const laidOutByResource = React2.useMemo(() => {
726
+ if (drag.kind !== "resize") {
727
+ return baseLaidOutByResource;
728
+ }
729
+ const next = new Map(baseLaidOutByResource);
730
+ const nextAppointmentsForResource = effectiveAppts.filter(
731
+ (appointment) => appointment.resourceId === drag.resourceId
732
+ );
733
+ next.set(drag.resourceId, buildLaidOutAppointments(nextAppointmentsForResource));
734
+ return next;
735
+ }, [baseLaidOutByResource, drag, effectiveAppts]);
736
+ const appointmentBgByResource = React2.useMemo(() => {
684
737
  if (!getResourceAppointmentBackground) {
685
738
  return /* @__PURE__ */ new Map();
686
739
  }
@@ -691,7 +744,7 @@ function useSchedulerPresentationData({
691
744
  toKey: (resource) => resource.id
692
745
  });
693
746
  }, [getResourceAppointmentBackground, resources]);
694
- const appointmentColorTokenClassByResource = React5.useMemo(() => {
747
+ const appointmentColorTokenClassByResource = React2.useMemo(() => {
695
748
  if (!getResourceAppointmentColorToken) {
696
749
  return /* @__PURE__ */ new Map();
697
750
  }
@@ -709,7 +762,7 @@ function useSchedulerPresentationData({
709
762
  toKey: (resource) => resource.id
710
763
  });
711
764
  }, [appointmentColorTokenClassMap, getResourceAppointmentColorToken, resources]);
712
- const appointmentAppearanceByResource = React5.useMemo(() => {
765
+ const appointmentAppearanceByResource = React2.useMemo(() => {
713
766
  return buildAppointmentAppearanceByResource(
714
767
  resources,
715
768
  resourceAppointmentClassMap,
@@ -731,6 +784,453 @@ function useSchedulerPresentationData({
731
784
  laidOutByResource
732
785
  };
733
786
  }
787
+ function Gutter({ dayMinutes, dayStartAbs, gridHeight }) {
788
+ return /* @__PURE__ */ jsx(
789
+ "div",
790
+ {
791
+ className: "rfs-times border-border bg-card",
792
+ style: { width: GUTTER_W, height: gridHeight },
793
+ children: Array.from({ length: Math.floor(dayMinutes / 60) + 1 }).map((_, i) => /* @__PURE__ */ jsxs(
794
+ "div",
795
+ {
796
+ className: "rfs-time-tick text-muted-foreground",
797
+ style: { top: TOP_PAD + i * 60 * PX_PER_MIN },
798
+ children: [
799
+ String(Math.floor((dayStartAbs + i * 60) / 60)).padStart(2, "0"),
800
+ ":",
801
+ String((dayStartAbs + i * 60) % 60).padStart(2, "0")
802
+ ]
803
+ },
804
+ `tick-${i}`
805
+ ))
806
+ }
807
+ );
808
+ }
809
+ function defaultRenderResourceHeader({ resource }) {
810
+ return /* @__PURE__ */ jsx("strong", { children: resource.label });
811
+ }
812
+ function Header({ resources, renderResourceHeader }) {
813
+ return /* @__PURE__ */ jsxs("div", { className: "rfs-header border-border bg-card", children: [
814
+ /* @__PURE__ */ jsx(
815
+ "div",
816
+ {
817
+ className: "rfs-gutter border-border",
818
+ style: { width: GUTTER_W }
819
+ }
820
+ ),
821
+ resources.map((resource) => /* @__PURE__ */ jsxs(
822
+ "div",
823
+ {
824
+ className: "rfs-resource-header border-border bg-card text-foreground",
825
+ style: { minWidth: RESOURCE_MIN_W, flex: 1 },
826
+ children: [
827
+ (renderResourceHeader ?? ((r) => defaultRenderResourceHeader({ resource: r })))(
828
+ resource
829
+ ),
830
+ isNameLike(resource) ? /* @__PURE__ */ jsx("div", { className: "rfs-resource-subtitle text-muted-foreground", children: fullName(resource) }) : null
831
+ ]
832
+ },
833
+ String(resource.id)
834
+ ))
835
+ ] });
836
+ }
837
+ var ResourceColInner = ({
838
+ resource,
839
+ colRefs,
840
+ gridHeight,
841
+ dayMinutes,
842
+ appointments,
843
+ appointmentAppearance,
844
+ appointmentBg,
845
+ isDropInvalid,
846
+ renderAppointment,
847
+ onApptPointerDown,
848
+ onResizePointerDown,
849
+ drag,
850
+ suppressClickRef
851
+ }) => {
852
+ return /* @__PURE__ */ jsx(
853
+ "div",
854
+ {
855
+ className: "rfs-col border-border bg-card",
856
+ style: { minWidth: RESOURCE_MIN_W, flex: 1 },
857
+ children: /* @__PURE__ */ jsxs(
858
+ "div",
859
+ {
860
+ ref: (el) => {
861
+ colRefs.current[String(resource.id)] = el;
862
+ },
863
+ className: "rfs-col-inner bg-card",
864
+ style: { height: gridHeight },
865
+ children: [
866
+ Array.from({ length: Math.floor(dayMinutes / 60) + 1 }).map((_, i) => /* @__PURE__ */ jsx(
867
+ "div",
868
+ {
869
+ className: "rfs-hour-line border-border/70",
870
+ style: { top: TOP_PAD + i * 60 * PX_PER_MIN }
871
+ },
872
+ `major-${i}`
873
+ )),
874
+ Array.from({ length: Math.floor(dayMinutes / STEP_MIN) + 1 }).map((_, i) => /* @__PURE__ */ jsx(
875
+ "div",
876
+ {
877
+ className: "rfs-slot-line border-border/50",
878
+ style: { top: TOP_PAD + i * STEP_MIN * PX_PER_MIN }
879
+ },
880
+ `minor-${i}`
881
+ )),
882
+ appointments.map((appointment) => {
883
+ const laneWidthPct = 100 / appointment.lanes;
884
+ const top = TOP_PAD + appointment.startMin * PX_PER_MIN;
885
+ const height = Math.max(
886
+ MIN_EVENT_MIN * PX_PER_MIN,
887
+ (appointment.endMin - appointment.startMin) * PX_PER_MIN
888
+ );
889
+ return /* @__PURE__ */ jsx(
890
+ "div",
891
+ {
892
+ className: `rfs-card-slot ${appointment.visualState === "ghost" ? "rfs-card-slot--ghost" : ""} ${appointment.visualState === "dragging" ? "rfs-card-slot--dragging" : ""}`,
893
+ style: {
894
+ top,
895
+ left: `${laneWidthPct * appointment.lane}%`,
896
+ width: `${laneWidthPct}%`,
897
+ height
898
+ },
899
+ children: (renderAppointment ?? defaultRenderAppointment)({
900
+ appointment,
901
+ isDropInvalid: isDropInvalid && drag.kind === "resize" && appointment.id === drag.appointmentId,
902
+ onPointerDown: (event) => onApptPointerDown(event, appointment),
903
+ onResizePointerDown: (event) => onResizePointerDown(event, appointment),
904
+ appointmentAppearance,
905
+ appointmentBackgroundColor: appointmentBg,
906
+ drag,
907
+ suppressClickRef
908
+ })
909
+ },
910
+ appointment.renderKey
911
+ );
912
+ })
913
+ ]
914
+ }
915
+ )
916
+ }
917
+ );
918
+ };
919
+
920
+ // src/components/scheduler/parts/ResourceCol.tsx
921
+ var areResourceColsEqual = (prev, next) => [
922
+ prev.resource === next.resource,
923
+ prev.colRefs === next.colRefs,
924
+ prev.gridHeight === next.gridHeight,
925
+ prev.dayMinutes === next.dayMinutes,
926
+ prev.appointments === next.appointments,
927
+ prev.appointmentAppearance === next.appointmentAppearance,
928
+ prev.appointmentBg === next.appointmentBg,
929
+ prev.isDropInvalid === next.isDropInvalid,
930
+ prev.renderAppointment === next.renderAppointment,
931
+ prev.onApptPointerDown === next.onApptPointerDown,
932
+ prev.onResizePointerDown === next.onResizePointerDown,
933
+ prev.drag === next.drag,
934
+ prev.suppressClickRef === next.suppressClickRef
935
+ ].every(Boolean);
936
+ var ResourceCol = React2.memo(
937
+ ResourceColInner,
938
+ areResourceColsEqual
939
+ );
940
+
941
+ // src/lib/cn.ts
942
+ function cn(...inputs) {
943
+ return inputs.filter(Boolean).join(" ");
944
+ }
945
+ var variantClasses = {
946
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
947
+ outline: "border border-border bg-card text-foreground hover:bg-muted",
948
+ ghost: "text-foreground hover:bg-muted"
949
+ };
950
+ var sizeClasses = {
951
+ default: "h-9 px-4 py-2",
952
+ sm: "h-8 px-3 text-xs",
953
+ icon: "h-8 w-8"
954
+ };
955
+ var Button = React2.forwardRef(function Button2({ className, variant = "default", size = "default", type = "button", ...props }, ref) {
956
+ return /* @__PURE__ */ jsx(
957
+ "button",
958
+ {
959
+ ref,
960
+ type,
961
+ className: cn(
962
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
963
+ variantClasses[variant],
964
+ sizeClasses[size],
965
+ className
966
+ ),
967
+ ...props
968
+ }
969
+ );
970
+ });
971
+ function Calendar({
972
+ selected,
973
+ onSelect
974
+ }) {
975
+ const [month, setMonth] = React2.useState(selected ?? /* @__PURE__ */ new Date());
976
+ React2.useEffect(() => {
977
+ if (selected) {
978
+ setMonth(selected);
979
+ }
980
+ }, [selected]);
981
+ const monthStart = startOfMonth(month);
982
+ const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
983
+ const calendarEnd = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
984
+ const days = [];
985
+ for (let day = calendarStart; day <= calendarEnd; day = addDays(day, 1)) {
986
+ days.push(day);
987
+ }
988
+ return /* @__PURE__ */ jsxs("div", { className: "w-72 rounded-md bg-popover", children: [
989
+ /* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center justify-between", children: [
990
+ /* @__PURE__ */ jsx(
991
+ Button,
992
+ {
993
+ variant: "ghost",
994
+ size: "icon",
995
+ "aria-label": "Previous month",
996
+ onClick: () => setMonth(addMonths(month, -1)),
997
+ children: "<"
998
+ }
999
+ ),
1000
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: format(month, "MMMM yyyy") }),
1001
+ /* @__PURE__ */ jsx(
1002
+ Button,
1003
+ {
1004
+ variant: "ghost",
1005
+ size: "icon",
1006
+ "aria-label": "Next month",
1007
+ onClick: () => setMonth(addMonths(month, 1)),
1008
+ children: ">"
1009
+ }
1010
+ )
1011
+ ] }),
1012
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-1 text-center text-xs text-muted-foreground", children: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map((label) => /* @__PURE__ */ jsx(
1013
+ "div",
1014
+ {
1015
+ className: "py-1",
1016
+ children: label
1017
+ },
1018
+ label
1019
+ )) }),
1020
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-1", children: days.map((day) => {
1021
+ const isSelected = selected ? isSameDay(day, selected) : false;
1022
+ return /* @__PURE__ */ jsx(
1023
+ Button,
1024
+ {
1025
+ variant: isSelected ? "default" : "ghost",
1026
+ size: "icon",
1027
+ className: cn(
1028
+ "text-xs",
1029
+ !isSameMonth(day, month) && "text-muted-foreground opacity-60",
1030
+ isToday(day) && !isSelected && "border border-border"
1031
+ ),
1032
+ onClick: () => onSelect?.(day),
1033
+ children: format(day, "d")
1034
+ },
1035
+ day.toISOString()
1036
+ );
1037
+ }) })
1038
+ ] });
1039
+ }
1040
+ var PopoverContext = React2.createContext(null);
1041
+ function usePopoverContext() {
1042
+ const context = React2.useContext(PopoverContext);
1043
+ if (!context) {
1044
+ throw new Error("Popover components must be used within Popover.");
1045
+ }
1046
+ return context;
1047
+ }
1048
+ function Popover({
1049
+ children,
1050
+ open: controlledOpen,
1051
+ onOpenChange
1052
+ }) {
1053
+ const [uncontrolledOpen, setUncontrolledOpen] = React2.useState(false);
1054
+ const triggerRef = React2.useRef(null);
1055
+ const contentRef = React2.useRef(null);
1056
+ const open = controlledOpen ?? uncontrolledOpen;
1057
+ const setOpen = React2.useCallback(
1058
+ (value) => {
1059
+ const nextValue = typeof value === "function" ? value(open) : value;
1060
+ if (controlledOpen === void 0) {
1061
+ setUncontrolledOpen(nextValue);
1062
+ }
1063
+ onOpenChange?.(nextValue);
1064
+ },
1065
+ [controlledOpen, onOpenChange, open]
1066
+ );
1067
+ React2.useEffect(() => {
1068
+ if (!open) {
1069
+ return void 0;
1070
+ }
1071
+ const handlePointerDown = (event) => {
1072
+ const target = event.target;
1073
+ if (triggerRef.current?.contains(target) || contentRef.current?.contains(target)) {
1074
+ return;
1075
+ }
1076
+ setOpen(false);
1077
+ };
1078
+ const handleKeyDown = (event) => {
1079
+ if (event.key === "Escape") {
1080
+ setOpen(false);
1081
+ }
1082
+ };
1083
+ document.addEventListener("mousedown", handlePointerDown);
1084
+ document.addEventListener("keydown", handleKeyDown);
1085
+ return () => {
1086
+ document.removeEventListener("mousedown", handlePointerDown);
1087
+ document.removeEventListener("keydown", handleKeyDown);
1088
+ };
1089
+ }, [open, setOpen]);
1090
+ return /* @__PURE__ */ jsx(PopoverContext.Provider, { value: { open, setOpen, triggerRef, contentRef }, children: /* @__PURE__ */ jsx("div", { className: "relative inline-flex", children }) });
1091
+ }
1092
+ function PopoverTrigger({
1093
+ children,
1094
+ asChild = false
1095
+ }) {
1096
+ const { open, setOpen, triggerRef } = usePopoverContext();
1097
+ if (asChild && React2.isValidElement(children)) {
1098
+ const child = children;
1099
+ return React2.cloneElement(child, {
1100
+ ref: triggerRef,
1101
+ "aria-expanded": open,
1102
+ onClick: (event) => {
1103
+ child.props.onClick?.(event);
1104
+ setOpen((current) => !current);
1105
+ }
1106
+ });
1107
+ }
1108
+ return /* @__PURE__ */ jsx(
1109
+ "button",
1110
+ {
1111
+ ref: triggerRef,
1112
+ type: "button",
1113
+ "aria-expanded": open,
1114
+ onClick: () => setOpen((current) => !current),
1115
+ children
1116
+ }
1117
+ );
1118
+ }
1119
+ function PopoverContent({
1120
+ children,
1121
+ className,
1122
+ align = "start"
1123
+ }) {
1124
+ const { open, contentRef } = usePopoverContext();
1125
+ if (!open) {
1126
+ return null;
1127
+ }
1128
+ return /* @__PURE__ */ jsx(
1129
+ "div",
1130
+ {
1131
+ ref: contentRef,
1132
+ className: cn(
1133
+ "absolute top-full z-50 mt-2 min-w-72 rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md outline-none",
1134
+ align === "start" && "left-0",
1135
+ align === "center" && "left-1/2 -translate-x-1/2",
1136
+ align === "end" && "right-0",
1137
+ className
1138
+ ),
1139
+ children
1140
+ }
1141
+ );
1142
+ }
1143
+ function defaultRenderDatePicker({
1144
+ selectedDate,
1145
+ onSelectedDateChange
1146
+ }) {
1147
+ return /* @__PURE__ */ jsxs(Popover, { children: [
1148
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
1149
+ Button,
1150
+ {
1151
+ variant: "outline",
1152
+ size: "sm",
1153
+ className: "min-w-40 justify-start text-left font-normal",
1154
+ children: format(selectedDate, "PPP")
1155
+ }
1156
+ ) }),
1157
+ /* @__PURE__ */ jsx(
1158
+ PopoverContent,
1159
+ {
1160
+ className: "w-auto p-2",
1161
+ align: "center",
1162
+ children: /* @__PURE__ */ jsx(
1163
+ Calendar,
1164
+ {
1165
+ selected: selectedDate,
1166
+ onSelect: onSelectedDateChange
1167
+ }
1168
+ )
1169
+ }
1170
+ )
1171
+ ] });
1172
+ }
1173
+ function defaultRenderToolbar({
1174
+ goToPreviousDay,
1175
+ goToNextDay,
1176
+ defaultDatePicker,
1177
+ prevButtonLabel = "Prev",
1178
+ nextButtonLabel = "Next"
1179
+ }) {
1180
+ return /* @__PURE__ */ jsx("div", { className: "rfs-toolbar", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-border bg-card p-1 shadow-xs", children: [
1181
+ /* @__PURE__ */ jsx(
1182
+ Button,
1183
+ {
1184
+ variant: "outline",
1185
+ size: "sm",
1186
+ onClick: goToPreviousDay,
1187
+ children: prevButtonLabel
1188
+ }
1189
+ ),
1190
+ defaultDatePicker,
1191
+ /* @__PURE__ */ jsx(
1192
+ Button,
1193
+ {
1194
+ variant: "outline",
1195
+ size: "sm",
1196
+ onClick: goToNextDay,
1197
+ children: nextButtonLabel
1198
+ }
1199
+ )
1200
+ ] }) });
1201
+ }
1202
+ function Toolbar({
1203
+ selectedDate,
1204
+ onSelectedDateChange,
1205
+ prevButtonLabel,
1206
+ nextButtonLabel,
1207
+ renderToolbar,
1208
+ renderDatePicker
1209
+ }) {
1210
+ const goToPreviousDay = () => onSelectedDateChange(shiftDays(selectedDate, -1));
1211
+ const goToNextDay = () => onSelectedDateChange(shiftDays(selectedDate, 1));
1212
+ const defaultDatePicker = (renderDatePicker ?? defaultRenderDatePicker)({
1213
+ selectedDate,
1214
+ onSelectedDateChange
1215
+ });
1216
+ if (renderToolbar) {
1217
+ return /* @__PURE__ */ jsx(Fragment, { children: renderToolbar({
1218
+ selectedDate,
1219
+ onSelectedDateChange,
1220
+ goToPreviousDay,
1221
+ goToNextDay,
1222
+ defaultDatePicker
1223
+ }) });
1224
+ }
1225
+ return /* @__PURE__ */ jsx(Fragment, { children: defaultRenderToolbar({
1226
+ goToPreviousDay,
1227
+ goToNextDay,
1228
+ defaultDatePicker,
1229
+ prevButtonLabel,
1230
+ nextButtonLabel
1231
+ }) });
1232
+ }
1233
+ var IDLE_DRAG = { kind: "none" };
734
1234
  function Scheduler({
735
1235
  resources,
736
1236
  appointments,
@@ -753,24 +1253,12 @@ function Scheduler({
753
1253
  dayStart = "09:00",
754
1254
  dayEnd = "18:00"
755
1255
  }) {
756
- const {
757
- appointmentMap,
758
- dayMinutes,
759
- dayStartAbs,
760
- gridHeight,
761
- gridMinWidth,
762
- renderAppts
763
- } = useSchedulerBaseData({
764
- adapter,
765
- appointments,
766
- dayEnd,
767
- dayStart,
768
- resources,
769
- selectedDate
770
- });
1256
+ const { appointmentMap, dayMinutes, dayStartAbs, gridHeight, gridMinWidth, renderAppts } = useSchedulerBaseData({ adapter, appointments, dayEnd, dayStart, resources, selectedDate });
771
1257
  const {
772
1258
  colRefs,
773
1259
  drag,
1260
+ isDropInvalid,
1261
+ movePreview,
774
1262
  onApptPointerDown,
775
1263
  onGlobalPointerMove,
776
1264
  onGlobalPointerUp,
@@ -786,11 +1274,7 @@ function Scheduler({
786
1274
  resources,
787
1275
  selectedDate
788
1276
  });
789
- const {
790
- appointmentAppearanceByResource,
791
- appointmentBgByResource,
792
- laidOutByResource
793
- } = useSchedulerPresentationData({
1277
+ const { appointmentAppearanceByResource, appointmentBgByResource, laidOutByResource } = useSchedulerPresentationData({
794
1278
  appointmentColorTokenClassMap,
795
1279
  dayStartAbs,
796
1280
  drag,
@@ -802,23 +1286,29 @@ function Scheduler({
802
1286
  resources,
803
1287
  selectedDate
804
1288
  });
805
- const defaultResourceHeaderRenderer = (resource) => defaultRenderResourceHeader({ resource });
806
- const goToPreviousDay = () => onSelectedDateChange(shiftDays(selectedDate, -1));
807
- const goToNextDay = () => onSelectedDateChange(shiftDays(selectedDate, 1));
808
- const defaultDatePicker = (renderDatePicker ?? defaultRenderDatePicker)({ selectedDate, onSelectedDateChange });
809
- const toolbar = renderToolbar ? renderToolbar({
810
- selectedDate,
811
- onSelectedDateChange,
812
- goToPreviousDay,
813
- goToNextDay,
814
- defaultDatePicker
815
- }) : defaultRenderToolbar({
816
- goToPreviousDay,
817
- goToNextDay,
818
- defaultDatePicker,
819
- prevButtonLabel,
820
- nextButtonLabel
821
- });
1289
+ const moveOverlay = React2.useMemo(() => {
1290
+ if (drag.kind !== "move" || !movePreview) {
1291
+ return null;
1292
+ }
1293
+ return {
1294
+ appointment: {
1295
+ ...movePreview.appointment,
1296
+ resourceId: drag.resourceId,
1297
+ startMin: drag.startMin,
1298
+ endMin: drag.endMin,
1299
+ visualState: "ghost",
1300
+ renderKey: `${movePreview.appointment.renderKey}-overlay`,
1301
+ lane: 0,
1302
+ lanes: 1
1303
+ },
1304
+ appearance: appointmentAppearanceByResource.get(drag.resourceId),
1305
+ background: appointmentBgByResource.get(drag.resourceId),
1306
+ left: movePreview.clientX - movePreview.offsetX,
1307
+ top: movePreview.clientY - movePreview.offsetY,
1308
+ width: movePreview.width,
1309
+ height: movePreview.height
1310
+ };
1311
+ }, [appointmentAppearanceByResource, appointmentBgByResource, drag, movePreview]);
822
1312
  return /* @__PURE__ */ jsxs(
823
1313
  "section",
824
1314
  {
@@ -827,104 +1317,77 @@ function Scheduler({
827
1317
  onPointerUp: onGlobalPointerUp,
828
1318
  onPointerCancel: onGlobalPointerUp,
829
1319
  children: [
830
- toolbar,
1320
+ /* @__PURE__ */ jsx(
1321
+ Toolbar,
1322
+ {
1323
+ selectedDate,
1324
+ onSelectedDateChange,
1325
+ prevButtonLabel,
1326
+ nextButtonLabel,
1327
+ renderToolbar,
1328
+ renderDatePicker
1329
+ }
1330
+ ),
831
1331
  /* @__PURE__ */ jsx("div", { className: "rfs-shell border-border bg-card", children: /* @__PURE__ */ jsxs("div", { style: { minWidth: gridMinWidth }, children: [
832
- /* @__PURE__ */ jsxs("div", { className: "rfs-header border-border bg-card", children: [
833
- /* @__PURE__ */ jsx("div", { className: "rfs-gutter border-border", style: { width: GUTTER_W } }),
834
- resources.map((resource) => /* @__PURE__ */ jsxs(
835
- "div",
836
- {
837
- className: "rfs-resource-header border-border bg-card text-foreground",
838
- style: { minWidth: RESOURCE_MIN_W, flex: 1 },
839
- children: [
840
- (renderResourceHeader ?? defaultResourceHeaderRenderer)(resource),
841
- isNameLike(resource) ? /* @__PURE__ */ jsx("div", { className: "rfs-resource-subtitle text-muted-foreground", children: fullName(resource) }) : null
842
- ]
843
- },
844
- String(resource.id)
845
- ))
846
- ] }),
1332
+ /* @__PURE__ */ jsx(
1333
+ Header,
1334
+ {
1335
+ resources,
1336
+ renderResourceHeader
1337
+ }
1338
+ ),
847
1339
  /* @__PURE__ */ jsxs("div", { className: "rfs-body", children: [
848
- /* @__PURE__ */ jsx("div", { className: "rfs-times border-border bg-card", style: { width: GUTTER_W, height: gridHeight }, children: Array.from({ length: Math.floor(dayMinutes / 60) + 1 }).map((_, i) => /* @__PURE__ */ jsxs(
849
- "div",
1340
+ /* @__PURE__ */ jsx(
1341
+ Gutter,
850
1342
  {
851
- className: "rfs-time-tick text-muted-foreground",
852
- style: { top: TOP_PAD + i * 60 * PX_PER_MIN },
853
- children: [
854
- String(Math.floor((dayStartAbs + i * 60) / 60)).padStart(2, "0"),
855
- ":",
856
- String((dayStartAbs + i * 60) % 60).padStart(2, "0")
857
- ]
858
- },
859
- `tick-${i}`
860
- )) }),
1343
+ dayMinutes,
1344
+ dayStartAbs,
1345
+ gridHeight
1346
+ }
1347
+ ),
861
1348
  /* @__PURE__ */ jsx("div", { className: "rfs-grid", children: resources.map((resource) => /* @__PURE__ */ jsx(
862
- "div",
1349
+ ResourceCol,
863
1350
  {
864
- className: "rfs-col border-border bg-card",
865
- style: { minWidth: RESOURCE_MIN_W, flex: 1 },
866
- children: /* @__PURE__ */ jsxs(
867
- "div",
868
- {
869
- ref: (el) => {
870
- colRefs.current[String(resource.id)] = el;
871
- },
872
- className: "rfs-col-inner bg-card",
873
- style: { height: gridHeight },
874
- children: [
875
- Array.from({ length: Math.floor(dayMinutes / 60) + 1 }).map((_, i) => /* @__PURE__ */ jsx(
876
- "div",
877
- {
878
- className: "rfs-hour-line border-border/70",
879
- style: { top: TOP_PAD + i * 60 * PX_PER_MIN }
880
- },
881
- `major-${String(resource.id)}-${i}`
882
- )),
883
- Array.from({ length: Math.floor(dayMinutes / STEP_MIN) + 1 }).map((_, i) => /* @__PURE__ */ jsx(
884
- "div",
885
- {
886
- className: "rfs-slot-line border-border/50",
887
- style: { top: TOP_PAD + i * STEP_MIN * PX_PER_MIN }
888
- },
889
- `minor-${String(resource.id)}-${i}`
890
- )),
891
- (laidOutByResource.get(resource.id) ?? []).map((appointment) => {
892
- const laneWidthPct = 100 / appointment.lanes;
893
- const top = TOP_PAD + appointment.startMin * PX_PER_MIN;
894
- const height = Math.max(MIN_EVENT_MIN * PX_PER_MIN, (appointment.endMin - appointment.startMin) * PX_PER_MIN);
895
- const appointmentAppearance = appointmentAppearanceByResource.get(resource.id);
896
- const appointmentBackgroundColor = appointmentBgByResource.get(resource.id);
897
- return /* @__PURE__ */ jsx(
898
- "div",
899
- {
900
- className: "rfs-card-slot",
901
- style: {
902
- top,
903
- left: `${laneWidthPct * appointment.lane}%`,
904
- width: `${laneWidthPct}%`,
905
- height
906
- },
907
- children: (renderAppointment ?? defaultRenderAppointment)({
908
- appointment,
909
- onPointerDown: (event) => onApptPointerDown(event, appointment),
910
- onResizePointerDown: (event) => onResizePointerDown(event, appointment),
911
- appointmentAppearance,
912
- appointmentBackgroundColor,
913
- drag,
914
- suppressClickRef
915
- })
916
- },
917
- String(appointment.id)
918
- );
919
- })
920
- ]
921
- }
922
- )
1351
+ resource,
1352
+ colRefs,
1353
+ gridHeight,
1354
+ dayMinutes,
1355
+ appointments: laidOutByResource.get(resource.id) ?? [],
1356
+ appointmentAppearance: appointmentAppearanceByResource.get(resource.id),
1357
+ appointmentBg: appointmentBgByResource.get(resource.id),
1358
+ isDropInvalid,
1359
+ renderAppointment,
1360
+ onApptPointerDown,
1361
+ onResizePointerDown,
1362
+ drag: drag.kind === "resize" && drag.resourceId === resource.id ? drag : IDLE_DRAG,
1363
+ suppressClickRef
923
1364
  },
924
1365
  String(resource.id)
925
1366
  )) })
926
1367
  ] })
927
- ] }) })
1368
+ ] }) }),
1369
+ moveOverlay ? /* @__PURE__ */ jsx(
1370
+ "div",
1371
+ {
1372
+ className: "rfs-drag-overlay",
1373
+ style: {
1374
+ left: moveOverlay.left,
1375
+ top: moveOverlay.top,
1376
+ width: moveOverlay.width,
1377
+ height: moveOverlay.height
1378
+ },
1379
+ children: (renderAppointment ?? defaultRenderAppointment)({
1380
+ appointment: moveOverlay.appointment,
1381
+ isDropInvalid,
1382
+ onPointerDown: () => void 0,
1383
+ onResizePointerDown: () => void 0,
1384
+ appointmentAppearance: moveOverlay.appearance,
1385
+ appointmentBackgroundColor: moveOverlay.background,
1386
+ drag,
1387
+ suppressClickRef
1388
+ })
1389
+ }
1390
+ ) : null
928
1391
  ]
929
1392
  }
930
1393
  );