@taskgenius/calendar
Version:
A lightweight, configurable TypeScript calendar component with drag-and-drop support
1 lines • 276 kB
Source Map (JSON)
{"version":3,"file":"presets-BUMiolmt.cjs","sources":["../src/utils/dom.ts","../src/core/DragController.ts","../src/core/EventManager.ts","../src/core/InteractionController.ts","../src/styles/index.ts","../src/constants/formats.ts","../src/views/BaseView.ts","../src/views/ViewRegistry.ts","../src/engines/MonthEngine.ts","../src/renderers/MonthRenderer.ts","../src/views/MonthView.ts","../src/engines/TimeEngine.ts","../src/renderers/TimeRenderer.ts","../src/views/TimeView.ts","../src/core/Calendar.ts","../src/presets.ts"],"sourcesContent":["/**\n * DOM utility functions for building calendar UI\n */\n\n/**\n * Create an HTML element with optional class and attributes\n *\n * @param tag - HTML tag name\n * @param className - CSS class string\n * @param attributes - Additional attributes to set\n * @returns Created element\n */\nexport function createElement<K extends keyof HTMLElementTagNameMap>(\n tag: K,\n className?: string,\n attributes?: Record<string, string>\n): HTMLElementTagNameMap[K] {\n const el = document.createElement(tag);\n\n if (className) {\n el.className = className;\n }\n\n if (attributes) {\n for (const [key, value] of Object.entries(attributes)) {\n el.setAttribute(key, value);\n }\n }\n\n return el;\n}\n\n/**\n * Set multiple styles on an element\n *\n * @param el - Target element\n * @param styles - Style properties to set\n */\nexport function setStyles(\n el: HTMLElement,\n styles: Partial<CSSStyleDeclaration>\n): void {\n Object.assign(el.style, styles);\n}\n\n/**\n * Clear all child elements from an element\n *\n * @param el - Target element\n */\nexport function clearElement(el: HTMLElement): void {\n el.innerHTML = '';\n}\n\n/**\n * Append multiple children to a parent element\n *\n * @param parent - Parent element\n * @param children - Children to append\n */\nexport function appendChildren(\n parent: HTMLElement,\n children: HTMLElement[]\n): void {\n for (const child of children) {\n parent.appendChild(child);\n }\n}\n\n/**\n * Query selector with type safety\n *\n * @param selector - CSS selector\n * @param parent - Parent element to search within\n * @returns Found element or null\n */\nexport function querySelector<T extends HTMLElement>(\n selector: string,\n parent: HTMLElement | Document = document\n): T | null {\n return parent.querySelector<T>(selector);\n}\n\n/**\n * Query all matching elements with type safety\n *\n * @param selector - CSS selector\n * @param parent - Parent element to search within\n * @returns NodeList of found elements\n */\nexport function querySelectorAll<T extends HTMLElement>(\n selector: string,\n parent: HTMLElement | Document = document\n): NodeListOf<T> {\n return parent.querySelectorAll<T>(selector);\n}\n\n/**\n * Add event listener with automatic cleanup\n *\n * @param el - Target element\n * @param event - Event name\n * @param handler - Event handler\n * @param options - Event listener options\n * @returns Cleanup function\n */\nexport function addListener<K extends keyof HTMLElementEventMap>(\n el: HTMLElement | Document,\n event: K,\n handler: (e: HTMLElementEventMap[K]) => void,\n options?: boolean | AddEventListenerOptions\n): () => void {\n el.addEventListener(event, handler as EventListener, options);\n return () => el.removeEventListener(event, handler as EventListener, options);\n}\n\n/**\n * Get element's position relative to another element\n *\n * @param el - Target element\n * @param relativeTo - Reference element (default: document.body)\n * @returns Position object with top, left, width, height\n */\nexport function getRelativePosition(\n el: HTMLElement,\n relativeTo: HTMLElement = document.body\n): { top: number; left: number; width: number; height: number } {\n const elRect = el.getBoundingClientRect();\n const refRect = relativeTo.getBoundingClientRect();\n\n return {\n top: elRect.top - refRect.top,\n left: elRect.left - refRect.left,\n width: elRect.width,\n height: elRect.height\n };\n}\n","/**\n * Drag and drop controller for calendar events\n */\nimport type {\n DateAdapter,\n CalendarEvent,\n DraggableConfig,\n DragState,\n DragMode,\n DateFormatConfig,\n} from \"../types\";\nimport { createElement, setStyles, querySelectorAll } from \"../utils/dom\";\n\n/**\n * Handles drag and drop interactions for calendar events\n */\nexport class DragController<T> {\n private state: DragState<T> | null = null;\n private proxyElement: HTMLElement | null = null;\n private boundOnMove: (e: MouseEvent) => void;\n private boundOnUp: (e: MouseEvent) => void;\n\n /**\n * Safely compute cell width even when offsetWidth is 0 (e.g., JSDOM).\n */\n private resolveCellWidth(\n container: HTMLElement,\n columnCount: number,\n ): number {\n const rectWidth = container.getBoundingClientRect().width;\n const computedWidth =\n parseFloat(getComputedStyle(container).width || \"0\") || 0;\n const baseWidth =\n container.offsetWidth || rectWidth || computedWidth || columnCount || 1;\n return baseWidth / Math.max(columnCount, 1);\n }\n\n /**\n * Get the column count from a container's --tg-allday-columns CSS variable.\n * Uses getComputedStyle to read both inline and stylesheet values (DRY helper).\n */\n private getAllDayColumnCount(container: HTMLElement): number {\n const columnsVar = getComputedStyle(container).getPropertyValue(\n \"--tg-allday-columns\",\n );\n return parseInt(columnsVar, 10) || 7;\n }\n\n constructor(\n private adapter: DateAdapter<T>,\n private config: Required<DraggableConfig>,\n private onDrop: (\n event: CalendarEvent,\n newStart: Date,\n newEnd: Date,\n ) => void,\n private onResize: (\n event: CalendarEvent,\n newStart: Date,\n newEnd: Date,\n ) => void,\n private cellHeight: number = 60,\n private dateFormats: Required<DateFormatConfig>,\n ) {\n this.boundOnMove = this.onMove.bind(this);\n this.boundOnUp = this.onUp.bind(this);\n }\n\n /**\n * Initialize drag for a month view event\n */\n initMonthDrag(\n el: HTMLElement,\n event: CalendarEvent,\n renderCallback: () => void,\n ): void {\n if (!this.config.enabled) return;\n\n el.onmousedown = (e: MouseEvent) => {\n if (e.button !== 0) return;\n // Runtime check: support dynamic disable\n if (!this.config.enabled) return;\n e.stopPropagation();\n\n // Support both month view rows and all-day section\n const row = el.closest(\".tg-month-row\") as HTMLElement | null;\n const allDayContainer = el.closest(\n \".tg-allday-events-container\",\n ) as HTMLElement | null;\n\n let cellW: number;\n let columnCount: number;\n\n if (row) {\n // Month view\n columnCount = 7;\n cellW = this.resolveCellWidth(row, columnCount);\n } else if (allDayContainer) {\n // All-day section in time view - use getComputedStyle for stylesheet values\n columnCount = this.getAllDayColumnCount(allDayContainer);\n cellW = this.resolveCellWidth(allDayContainer, columnCount);\n } else {\n return;\n }\n\n let mode: DragMode = \"move\";\n\n const target = e.target as HTMLElement;\n if (target.classList.contains(\"tg-left\")) mode = \"resize-left\";\n if (target.classList.contains(\"tg-right\")) mode = \"resize-right\";\n\n // Calculate click offset in days within the event's span\n // For multi-day events, this determines which day the user clicked on\n const startDate = this.adapter.parse(event.start);\n const endDate = this.adapter.parse(event.end);\n const eventDurationDays =\n this.adapter.diff(\n this.adapter.startOf(endDate, \"day\"),\n this.adapter.startOf(startDate, \"day\"),\n \"day\",\n ) + 1;\n\n // Calculate click position relative to the event element\n const elRect = el.getBoundingClientRect();\n const clickXInEvent = e.clientX - elRect.left;\n const eventWidth = elRect.width || cellW; // fallback to cellW if width is 0\n\n // Calculate which day within the event was clicked\n // For single-day events, this will always be 0\n const clickOffsetDays = Math.max(\n 0,\n Math.min(\n eventDurationDays - 1,\n Math.floor((clickXInEvent / eventWidth) * eventDurationDays),\n ),\n );\n\n this.startDrag({\n type: \"month\",\n mode,\n event,\n startX: e.clientX,\n startY: e.clientY,\n startDate,\n endDate,\n cellW,\n renderCallback,\n clickOffsetDays,\n });\n };\n }\n\n /**\n * Initialize drag for a time view event\n */\n initTimeDrag(\n el: HTMLElement,\n event: CalendarEvent,\n renderCallback: () => void,\n ): void {\n if (!this.config.enabled) return;\n\n el.onmousedown = (e: MouseEvent) => {\n if (e.button !== 0) return;\n // Runtime check: support dynamic disable\n if (!this.config.enabled) return;\n e.stopPropagation();\n\n const col = el.closest(\".tg-day-column\") as HTMLElement | null;\n if (!col) return;\n\n const colRect = col.getBoundingClientRect();\n let mode: DragMode = \"move\";\n\n const target = e.target as HTMLElement;\n if (target.classList.contains(\"tg-resize-v\")) {\n mode = target.classList.contains(\"tg-top\")\n ? \"resize-top\"\n : \"resize-bottom\";\n }\n\n const startDate = this.adapter.parse(event.start);\n const endDate = this.adapter.parse(event.end);\n\n this.startDrag({\n type: \"time\",\n mode,\n event,\n startX: e.clientX,\n startY: e.clientY,\n startDate,\n endDate,\n colW: colRect.width,\n renderCallback,\n origStartMin:\n this.adapter.hour(startDate) * 60 + this.adapter.minute(startDate),\n origDuration: this.adapter.diff(endDate, startDate, \"minute\"),\n });\n };\n }\n\n /**\n * Update cell height for time calculations\n */\n setCellHeight(height: number): void {\n this.cellHeight = height;\n }\n\n /**\n * Check if currently dragging an event\n * Used by InteractionController to prevent conflicts with range selection\n */\n isDragging(): boolean {\n return this.state !== null;\n }\n\n /**\n * Cleanup resources\n */\n destroy(): void {\n document.removeEventListener(\"mousemove\", this.boundOnMove);\n document.removeEventListener(\"mouseup\", this.boundOnUp);\n this.removeProxy();\n this.state = null;\n }\n\n // ==========================================================================\n // Private Methods\n // ==========================================================================\n\n private startDrag(stateData: DragState<T>): void {\n this.state = stateData;\n document.body.style.cursor = \"grabbing\";\n\n // Mark source element\n querySelectorAll(`[data-eid=\"${stateData.event.id}\"]`).forEach((el) => {\n el.classList.add(\"tg-is-dragging-source\");\n });\n\n // Create proxy element\n this.createProxy(stateData.event);\n\n document.addEventListener(\"mousemove\", this.boundOnMove);\n document.addEventListener(\"mouseup\", this.boundOnUp);\n }\n\n private createProxy(event: CalendarEvent): void {\n // Check for existing proxy\n this.proxyElement = document.getElementById(\n \"tg-drag-proxy\",\n ) as HTMLElement | null;\n\n if (!this.proxyElement) {\n this.proxyElement = createElement(\"div\", undefined, {\n id: \"tg-drag-proxy\",\n });\n document.body.appendChild(this.proxyElement);\n }\n\n this.proxyElement.textContent = event.title;\n setStyles(this.proxyElement, {\n backgroundColor: event.color || \"#3b82f6\",\n color: \"white\",\n padding: \"4px 8px\",\n fontSize: \"12px\",\n });\n }\n\n private removeProxy(): void {\n if (this.proxyElement) {\n this.proxyElement.style.visibility = \"hidden\";\n }\n }\n\n private onMove(e: MouseEvent): void {\n if (!this.state) return;\n\n // Update proxy position\n if (this.proxyElement) {\n setStyles(this.proxyElement, {\n visibility: \"visible\",\n left: `${e.clientX + 10}px`,\n top: `${e.clientY + 10}px`,\n transform: \"none\",\n });\n }\n\n if (this.state.type === \"month\") {\n this.handleMonthMove(e);\n } else {\n this.handleTimeMove(e);\n }\n }\n\n private handleMonthMove(e: MouseEvent): void {\n const s = this.state!;\n\n // Support both month view rows and all-day section\n const row = document\n .elementFromPoint(e.clientX, e.clientY)\n ?.closest(\".tg-month-row\") as HTMLElement | null;\n const allDayContainer = document\n .elementFromPoint(e.clientX, e.clientY)\n ?.closest(\".tg-allday-events-container\") as HTMLElement | null;\n\n let hoverDate: T;\n\n if (row?.dataset[\"date\"]) {\n // Month view row - recalculate cellW from current container width\n const rowStart = this.adapter.parse(row.dataset[\"date\"]);\n const rowRect = row.getBoundingClientRect();\n const currentCellW = rowRect.width / 7;\n const cellIdx = Math.floor(\n (e.clientX - rowRect.left) / (currentCellW || 1),\n );\n hoverDate = this.adapter.add(\n rowStart,\n Math.max(0, Math.min(6, cellIdx)),\n \"day\",\n );\n } else if (allDayContainer) {\n // All-day section - find which column we're over\n const rect = allDayContainer.getBoundingClientRect();\n // Use getComputedStyle helper for stylesheet-defined values (DRY)\n const columnCount = this.getAllDayColumnCount(allDayContainer);\n const colWidth = rect.width / columnCount;\n const colIdx = Math.floor((e.clientX - rect.left) / colWidth);\n\n // Scope lookup to the current calendar container (SRP - avoid cross-calendar coupling)\n const calendarContainer = allDayContainer.closest(\n \".tg-time-view, .tg-calendar\",\n );\n if (!calendarContainer) return;\n\n // Get base date from the first day column within THIS calendar instance\n const dayColumn = calendarContainer.querySelector(\n \".tg-day-column[data-date]\",\n ) as HTMLElement | null;\n if (dayColumn?.dataset[\"date\"]) {\n const baseDate = this.adapter.parse(dayColumn.dataset[\"date\"]);\n hoverDate = this.adapter.add(\n baseDate,\n Math.max(0, Math.min(columnCount - 1, colIdx)),\n \"day\",\n );\n } else {\n return;\n }\n } else {\n return;\n }\n\n let newStart = s.startDate;\n let newEnd = s.endDate;\n\n if (s.mode === \"move\") {\n // Calculate duration in calendar days (date part only, ignoring time)\n // This correctly handles cross-midnight events like 22:00 -> 02:00 next day\n const startDateOnly = this.adapter.startOf(s.startDate, \"day\");\n const endDateOnly = this.adapter.startOf(s.endDate, \"day\");\n const duration = this.adapter.diff(endDateOnly, startDateOnly, \"day\");\n\n // Adjust for click offset: if user clicked in the middle of a multi-day event,\n // the new start should account for that offset to keep the event under the cursor\n const clickOffset = s.clickOffsetDays || 0;\n const adjustedHoverDate = this.adapter.add(\n hoverDate,\n -clickOffset,\n \"day\",\n );\n\n // DEBUG: Log drag calculation details\n console.log(\"[DragController] handleMonthMove - move mode:\", {\n eventTitle: s.event.title,\n originalStart: this.adapter.format(s.startDate, \"yyyy-MM-dd HH:mm\"),\n originalEnd: this.adapter.format(s.endDate, \"yyyy-MM-dd HH:mm\"),\n startDateOnly: this.adapter.format(startDateOnly, \"yyyy-MM-dd\"),\n endDateOnly: this.adapter.format(endDateOnly, \"yyyy-MM-dd\"),\n duration,\n clickOffset,\n hoverDate: this.adapter.format(hoverDate, \"yyyy-MM-dd HH:mm\"),\n adjustedHoverDate: this.adapter.format(\n adjustedHoverDate,\n \"yyyy-MM-dd HH:mm\",\n ),\n dateOnly: this.config.dateOnly,\n });\n\n if (this.config.dateOnly) {\n // Date-only mode: calculate date difference and preserve time\n const daysDiff = this.adapter.diff(\n adjustedHoverDate,\n startDateOnly,\n \"day\",\n );\n newStart = this.adapter.add(s.startDate, daysDiff, \"day\");\n newEnd = this.adapter.add(s.endDate, daysDiff, \"day\");\n } else {\n // Normal mode: preserve original time while adjusting date\n const originalStartHour = this.adapter.hour(s.startDate);\n const originalStartMinute = this.adapter.minute(s.startDate);\n const originalEndHour = this.adapter.hour(s.endDate);\n const originalEndMinute = this.adapter.minute(s.endDate);\n\n newStart = this.adapter.setMinute(\n this.adapter.setHour(adjustedHoverDate, originalStartHour),\n originalStartMinute,\n );\n newEnd = this.adapter.setMinute(\n this.adapter.setHour(\n this.adapter.add(adjustedHoverDate, duration, \"day\"),\n originalEndHour,\n ),\n originalEndMinute,\n );\n }\n\n // DEBUG: Log calculated new dates\n console.log(\n \"[DragController] handleMonthMove - calculated newStart/newEnd:\",\n {\n newStart: this.adapter.format(newStart, \"yyyy-MM-dd HH:mm\"),\n newEnd: this.adapter.format(newEnd, \"yyyy-MM-dd HH:mm\"),\n },\n );\n } else if (s.mode === \"resize-right\") {\n // Resize right: keep start unchanged, adjust end\n newStart = s.startDate;\n if (this.config.dateOnly) {\n // Preserve time when resizing in date-only mode\n const originalHour = this.adapter.hour(s.endDate);\n const originalMinute = this.adapter.minute(s.endDate);\n newEnd = this.adapter.setMinute(\n this.adapter.setHour(hoverDate, originalHour),\n originalMinute,\n );\n\n // Handle cross-midnight events: if end time is before start time on same day,\n // set end to end of day (23:59)\n if (\n this.adapter.isSame(newEnd, newStart, \"day\") &&\n this.adapter.isBefore(newEnd, newStart)\n ) {\n newEnd = this.adapter.setMinute(\n this.adapter.setHour(hoverDate, 23),\n 59,\n );\n }\n } else {\n newEnd = hoverDate;\n }\n if (this.adapter.isBefore(newEnd, newStart)) {\n newEnd = newStart;\n }\n } else if (s.mode === \"resize-left\") {\n // Resize left: keep end unchanged, adjust start\n newEnd = s.endDate;\n if (this.config.dateOnly) {\n // Preserve time when resizing in date-only mode\n const originalHour = this.adapter.hour(s.startDate);\n const originalMinute = this.adapter.minute(s.startDate);\n newStart = this.adapter.setMinute(\n this.adapter.setHour(hoverDate, originalHour),\n originalMinute,\n );\n\n // Handle cross-midnight events: if start time is after end time on same day,\n // set start to beginning of day (00:00)\n if (\n this.adapter.isSame(newStart, newEnd, \"day\") &&\n this.adapter.isAfter(newStart, newEnd)\n ) {\n newStart = this.adapter.setMinute(\n this.adapter.setHour(hoverDate, 0),\n 0,\n );\n }\n } else {\n newStart = hoverDate;\n }\n if (this.adapter.isAfter(newStart, newEnd)) {\n newStart = newEnd;\n }\n }\n\n this.renderMonthGhost(newStart, newEnd);\n s.tentativeStart = newStart;\n s.tentativeEnd = newEnd;\n }\n\n private handleTimeMove(e: MouseEvent): void {\n const s = this.state!;\n const col = document\n .elementFromPoint(e.clientX, e.clientY)\n ?.closest(\".tg-day-column\") as HTMLElement | null;\n\n if (!col?.dataset[\"date\"]) return;\n\n const newDateBase = this.adapter.parse(col.dataset[\"date\"]);\n const rect = col.getBoundingClientRect();\n const relY = e.clientY - rect.top;\n\n const rawMins = (relY / this.cellHeight) * 60;\n const snapMinutes = this.config.dateOnly\n ? 1440 // Date-only mode: snap to full day (24 hours)\n : this.config.snapMinutes || 15;\n const snappedMins = Math.max(\n 0,\n Math.min(1440, Math.round(rawMins / snapMinutes) * snapMinutes),\n );\n\n let newStart: T;\n let newEnd: T;\n\n if (s.mode === \"move\") {\n if (this.config.dateOnly) {\n // Date-only mode: only adjust date, keep original time\n const daysDiff = this.adapter.diff(newDateBase, s.startDate, \"day\");\n newStart = this.adapter.add(s.startDate, daysDiff, \"day\");\n newEnd = this.adapter.add(s.endDate, daysDiff, \"day\");\n } else {\n // Normal mode: allow full time adjustment\n newStart = this.adapter.setMinute(\n this.adapter.setHour(newDateBase, 0),\n snappedMins,\n );\n newEnd = this.adapter.add(newStart, s.origDuration || 60, \"minute\");\n }\n } else if (s.mode === \"resize-top\") {\n // Resize top: adjust start time, keep end time unchanged\n newEnd = s.endDate;\n\n if (this.config.dateOnly) {\n // Date-only mode: only adjust date, keep original time\n const daysDiff = this.adapter.diff(newDateBase, s.startDate, \"day\");\n newStart = this.adapter.add(s.startDate, daysDiff, \"day\");\n } else {\n if (this.adapter.isSame(newDateBase, s.endDate, \"day\")) {\n // Same day: ensure start time is before end time with minimum 15 minutes\n const endMin =\n this.adapter.hour(s.endDate) * 60 + this.adapter.minute(s.endDate);\n const startMins = Math.min(endMin - 15, snappedMins);\n newStart = this.adapter.setMinute(\n this.adapter.setHour(newDateBase, 0),\n startMins,\n );\n } else {\n // Different day: allow any time\n newStart = this.adapter.setMinute(\n this.adapter.setHour(newDateBase, 0),\n snappedMins,\n );\n }\n }\n\n // Clamp newStart to not exceed newEnd (LSP/time invariant: start <= end)\n // Prevents inverted dates when dragging top handle past end time\n if (this.adapter.isAfter(newStart, newEnd)) {\n newStart = newEnd;\n }\n } else {\n // resize-bottom\n if (this.config.dateOnly) {\n // In date-only mode, resizing only changes the date part\n newStart = s.startDate;\n const daysDiff = this.adapter.diff(newDateBase, s.endDate, \"day\");\n newEnd = this.adapter.add(s.endDate, daysDiff, \"day\");\n } else {\n newStart = s.startDate;\n\n if (this.adapter.isSame(newDateBase, s.startDate, \"day\")) {\n const endMins = Math.max((s.origStartMin || 0) + 15, snappedMins);\n newEnd = this.adapter.setMinute(\n this.adapter.setHour(newDateBase, 0),\n endMins,\n );\n } else {\n newEnd = s.endDate;\n }\n }\n }\n\n this.renderTimeGhost(newStart, newEnd);\n s.tentativeStart = newStart;\n s.tentativeEnd = newEnd;\n }\n\n private renderMonthGhost(start: T, end: T): void {\n // Remove existing ghosts\n querySelectorAll(\".tg-ghost-event\").forEach((el) => el.remove());\n\n // Try month view rows first\n const rows = querySelectorAll<HTMLElement>(\".tg-month-row\");\n\n if (rows.length > 0) {\n // Month view ghost rendering - use dynamic column width calculation\n const columnCount = 7;\n const colWidth = 100 / columnCount;\n\n for (const row of rows) {\n if (!row.dataset[\"date\"]) continue;\n\n const rStart = this.adapter.parse(row.dataset[\"date\"]);\n const rEnd = this.adapter.add(rStart, columnCount - 1, \"day\");\n\n // Check if event overlaps this row\n if (\n !this.adapter.isBefore(end, rStart) &&\n !this.adapter.isAfter(start, rEnd)\n ) {\n const dStart = this.adapter.isBefore(start, rStart) ? rStart : start;\n const dEnd = this.adapter.isAfter(end, rEnd) ? rEnd : end;\n\n const startIdx = this.adapter.diff(dStart, rStart, \"day\");\n const span = this.adapter.diff(dEnd, dStart, \"day\") + 1;\n\n const left = startIdx * colWidth;\n const width = span * colWidth;\n const ghostEndIdx = startIdx + span - 1;\n\n // Calculate top position based on events that overlap with ghost's column range\n // Only consider events whose columns intersect with the ghost's columns\n const eventBars = row.querySelectorAll<HTMLElement>(\".tg-event-bar\");\n let maxTop = 26; // Base offset for date numbers\n for (const bar of eventBars) {\n // Parse the bar's left percentage to determine its start column\n const barLeftMatch = bar.style.left.match(/calc\\(\\s*([\\d.]+)%/);\n const barWidthMatch = bar.style.width.match(/calc\\(\\s*([\\d.]+)%/);\n if (!barLeftMatch?.[1] || !barWidthMatch?.[1]) continue;\n\n const barLeftPct = parseFloat(barLeftMatch[1]);\n const barWidthPct = parseFloat(barWidthMatch[1]);\n const barStartIdx = Math.round(barLeftPct / colWidth);\n const barSpan = Math.round(barWidthPct / colWidth);\n const barEndIdx = barStartIdx + barSpan - 1;\n\n // Check if this event overlaps with the ghost's column range\n if (barStartIdx <= ghostEndIdx && barEndIdx >= startIdx) {\n const barTop = parseFloat(bar.style.top) || 0;\n const barHeight = bar.offsetHeight || 26;\n maxTop = Math.max(maxTop, barTop + barHeight);\n }\n }\n\n const ghost = createElement(\"div\", \"tg-ghost-event\");\n setStyles(ghost, {\n left: `calc(${left}% + 2px)`,\n width: `calc(${width}% - 4px)`,\n top: `${maxTop + 2}px`,\n height: \"26px\",\n });\n\n row.appendChild(ghost);\n }\n }\n } else {\n // All-day section ghost rendering\n const allDayContainer = document.querySelector(\n \".tg-allday-events-container\",\n ) as HTMLElement | null;\n\n if (!allDayContainer) return;\n\n // Use getComputedStyle helper for stylesheet-defined values (DRY)\n const columnCount = this.getAllDayColumnCount(allDayContainer);\n\n // Scope lookup to the current calendar container (SRP - avoid cross-calendar coupling)\n const calendarContainer = allDayContainer.closest(\n \".tg-time-view, .tg-calendar\",\n );\n if (!calendarContainer) return;\n\n // Get day columns within THIS calendar instance\n const dayColumns = Array.from(\n calendarContainer.querySelectorAll<HTMLElement>(\n \".tg-day-column[data-date]\",\n ),\n );\n if (dayColumns.length === 0) return;\n\n const firstColDateStr = dayColumns[0]?.dataset[\"date\"];\n const lastColDateStr = dayColumns[dayColumns.length - 1]?.dataset[\"date\"];\n if (!firstColDateStr || !lastColDateStr) return;\n\n const firstColDate = this.adapter.parse(firstColDateStr);\n const lastColDate = this.adapter.parse(lastColDateStr);\n\n // Check if event overlaps visible range\n if (\n !this.adapter.isBefore(end, firstColDate, \"day\") &&\n !this.adapter.isAfter(start, lastColDate, \"day\")\n ) {\n const dStart = this.adapter.isBefore(start, firstColDate)\n ? firstColDate\n : start;\n const dEnd = this.adapter.isAfter(end, lastColDate) ? lastColDate : end;\n\n const startIdx = this.adapter.diff(dStart, firstColDate, \"day\");\n const span = this.adapter.diff(dEnd, dStart, \"day\") + 1;\n\n const colWidth = 100 / columnCount;\n const left = startIdx * colWidth;\n const width = span * colWidth;\n const ghostEndIdx = startIdx + span - 1;\n\n // Calculate top position based on events that overlap with ghost's column range\n // Use .tg-allday-event for all-day section (not .tg-event-bar which is for month view)\n const eventBars =\n allDayContainer.querySelectorAll<HTMLElement>(\".tg-allday-event\");\n let maxTop = 0;\n for (const bar of eventBars) {\n // Parse the bar's left percentage to determine its start column\n const barLeftMatch = bar.style.left.match(/calc\\(\\s*([\\d.]+)%/);\n const barWidthMatch = bar.style.width.match(/calc\\(\\s*([\\d.]+)%/);\n if (!barLeftMatch?.[1] || !barWidthMatch?.[1]) continue;\n\n const barLeftPct = parseFloat(barLeftMatch[1]);\n const barWidthPct = parseFloat(barWidthMatch[1]);\n const barStartIdx = Math.round(barLeftPct / colWidth);\n const barSpan = Math.round(barWidthPct / colWidth);\n const barEndIdx = barStartIdx + barSpan - 1;\n\n // Check if this event overlaps with the ghost's column range\n if (barStartIdx <= ghostEndIdx && barEndIdx >= startIdx) {\n const barTop = parseFloat(bar.style.top) || 0;\n const barHeight = bar.offsetHeight || 22;\n maxTop = Math.max(maxTop, barTop + barHeight);\n }\n }\n\n const ghost = createElement(\"div\", \"tg-ghost-event\");\n setStyles(ghost, {\n left: `calc(${left}% + 2px)`,\n width: `calc(${width}% - 4px)`,\n top: `${maxTop + 4}px`,\n height: \"22px\",\n });\n\n allDayContainer.appendChild(ghost);\n }\n }\n }\n\n private renderTimeGhost(start: T, end: T): void {\n // Remove existing ghosts\n querySelectorAll(\".tg-ghost-event\").forEach((el) => el.remove());\n\n const dateStr = this.adapter.format(start, this.dateFormats.date);\n const col = document.querySelector(\n `.tg-day-column[data-date=\"${dateStr}\"]`,\n ) as HTMLElement | null;\n\n if (!col) return;\n\n const startMin = this.adapter.hour(start) * 60 + this.adapter.minute(start);\n const top = startMin * (this.cellHeight / 60);\n const height =\n this.adapter.diff(end, start, \"minute\") * (this.cellHeight / 60);\n\n const ghost = createElement(\"div\", \"tg-ghost-event\");\n setStyles(ghost, {\n top: `${top}px`,\n height: `${height}px`,\n width: \"90%\",\n left: \"5%\",\n });\n\n ghost.textContent = `${this.adapter.format(start, this.dateFormats.time)} - ${this.adapter.format(end, this.dateFormats.time)}`;\n setStyles(ghost, {\n color: \"#3b82f6\",\n fontSize: \"10px\",\n padding: \"2px\",\n });\n\n col.appendChild(ghost);\n }\n\n private onUp(_e: MouseEvent): void {\n if (!this.state) return;\n\n const s = this.state;\n\n // Cleanup\n document.body.style.cursor = \"\";\n this.removeProxy();\n\n querySelectorAll(\".tg-ghost-event\").forEach((el) => el.remove());\n querySelectorAll(\".tg-is-dragging-source\").forEach((el) => {\n el.classList.remove(\"tg-is-dragging-source\");\n });\n\n document.removeEventListener(\"mousemove\", this.boundOnMove);\n document.removeEventListener(\"mouseup\", this.boundOnUp);\n\n // Apply changes if we have tentative dates\n if (s.tentativeStart && s.tentativeEnd) {\n // Convert adapter dates to native Date objects\n const newStart = this.toDate(s.tentativeStart);\n const newEnd = this.toDate(s.tentativeEnd);\n\n // DEBUG: Log final dates being applied\n console.log(\"[DragController] onUp - applying changes:\", {\n eventTitle: s.event.title,\n mode: s.mode,\n tentativeStart: this.adapter.format(\n s.tentativeStart,\n \"yyyy-MM-dd HH:mm\",\n ),\n tentativeEnd: this.adapter.format(s.tentativeEnd, \"yyyy-MM-dd HH:mm\"),\n newStartDate: newStart.toISOString(),\n newEndDate: newEnd.toISOString(),\n });\n\n // Call appropriate callback based on operation type\n if (s.mode === \"move\") {\n this.onDrop(s.event, newStart, newEnd);\n } else {\n // resize-left, resize-right, resize-top, resize-bottom\n this.onResize(s.event, newStart, newEnd);\n }\n s.renderCallback();\n } else {\n // DEBUG: Log when no tentative dates\n console.log(\"[DragController] onUp - no tentative dates, drag cancelled\");\n }\n\n this.state = null;\n }\n\n /**\n * Convert adapter date to native Date object\n */\n private toDate(adapterDate: T): Date {\n return new Date(\n this.adapter.year(adapterDate),\n this.adapter.month(adapterDate),\n this.adapter.date(adapterDate),\n this.adapter.hour(adapterDate),\n this.adapter.minute(adapterDate),\n );\n }\n}\n","/**\n * Event data management\n */\nimport type { CalendarEvent } from '../types';\n\n/**\n * Manages calendar event data with CRUD operations\n */\nexport class EventManager {\n private events: CalendarEvent[] = [];\n\n /**\n * Initialize with optional events\n *\n * @param initialEvents - Initial events to load\n */\n constructor(initialEvents?: CalendarEvent[]) {\n if (initialEvents) {\n this.events = [...initialEvents];\n }\n }\n\n /**\n * Add a new event\n *\n * @param event - Event to add\n */\n addEvent(event: CalendarEvent): void {\n this.events.push(event);\n }\n\n /**\n * Remove an event by ID\n *\n * @param id - Event ID to remove\n * @returns true if event was found and removed\n */\n removeEvent(id: string): boolean {\n const index = this.events.findIndex(e => e.id === id);\n\n if (index > -1) {\n this.events.splice(index, 1);\n return true;\n }\n\n return false;\n }\n\n /**\n * Update an existing event\n *\n * @param id - Event ID to update\n * @param updates - Partial event data to merge\n * @returns true if event was found and updated\n */\n updateEvent(id: string, updates: Partial<CalendarEvent>): boolean {\n const event = this.events.find(e => e.id === id);\n\n if (event) {\n Object.assign(event, updates);\n return true;\n }\n\n return false;\n }\n\n /**\n * Get all events\n *\n * @returns Copy of events array\n */\n getEvents(): CalendarEvent[] {\n return [...this.events];\n }\n\n /**\n * Find an event by ID\n *\n * @param id - Event ID to find\n * @returns Event or undefined if not found\n */\n findEvent(id: string): CalendarEvent | undefined {\n return this.events.find(e => e.id === id);\n }\n\n /**\n * Clear all events\n */\n clear(): void {\n this.events = [];\n }\n\n /**\n * Set events (replaces all existing)\n *\n * @param events - New events array\n */\n setEvents(events: CalendarEvent[]): void {\n this.events = [...events];\n }\n\n /**\n * Get number of events\n */\n get count(): number {\n return this.events.length;\n }\n}\n","/**\n * Interaction controller for calendar user interactions\n * Handles clicks, double-clicks, context menus, and range selections\n */\nimport type {\n\tDateAdapter,\n\tCalendarEvent,\n\tResolvedCalendarConfig,\n\tViewType,\n\tRangeSelectionState,\n} from \"../types\";\nimport type { DragController } from \"./DragController\";\nimport type { EventManager } from \"./EventManager\";\nimport { createElement, setStyles, querySelectorAll } from \"../utils/dom\";\n\n/**\n * Manages all user interactions with the calendar\n * Uses event delegation pattern for performance\n */\nexport class InteractionController<T> {\n\tprivate container: HTMLElement | null = null;\n\tprivate rangeState: RangeSelectionState<T> | null = null;\n\n\t// Bound event handlers for cleanup\n\tprivate boundHandleClick: (e: MouseEvent) => void;\n\tprivate boundHandleDblClick: (e: MouseEvent) => void;\n\tprivate boundHandleContextMenu: (e: MouseEvent) => void;\n\tprivate boundHandleMouseDown: (e: MouseEvent) => void;\n\tprivate boundHandleRangeMove: (e: MouseEvent) => void;\n\tprivate boundHandleRangeEnd: (e: MouseEvent) => void;\n\n\tconstructor(\n\t\tprivate adapter: DateAdapter<T>,\n\t\tprivate config: ResolvedCalendarConfig,\n\t\tprivate dragController: DragController<T>,\n\t\tprivate currentView: () => ViewType,\n\t\tprivate eventManager: EventManager,\n\t) {\n\t\t// Bind event handlers\n\t\tthis.boundHandleClick = this.handleClick.bind(this);\n\t\tthis.boundHandleDblClick = this.handleDblClick.bind(this);\n\t\tthis.boundHandleContextMenu = this.handleContextMenu.bind(this);\n\t\tthis.boundHandleMouseDown = this.handleMouseDown.bind(this);\n\t\tthis.boundHandleRangeMove = this.handleRangeMove.bind(this);\n\t\tthis.boundHandleRangeEnd = this.handleRangeEnd.bind(this);\n\t}\n\n\t// ==========================================================================\n\t// Public Methods\n\t// ==========================================================================\n\n\t/**\n\t * Initialize interaction listeners on the calendar container\n\t */\n\tinit(container: HTMLElement): void {\n\t\t// Clean up previous listeners if any\n\t\tthis.destroy();\n\n\t\tthis.container = container;\n\n\t\t// Add event listeners using event delegation\n\t\tcontainer.addEventListener(\"click\", this.boundHandleClick);\n\t\tcontainer.addEventListener(\"dblclick\", this.boundHandleDblClick);\n\t\tcontainer.addEventListener(\"contextmenu\", this.boundHandleContextMenu);\n\t\tcontainer.addEventListener(\"mousedown\", this.boundHandleMouseDown);\n\t}\n\n\t/**\n\t * Clean up all event listeners\n\t */\n\tdestroy(): void {\n\t\tif (this.container) {\n\t\t\tthis.container.removeEventListener(\"click\", this.boundHandleClick);\n\t\t\tthis.container.removeEventListener(\n\t\t\t\t\"dblclick\",\n\t\t\t\tthis.boundHandleDblClick,\n\t\t\t);\n\t\t\tthis.container.removeEventListener(\n\t\t\t\t\"contextmenu\",\n\t\t\t\tthis.boundHandleContextMenu,\n\t\t\t);\n\t\t\tthis.container.removeEventListener(\n\t\t\t\t\"mousedown\",\n\t\t\t\tthis.boundHandleMouseDown,\n\t\t\t);\n\t\t}\n\n\t\t// Clean up range selection listeners\n\t\tdocument.removeEventListener(\"mousemove\", this.boundHandleRangeMove);\n\t\tdocument.removeEventListener(\"mouseup\", this.boundHandleRangeEnd);\n\n\t\tthis.clearRangePreview();\n\t\tthis.rangeState = null;\n\t\tthis.container = null;\n\t}\n\n\t// ==========================================================================\n\t// Event Handlers\n\t// ==========================================================================\n\n\t/**\n\t * Handle click events\n\t */\n\tprivate handleClick(e: MouseEvent): void {\n\t\tconst target = e.target as HTMLElement;\n\n\t\t// 1. Check if clicking on an event - already handled by existing onEventClick\n\t\tconst eventEl = target.closest(\".tg-event-block, .tg-event-bar\");\n\t\tif (eventEl) {\n\t\t\treturn; // Let existing event click handler deal with it\n\t\t}\n\n\t\t// 2. Check if clicking on a date cell (month view)\n\t\tif (this.currentView() === \"month\") {\n\t\t\tconst cellEl = target.closest(\".tg-month-cell\");\n\t\t\tif (cellEl) {\n\t\t\t\tthis.handleDateClick(cellEl as HTMLElement);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// 3. Check if clicking on a time slot (week/day view)\n\t\tif (this.currentView() === \"week\" || this.currentView() === \"day\") {\n\t\t\tconst colEl = target.closest(\".tg-day-column\");\n\t\t\tif (colEl && !target.closest(\".tg-event-block\")) {\n\t\t\t\tthis.handleTimeSlotClick(e, colEl as HTMLElement);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handle double-click events\n\t */\n\tprivate handleDblClick(e: MouseEvent): void {\n\t\tconst target = e.target as HTMLElement;\n\n\t\t// 1. Event double-click\n\t\tconst eventEl = target.closest(\".tg-event-block, .tg-event-bar\");\n\t\tif (eventEl) {\n\t\t\tconst eventId = eventEl.getAttribute(\"data-eid\");\n\t\t\tconst event = this.getEventById(eventId);\n\t\t\tif (event) {\n\t\t\t\tthis.config.onEventDoubleClick?.(event);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// 2. Date double-click (month view)\n\t\tif (this.currentView() === \"month\") {\n\t\t\tconst cellEl = target.closest(\".tg-month-cell\");\n\t\t\tif (cellEl) {\n\t\t\t\tconst date = this.getDateFromCell(cellEl as HTMLElement);\n\t\t\t\tif (date) {\n\t\t\t\t\tthis.config.onDateDoubleClick?.(this.toDate(date));\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// 3. Time slot double-click (week/day view)\n\t\tif (this.currentView() === \"week\" || this.currentView() === \"day\") {\n\t\t\tconst colEl = target.closest(\".tg-day-column\");\n\t\t\tif (colEl && !target.closest(\".tg-event-block\")) {\n\t\t\t\tconst dateTime = this.getDateTimeFromSlot(\n\t\t\t\t\te,\n\t\t\t\t\tcolEl as HTMLElement,\n\t\t\t\t);\n\t\t\t\tif (dateTime) {\n\t\t\t\t\tthis.config.onTimeSlotDoubleClick?.(this.toDate(dateTime));\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handle context menu (right-click) events\n\t */\n\tprivate handleContextMenu(e: MouseEvent): void {\n\t\tconst target = e.target as HTMLElement;\n\n\t\t// Check if any context menu callback is defined\n\t\tconst hasContextMenu =\n\t\t\tthis.config.onEventContextMenu ||\n\t\t\tthis.config.onDateContextMenu ||\n\t\t\tthis.config.onTimeSlotContextMenu;\n\n\t\tif (!hasContextMenu) {\n\t\t\treturn; // Let default context menu show\n\t\t}\n\n\t\t// 1. Event context menu\n\t\tconst eventEl = target.closest(\".tg-event-block, .tg-event-bar\");\n\t\tif (eventEl && this.config.onEventContextMenu) {\n\t\t\te.preventDefault();\n\t\t\tconst eventId = eventEl.getAttribute(\"data-eid\");\n\t\t\tconst event = this.getEventById(eventId);\n\t\t\t// Debug logging\n\t\t\tconsole.log(\"[InteractionController] Context menu on event:\", {\n\t\t\t\teventId,\n\t\t\t\teventFound: !!event,\n\t\t\t\teventEl: eventEl.className,\n\t\t\t\tallEvents: this.eventManager.getEvents().map((e) => e.id),\n\t\t\t});\n\t\t\tif (event) {\n\t\t\t\tthis.config.onEventContextMenu(event, e.clientX, e.clientY);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// 2. Date context menu (month view)\n\t\tif (this.currentView() === \"month\" && this.config.onDateContextMenu) {\n\t\t\tconst cellEl = target.closest(\".tg-month-cell\");\n\t\t\tif (cellEl) {\n\t\t\t\te.preventDefault();\n\t\t\t\tconst date = this.getDateFromCell(cellEl as HTMLElement);\n\t\t\t\tif (date) {\n\t\t\t\t\tthis.config.onDateContextMenu(\n\t\t\t\t\t\tthis.toDate(date),\n\t\t\t\t\t\te.clientX,\n\t\t\t\t\t\te.clientY,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// 3. Time slot context menu (week/day view)\n\t\tif (\n\t\t\t(this.currentView() === \"week\" || this.currentView() === \"day\") &&\n\t\t\tthis.config.onTimeSlotContextMenu\n\t\t) {\n\t\t\tconst colEl = target.closest(\".tg-day-column\");\n\t\t\tif (colEl && !target.closest(\".tg-event-block\")) {\n\t\t\t\te.preventDefault();\n\t\t\t\tconst dateTime = this.getDateTimeFromSlot(\n\t\t\t\t\te,\n\t\t\t\t\tcolEl as HTMLElement,\n\t\t\t\t);\n\t\t\t\tif (dateTime) {\n\t\t\t\t\tthis.config.onTimeSlotContextMenu(\n\t\t\t\t\t\tthis.toDate(dateTime),\n\t\t\t\t\t\te.clientX,\n\t\t\t\t\t\te.clientY,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handle mouse down for range selection\n\t */\n\tprivate handleMouseDown(e: MouseEvent): void {\n\t\t// Only left button\n\t\tif (e.button !== 0) return;\n\n\t\tconst target = e.target as HTMLElement;\n\n\t\t// Don't start range selection if clicking on events\n\t\tif (target.closest(\".tg-event-block, .tg-event-bar\")) return;\n\n\t\t// Don't start range selection if drag controller is active\n\t\tif (this.dragController.isDragging()) return;\n\n\t\t// Check if range selection callbacks are defined\n\t\tconst hasRangeSelect =\n\t\t\tthis.config.onDateRangeSelect || this.config.onTimeRangeSelect;\n\t\tif (!hasRangeSelect) return;\n\n\t\t// Handle month view range selection\n\t\tif (this.currentView() === \"month\" && this.config.onDateRangeSelect) {\n\t\t\tconst cellEl = target.closest(\".tg-month-cell\");\n\t\t\tif (!cellEl) return;\n\n\t\t\tconst date = this.getDateFromCell(cellEl as HTMLElement);\n\t\t\tif (!date) return;\n\n\t\t\tthis.rangeState = {\n\t\t\t\tisSelecting: true,\n\t\t\t\tstartDate: date,\n\t\t\t\tstartX: e.clientX,\n\t\t\t\tstartY: e.clientY,\n\t\t\t\tcurrentDate: date,\n\t\t\t\tviewType: \"month\",\n\t\t\t};\n\n\t\t\tdocument.addEventListener(\"mousemove\", this.boundHandleRangeMove);\n\t\t\tdocument.addEventListener(\"mouseup\", this.boundHandleRangeEnd);\n\n\t\t\t// Add selecting class to prevent text selection\n\t\t\tif (this.container) {\n\t\t\t\tthis.container.classList.add(\"tg-selecting\");\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle time view range selection\n\t\tif (\n\t\t\t(this.currentView() === \"week\" || this.currentView() === \"day\") &&\n\t\t\tthis.config.onTimeRangeSelect\n\t\t) {\n\t\t\tconst colEl = target.closest(\".tg-day-column\");\n\t\t\tif (!colEl) return;\n\n\t\t\tconst dateTime = this.getDateTimeFromSlot(e, colEl as HTMLElement);\n\t\t\tif (!dateTime) return;\n\n\t\t\tthis.rangeState = {\n\t\t\t\tisSelecting: true,\n\t\t\t\tstartDate: dateTime,\n\t\t\t\tstartX: e.clientX,\n\t\t\t\tstartY: e.clientY,\n\t\t\t\tcurrentDate: dateTime,\n\t\t\t\tviewType: \"time\",\n\t\t\t\tcolumnEl: colEl as HTMLElement,\n\t\t\t};\n\n\t\t\tdocument.addEventListener(\"mousemove\", this.boundHandleRangeMove);\n\t\t\tdocument.addEventListener(\"mouseup\", this.boundHandleRangeEnd);\n\n\t\t\t// Add selecting class to prevent text selection\n\t\t\tif (this.container) {\n\t\t\t\tthis.container.classList.add(\"tg-selecting\");\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\t}\n\n\t/**\n\t * Handle mouse move during range selection\n\t */\n\tprivate handleRangeMove(e: MouseEvent): void {\n\t\tif (!this.rangeState?.isSelecting) return;\n\n\t\tconst target = document.elementFromPoint(e.clientX, e.clientY);\n\t\tif (!target) return;\n\n\t\tif (this.rangeState.viewType === \"month\") {\n\t\t\tconst cellEl = target.closest(\".tg-month-cell\");\n\t\t\tif (cellEl) {\n\t\t\t\tconst date = this.getDateFromCell(cellEl as HTMLElement);\n\t\t\t\tif (date) {\n\t\t\t\t\tthis.rangeState.currentDate = date;\n\t\t\t\t\tthis.renderMonthRangePreview();\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// Time view\n\t\t\tconst colEl = target.closest(\".tg-day-column\");\n\t\t\tif (colEl) {\n\t\t\t\tconst dateTime = this.getDateTimeFromSlot(\n\t\t\t\t\te,\n\t\t\t\t\tcolEl as HTMLElement,\n\t\t\t\t);\n\t\t\t\tif (dateTime) {\n\t\t\t\t\tthis.rangeState.currentDate = dateTime;\n\t\t\t\t\tthis.renderTimeRangePreview();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handle mouse up to complete range selection\n\t */\n\tprivate handleRangeEnd(_e: MouseEvent): void {\n\t\tif (!this.rangeState?.isSelecting) return;\n\n\t\t// Clean up\n\t\tdocument.removeEventListener(\"mousemove\", this.boundHandleRangeMove);\n\t\tdocument.removeEventListener(\"mouseup\", this.boundHandleRangeEnd);\n\n\t\tif (this.container) {\n\t\t\tthis.container.classList.remove(\"tg-selecting\");\n\t\t}\n\n\t\t// Trigger callback if we have both start and end dates\n\t\tif (this.rangeState.startDate && this.rangeState.currentDate) {\n\t\t\tconst startDate = this.rangeState.startDate;\n\t\t\tconst endDate = this.rangeState.currentDate;\n\n\t\t\t// Ensure start is before end\n\t\t\tconst isStartAfterEnd = this.adapter.isAfter(startDate, endDate);\n\n\t\t\tif (this.rangeState.viewType === \"month\") {\n\t\t\t\tconst start = this.toDate(\n\t\t\t\t\tisStartAfterEnd ? endDate : startDate,\n\t\t\t\t);\n\t\t\t\tconst end = this.toDate(isStartAfterEnd ? startDate : endDate);\n\t\t\t\tthis.config.onDateRangeSelect?.(start, end);\n\t\t\t} else {\n\t\t\t\tconst start = this.toDate(\n\t\t\t\t\tisStartAfterEnd ? endDate : startDate,\n\t\t\t\t);\n\t\t\t\tconst end = this.toDate(isStartAfterEnd ? startDate : endDate);\n\t\t\t\tthis.config.onTimeRangeSelect?.(start, end);\n\t\t\t}\n\t\t}\n\n\t\tthis.clearRangePreview();\n\t\tthis.rangeState = null;\n\t}\n\n\t// ==========================================================================\n\t// Helper Methods\n\t// ==========================================================================\n\n\t/**\n\t * Convert adapter date to native Date object\n\t */\n\tprivate toDate(adapterDate: T): Date {\n\t\treturn new Date(\n\t\t\tthis.adapter.year(adapterDate),\n\t\t\tthis.adapter.month(adapterDate),\n\t\t\tthis.adapter.date(adapterDate),\n\t\t\tthis.adapter.hour(adapterDate),\n\t\t\tthis.adapter.minute(adapterDate),\n\t\t);\n\t}\n\n\t/**\n\t * Get event by ID from event manager\n\t */\n\tprivate getEventById(eventId: string | null): CalendarEvent | undefined {\n\t\tif (!eventId) return undefined;\n\t\treturn this.eventManager.findEvent(eventId);\n\t}\n\n\t/**\n\t * Get date from a month view cell\n\t */\n\tprivate getDateFromCell(cellEl: HTMLElement): T | null {\n\t\tconst row = cellEl.closest(\".tg-month-row\") as HTMLElement;\n\t\tif (!row?.dataset[\"date\"]) return null;\n\n\t\tconst cells = Array.from(row.querySelectorAll(\".tg-month-cell\"));\n\t\tconst index = cells.indexOf(cellEl);\n\t\tif (index < 0) return null;\n\n\t\tconst rowStart = this.adapter.parse(row.dataset[\"date\"]);\n\t\tconst clickedDate = this.adapter.add(rowStart, index, \"day\");\n\t\treturn clickedDate;\n\t}\n\n\t/**\n\t * Get date-time from a time slot click\n\t */\n\tprivate getDateTimeFromSlot(e: MouseEvent, colEl: HTMLElement): T | null {\n\t\tconst dateStr = colEl.dataset[\"date\"];\n\t\tif (!dateStr) return null;\n\n\t\tconst rect = c