shadimage

Timeline

A composable horizontal timeline with draggable slots, rows, drop validation, current-time markers, and custom slot rendering.

registry/corr/components/timeline.tsx

import * as React from "react"
import {
  closestCenter,
  DndContext,
  DragOverlay,
  PointerSensor,
  pointerWithin,
  useDraggable,
  useDroppable,
  useSensor,
  useSensors,
  type CollisionDetection,
  type DragEndEvent,
  type DragMoveEvent,
  type DragOverEvent,
  type DragStartEvent,
} from "@dnd-kit/core"
import { Slot } from "@radix-ui/react-slot"
import { createPortal } from "react-dom"
import tunnel from "tunnel-rat"
import { motion, useReducedMotion } from "motion/react"

import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"

export interface TimelineSlotData {
  id: string
  rowId: string
  startTime: string
  duration: number
  [key: string]: unknown
}

export interface TimelineRowData {
  id: string
  label: string
  [key: string]: unknown
}

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

type DragPreviewTunnel = ReturnType<typeof tunnel>

type TimelineContextValue = {
  config: TimelineConfig
  pixelsPerMinute: number
  timelineWidth: number
  timelineRef: React.RefObject<HTMLDivElement | null>
  columnRef: React.RefObject<HTMLDivElement | null>
  dragPreviewTunnel: DragPreviewTunnel
  onSlotPositionChange?: (
    slotId: string,
    newTime: string,
    newRowId: string
  ) => Promise<boolean> | boolean
  onSlotResize?: (
    slotId: string,
    newTime: string,
    newDuration: number,
    isComplete?: boolean
  ) => Promise<boolean> | boolean | void
  onValidateDrop?: (
    slotId: string,
    newTime: string,
    newRowId: string
  ) => boolean
  onSlotClick?: (slotId: string) => void
}

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

const TIMELINE_AGENDA_BREAKPOINT = 700

export function useTimeline() {
  const context = React.useContext(TimelineContext)

  if (!context) {
    throw new Error("Timeline components must be used within TimelineProvider")
  }

  return context
}

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

export function minutesToTime(minutes: number): string {
  const hours = Math.floor(minutes / 60)
  const remainingMinutes = minutes % 60

  return `${hours.toString().padStart(2, "0")}:${remainingMinutes
    .toString()
    .padStart(2, "0")}`
}

