{"$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":["@reui/event-calendar","@reui/event-calendar-lib","@reui/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/reui/event-calendar/event-calendar\"\nimport {\n  snapMinutes,\n  toZoned,\n  zonedStartOfDay,\n} from \"@/components/reui/event-calendar/event-calendar-lib\"\nimport type {\n  EventCalendarProposedUpdate,\n  EventCalendarSegment,\n} from \"@/components/reui/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<HTMLElement>(\"[data-slot=event-calendar-time-grid]\") ??\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar-resource-view]\") ??\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar-month-view]\") ??\n    origin.closest<HTMLElement>(\"[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<HTMLElement>(\"[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<HTMLElement>(\"[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<TData> {\n  instance: EventCalendarInstance<TData>\n  kind: GestureKind\n  origin: HTMLElement\n  startEvent: PointerEvent\n  segment?: EventCalendarSegment<TData>\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<TData>(\n  instance: EventCalendarInstance<TData>,\n  startEvent: PointerEvent,\n  segment: EventCalendarSegment<TData>,\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<TData>(config: BeginGestureConfig<TData>) {\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<HTMLElement>(\"[data-slot=event-calendar]\")\n      ?.querySelector<HTMLElement>(\"[data-slot=event-calendar-announcer]\") ??\n    internals\n      .getRootEl()\n      ?.querySelector<HTMLElement>(\"[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<typeof setTimeout> | 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<TData> = {\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<HTMLElement>(\"[data-slot=event-calendar-event]\") ?? origin\n    const rect = chip.getBoundingClientRect()\n    // The clone is re-parented to <body>, 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<TData = unknown>() {\n  const instance = useEventCalendar<TData>()\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<TData>()\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<TData>) => {\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<TData>) => {\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<TData>) => {\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<TData>,\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/reui/event-calendar/event-calendar-dnd.tsx"}]}