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