export interface TimelineProviderProps {
  children: React.ReactNode
  config: TimelineConfig
  percentageInView?: number
  onSlotPositionChange?: (
    slotId: string,
    newTime: string,
    newRowId: string
  ) => Promise<boolean> | boolean
  onSlotResize?: (
    slotId: string,
    newTime: string,
    newDuration: number,
    isComplete?: boolean
  ) => Promise<boolean> | boolean | void
  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,
  onSlotResize,
  onValidateDrop,
  onSlotClick,
  style,
  className,
}: TimelineProviderProps) {
  const [viewportWidth, setViewportWidth] = React.useState(0)
  const timelineRef = React.useRef<HTMLDivElement>(null)
  const columnRef = React.useRef<HTMLDivElement>(null)
  const dragPreviewTunnel = React.useMemo(() => tunnel(), [])
  const columnWidth = config.columnWidth ?? 112

  React.useEffect(() => {
    function measure() {
      if (timelineRef.current) {
        setViewportWidth(timelineRef.current.clientWidth - columnWidth)
      }
    }

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

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

  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 = React.useMemo<TimelineContextValue>(
    () => ({
      config,
      pixelsPerMinute,
      timelineWidth,
      timelineRef,
      columnRef,
      dragPreviewTunnel,
      onSlotPositionChange,
      onSlotResize,
      onValidateDrop,
      onSlotClick,
    }),
    [
      config,
      dragPreviewTunnel,
      onSlotClick,
      onSlotPositionChange,
      onSlotResize,
      onValidateDrop,
      pixelsPerMinute,
      timelineWidth,
    ]
  )

  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>
  )
}

type TimelineInjectedProps = {
  slots?: TimelineSlotData[]
  rows?: TimelineRowData[]
  activeSlotId?: string | null
  overRowId?: string | null
  draggedNewTime?: string | null
  isValidDrop?: boolean
  getSnappedDelta?: (deltaX: number) => number
  _showDropRegion?: boolean
  _dropRegionTime?: string | null
}

export interface TimelineProps {
  slots: TimelineSlotData[]
  rows: TimelineRowData[]
  children: React.ReactNode
  mobileMode?: "auto" | "agenda" | "scroll"
  afterGrid?: React.ReactNode
  onSlotCreate?: (slot: TimelineSlotData) => void
  onExternalDrop?: (
    activeId: string,
    overId: string | null
  ) => Promise<void> | void
  className?: string
}

export function Timeline({
  slots,
  rows,
  children,
  mobileMode = "auto",
  afterGrid,
  onSlotCreate,
  onExternalDrop,
  className,
}: TimelineProps) {
  const {
    config,
    pixelsPerMinute,
    timelineRef,
    dragPreviewTunnel,
    onSlotClick,
    onSlotPositionChange,
    onValidateDrop,
  } = useTimeline()
  const [mousePosition, setMousePosition] = React.useState<{
    x: number
    y: number
  } | null>(null)
  const [localActiveSlot, setLocalActiveSlot] = React.useState<string | null>(
    null
  )
  const [localOverRow, setLocalOverRow] = React.useState<string | null>(null)
  const [localDraggedTime, setLocalDraggedTime] = React.useState<string | null>(
    null
  )
  const [isValid, setIsValid] = React.useState(true)
  const DragPreviewOut = dragPreviewTunnel.Out
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 8 },
    })
  )
  const reduceMotion = useReducedMotion()
  const snapInterval = config.snapIntervalMinutes ?? 15
  const columnWidth = config.columnWidth ?? 112
  const rowIds = React.useMemo(
    () => new Set(rows.map((row) => String(row.id))),
    [rows]
  )
  const containerRef = React.useRef<HTMLDivElement>(null)
  const [containerWidth, setContainerWidth] = React.useState(0)

  React.useEffect(() => {
    const element = containerRef.current
    if (!element) return

    const measure = () => setContainerWidth(element.clientWidth)
    const observer = new ResizeObserver(measure)

    measure()
    observer.observe(element)

    return () => observer.disconnect()
  }, [])

  function snapToInterval(minutes: number): number {
    return Math.round(minutes / snapInterval) * snapInterval
  }

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

    return snappedDeltaMinutes * pixelsPerMinute
  }

  function 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)
  }

  function handleMouseMove(event: React.MouseEvent) {
    if (!timelineRef.current) return

    const rect = timelineRef.current.getBoundingClientRect()
    setMousePosition({
      x: event.clientX - rect.left + timelineRef.current.scrollLeft,
      y: event.clientY - rect.top,
    })
  }

  function handleMouseLeave() {
    setMousePosition(null)
  }

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

  function handleDragStart(event: DragStartEvent) {
    const activeId = String(event.active.id)
    const isSlotDrag = slots.some((slot) => slot.id === activeId)
    setLocalActiveSlot(isSlotDrag ? activeId : null)
  }

  function handleDragOver(event: DragOverEvent) {
    if (localActiveSlot && event.over && rowIds.has(String(event.over.id))) {
      setLocalOverRow(String(event.over.id))
    } else {
      setLocalOverRow(null)
      setLocalDraggedTime(null)
    }
  }

  function handleDragMove(event: DragMoveEvent) {
    const { over, active, delta } = event

    if (localActiveSlot && over && rowIds.has(String(over.id))) {
      const slot = slots.find((item) => item.id === active.id)

      if (slot) {
        const newTime = calculateNewTime(slot.startTime, delta.x)
        const overId = String(over.id)
        setLocalDraggedTime(newTime)
        setIsValid(
          onValidateDrop ? onValidateDrop(slot.id, newTime, overId) : true
        )
      }
    } else if (localActiveSlot) {
      setLocalDraggedTime(null)
      setIsValid(true)
    }
  }

  const collisionDetection: CollisionDetection = (args) => {
    const pointerCollisions = pointerWithin(args)

    if (pointerCollisions.length > 0) {
      const externalCollision = pointerCollisions.find(
        (collision) => !rowIds.has(String(collision.id))
      )

      if (externalCollision) {
        return [externalCollision]
      }

      return pointerCollisions
    }

    return closestCenter(args)
  }

  function handleDragCancel() {
    setLocalActiveSlot(null)
    setLocalOverRow(null)
    setLocalDraggedTime(null)
    setIsValid(true)
  }

  async function handleDragEnd(event: DragEndEvent) {
    const { active, over, delta } = event

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

    const activeId = String(active.id)

    if (over) {
      const overId = String(over.id)
      const isRowTarget = rows.some((row) => row.id === overId)
      const slot = slots.find((item) => item.id === active.id)

      if (!slot || !isRowTarget) {
        await onExternalDrop?.(activeId, overId)
        return
      }

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

      if (onValidateDrop && !onValidateDrop(slot.id, newTime, newRowId)) {
        return
      }

      if (slot.rowId === newRowId && slot.startTime === newTime) {
        return
      }

      await onSlotPositionChange?.(activeId, newTime, newRowId)
    } else if (onExternalDrop && !slots.some((slot) => slot.id === activeId)) {
      await onExternalDrop(activeId, null)
    }
  }

  const activeSlot = localActiveSlot
    ? slots.find((slot) => slot.id === localActiveSlot)
    : null
  const showAgenda =
    mobileMode === "agenda" ||
    (mobileMode === "auto" &&
      containerWidth > 0 &&
      containerWidth < TIMELINE_AGENDA_BREAKPOINT)
  const showGrid = mobileMode === "scroll" || !showAgenda

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={collisionDetection}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragMove={handleDragMove}
      onDragEnd={handleDragEnd}
      onDragCancel={handleDragCancel}
    >
      <div ref={containerRef} className="relative w-full">
        {showAgenda ? (
          <TimelineAgenda
            slots={slots}
            rows={rows}
            onSlotClick={onSlotClick}
            onSlotCreate={onSlotCreate}
            reduceMotion={reduceMotion}
          />
        ) : null}
        {showGrid && mousePosition && mouseTime && !localActiveSlot ? (
          <TimelineMouseIndicator mouseX={mousePosition.x} time={mouseTime} />
        ) : null}

        {showGrid ? (
          <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<TimelineInjectedProps>(child))
                return child

              return React.cloneElement(child, {
                slots,
                rows,
                activeSlotId: localActiveSlot,
                overRowId: localOverRow,
                draggedNewTime: localDraggedTime,
                isValidDrop: isValid,
                getSnappedDelta,
                _showDropRegion: Boolean(localActiveSlot && localDraggedTime),
                _dropRegionTime: localDraggedTime,
              })
            })}
          </div>
        ) : null}

        {afterGrid}

        {typeof document !== "undefined"
          ? createPortal(
              <DragOverlay dropAnimation={null}>
                {localActiveSlot && activeSlot ? (
                  <div
                    data-slot="timeline-drag-overlay"
                    style={{
                      width: `${Math.max(activeSlot.duration * pixelsPerMinute, 60)}px`,
                      height: "54px",
                      position: "relative",
                    }}
                  >
                    <DragPreviewOut />
                  </div>
                ) : null}
              </DragOverlay>,
              document.body
            )
          : null}
      </div>
    </DndContext>
  )
}

