Timeline

PreviousNext

A Gantt-chart style timeline with draggable time slots.

Docs
abuiblock

Preview

Loading preview…
registry/abui/ui/timeline.tsx
"use client"

import * as React from "react"
import { useState, useRef, useEffect, createContext, useContext, useMemo } from "react"
import { Slot } from "@radix-ui/react-slot"
import {
  DndContext,
  DragOverlay,
  useSensor,
  useSensors,
  PointerSensor,
  closestCenter,
  useDraggable,
  useDroppable,
} from "@dnd-kit/core"
import tunnel from "tunnel-rat"
import { cn } from "@/lib/utils"

/* ============================================================================
 * TYPES & INTERFACES
 * ========================================================================== */

export interface TimelineSlotData {
  id: string
  rowId: string
  startTime: string // "14:30"
  duration: number // minutes
  [key: string]: any // Additional custom data
}

export interface TimelineRowData {
  id: string
  label: string
  [key: string]: any // Additional custom data
}

export interface TimelineConfig {
  startHour: number
  endHour: number
  snapIntervalMinutes?: number
  columnWidth?: number
}

/* ============================================================================
 * CONTEXT
 * ========================================================================== */

type TimelineContextValue = {
  // Configuration
  config: TimelineConfig
  pixelsPerMinute: number
  timelineWidth: number

  // Refs
  timelineRef: React.RefObject<HTMLDivElement | null>
  columnRef: React.RefObject<HTMLDivElement | null>

  // Tunnel for drag preview
  dragPreviewTunnel: ReturnType<typeof tunnel>

  // Callbacks
  onSlotPositionChange?: (slotId: string, newTime: string, newRowId: string) => Promise<boolean>
  onValidateDrop?: (slotId: string, newTime: string, newRowId: string) => boolean
  onSlotClick?: (slotId: string) => void
}

const TimelineContext = createContext<TimelineContextValue | null>(null)

function useTimeline() {
  const context = useContext(TimelineContext)
  if (!context) {
    throw new Error("Timeline components must be used within TimelineProvider")
  }
  return context
}

/* ============================================================================
 * UTILITIES
 * ========================================================================== */

export const timeToMinutes = (time: string): number => {
  const [hours, minutes] = time.split(":").map(Number)
  return hours * 60 + minutes
}

