{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar-dnd","type":"registry:ui","title":"Custom pointer-event interaction engine - move, resize, and drag-create across month, week, day, and N-day views with live validation.","description":"Custom pointer-event interaction engine - move, resize, and drag-create across month, week, day, and N-day views with live validation.","dependencies":["date-fns"],"registryDependencies":["@neui/event-calendar","@neui/event-calendar-lib","@neui/event-calendar-types"],"files":[{"path":"event-calendar-dnd.tsx","type":"registry:ui","content":"// Title: Event Calendar Dnd\n// Description: Custom pointer-event interaction engine - move, resize, and drag-create across month, week, day, and N-day views with live validation.\n\n\"use client\"\n\nimport { useCallback, useEffect, useMemo } from \"react\"\nimport {\n useEventCalendar,\n useEventCalendarViewConfig,\n type EventCalendarInstance,\n} from \"@/components/neui/event-calendar/event-calendar\"\nimport {\n snapMinutes,\n toZoned,\n zonedStartOfDay,\n} from \"@/components/neui/event-calendar/event-calendar-lib\"\nimport type {\n EventCalendarProposedUpdate,\n EventCalendarSegment,\n} from \"@/components/neui/event-calendar/event-calendar-types\"\nimport { addDays, addMinutes, differenceInCalendarDays } from \"date-fns\"\n\n/**\n * Activation policy (dnd-kit parity where proven):\n * mouse move 5px before a drag starts (below = click), create 4px;\n * touch long-press 250ms with 5px tolerance (movement past tolerance\n * before the delay cancels the drag so taps stay taps).\n */\nconst EVENT_CALENDAR_ACTIVATION = {\n moveDistancePx: 5,\n createDistancePx: 4,\n touchDelayMs: 250,\n touchTolerancePx: 5,\n autoScrollEdgePx: 48,\n autoScrollMaxStepPx: 15,\n} as const\n\ntype GestureKind = \"move\" | \"resize-start\" | \"resize-end\" | \"create\"\n\ninterface TimeColumnRect {\n day: Date\n rect: DOMRect\n boundsStartMin: number\n boundsEndMin: number\n resourceId?: string\n}\n\ninterface DayCellRect {\n day: Date\n rect: DOMRect\n}\n\ninterface Surface {\n /** Minute-precise day columns (week/day/days) or resource columns. */\n columns: TimeColumnRect[]\n /** Day-precise cells (month grid, all-day row). */\n cells: DayCellRect[]\n viewport: HTMLElement | null\n /**\n * Viewport rect captured ONCE at gesture start. The scroll container does\n * not move on screen while a pointer drag is captured (auto-scroll changes\n * its scrollTop, not its box), so reusing this avoids a per-pointermove\n * getBoundingClientRect - that read forces a synchronous full-document\n * reflow, which is cheap on a bare demo page but ~200ms on a long docs page\n * (big prop tables re-lay-out on every flush), turning drag into a slideshow.\n */\n viewportRect: DOMRect | null\n viewportStartScrollTop: number\n /**\n * Live scrollTop, seeded from the start value and advanced by auto-scroll\n * itself. Tracking it here lets pointerMinutes read a number instead of the\n * DOM `scrollTop` property every move (another forced reflow).\n */\n scrollTop: number\n}\n\n/** Module flag so chip onClick can ignore the click that ends a drag. */\nlet lastGestureEndedAt = 0\nfunction wasRecentDrag(): boolean {\n return performance.now() - lastGestureEndedAt < 250\n}\n\n/**\n * A press that started on an event chip. Slot-create clicks consult this so a\n * refused drag (e.g. a locked chip that never registers a gesture) whose\n * trailing native click retargets to the empty grid does NOT open a create\n * dialog. Refreshed on release so it covers long presses; the chip's own\n * click-to-edit is unaffected (only grid slot-clicks check it).\n */\nlet lastChipPressAt = 0\nfunction markChipPress(): void {\n lastChipPressAt = performance.now()\n window.addEventListener(\n \"pointerup\",\n () => {\n lastChipPressAt = performance.now()\n },\n { once: true, capture: true }\n )\n}\nfunction wasRecentChipPress(): boolean {\n return performance.now() - lastChipPressAt < 300\n}\n\n/**\n * Registry of in-flight gesture cancels. A gesture measures its surface (day\n * columns and cells) once at activation and then lives on window listeners, so\n * the CALENDAR - not the chip, chips legitimately unmount mid-gesture (lane\n * repacking, a \"+N more\" popover closing) - is what must be able to abort it.\n * Cancel fully reverts: listeners, overlays and the body drag state all clear,\n * and no update is committed.\n */\nconst activeGestureCancels = new Set<() => void>()\n\n/** Cancel (and fully revert) every in-flight event calendar pointer gesture. */\nfunction cancelActiveEventCalendarGestures(): void {\n for (const cancel of [...activeGestureCancels]) cancel()\n}\n\n/**\n * Mounted-consumer count for the gestures hook. Every chip holds one, so only\n * the LAST consumer leaving means the calendar itself is gone; aborting when\n * any single chip unmounts would kill a drag the user is still holding.\n */\nlet gestureConsumers = 0\n\n/**\n * Snap a translate offset to the device pixel grid. The cursor-following\n * overlays (carry clone, drop hint) are their own `will-change: transform`\n * compositing layers: the GPU rasterizes their text once and repositions that\n * texture each frame, so a subpixel translate (getBoundingClientRect and raw\n * clientX/Y are routinely fractional) resamples the texture and blurs the\n * text. Rounding each offset to a whole device pixel lands the layer on the\n * grid so glyphs stay crisp, without giving up the per-frame GPU transform.\n */\nfunction snapToPixel(value: number): number {\n const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1\n return Math.round(value * dpr) / dpr\n}\n\nfunction collectSurface(\n origin: HTMLElement,\n fallbackRoot?: HTMLElement | null\n): Surface {\n // A gesture from a portaled surface (the \"+N more\" popover) has no calendar\n // ancestor, so fall back to the root the host registered on the instance.\n const root =\n origin.closest(\"[data-slot=event-calendar-time-grid]\") ??\n origin.closest(\"[data-slot=event-calendar-resource-view]\") ??\n origin.closest(\"[data-slot=event-calendar-month-view]\") ??\n origin.closest(\"[data-slot=event-calendar]\") ??\n fallbackRoot ??\n null\n\n const columns: TimeColumnRect[] = []\n const cells: DayCellRect[] = []\n if (root) {\n for (const el of root.querySelectorAll(\"[data-ec-day]\")) {\n const day = new Date(Number(el.dataset.ecDay))\n if (el.dataset.ecBoundsStart !== undefined) {\n columns.push({\n day,\n rect: el.getBoundingClientRect(),\n boundsStartMin: Number(el.dataset.ecBoundsStart),\n boundsEndMin: Number(el.dataset.ecBoundsEnd),\n resourceId: el.dataset.ecResource,\n })\n } else {\n cells.push({ day, rect: el.getBoundingClientRect() })\n }\n }\n }\n const viewport =\n root?.querySelector(\"[data-slot=scroll-area-viewport]\") ?? null\n const viewportStartScrollTop = viewport?.scrollTop ?? 0\n return {\n columns,\n cells,\n viewport,\n viewportRect: viewport?.getBoundingClientRect() ?? null,\n viewportStartScrollTop,\n scrollTop: viewportStartScrollTop,\n }\n}\n\nfunction findColumn(\n surface: Surface,\n clientX: number\n): TimeColumnRect | undefined {\n const scrollAdjusted = surface.columns\n let best: TimeColumnRect | undefined\n for (const col of scrollAdjusted) {\n if (clientX >= col.rect.left && clientX < col.rect.right) return col\n if (!best) best = col\n // clamp to nearest horizontal column\n const bestDist = Math.min(\n Math.abs(clientX - best.rect.left),\n Math.abs(clientX - best.rect.right)\n )\n const dist = Math.min(\n Math.abs(clientX - col.rect.left),\n Math.abs(clientX - col.rect.right)\n )\n if (dist < bestDist) best = col\n }\n return best\n}\n\nfunction findCell(\n surface: Surface,\n x: number,\n y: number\n): DayCellRect | undefined {\n return surface.cells.find(\n (cell) =>\n x >= cell.rect.left &&\n x < cell.rect.right &&\n y >= cell.rect.top &&\n y < cell.rect.bottom\n )\n}\n\nfunction pointerMinutes(\n surface: Surface,\n col: TimeColumnRect,\n clientY: number\n): number {\n const scrollDelta = surface.scrollTop - surface.viewportStartScrollTop\n const boundsMinutes = col.boundsEndMin - col.boundsStartMin\n const pxPerMinute = col.rect.height / Math.max(1, boundsMinutes)\n const y = clientY - col.rect.top + scrollDelta\n return col.boundsStartMin + y / pxPerMinute\n}\n\ninterface BeginGestureConfig {\n instance: EventCalendarInstance\n kind: GestureKind\n origin: HTMLElement\n startEvent: PointerEvent\n segment?: EventCalendarSegment\n /** create only */\n createDay?: Date\n createAllDay?: boolean\n /**\n * Consumer classNames for the engine's vanilla-DOM overlays (carry clone,\n * validation hint pill), forwarded by the gestures hook - the engine itself\n * must never import from the React chip module.\n */\n ui?: {\n dragCarry?: string\n dragCarryInvalid?: string\n dropHint?: string\n }\n}\n\n/**\n * A move/resize was refused (locked, per-event disabled, or interaction off).\n * Rather than silently swallow the press, track the pointer: once it crosses\n * the activation threshold - i.e. the user genuinely tried to drag - show a\n * not-allowed cursor and broadcast once via onDragBlocked so the consumer can\n * explain it. The calendar picks no message. Fires at most once per gesture.\n */\nfunction beginBlockedGesture(\n instance: EventCalendarInstance,\n startEvent: PointerEvent,\n segment: EventCalendarSegment,\n gesture: \"move\" | \"resize\"\n) {\n const startX = startEvent.clientX\n const startY = startEvent.clientY\n const activation = {\n ...EVENT_CALENDAR_ACTIVATION,\n ...instance.settings.activation,\n }\n const event = segment.occurrence.event\n const reason: \"readOnly\" | \"disabled\" | \"interactions-off\" = event.readOnly\n ? \"readOnly\"\n : (gesture === \"move\" ? event.draggable : event.resizable) === false\n ? \"disabled\"\n : \"interactions-off\"\n const pointerId = startEvent.pointerId\n let activated = false\n let finished = false\n const onMove = (e: PointerEvent) => {\n if (e.pointerId !== pointerId || activated) return\n if (\n Math.hypot(e.clientX - startX, e.clientY - startY) <\n activation.moveDistancePx\n ) {\n return\n }\n activated = true\n document.body.style.cursor = \"not-allowed\"\n // body class alongside the inline cursor so consumer CSS can restyle or\n // detect the blocked-drag state (mirrors \"ec-dragging\")\n document.body.classList.add(\"ec-drag-blocked\")\n // only now is there anything to get stuck: focus loss means the release\n // may never be delivered, leaving the cursor not-allowed document-wide\n window.addEventListener(\"blur\", cleanup)\n instance.settings.onDragBlocked?.(segment.occurrence, { gesture, reason })\n }\n const cleanup = () => {\n if (finished) return\n finished = true\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", onRelease)\n window.removeEventListener(\"pointercancel\", onRelease)\n window.removeEventListener(\"blur\", cleanup)\n if (activated) {\n document.body.style.cursor = \"\"\n document.body.classList.remove(\"ec-drag-blocked\")\n // the pointer travelled: suppress the trailing click so the event's own\n // dialog does not open on top of the rejection message\n lastGestureEndedAt = performance.now()\n }\n }\n // only the pointer that started the press may end it: a second finger\n // lifting must not clear the not-allowed cursor out from under it\n const onRelease = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cleanup()\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", onRelease)\n window.addEventListener(\"pointercancel\", onRelease)\n}\n\nfunction beginGesture(config: BeginGestureConfig) {\n const { instance, kind, origin, startEvent, segment, ui } = config\n const { settings, internals, api } = instance\n const timeZone = settings.timeZone\n const snap = settings.snapDuration\n // per-calendar tuning shallow-merged over the module defaults\n const activation = { ...EVENT_CALENDAR_ACTIVATION, ...settings.activation }\n const startX = startEvent.clientX\n const startY = startEvent.clientY\n const pointerId = startEvent.pointerId\n // Resolved once: the chip can be re-rendered away mid-gesture. A gesture\n // from a portaled surface (the \"+N more\" popover) has no calendar ancestor,\n // so fall back to the registered root - same reason as collectSurface.\n const announcer =\n origin\n .closest(\"[data-slot=event-calendar]\")\n ?.querySelector(\"[data-slot=event-calendar-announcer]\") ??\n internals\n .getRootEl()\n ?.querySelector(\"[data-slot=event-calendar-announcer]\") ??\n null\n\n const isTouch = startEvent.pointerType === \"touch\"\n // resize activates immediately on precise pointers; on touch it waits for\n // the same long-press as a move, so a finger landing on the (invisible on\n // touch) handle strip can never start a resize the user never asked for\n let active = kind.startsWith(\"resize\") && !isTouch\n let surface: Surface | null = active\n ? collectSurface(origin, internals.getRootEl())\n : null\n let rafScroll: number | null = null\n let lastProposalKey = \"\"\n let touchTimer: ReturnType | null = null\n let hintEl: HTMLDivElement | null = null\n let lastValid = true\n // Drag-create anchor minute, frozen at the first proposal: re-deriving it\n // from the stale startY client coordinate + the CURRENT scroll delta would\n // make the anchor drift with auto-scroll instead of staying pinned.\n let createAnchorMin: number | null = null\n let lastPointer: PointerEvent = startEvent\n\n const occurrence = segment?.occurrence\n const isBar = occurrence\n ? occurrence.allDay ||\n occurrence.end.getTime() - occurrence.start.getTime() >\n 24 * 60 * 60 * 1000\n : false\n\n // Preserve the grab offset so the event does not jump to the pointer\n let grabOffsetMin = 0\n /**\n * The grabbed CHIP, not the whole occurrence. A cross-midnight event renders\n * one chip per day, so the offset above is measured in the grabbed chip's own\n * day frame (mixing the two frames jumps a tail chip a full day on grab) and\n * these two carry that chip back to the occurrence. For a chip that fits in\n * one day the lead is 0 and the duration is the occurrence's, i.e. unchanged.\n */\n let grabLeadMs = 0\n let grabSegDurationMs = occurrence\n ? occurrence.end.getTime() - occurrence.start.getTime()\n : 0\n /**\n * Which day of a multi-day bar was grabbed. Without it the day-granular move\n * slides the bar's START under the pointer, so a Mon-Fri bar grabbed on\n * Wednesday teleports two days forward on the first nudge.\n */\n let grabDayOffset = 0\n\n const activationDistance =\n kind === \"create\" ? activation.createDistancePx : activation.moveDistancePx\n\n // Each gesture keeps its own cursor: a resize must stay ns/ew-resize for\n // the whole drag (flipping to grabbing reads as a move) - vertical for\n // timed blocks, horizontal for day-granular bars. Moves grab.\n const gestureCursor = kind.startsWith(\"resize\")\n ? isBar\n ? \"ew-resize\"\n : \"ns-resize\"\n : \"grabbing\"\n const setBodyDragging = (on: boolean, invalid = false) => {\n document.body.classList.toggle(\"ec-dragging\", on)\n document.body.style.cursor = on\n ? invalid\n ? \"not-allowed\"\n : gestureCursor\n : \"\"\n document.body.style.userSelect = on ? \"none\" : \"\"\n if (!on) document.body.style.removeProperty(\"-webkit-user-select\")\n }\n\n const activate = () => {\n if (active) return\n active = true\n // armed with the gesture, not with the press: a press that never activates\n // holds no drag state, no overlay and no body class to strand\n window.addEventListener(\"blur\", onWindowBlur)\n surface = collectSurface(origin, internals.getRootEl())\n if (kind === \"move\" && occurrence) {\n const grabCell = findCell(surface, startX, startY)\n if (grabCell) {\n const originDay = zonedStartOfDay(occurrence.start, timeZone)\n // end is exclusive, so step back an instant for the last covered day\n const lastDay = zonedStartOfDay(\n new Date(\n Math.max(occurrence.end.getTime() - 1, occurrence.start.getTime())\n ),\n timeZone\n )\n const offset = differenceInCalendarDays(\n zonedStartOfDay(grabCell.day, timeZone),\n originDay\n )\n // A gesture from the \"+N more\" popover grabs over whatever cell that\n // floating surface happens to cover, so only a day the event actually\n // spans can be the grabbed day.\n if (\n offset >= 0 &&\n offset <= differenceInCalendarDays(lastDay, originDay)\n ) {\n grabDayOffset = offset\n }\n }\n if (surface.columns.length > 0 && !isBar) {\n const col = findColumn(surface, startX)\n if (col) {\n const colDayStart = zonedStartOfDay(col.day, timeZone)\n const segStartMs = Math.max(\n occurrence.start.getTime(),\n colDayStart.getTime()\n )\n grabLeadMs = segStartMs - occurrence.start.getTime()\n grabSegDurationMs =\n Math.min(\n occurrence.end.getTime(),\n addDays(colDayStart, 1).getTime()\n ) - segStartMs\n grabOffsetMin =\n pointerMinutes(surface, col, startY) -\n (segStartMs - colDayStart.getTime()) / 60000\n }\n }\n }\n setBodyDragging(true)\n createCarry()\n }\n\n const computeProposal = (\n e: PointerEvent\n ): {\n start: Date\n end: Date\n allDay: boolean\n dayGranular?: boolean\n resourceId?: string\n } | null => {\n if (!surface) return null\n\n // ---- create: select a slot range\n if (kind === \"create\") {\n if (config.createAllDay || surface.columns.length === 0) {\n const anchor = zonedStartOfDay(config.createDay!, timeZone)\n const cell = findCell(surface, e.clientX, e.clientY)\n const target = cell ? zonedStartOfDay(cell.day, timeZone) : anchor\n const start = anchor <= target ? anchor : target\n const end = addDays(anchor <= target ? target : anchor, 1)\n return { start, end, allDay: true, dayGranular: true }\n }\n const col = findColumn(surface, startX)\n if (!col) return null\n if (createAnchorMin === null) {\n createAnchorMin = snapMinutes(\n pointerMinutes(surface, col, startY),\n snap\n )\n }\n const anchorMin = createAnchorMin\n const curMin = snapMinutes(pointerMinutes(surface, col, e.clientY), snap)\n const lo = Math.max(col.boundsStartMin, Math.min(anchorMin, curMin))\n const hi = Math.min(\n col.boundsEndMin,\n Math.max(anchorMin, curMin, lo + snap)\n )\n const dayStart = zonedStartOfDay(col.day, timeZone)\n return {\n start: addMinutes(dayStart, lo),\n end: addMinutes(dayStart, hi),\n allDay: false,\n resourceId: col.resourceId,\n }\n }\n\n if (!occurrence) return null\n const durationMs = occurrence.end.getTime() - occurrence.start.getTime()\n\n // ---- day-granularity: month cells and bars in the all-day row\n const overCell = findCell(surface, e.clientX, e.clientY)\n\n // A timed event dropped on the all-day lane (a day cell inside a surface\n // that also has time columns) converts to a full-day event on that day.\n if (\n kind === \"move\" &&\n !isBar &&\n surface.columns.length > 0 &&\n overCell !== undefined\n ) {\n const targetDay = zonedStartOfDay(overCell.day, timeZone)\n return {\n start: targetDay,\n end: addDays(targetDay, 1),\n allDay: true,\n dayGranular: true,\n }\n }\n\n const useCells =\n surface.columns.length === 0 || (isBar && overCell !== undefined)\n\n if (useCells) {\n const cell = overCell ?? findCell(surface, startX, startY)\n if (!cell) return null\n const targetDay = zonedStartOfDay(cell.day, timeZone)\n if (kind === \"move\") {\n const originDay = zonedStartOfDay(occurrence.start, timeZone)\n // minus the grabbed day: the bar follows the pointer by the distance\n // travelled, it does not re-anchor its start under the pointer\n const delta =\n differenceInCalendarDays(targetDay, originDay) - grabDayOffset\n const start = addDays(toZoned(occurrence.start, timeZone), delta)\n return {\n start,\n end: new Date(start.getTime() + durationMs),\n allDay: occurrence.allDay,\n dayGranular: true,\n }\n }\n // bar edge resize: day granularity\n if (kind === \"resize-start\") {\n const time =\n occurrence.start.getTime() -\n zonedStartOfDay(occurrence.start, timeZone).getTime()\n const start = new Date(targetDay.getTime() + time)\n if (start >= occurrence.end) return null\n return {\n start,\n end: occurrence.end,\n allDay: occurrence.allDay,\n dayGranular: true,\n }\n }\n const time = occurrence.allDay\n ? 0\n : occurrence.end.getTime() -\n zonedStartOfDay(occurrence.end, timeZone).getTime()\n const end = occurrence.allDay\n ? addDays(targetDay, 1)\n : new Date(addDays(targetDay, time > 0 ? 0 : 1).getTime() + time)\n if (end <= occurrence.start) return null\n return {\n start: occurrence.start,\n end,\n allDay: occurrence.allDay,\n dayGranular: true,\n }\n }\n\n // ---- minute-granularity: time-grid columns\n const col = findColumn(surface, e.clientX)\n if (!col) return null\n const dayStart = zonedStartOfDay(col.day, timeZone)\n const rawMin = pointerMinutes(surface, col, e.clientY)\n\n if (kind === \"move\") {\n const newStartMin = snapMinutes(rawMin - grabOffsetMin, snap)\n // the clamp keeps the grabbed CHIP inside the column it is over; the\n // lead then carries the rest of a cross-midnight occurrence with it\n const chipDurationMin = Math.round(grabSegDurationMs / 60000)\n const clamped = Math.min(\n Math.max(newStartMin, col.boundsStartMin),\n col.boundsEndMin - chipDurationMin\n )\n const start = new Date(\n addMinutes(dayStart, clamped).getTime() - grabLeadMs\n )\n return {\n start,\n end: new Date(start.getTime() + durationMs),\n allDay: false,\n resourceId: col.resourceId,\n }\n }\n\n const min = snapMinutes(rawMin, snap)\n // Both endpoints expressed in the POINTED column's day coordinates.\n // Anchoring each endpoint to its OWN day breaks cross-midnight events:\n // a 23:30 pointer in the start day's column would apply 1410 minutes to\n // the end's next-day anchor and jump the end a full day late (and a\n // midnight-ending event computes occEndMin 0 against its own day).\n const occStartMinInCol = Math.round(\n (occurrence.start.getTime() - dayStart.getTime()) / 60000\n )\n const occEndMinInCol = Math.round(\n (occurrence.end.getTime() - dayStart.getTime()) / 60000\n )\n if (kind === \"resize-start\") {\n const clamped = Math.min(\n Math.max(min, col.boundsStartMin),\n Math.min(occEndMinInCol - snap, col.boundsEndMin)\n )\n const start = addMinutes(dayStart, clamped)\n if (start >= occurrence.end) return null\n return { start, end: occurrence.end, allDay: false }\n }\n const clamped = Math.max(\n Math.min(min, col.boundsEndMin),\n Math.max(occStartMinInCol + snap, col.boundsStartMin)\n )\n const end = addMinutes(dayStart, clamped)\n if (end <= occurrence.start) return null\n return { start: occurrence.start, end, allDay: false }\n }\n\n const applyProposal = (e: PointerEvent) => {\n const proposal = computeProposal(e)\n if (!proposal) return\n const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? \"\"}`\n if (key === lastProposalKey) return\n lastProposalKey = key\n\n if (kind === \"create\") {\n const draft = { ...proposal, view: instance.getState().view }\n if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return\n internals.setSlotDraft(draft)\n return\n }\n const update: EventCalendarProposedUpdate = {\n event: occurrence!.event,\n occurrence: occurrence!,\n ...proposal,\n source: kind as \"drag\" | \"resize-start\" | \"resize-end\",\n }\n if (kind === \"move\") update.source = \"drag\"\n const valid = settings.canDropEvent ? settings.canDropEvent(update) : true\n lastValid = valid\n setBodyDragging(true, !valid)\n internals.setDrag({\n kind: kind === \"move\" ? \"move\" : (kind as \"resize-start\" | \"resize-end\"),\n occurrence: occurrence!,\n proposedStart: proposal.start,\n proposedEnd: proposal.end,\n proposedAllDay: proposal.allDay,\n proposedDayGranular: proposal.dayGranular ?? false,\n proposedResourceId: proposal.resourceId,\n valid,\n })\n }\n\n // Cursor-attached carry clone for MOVE gestures: the event travels freely\n // with the pointer, like an absolutely positioned overlay, while the\n // in-grid ghost renders only a faint dashed placeholder at the snapped\n // drop slot (EVENT_CALENDAR_GHOST.move). Vanilla DOM: cloned once at\n // activation, transformed per pointermove, zero React work per frame.\n let carryEl: HTMLDivElement | null = null\n let carryDX = 0\n let carryDY = 0\n const CARRY_CLASS =\n \"bg-background pointer-events-none fixed top-0 left-0 z-100 overflow-hidden rounded-sm opacity-90 shadow-md will-change-transform\" +\n (ui?.dragCarry ? \" \" + ui.dragCarry : \"\")\n const CARRY_INVALID_CLASS =\n `${CARRY_CLASS} ring-destructive/60 ring-1` +\n (ui?.dragCarryInvalid ? \" \" + ui.dragCarryInvalid : \"\")\n\n const createCarry = () => {\n if (kind !== \"move\") return\n const chip =\n origin.closest(\"[data-slot=event-calendar-event]\") ?? origin\n const rect = chip.getBoundingClientRect()\n // The clone is re-parented to , escaping the calendar's inherited\n // font-size, so copy the source chip's resolved type - this keeps the carry\n // matching the grid at any consumer text scale (e.g. root text-sm).\n const chipFont = getComputedStyle(chip)\n carryDX = startX - rect.left\n carryDY = startY - rect.top\n carryEl = document.createElement(\"div\")\n carryEl.setAttribute(\"data-slot\", \"event-calendar-drag-carry\")\n carryEl.setAttribute(\"aria-hidden\", \"true\")\n carryEl.className = CARRY_CLASS\n carryEl.style.width = `${rect.width}px`\n carryEl.style.height = `${rect.height}px`\n carryEl.style.fontSize = chipFont.fontSize\n carryEl.style.lineHeight = chipFont.lineHeight\n carryEl.style.transform = `translate3d(${snapToPixel(rect.left)}px, ${snapToPixel(rect.top)}px, 0)`\n const clone = chip.cloneNode(true) as HTMLElement\n clone.removeAttribute(\"data-dragging\")\n clone.style.width = \"100%\"\n clone.style.height = \"100%\"\n carryEl.appendChild(clone)\n document.body.appendChild(carryEl)\n }\n\n const positionCarry = (e: PointerEvent) => {\n if (!carryEl) return\n carryEl.style.transform = `translate3d(${snapToPixel(e.clientX - carryDX)}px, ${snapToPixel(e.clientY - carryDY)}px, 0)`\n const cls = lastValid ? CARRY_CLASS : CARRY_INVALID_CLASS\n if (carryEl.className !== cls) carryEl.className = cls\n }\n\n // Cursor-following validation hint, visible ONLY while the proposal is\n // rejected (canDropEvent / bounds). Vanilla DOM: zero React work per frame;\n // pairs with the not-allowed cursor and the ghost's destructive marking.\n const updateHint = (e: PointerEvent) => {\n if (lastValid || !active) {\n hintEl?.remove()\n hintEl = null\n return\n }\n if (!hintEl) {\n hintEl = document.createElement(\"div\")\n hintEl.setAttribute(\"data-slot\", \"event-calendar-drop-hint\")\n // physical left-0 anchor: translate3d positions in physical clientX\n // coordinates, so a logical start-0 anchor would fling it off-screen\n // in RTL documents\n hintEl.className =\n \"bg-background text-destructive border-destructive/40 pointer-events-none fixed top-0 left-0 z-100 rounded-md border px-2 py-0.5 text-xs font-medium shadow-sm\" +\n (ui?.dropHint ? \" \" + ui.dropHint : \"\")\n hintEl.textContent = settings.i18n.labels.dropNotAllowed\n document.body.appendChild(hintEl)\n }\n hintEl.style.transform = `translate3d(${snapToPixel(e.clientX + 12)}px, ${snapToPixel(e.clientY + 16)}px, 0)`\n }\n\n const autoScroll = (e: PointerEvent) => {\n // Cached rect (see Surface.viewportRect) - never getBoundingClientRect per\n // move; the box is stable while the pointer is captured.\n const rect = surface?.viewportRect\n if (!surface?.viewport || !rect) return\n const edge = activation.autoScrollEdgePx\n const step = activation.autoScrollMaxStepPx\n let delta = 0\n /**\n * The scroller wraps the time track ONLY - the day headers and the all-day\n * row sit above it, outside the box - so its top edge is a hard floor. A\n * pointer above that floor is over one of those rows, and both are drop\n * targets in their own right (a bar moving across dates, a timed chip\n * lifted onto the all-day lane to convert it); they are already fully\n * visible, so reaching them is never a request to scroll. Without the\n * floor, \"past the top edge\" is true on every single move of a horizontal\n * all-day drag and the track pans out from under a gesture that only wants\n * to change the date - fast, because the proximity ratio grows past 1 the\n * further above the box the pointer sits.\n *\n * Below the track there is no such row, so overshooting the bottom keeps\n * scrolling - that is how a grid is normally asked to keep going - but the\n * eased speed is capped at one step per frame for the same reason.\n */\n if (e.clientY >= rect.top) {\n if (e.clientY < rect.top + edge) {\n delta = -step * ((rect.top + edge - e.clientY) / edge)\n } else if (e.clientY > rect.bottom - edge) {\n delta = step * Math.min(1, (e.clientY - (rect.bottom - edge)) / edge)\n }\n }\n if (rafScroll) cancelAnimationFrame(rafScroll)\n if (delta !== 0) {\n const tick = () => {\n // Write-then-readback: the browser clamps scrollTop at the scroll\n // extent, so mirror only the APPLIED delta - otherwise parking the\n // pointer in the edge zone at the limit keeps inflating the tracked\n // value past reality and poisons every later minute mapping. When\n // parked (nothing applied), stop the loop; the next pointermove\n // restarts it.\n const before = surface!.viewport!.scrollTop\n surface!.viewport!.scrollTop = before + delta\n const applied = surface!.viewport!.scrollTop - before\n if (applied === 0) return\n // keep the tracked scrollTop in step so pointerMinutes stays a pure\n // number read (no DOM scrollTop, no forced reflow)\n surface!.scrollTop += applied\n applyProposal(e)\n rafScroll = requestAnimationFrame(tick)\n }\n rafScroll = requestAnimationFrame(tick)\n }\n }\n\n // idempotent: pointerup, pointercancel, Escape, blur and the calendar-level\n // teardown can race; whichever lands first wins and the rest no-op\n let finished = false\n const cleanup = () => {\n if (finished) return\n finished = true\n activeGestureCancels.delete(cancel)\n window.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onCancel)\n window.removeEventListener(\"blur\", onWindowBlur)\n window.removeEventListener(\"keydown\", onKeyDown, true)\n if (rafScroll) cancelAnimationFrame(rafScroll)\n if (touchTimer) clearTimeout(touchTimer)\n hintEl?.remove()\n hintEl = null\n carryEl?.remove()\n carryEl = null\n setBodyDragging(false)\n }\n\n const cancel = () => {\n cleanup()\n if (active) {\n lastGestureEndedAt = performance.now()\n internals.setDrag(null)\n internals.setSlotDraft(null)\n }\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.stopPropagation()\n cancel()\n }\n }\n\n // focus loss mid-gesture (alt-tab, OS dialogs) means the release may never\n // be delivered; treat it as a cancel so the gesture cannot get stuck and\n // commit its stale proposal on the next click\n const onWindowBlur = () => cancel()\n\n const onPointerMove = (e: PointerEvent) => {\n // a second finger must not drive - or cancel the pending long press of -\n // the gesture this pointer started\n if (e.pointerId !== pointerId) return\n lastPointer = e\n if (!active) {\n const distance = Math.hypot(e.clientX - startX, e.clientY - startY)\n if (isTouch) {\n // Long-press pending: moving past tolerance means scroll, not drag\n if (distance > activation.touchTolerancePx) cancel()\n return\n }\n if (distance < activationDistance) return\n activate()\n }\n applyProposal(e)\n autoScroll(e)\n updateHint(e)\n positionCarry(e)\n }\n\n const onPointerUp = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cleanup()\n if (!active) return\n lastGestureEndedAt = performance.now()\n\n const state = instance.getState()\n if (kind === \"create\") {\n const draft = state.slotDraft\n internals.setSlotDraft(null)\n if (draft) {\n api.select({\n slot: { start: draft.start, end: draft.end, allDay: draft.allDay },\n })\n settings.onSelectSlot?.(draft)\n }\n return\n }\n const drag = state.drag\n internals.setDrag(null)\n if (!drag || !occurrence) return\n const unchanged =\n drag.proposedStart.getTime() === occurrence.start.getTime() &&\n drag.proposedEnd.getTime() === occurrence.end.getTime() &&\n (drag.proposedResourceId === undefined ||\n drag.proposedResourceId === occurrence.event.resourceId)\n if (unchanged) return\n // Commit through the one validation funnel; consumer reject = automatic\n // revert because the calendar never mutated during the gesture.\n const accepted = internals.applyProposedUpdate({\n event: occurrence.event,\n occurrence,\n start: drag.proposedStart,\n end: drag.proposedEnd,\n allDay: drag.proposedAllDay,\n resourceId: drag.proposedResourceId,\n source:\n kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n })\n // the polite live region is the only feedback a screen-reader user gets\n // that the drop landed, and on what; a vetoed commit stays silent\n if (accepted && announcer) {\n announcer.textContent = `${occurrence.event.title}, ${settings.i18n.functions.formatEventTime(\n toZoned(drag.proposedStart, timeZone),\n toZoned(drag.proposedEnd, timeZone),\n drag.proposedAllDay,\n { locale: settings.locale }\n )}`\n }\n }\n\n const onCancel = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cancel()\n }\n\n window.addEventListener(\"pointermove\", onPointerMove)\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onCancel)\n // a resize on a precise pointer is live from the first frame, so it never\n // reaches activate() to arm its own blur cancel\n if (active) window.addEventListener(\"blur\", onWindowBlur)\n window.addEventListener(\"keydown\", onKeyDown, true)\n activeGestureCancels.add(cancel)\n\n // Touch: long-press activation (movement past tolerance cancels above)\n if (isTouch && !active) {\n touchTimer = setTimeout(() => {\n activate()\n applyProposal(lastPointer)\n }, activation.touchDelayMs)\n }\n}\n\n/** Per-chip / per-surface pointer gesture wiring. */\nfunction useEventCalendarGestures() {\n const instance = useEventCalendar()\n // Overlay classNames bridged from React config into the vanilla-DOM engine\n // (which must never import from the chip module - circular).\n const { classNames } = useEventCalendarViewConfig()\n const ui = useMemo(\n () => ({\n dragCarry: classNames?.dragCarry,\n dragCarryInvalid: classNames?.dragCarryInvalid,\n dropHint: classNames?.dropHint,\n }),\n [classNames]\n )\n\n // An in-flight gesture lives on window listeners and body-appended overlays,\n // so it must never outlive the calendar. Chips hold this hook too and they\n // legitimately unmount mid-gesture, so only the LAST consumer leaving - the\n // calendar itself going away - aborts.\n useEffect(() => {\n gestureConsumers += 1\n return () => {\n gestureConsumers -= 1\n if (gestureConsumers === 0) cancelActiveEventCalendarGestures()\n }\n }, [])\n\n const canDrag = useCallback(\n (segment: EventCalendarSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.drag && !event.readOnly && event.draggable !== false\n },\n [instance]\n )\n\n const canResize = useCallback(\n (segment: EventCalendarSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.resize && !event.readOnly && event.resizable !== false\n },\n [instance]\n )\n\n const beginMove = useCallback(\n (e: React.PointerEvent, segment: EventCalendarSegment) => {\n if (e.button !== 0) return\n if (!canDrag(segment)) {\n // refused, but still give feedback + broadcast on a real drag attempt\n beginBlockedGesture(instance, e.nativeEvent, segment, \"move\")\n return\n }\n beginGesture({\n instance,\n kind: \"move\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n ui,\n })\n },\n [instance, canDrag, ui]\n )\n\n const beginResize = useCallback(\n (\n e: React.PointerEvent,\n segment: EventCalendarSegment,\n edge: \"start\" | \"end\"\n ) => {\n if (e.button !== 0) return\n if (!canResize(segment)) {\n e.stopPropagation()\n beginBlockedGesture(instance, e.nativeEvent, segment, \"resize\")\n return\n }\n e.stopPropagation()\n e.preventDefault()\n beginGesture({\n instance,\n kind: edge === \"start\" ? \"resize-start\" : \"resize-end\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n ui,\n })\n },\n [instance, canResize, ui]\n )\n\n const beginCreate = useCallback(\n (e: React.PointerEvent, day: Date, allDay: boolean) => {\n if (e.button !== 0) return\n if (!instance.getState().interactions.selectSlot) return\n beginGesture({\n instance,\n kind: \"create\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n createDay: day,\n createAllDay: allDay,\n ui,\n })\n },\n [instance, ui]\n )\n\n return { beginMove, beginResize, beginCreate, canDrag, canResize }\n}\n\nexport {\n EVENT_CALENDAR_ACTIVATION,\n cancelActiveEventCalendarGestures,\n markChipPress,\n useEventCalendarGestures,\n wasRecentChipPress,\n wasRecentDrag,\n}","target":"components/neui/event-calendar/event-calendar-dnd.tsx"}]}