function TimelineAgenda({
  slots,
  rows,
  onSlotClick,
  onSlotCreate,
  reduceMotion,
}: {
  slots: TimelineSlotData[]
  rows: TimelineRowData[]
  onSlotClick?: (slotId: string) => void
  onSlotCreate?: (slot: TimelineSlotData) => void
  reduceMotion: boolean | null
}) {
  const rowMap = React.useMemo(
    () => new Map(rows.map((row) => [row.id, row])),
    [rows]
  )
  const sortedSlots = React.useMemo(
    () =>
      slots
        .slice()
        .sort(
          (left, right) =>
            timeToMinutes(left.startTime) - timeToMinutes(right.startTime)
        ),
    [slots]
  )

  return (
    <div
      data-slot="timeline-agenda"
      className="grid gap-3 rounded-md border bg-background p-3"
    >
      <div className="flex items-start justify-between gap-3 border-b pb-3">
        <div>
          <div className="text-sm font-semibold">Agenda</div>
          <div className="text-xs text-muted-foreground">
            {sortedSlots.length} scheduled items
          </div>
        </div>
        <div className="rounded-full border bg-muted/40 px-2 py-1 text-xs font-medium">
          {rows.length} lanes
        </div>
      </div>
      <div className="grid gap-2">
        {sortedSlots.map((slot, index) => {
          const row = rowMap.get(slot.rowId)
          const nextSlot = sortedSlots[index + 1]

          return (
            <React.Fragment key={slot.id}>
              <motion.button
                type="button"
                onClick={() => onSlotClick?.(slot.id)}
                className="grid grid-cols-[4.5rem_1fr] gap-3 rounded-md border bg-card p-3 text-left shadow-xs transition-colors hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
                initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{
                  duration: reduceMotion ? 0 : 0.16,
                  delay: reduceMotion ? 0 : index * 0.02,
                }}
              >
                <div className="text-xs font-medium tabular-nums text-muted-foreground">
                  <div className="text-foreground">{slot.startTime}</div>
                  <div className="mt-1">
                    {formatTimelineDuration(slot.duration)}
                  </div>
                </div>
                <div className="min-w-0">
                  <div className="flex items-start justify-between gap-3">
                    <div className="min-w-0">
                      <div className="truncate text-sm font-medium">
                        {String(slot.title ?? slot.label ?? "Timeline item")}
                      </div>
                      <div className="mt-1 truncate text-xs text-muted-foreground">
                        {row?.label ?? slot.rowId}
                      </div>
                    </div>
                    {slot.owner ? (
                      <div className="shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
                        {String(slot.owner)}
                      </div>
                    ) : null}
                  </div>
                </div>
              </motion.button>
              {onSlotCreate ? (
                <TimelineAgendaCreateSlot
                  rows={rows}
                  afterSlot={slot}
                  beforeSlot={nextSlot}
                  onSlotCreate={onSlotCreate}
                />
              ) : null}
            </React.Fragment>
          )
        })}
        {sortedSlots.length === 0 && onSlotCreate ? (
          <TimelineAgendaCreateSlot
            rows={rows}
            onSlotCreate={onSlotCreate}
          />
        ) : null}
      </div>
    </div>
  )
}