export const minutesToTime = (minutes: number): string => {
  const hours = Math.floor(minutes / 60)
  const mins = minutes % 60
  return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`
}

/* ============================================================================
 * TIMELINE PROVIDER
 * ========================================================================== */

interface TimelineProviderProps {
  children: React.ReactNode
  config: TimelineConfig
  percentageInView?: number
  onSlotPositionChange?: (slotId: string, newTime: string, newRowId: string) => Promise<boolean>
  onValidateDrop?: (slotId: string, newTime: string, newRowId: string) => boolean
  onSlotClick?: (slotId: string) => void
  style?: React.CSSProperties
  className?: string
}

export function TimelineProvider({
  children,
  config,
  percentageInView = 100,
  onSlotPositionChange,
  onValidateDrop,
  onSlotClick,
  style,
  className,
}: TimelineProviderProps) {
  const [viewportWidth, setViewportWidth] = useState(0)

  const timelineRef = useRef<HTMLDivElement>(null)
  const columnRef = useRef<HTMLDivElement>(null)

  // Create tunnel for drag preview (stable across renders)
  const dragPreviewTunnel = useMemo(() => tunnel(), [])

  const columnWidth = config.columnWidth || 112

  // Measure viewport
  useEffect(() => {
    const measure = () => {
      if (timelineRef.current) {
        setViewportWidth(timelineRef.current.clientWidth - columnWidth)
      }
    }

    measure()
    window.addEventListener("resize", measure)
    const timeout = setTimeout(measure, 100)

    return () => {
      window.removeEventListener("resize", measure)
      clearTimeout(timeout)
    }
  }, [columnWidth])

  // Calculate timeline dimensions
  const totalMinutes = (config.endHour - config.startHour) * 60
  const basePixelsPerMinute = viewportWidth > 0 ? viewportWidth / totalMinutes : 10
  const pixelsPerMinute = basePixelsPerMinute * (100 / percentageInView)
  const timelineWidth = totalMinutes * pixelsPerMinute

  const contextValue: TimelineContextValue = {
    config,
    pixelsPerMinute,
    timelineWidth,
    timelineRef,
    columnRef,
    dragPreviewTunnel,
    onSlotPositionChange,
    onValidateDrop,
    onSlotClick,
  }

  return (
    <TimelineContext.Provider value={contextValue}>
      <div
        data-slot="timeline-wrapper"
        style={
          {
            "--timeline-column-width": `${columnWidth}px`,
            "--timeline-width": `${timelineWidth}px`,
            "--timeline-pixels-per-minute": pixelsPerMinute,
            ...style,
          } as React.CSSProperties
        }
        className={cn("relative w-full", className)}
      >
        {children}
      </div>
    </TimelineContext.Provider>
  )
}

/* ============================================================================
 * TIMELINE (Main Container with DnD)
 * ========================================================================== */

interface TimelineProps {
  slots: TimelineSlotData[]
  rows: TimelineRowData[]
  children: React.ReactNode
  className?: string
}

export function Timeline({ slots, rows, children, className }: TimelineProps) {
  const { config, pixelsPerMinute, timelineRef, dragPreviewTunnel, onSlotPositionChange, onValidateDrop } =
    useTimeline()

  const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null)

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 8 },
    }),
  )

  const snapInterval = config.snapIntervalMinutes || 15
  const columnWidth = config.columnWidth || 112

  // Snapping utilities
  const snapToInterval = (minutes: number): number => {
    return Math.round(minutes / snapInterval) * snapInterval
  }

  const getSnappedDelta = (deltaX: number): number => {
    const deltaMinutes = deltaX / pixelsPerMinute
    const snappedDeltaMinutes = Math.round(deltaMinutes / snapInterval) * snapInterval
    return snappedDeltaMinutes * pixelsPerMinute
  }

  const calculateNewTime = (originalTime: string, deltaX: number): string => {
    const originalMinutes = timeToMinutes(originalTime)
    const deltaMinutes = Math.round(deltaX / pixelsPerMinute)
    const newMinutes = originalMinutes + deltaMinutes
    const snappedMinutes = snapToInterval(newMinutes)
    const clampedMinutes = Math.max(config.startHour * 60, Math.min((config.endHour - 1) * 60, snappedMinutes))
    return minutesToTime(clampedMinutes)
  }

  // Mouse handlers
  const handleMouseMove = (e: React.MouseEvent) => {
    if (timelineRef.current) {
      const rect = timelineRef.current.getBoundingClientRect()
      setMousePosition({
        x: e.clientX - rect.left + timelineRef.current.scrollLeft,
        y: e.clientY - rect.top,
      })
    }
  }

  const handleMouseLeave = () => {
    setMousePosition(null)
  }

  // Calculate mouse time
  const mouseTime = mousePosition
    ? minutesToTime(Math.floor((mousePosition.x - columnWidth) / pixelsPerMinute) + config.startHour * 60)
    : null

  // Drag handlers
  const [localActiveSlot, setLocalActiveSlot] = useState<string | null>(null)
  const [localOverRow, setLocalOverRow] = useState<string | null>(null)
  const [localDraggedTime, setLocalDraggedTime] = useState<string | null>(null)
  const [isValid, setIsValid] = useState(true)

  const handleDragStart = (event: any) => {
    setLocalActiveSlot(event.active.id)
  }

  const handleDragOver = (event: any) => {
    if (event.over) {
      setLocalOverRow(event.over.id)
    } else {
      setLocalOverRow(null)
      setLocalDraggedTime(null)
    }
  }

  const handleDragMove = (event: any) => {
    const { over, active, delta } = event

    if (over) {
      const slot = slots.find((s: TimelineSlotData) => s.id === active.id)
      if (slot) {
        const newTime = calculateNewTime(slot.startTime, delta.x)
        setLocalDraggedTime(newTime)

        // Validate with host
        const valid = onValidateDrop ? onValidateDrop(active.id, newTime, over.id) : true
        setIsValid(valid)
      }
    }
  }

  const handleDragCancel = () => {
    // Clean up all drag state when drag is cancelled (e.g., ESC key)
    setLocalActiveSlot(null)
    setLocalOverRow(null)
    setLocalDraggedTime(null)
    setIsValid(true)
  }

  const handleDragEnd = async (event: any) => {
    const { active, over, delta } = event

    setLocalActiveSlot(null)
    setLocalOverRow(null)
    setLocalDraggedTime(null)
    setIsValid(true)

    if (over) {
      const slot = slots.find((s: TimelineSlotData) => s.id === active.id)
      if (!slot) return

      const newTime = calculateNewTime(slot.startTime, delta.x)
      const newRowId = over.id

      // Validate
      if (onValidateDrop && !onValidateDrop(active.id, newTime, newRowId)) {
        return
      }

      // Check if anything changed
      if (slot.rowId === newRowId && slot.startTime === newTime) {
        return
      }

      // Call position change handler
      if (onSlotPositionChange) {
        await onSlotPositionChange(active.id, newTime, newRowId)
      }
    }
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragMove={handleDragMove}
      onDragEnd={handleDragEnd}
      onDragCancel={handleDragCancel}
    >
      <div className="relative w-full">
        {/* Mouse time indicator */}
        {mousePosition && mouseTime && !localActiveSlot && (
          <TimelineMouseIndicator mouseX={mousePosition.x} time={mouseTime} />
        )}

        <div
          ref={timelineRef}
          data-slot="timeline-grid"
          className={cn("relative overflow-auto border bg-background", className)}
          onMouseMove={handleMouseMove}
          onMouseLeave={handleMouseLeave}
        >
          {React.Children.map(children, child => {
            if (React.isValidElement(child)) {
              return React.cloneElement(child as React.ReactElement<any>, {
                slots,
                rows,
                activeSlotId: localActiveSlot,
                overRowId: localOverRow,
                draggedNewTime: localDraggedTime,
                isValidDrop: isValid,
                getSnappedDelta,
                _showDropRegion: !!(localActiveSlot && localDraggedTime),
                _dropRegionTime: localDraggedTime,
              })
            }
            return child
          })}
        </div>

        {/* Drag overlay - receives content from tunnel */}
        <DragOverlay dropAnimation={null}>
          {localActiveSlot &&
            (() => {
              const activeSlot = slots.find((s: TimelineSlotData) => s.id === localActiveSlot)
              if (!activeSlot) return null

              console.log("📺 DragOverlay rendering for slot:", activeSlot.id)

              return (
                <div
                  data-slot="timeline-drag-overlay"
                  style={{
                    width: `${Math.max(activeSlot.duration * pixelsPerMinute, 60)}px`,
                    height: "54px",
                    position: "relative",
                  }}
                >
                  {/* Receive tunneled content from the active slot */}
                  <dragPreviewTunnel.Out />
                </div>
              )
            })()}
        </DragOverlay>
      </div>
    </DndContext>
  )
}

/* ============================================================================
 * TIMELINE HEADER
 * ========================================================================== */

interface TimelineHeaderProps {
  className?: string
  columnLabel?: React.ReactNode
}

export function TimelineHeader({ className, columnLabel = "Row" }: TimelineHeaderProps) {
  const { config, pixelsPerMinute, columnRef } = useTimeline()

  // Generate hour markers
  const hourMarkers = []
  for (let hour = config.startHour; hour < config.endHour; hour++) {
    hourMarkers.push({
      hour,
      label: `${hour}:00`,
      position: (hour - config.startHour) * 60 * pixelsPerMinute,
    })
  }

  return (
    <div data-slot="timeline-header" className={cn("sticky top-0 z-10 bg-background border-b", className)}>
      <div className="flex h-12">
        <div
          ref={columnRef}
          data-slot="timeline-header-column"
          className="sticky left-0 w-[var(--timeline-column-width)] bg-background border-r flex items-center px-4 font-semibold text-sm z-1"
        >
          {columnLabel}
        </div>
        <div data-slot="timeline-header-markers" className="relative flex-1">
          {hourMarkers.map(marker => (
            <div
              key={marker.hour}
              data-slot="timeline-hour-marker"
              className="absolute top-0 bottom-0 flex items-center pl-2 text-xs text-muted-foreground"
              style={{ left: `${marker.position}px` }}
            >
              {marker.label}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

/* ============================================================================
 * TIMELINE ROW
 * ========================================================================== */

interface TimelineRowProps {
  row: TimelineRowData
  slots: TimelineSlotData[]
  children: (slot: TimelineSlotData) => React.ReactNode
  renderRowHeader?: (row: TimelineRowData) => React.ReactNode
  renderDropGhost?: (slot: TimelineSlotData, newTime: string, isValid: boolean) => React.ReactNode
  className?: string
  asChild?: boolean
}

export function TimelineRow({
  row,
  slots,
  children,
  renderRowHeader,
  className,
  asChild,
  ...props
}: TimelineRowProps & any) {
  const { config, pixelsPerMinute, timelineWidth } = useTimeline()
  const Comp = asChild ? Slot : "div"

  const { setNodeRef, isOver } = useDroppable({
    id: row.id,
  })

  const rowSlots = slots.filter((s: TimelineSlotData) => s.rowId === row.id)

  // Check if this row has invalid drop (from props passed by Timeline)
  const isValidDrop = props.isValidDrop !== false
  const isHovered = isOver || props.overRowId === row.id // Try BOTH

  // Generate grid markers
  const hourMarkers = []
  for (let hour = config.startHour; hour <= config.endHour; hour++) {
    hourMarkers.push({
      hour,
      position: (hour - config.startHour) * 60 * pixelsPerMinute,
    })
  }

  const quarterHourMarkers = []
  const totalMinutes = (config.endHour - config.startHour) * 60
  for (let minutes = 15; minutes < totalMinutes; minutes += 15) {
    if (minutes % 60 !== 0) {
      quarterHourMarkers.push({
        position: minutes * pixelsPerMinute,
      })
    }
  }

  return (
    <Comp
      ref={setNodeRef}
      data-slot="timeline-row"
      data-state={isHovered ? (isValidDrop ? "hover-valid" : "hover-invalid") : "idle"}
      className={cn(
        "flex border-b h-12",
        isHovered && isValidDrop && "ring-2 ring-inset ring-blue-500",
        isHovered && !isValidDrop && "ring-2 ring-inset ring-red-500",
        className,
      )}
    >
      {/* Row label column */}
      <div
        data-slot="timeline-row-label"
        className="sticky left-0 w-[var(--timeline-column-width)] bg-inherit border-r flex items-center px-0 z-[5]"
      >
        {renderRowHeader ? renderRowHeader(row) : row.label}
      </div>

      {/* Timeline grid */}
      <div data-slot="timeline-row-grid" className="relative flex-1" style={{ width: `${timelineWidth}px` }}>
        {/* Grid lines */}
        {quarterHourMarkers.map((marker, idx) => (
          <div
            key={`quarter-${idx}`}
            data-slot="timeline-grid-line-quarter"
            className="absolute top-0 bottom-0 w-px bg-border/30"
            style={{ left: `${marker.position}px` }}
          />
        ))}
        {hourMarkers.map(marker => (
          <div
            key={marker.hour}
            data-slot="timeline-grid-line-hour"
            className="absolute top-0 bottom-0 w-px bg-border"
            style={{ left: `${marker.position}px` }}
          />
        ))}

        {/* Slots */}
        {rowSlots.map((slot: TimelineSlotData) => {
          const slotElement = children(slot)
          // Clone the element to inject activeSlotId prop
          return (
            <React.Fragment key={slot.id}>
              {React.isValidElement(slotElement)
                ? React.cloneElement(slotElement, { activeSlotId: props.activeSlotId } as any)
                : slotElement}
            </React.Fragment>
          )
        })}

        {/* Drop ghost - shows where slot will land if dropped in this row */}
        {isHovered && props.draggedNewTime && props.activeSlotId && (
          <TimelineDropGhost
            activeSlotId={props.activeSlotId}
            allSlots={slots}
            newTime={props.draggedNewTime}
            isValid={isValidDrop}
            pixelsPerMinute={pixelsPerMinute}
            config={config}
          />
        )}
      </div>
    </Comp>
  )
}

/* ============================================================================
 * TIMELINE SLOT (Draggable)
 * ========================================================================== */

interface TimelineSlotProps {
  slot: TimelineSlotData
  children: React.ReactNode
  className?: string
  asChild?: boolean
}

export function TimelineSlot({ slot, children, className, asChild, ...props }: TimelineSlotProps & any) {
  const timeline = useTimeline()
  const { config, pixelsPerMinute, onSlotClick, dragPreviewTunnel } = timeline
  const Comp = asChild ? Slot : "div"

  const { attributes, listeners, setNodeRef, isDragging, transform } = useDraggable({
    id: slot.id,
  })

  const startMinutes = timeToMinutes(slot.startTime)
  const left = (startMinutes - config.startHour * 60) * pixelsPerMinute
  const width = slot.duration * pixelsPerMinute

  // Get activeSlotId from Timeline component via props (passed through TimelineRow)
  const activeSlotIdFromTimeline = props.activeSlotId
  const isActiveSlot = activeSlotIdFromTimeline === slot.id

  // When dragging, show slot at its snapped target position (not at cursor)
  // The DragOverlay will show what you're dragging at cursor position
  const style =
    transform && props.getSnappedDelta
      ? {
          left: `${left}px`,
          width: `${Math.max(width, 60)}px`,
          top: 0,
          bottom: 0,
          transform: `translate3d(${props.getSnappedDelta(transform.x)}px, ${transform.y}px, 0)`,
        }
      : {
          left: `${left}px`,
          width: `${Math.max(width, 60)}px`,
          top: 0,
          bottom: 0,
        }

  const slotContent = (
    <Comp
      data-slot="timeline-slot"
      data-state={isDragging ? "dragging" : "idle"}
      data-active={isActiveSlot}
      className={cn(
        "absolute inset-1 rounded cursor-move transition-all overflow-hidden",
        isDragging ? "opacity-40 shadow-sm ring-2 ring-foreground/50" : "shadow-md",
        onSlotClick && "cursor-pointer hover:ring-2 hover:ring-foreground/30",
        className,
      )}
      onClick={(e: React.MouseEvent) => {
        if (onSlotClick && !isDragging) {
          e.stopPropagation()
          onSlotClick(slot.id)
        }
      }}
      style={
        {
          "--slot-start-time": slot.startTime,
          "--slot-duration": `${slot.duration}min`,
        } as React.CSSProperties
      }
    >
      {children}
    </Comp>
  )

  return (
    <div ref={setNodeRef} {...listeners} {...attributes} className="absolute" style={style}>
      {/* Always render slot content normally */}
      {slotContent}

      {/* When active and dragging, ALSO send content through tunnel for DragOverlay */}
      {isActiveSlot && isDragging && (
        <>
          {console.log("🚇 Sending content through tunnel for slot:", slot.id)}
          <dragPreviewTunnel.In>
            <Comp
              data-slot="timeline-slot-preview"
              className={cn(
                "rounded cursor-move overflow-hidden shadow-lg",
                "h-full w-full", // Ensure it fills container
                className,
              )}
              style={
                {
                  "--slot-start-time": slot.startTime,
                  "--slot-duration": `${slot.duration}min`,
                } as React.CSSProperties
              }
            >
              {children}
            </Comp>
          </dragPreviewTunnel.In>
        </>
      )}
    </div>
  )
}

/* ============================================================================
 * TIMELINE SLOT PRIMITIVES (for content composition)
 * ========================================================================== */

interface TimelineSlotLabelProps extends React.ComponentProps<"div"> {
  asChild?: boolean
}

export function TimelineSlotLabel({ asChild, className, ...props }: TimelineSlotLabelProps) {
  const Comp = asChild ? Slot : "div"
  return <Comp data-slot="timeline-slot-label" className={cn("font-medium truncate text-xs", className)} {...props} />
}

interface TimelineSlotContentProps extends React.ComponentProps<"div"> {
  asChild?: boolean
}

export function TimelineSlotContent({ asChild, className, ...props }: TimelineSlotContentProps) {
  const Comp = asChild ? Slot : "div"
  return <Comp data-slot="timeline-slot-content" className={cn("text-xs", className)} {...props} />
}

/* ============================================================================
 * TIMELINE INDICATORS
 * ========================================================================== */

function TimelineMouseIndicator({ mouseX, time }: { mouseX: number; time: string }) {
  return (
    <div
      data-slot="timeline-mouse-indicator"
      className="absolute top-0 bottom-0 pointer-events-none z-20"
      style={{ left: `${mouseX}px` }}
    >
      <div className="absolute top-0 bottom-0 w-px bg-accent left-0" />
      <div className="absolute top-0 left-1/2 -translate-x-1/2 bg-accent text-accent-foreground px-2 py-1 rounded text-xs font-semibold whitespace-nowrap shadow-md">
        {time}
      </div>
    </div>
  )
}

interface TimelineDropRegionProps {
  startTime: string
  duration: number
}

export function TimelineDropRegion({ startTime, duration }: TimelineDropRegionProps) {
  const { config, pixelsPerMinute } = useTimeline()
  const columnWidth = config.columnWidth || 112

  const startMinutes = timeToMinutes(startTime)
  const endMinutes = startMinutes + duration
  const endTime = minutesToTime(endMinutes)

  const startPosition = (startMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth
  const endPosition = (endMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth
  const width = endPosition - startPosition

  return (
    <div
      data-slot="timeline-drop-region"
      className="absolute top-0 bottom-0 pointer-events-none z-[12]"
      style={{ left: `${startPosition}px`, width: `${width}px` }}
    >
      <div className="absolute top-2 left-1/2 -translate-x-1/2 bg-accent text-accent-foreground px-3 py-1.5 rounded text-sm font-semibold whitespace-nowrap shadow-md">
        {startTime} - {endTime}
      </div>
      <div className="absolute top-0 bottom-0 left-0 w-0.5 bg-accent" />
      <div className="absolute top-0 bottom-0 right-0 w-0.5 bg-accent" />
      <div className="absolute inset-0 bg-accent/[0.07]" />
    </div>
  )
}

interface TimelineCurrentTimeProps {
  className?: string
  nowLabel?: string
}

export function TimelineCurrentTime({ className, nowLabel = "Now" }: TimelineCurrentTimeProps) {
  const { config, pixelsPerMinute } = useTimeline()
  const columnWidth = config.columnWidth || 112

  const [now, setNow] = useState(new Date())

  useEffect(() => {
    const interval = setInterval(() => setNow(new Date()), 60000) // Update every minute
    return () => clearInterval(interval)
  }, [])

  const currentMinutes = now.getHours() * 60 + now.getMinutes()
  const position = (currentMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth

  // Only show if within timeline range
  if (currentMinutes < config.startHour * 60 || currentMinutes > config.endHour * 60) {
    return null
  }

  return (
    <div
      data-slot="timeline-current-time"
      className={cn("absolute top-0 bottom-0 w-0.5 bg-secondary pointer-events-none z-[15]", className)}
      style={{ left: `${position}px` }}
    >
      <div className="absolute top-0 left-1/2 -translate-x-1/2 bg-secondary text-foreground px-2 py-1 rounded text-xs font-medium whitespace-nowrap shadow-md z-50">
        {nowLabel}: {minutesToTime(currentMinutes)}
      </div>
    </div>
  )
}

/* ============================================================================
 * TIMELINE GRID (for custom layouts)
 * ========================================================================== */

interface TimelineGridProps {
  children: React.ReactNode
  className?: string
}

export function TimelineGrid({ children, className, ...props }: TimelineGridProps & any) {
  const { timelineWidth } = useTimeline()

  // Get active slot data for drop region and ghost preview
  const activeSlot =
    props._showDropRegion && props.slots ? props.slots.find((s: TimelineSlotData) => s.id === props.activeSlotId) : null

  return (
    <div
      data-slot="timeline-grid-container"
      className={cn("relative", className)}
      style={{ minWidth: `${timelineWidth + 200}px` }}
    >
      {React.Children.map(children, child => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child as React.ReactElement<any>, props)
        }
        return child
      })}

      {/* Drop region highlight */}
      {props._showDropRegion && props._dropRegionTime && activeSlot && (
        <TimelineDropRegion startTime={props._dropRegionTime} duration={activeSlot.duration} />
      )}
    </div>
  )
}

/* ============================================================================
 * DROP GHOST (Shows where slot will land)
 * ========================================================================== */

function TimelineDropGhost({
  activeSlotId,
  allSlots,
  newTime,
  isValid,
  config,
  pixelsPerMinute,
}: {
  activeSlotId: string
  allSlots: TimelineSlotData[]
  newTime: string
  isValid: boolean
  config: TimelineConfig
  pixelsPerMinute: number
}) {
  const slot = allSlots.find(s => s.id === activeSlotId)
  if (!slot) return null

  const startMinutes = timeToMinutes(newTime)
  const left = (startMinutes - config.startHour * 60) * pixelsPerMinute
  const width = slot.duration * pixelsPerMinute

  if (!isValid) return null

  return (
    <div
      data-slot="timeline-drop-ghost"
      className={cn("absolute rounded-md pointer-events-none", "bg-foreground/20")}
      style={{
        left: `${left}px`,
        width: `${Math.max(width, 60)}px`,
        top: "2px",
        bottom: "2px",
        zIndex: 100,
      }}
    />
  )
}

/* ============================================================================
 * EXPORTS
 * ========================================================================== */

export { useTimeline, TimelineMouseIndicator }

Installation

npx shadcn@latest add @abui/timeline

Usage

import { Timeline } from "@/components/timeline"
<Timeline />