function TimelineAgendaCreateSlot({
  rows,
  afterSlot,
  beforeSlot,
  onSlotCreate,
}: {
  rows: TimelineRowData[]
  afterSlot?: TimelineSlotData
  beforeSlot?: TimelineSlotData
  onSlotCreate: (slot: TimelineSlotData) => void
}) {
  const startMinutes = afterSlot
    ? timeToMinutes(afterSlot.startTime) + afterSlot.duration
    : 9 * 60
  const nextStartMinutes = beforeSlot ? timeToMinutes(beforeSlot.startTime) : null
  const defaultStart = minutesToTime(startMinutes)
  const defaultEnd = minutesToTime(
    nextStartMinutes && nextStartMinutes > startMinutes
      ? Math.min(nextStartMinutes, startMinutes + 60)
      : startMinutes + 60
  )
  const [open, setOpen] = React.useState(false)
  const [title, setTitle] = React.useState("New slot")
  const [startTime, setStartTime] = React.useState(defaultStart)
  const [endTime, setEndTime] = React.useState(defaultEnd)
  const [rowId, setRowId] = React.useState(
    afterSlot?.rowId ?? rows[0]?.id ?? "default"
  )

  React.useEffect(() => {
    if (!open) return

    setStartTime(defaultStart)
    setEndTime(defaultEnd)
    setRowId(afterSlot?.rowId ?? rows[0]?.id ?? "default")
  }, [afterSlot?.rowId, defaultEnd, defaultStart, open, rows])

  function createSlot(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const start = timeToMinutes(startTime)
    const end = timeToMinutes(endTime)
    const duration = Math.max(15, end > start ? end - start : 60)

    onSlotCreate({
      id: `slot-${Date.now()}`,
      rowId,
      startTime,
      duration,
      title: title.trim() || "New slot",
    })
    setTitle("New slot")
    setOpen(false)
  }

  return (
    <div className="group/timeline-create relative -my-1 flex h-2 items-center justify-center">
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            className="absolute top-1/2 z-1 size-6 -translate-y-1/2 scale-95 bg-transparent opacity-0 shadow-none transition-all duration-150 hover:bg-transparent focus:bg-transparent active:bg-transparent data-open:bg-transparent data-[state=open]:bg-transparent aria-expanded:bg-transparent focus-visible:scale-100 focus-visible:bg-transparent focus-visible:opacity-100 group-hover/timeline-create:scale-100 group-hover/timeline-create:opacity-100 group-focus-within/timeline-create:scale-100 group-focus-within/timeline-create:opacity-100"
            aria-label="Add timeline slot"
          >
            +
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-72" align="center">
          <PopoverHeader>
            <PopoverTitle>Add slot</PopoverTitle>
            <PopoverDescription>
              Create a scheduled item in the agenda.
            </PopoverDescription>
          </PopoverHeader>
          <form onSubmit={createSlot}>
            <FieldGroup className="gap-3">
              <Field>
                <FieldLabel htmlFor="timeline-slot-title">Title</FieldLabel>
                <Input
                  id="timeline-slot-title"
                  value={title}
                  onChange={(event) => setTitle(event.target.value)}
                />
              </Field>
              <div className="grid grid-cols-2 gap-2">
                <Field>
                  <FieldLabel htmlFor="timeline-slot-start">From</FieldLabel>
                  <Input
                    id="timeline-slot-start"
                    type="time"
                    value={startTime}
                    onChange={(event) => setStartTime(event.target.value)}
                  />
                </Field>
                <Field>
                  <FieldLabel htmlFor="timeline-slot-end">To</FieldLabel>
                  <Input
                    id="timeline-slot-end"
                    type="time"
                    value={endTime}
                    onChange={(event) => setEndTime(event.target.value)}
                  />
                </Field>
              </div>
              <Field>
                <FieldLabel>Lane</FieldLabel>
                <Select value={rowId} onValueChange={setRowId}>
                  <SelectTrigger>
                    <SelectValue placeholder="Choose lane" />
                  </SelectTrigger>
                  <SelectContent>
                    {rows.map((row) => (
                      <SelectItem key={row.id} value={row.id}>
                        {row.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </Field>
              <Button type="submit" className="w-full">
                Add slot
              </Button>
            </FieldGroup>
          </form>
        </PopoverContent>
      </Popover>
    </div>
  )
}

function formatTimelineDuration(duration: number) {
  if (duration < 60) return `${duration}m`
  const hours = Math.floor(duration / 60)
  const minutes = duration % 60
  return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
}

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

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

  for (let hour = config.startHour; hour < config.endHour; hour += 1) {
    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 border-b bg-background", className)}
    >
      <div className="flex h-12">
        <div
          ref={columnRef}
          data-slot="timeline-header-column"
          className="sticky left-0 z-1 flex w-[var(--timeline-column-width)] items-center border-r bg-background px-3 text-xs font-semibold"
        >
          {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>
  )
}

export interface TimelineRowProps extends TimelineInjectedProps {
  row: TimelineRowData
  slots: TimelineSlotData[]
  children: (slot: TimelineSlotData) => React.ReactNode
  renderRowHeader?: (row: TimelineRowData) => React.ReactNode
  renderRowExtras?: (row: TimelineRowData) => React.ReactNode
  className?: string
  asChild?: boolean
}

export function TimelineRow({
  row,
  slots,
  children,
  renderRowHeader,
  renderRowExtras,
  className,
  asChild,
  ...props
}: TimelineRowProps) {
  const { config, pixelsPerMinute, timelineWidth } = useTimeline()
  const Comp = asChild ? Slot : "div"
  const { setNodeRef, isOver } = useDroppable({
    id: row.id,
  })
  const rowSlots = slots.filter((slot) => slot.rowId === row.id)
  const isValidDrop = props.isValidDrop !== false
  const isHovered = isOver || props.overRowId === row.id
  const hourMarkers = []

  for (let hour = config.startHour; hour <= config.endHour; hour += 1) {
    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 h-12 border-b",
        isHovered && isValidDrop && "ring-2 ring-blue-500 ring-inset",
        isHovered && !isValidDrop && "ring-2 ring-red-500 ring-inset",
        className
      )}
    >
      <div
        data-slot="timeline-row-label"
        className="sticky left-0 z-[5] flex w-[var(--timeline-column-width)] items-center border-r bg-inherit text-xs font-medium"
      >
        {renderRowHeader ? (
          renderRowHeader(row)
        ) : (
          <span className="px-3">{row.label}</span>
        )}
      </div>

      <div
        data-slot="timeline-row-grid"
        className="relative flex-1"
        style={{ width: `${timelineWidth}px` }}
      >
        {quarterHourMarkers.map((marker, index) => (
          <div
            key={`quarter-${index}`}
            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` }}
          />
        ))}

        {renderRowExtras ? renderRowExtras(row) : null}

        {rowSlots.map((slot) => {
          const slotElement = children(slot)

          return (
            <React.Fragment key={slot.id}>
              {React.isValidElement<TimelineSlotInjectedProps>(slotElement)
                ? React.cloneElement(slotElement, {
                    activeSlotId: props.activeSlotId,
                    getSnappedDelta: props.getSnappedDelta,
                  })
                : slotElement}
            </React.Fragment>
          )
        })}

        {isHovered && props.draggedNewTime && props.activeSlotId ? (
          <TimelineDropGhost
            activeSlotId={props.activeSlotId}
            allSlots={slots}
            newTime={props.draggedNewTime}
            isValid={isValidDrop}
            pixelsPerMinute={pixelsPerMinute}
            config={config}
          />
        ) : null}
      </div>
    </Comp>
  )
}

type TimelineSlotInjectedProps = {
  activeSlotId?: string | null
  getSnappedDelta?: (deltaX: number) => number
}

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

export function TimelineSlot({
  slot,
  children,
  className,
  asChild,
  ...props
}: TimelineSlotProps) {
  const {
    config,
    pixelsPerMinute,
    onSlotClick,
    onSlotResize,
    dragPreviewTunnel,
  } = useTimeline()
  const Comp = asChild ? Slot : "div"
  const DragPreviewIn = dragPreviewTunnel.In
  const { attributes, listeners, setNodeRef, isDragging, transform } =
    useDraggable({
      id: slot.id,
    })
  const startMinutes = timeToMinutes(slot.startTime)
  const endMinutes = startMinutes + slot.duration
  const left = (startMinutes - config.startHour * 60) * pixelsPerMinute
  const width = slot.duration * pixelsPerMinute
  const isActiveSlot = props.activeSlotId === slot.id
  const snapInterval = config.snapIntervalMinutes ?? 15

  function handleResizeStart(
    event: React.PointerEvent,
    edge: "left" | "right"
  ) {
    if (!onSlotResize) return

    event.stopPropagation()
    event.preventDefault()

    const target = event.currentTarget as HTMLElement
    target.setPointerCapture(event.pointerId)

    const initialX = event.clientX
    const initialStart = startMinutes
    const initialEnd = endMinutes
    const timelineStart = config.startHour * 60
    const timelineEnd = config.endHour * 60
    const minDuration = snapInterval

    function getDeltaMinutes(clientX: number) {
      const rawDeltaMinutes = (clientX - initialX) / pixelsPerMinute
      return Math.round(rawDeltaMinutes / snapInterval) * snapInterval
    }

    function getNextSize(clientX: number) {
      const deltaMinutes = getDeltaMinutes(clientX)

      if (edge === "left") {
        const newStart = Math.max(
          timelineStart,
          Math.min(initialStart + deltaMinutes, initialEnd - minDuration)
        )

        return {
          start: newStart,
          duration: initialEnd - newStart,
        }
      }

      const newEnd = Math.max(
        initialStart + minDuration,
        Math.min(initialEnd + deltaMinutes, timelineEnd)
      )

      return {
        start: initialStart,
        duration: newEnd - initialStart,
      }
    }

    function handlePointerMove(moveEvent: PointerEvent) {
      const next = getNextSize(moveEvent.clientX)
      void onSlotResize?.(
        slot.id,
        minutesToTime(next.start),
        next.duration,
        false
      )
    }

    function handlePointerUp(upEvent: PointerEvent) {
      const next = getNextSize(upEvent.clientX)
      void onSlotResize?.(
        slot.id,
        minutesToTime(next.start),
        next.duration,
        true
      )
      target.releasePointerCapture(upEvent.pointerId)
      window.removeEventListener("pointermove", handlePointerMove)
      window.removeEventListener("pointerup", handlePointerUp)
    }

    window.addEventListener("pointermove", handlePointerMove)
    window.addEventListener("pointerup", handlePointerUp)
  }

  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, 0px, 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(
        "group/timeline-slot absolute inset-1 cursor-move overflow-hidden rounded transition-all",
        isDragging
          ? "opacity-40 shadow-sm ring-2 ring-foreground/50"
          : "shadow-md",
        onSlotClick && "cursor-pointer hover:ring-2 hover:ring-foreground/30",
        className
      )}
      onClick={(event: React.MouseEvent) => {
        if (onSlotClick && !isDragging) {
          event.stopPropagation()
          onSlotClick(slot.id)
        }
      }}
      style={
        {
          "--slot-start-time": slot.startTime,
          "--slot-duration": `${slot.duration}min`,
        } as React.CSSProperties
      }
    >
      {children}
      {onSlotResize ? (
        <>
          <div
            aria-hidden="true"
            className="absolute top-0 bottom-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize"
            onPointerDown={(event) => handleResizeStart(event, "left")}
          />
          <div
            aria-hidden="true"
            className="absolute top-0 right-0 bottom-0 z-20 w-4 translate-x-1/2 cursor-col-resize"
            onPointerDown={(event) => handleResizeStart(event, "right")}
          />
          <div className="pointer-events-none absolute top-1 bottom-1 left-0 w-1 rounded-r-sm bg-transparent group-hover/timeline-slot:bg-foreground/20" />
          <div className="pointer-events-none absolute top-1 right-0 bottom-1 w-1 rounded-l-sm bg-transparent group-hover/timeline-slot:bg-foreground/20" />
        </>
      ) : null}
    </Comp>
  )

  return (
    <div
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      className="absolute"
      style={style}
    >
      {slotContent}

      {isActiveSlot && isDragging ? (
        <DragPreviewIn>
          <Comp
            data-slot="timeline-slot-preview"
            className={cn(
              "h-full w-full cursor-move overflow-hidden rounded shadow-lg",
              className
            )}
            style={
              {
                "--slot-start-time": slot.startTime,
                "--slot-duration": `${slot.duration}min`,
              } as React.CSSProperties
            }
          >
            {children}
          </Comp>
        </DragPreviewIn>
      ) : null}
    </div>
  )
}

export 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("truncate text-xs font-medium", className)}
      {...props}
    />
  )
}

export 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}
    />
  )
}

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

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

export 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] = React.useState(new Date())

  React.useEffect(() => {
    const interval = window.setInterval(() => setNow(new Date()), 60000)
    return () => window.clearInterval(interval)
  }, [])

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

  if (
    currentMinutes < config.startHour * 60 ||
    currentMinutes > config.endHour * 60
  ) {
    return null
  }

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

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

export function TimelineGrid({
  children,
  className,
  ...props
}: TimelineGridProps) {
  const { timelineWidth } = useTimeline()
  const activeSlot =
    props._showDropRegion && props.slots
      ? props.slots.find((slot) => slot.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<TimelineInjectedProps>(child)) return child
        return React.cloneElement(child, props)
      })}

      {props._showDropRegion && props._dropRegionTime && activeSlot ? (
        <TimelineDropRegion
          startTime={props._dropRegionTime}
          duration={activeSlot.duration}
        />
      ) : null}
    </div>
  )
}

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

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

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

DependenciesBundle weight · light

ui-primitives
@radix-ui/react-slot