{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drop-target",
  "type": "registry:ui",
  "title": "Drop Target",
  "description": "Targets say what they take, pull the item in and send refusals home.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/drop-target.tsx",
      "content": "\"use client\";\nimport { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Provider\n * -----------------------------------------------------------------------------------------------*/\n\ntype Zone = {\n  id: string;\n  label: string;\n  accept?: (item: string) => boolean;\n  rejectLabel?: string;\n  magnet: number;\n  el: HTMLElement | null;\n};\n\ntype Flight = { id: string; label: string; from: string | null; phase: \"dragging\" | \"settling\"; width: number; height: number };\ntype Picked = { id: string; label: string; from: string | null; via: \"keyboard\" | \"pointer\" };\ntype Flash = { zone: string; kind: \"accepted\" | \"rejected\"; key: number };\n\ntype Ctx = {\n  flight: Flight | null;\n  picked: Picked | null;\n  over: string | null;\n  flash: Flash | null;\n  reduce: boolean;\n  hintId: string;\n  zones: React.RefObject<Map<string, Zone>>;\n  canDrop: (zone: string, item: string) => boolean;\n  startPointer: (e: React.PointerEvent<HTMLElement>, id: string, label: string, from: string | null) => void;\n  toggle: (id: string, label: string, from: string | null, via: \"keyboard\" | \"pointer\") => void;\n  dropPicked: (zone: string) => void;\n  consumeClick: (id: string) => boolean;\n};\n\nconst DnDCtx = createContext<Ctx | null>(null);\nconst ZoneCtx = createContext<{ id: string; state: DropTargetState; holding: string | null } | null>(null);\nconst useDnD = (part: string) => {\n  const ctx = useContext(DnDCtx);\n  if (!ctx) throw new Error(`${part} must be used inside DropTargetProvider`);\n  return ctx;\n};\n\nconst THRESHOLD = 5;\nconst sel = (id: string) => `[data-draggable-id=\"${CSS.escape(id)}\"]`;\n\nexport type DropTargetProviderProps = {\n  /** Called when an item is dropped on a target that accepts it. Move the item in your state here. */\n  onDrop: (item: string, target: string) => void;\n  children: React.ReactNode;\n};\n\n/** Connects Draggables to DropTargets. Handles the ghost, the magnet, the flight home and the keyboard path. */\nexport function DropTargetProvider({ onDrop, children }: DropTargetProviderProps) {\n  const reduce = !!useReducedMotion();\n  const hintId = useId();\n  const zones = useRef(new Map<string, Zone>());\n  const [flight, setFlight] = useState<Flight | null>(null);\n  const [picked, setPicked] = useState<Picked | null>(null);\n  const [over, setOver] = useState<string | null>(null);\n  const [flash, setFlash] = useState<Flash | null>(null);\n  const [message, setMessage] = useState(\"\");\n  const focusAfter = useRef<string | null>(null);\n  const suppressClick = useRef<string | null>(null);\n  const flashTimer = useRef<number>(undefined);\n\n  // The ghost follows the pointer exactly; the magnet adds a sprung pull toward a nearby target.\n  const px = useMotionValue(0);\n  const py = useMotionValue(0);\n  const pullX = useSpring(0, spring.follow);\n  const pullY = useSpring(0, spring.follow);\n  const gx = useTransform(() => px.get() + pullX.get());\n  const gy = useTransform(() => py.get() + pullY.get());\n  const gScale = useMotionValue(1);\n  const gw = useMotionValue(0);\n  const gh = useMotionValue(0);\n  const ghostRef = useRef<HTMLDivElement | null>(null);\n\n  const session = useRef<{\n    id: string;\n    label: string;\n    from: string | null;\n    pointerId: number;\n    startX: number;\n    startY: number;\n    grabX: number;\n    grabY: number;\n    width: number;\n    height: number;\n    source: HTMLElement;\n    started: boolean;\n    target: string | null;\n    cleanup: () => void;\n  } | null>(null);\n\n  useEffect(\n    () => () => {\n      session.current?.cleanup();\n      window.clearTimeout(flashTimer.current);\n    },\n    [],\n  );\n\n  const canDrop = (zone: string, item: string) => {\n    const z = zones.current.get(zone);\n    return !!z && (z.accept ? z.accept(item) : true);\n  };\n\n  const flashZone = (zone: string, kind: Flash[\"kind\"]) => {\n    window.clearTimeout(flashTimer.current);\n    setFlash({ zone, kind, key: Date.now() });\n    flashTimer.current = window.setTimeout(() => setFlash(null), kind === \"rejected\" ? 1400 : 700);\n  };\n\n  // The target under the ghost, or the nearest one within its magnet radius.\n  const hitTest = (cx: number, cy: number) => {\n    let best: { zone: Zone; d: number; r: DOMRect } | null = null;\n    for (const z of zones.current.values()) {\n      if (!z.el) continue;\n      const r = z.el.getBoundingClientRect();\n      const dx = cx < r.left ? r.left - cx : cx > r.right ? cx - r.right : 0;\n      const dy = cy < r.top ? r.top - cy : cy > r.bottom ? cy - r.bottom : 0;\n      const d = Math.hypot(dx, dy);\n      if (d <= z.magnet && (!best || d < best.d)) best = { zone: z, d, r };\n    }\n    return best;\n  };\n\n  const land = (s: { id: string; label: string; from: string | null; width: number; height: number }, target: string | null) => {\n    const ok = !!target && target !== s.from && canDrop(target, s.id);\n    setFlight({ id: s.id, label: s.label, from: s.from, phase: \"settling\", width: s.width, height: s.height });\n    setOver(null);\n    pullX.set(0);\n    pullY.set(0);\n    if (ok) {\n      const z = zones.current.get(target!)!;\n      onDrop(s.id, target!);\n      flashZone(target!, \"accepted\");\n      setMessage(`${s.label} moved to ${z.label}.`);\n    } else if (target && target !== s.from) {\n      const z = zones.current.get(target)!;\n      flashZone(target, \"rejected\");\n      setMessage(`${z.label} can't take ${s.label}${z.rejectLabel ? `: ${z.rejectLabel}` : \"\"}. It went back.`);\n    } else if (!target) {\n      setMessage(`${s.label} went back. Drop it on a target to move it.`);\n    }\n  };\n\n  const startPointer = (e: React.PointerEvent<HTMLElement>, id: string, label: string, from: string | null) => {\n    if (flight || session.current) return;\n    if (e.pointerType === \"mouse\" && e.button !== 0) return;\n    const source = e.currentTarget;\n    const r = source.getBoundingClientRect();\n\n    const onMove = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      if (!s.started) {\n        if (Math.hypot(ev.clientX - s.startX, ev.clientY - s.startY) < THRESHOLD) return;\n        s.started = true;\n        setPicked(null);\n        px.set(ev.clientX - s.grabX);\n        py.set(ev.clientY - s.grabY);\n        gScale.set(1);\n        gw.set(s.width);\n        gh.set(s.height);\n        if (!reduce) animate(gScale, 1.05, spring.snappy);\n        setFlight({ id: s.id, label: s.label, from: s.from, phase: \"dragging\", width: s.width, height: s.height });\n        setMessage(`${s.label} picked up.`);\n      }\n      const x = ev.clientX - s.grabX;\n      const y = ev.clientY - s.grabY;\n      px.set(x);\n      py.set(y);\n      const cx = x + s.width / 2;\n      const cy = y + s.height / 2;\n      const hit = hitTest(cx, cy);\n      const target = hit ? hit.zone.id : null;\n      // Magnetism: just outside a target that would take it, the ghost leans in, harder the closer it\n      // gets (up to 12px). Once inside, it's the hand's again.\n      if (hit && hit.d > 0 && !reduce && target !== s.from && canDrop(hit.zone.id, s.id)) {\n        const strength = 1 - hit.d / Math.max(1, hit.zone.magnet);\n        const nx = Math.max(hit.r.left, Math.min(cx, hit.r.right)) - cx;\n        const ny = Math.max(hit.r.top, Math.min(cy, hit.r.bottom)) - cy;\n        const len = Math.hypot(nx, ny) || 1;\n        pullX.set((nx / len) * 12 * strength);\n        pullY.set((ny / len) * 12 * strength);\n      } else {\n        pullX.set(0);\n        pullY.set(0);\n      }\n      if (target !== s.target) {\n        s.target = target;\n        setOver(target);\n      }\n    };\n    const end = (ev: PointerEvent, cancel: boolean) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      s.cleanup();\n      session.current = null;\n      if (!s.started) return;\n      // A drag should never also count as a click on the item.\n      suppressClick.current = s.id;\n      window.setTimeout(() => {\n        suppressClick.current = null;\n      }, 0);\n      land(s, cancel ? null : s.target);\n      if (cancel) setMessage(`Move cancelled. ${s.label} went back.`);\n    };\n    const onUp = (ev: PointerEvent) => end(ev, false);\n    const onCancel = (ev: PointerEvent) => end(ev, true);\n    const onKey = (ev: KeyboardEvent) => {\n      const s = session.current;\n      if (ev.key !== \"Escape\" || !s?.started) return;\n      ev.preventDefault();\n      s.cleanup();\n      session.current = null;\n      land(s, null);\n      setMessage(`Move cancelled. ${s.label} went back.`);\n    };\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onCancel);\n    window.addEventListener(\"keydown\", onKey);\n    session.current = {\n      id,\n      label,\n      from,\n      pointerId: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n      grabX: e.clientX - r.left,\n      grabY: e.clientY - r.top,\n      width: r.width,\n      height: r.height,\n      source,\n      started: false,\n      target: null,\n      cleanup: () => {\n        window.removeEventListener(\"pointermove\", onMove);\n        window.removeEventListener(\"pointerup\", onUp);\n        window.removeEventListener(\"pointercancel\", onCancel);\n        window.removeEventListener(\"keydown\", onKey);\n      },\n    };\n  };\n\n  // Copy the item's look into the ghost when a flight starts. It's a picture, not a second live copy.\n  const flightId = flight?.id;\n  useLayoutEffect(() => {\n    const ghost = ghostRef.current;\n    const src = flightId ? document.querySelector<HTMLElement>(sel(flightId)) : null;\n    if (!ghost || !src) return;\n    const clone = src.cloneNode(true) as HTMLElement;\n    clone.removeAttribute(\"data-draggable-id\");\n    clone.removeAttribute(\"id\");\n    clone.setAttribute(\"data-ghost\", \"\");\n    clone.setAttribute(\"data-state\", \"idle\");\n    clone.style.width = \"100%\";\n    clone.style.height = \"100%\";\n    ghost.replaceChildren(clone);\n  }, [flightId]);\n\n  // After a drop, fly the ghost to wherever the item now lives: its new target, or home.\n  const settling = flight?.phase === \"settling\" ? flight.id : null;\n  useLayoutEffect(() => {\n    if (!settling) return;\n    const el = document.querySelector<HTMLElement>(sel(settling));\n    const done = () => setFlight((f) => (f?.id === settling && f.phase === \"settling\" ? null : f));\n    if (!el || reduce) {\n      done();\n      return;\n    }\n    const r = el.getBoundingClientRect();\n    // Land from wherever the ghost is now, magnet included.\n    px.set(gx.get());\n    py.set(gy.get());\n    pullX.jump(0);\n    pullY.jump(0);\n    // The item may look different where it lands (a compact chip in a folder), so the ghost takes its size too.\n    animate(px, r.left, spring.soft);\n    animate(gw, r.width, spring.soft);\n    animate(gh, r.height, spring.soft);\n    animate(gScale, 1, spring.soft);\n    const a = animate(py, r.top, spring.soft);\n    a.then(done);\n    return () => a.stop();\n  }, [settling, reduce, px, py, gx, gy, pullX, pullY, gScale, gw, gh]);\n\n  // Keyboard focus follows the item to its new place.\n  useLayoutEffect(() => {\n    const id = focusAfter.current;\n    if (!id) return;\n    focusAfter.current = null;\n    document.querySelector<HTMLElement>(sel(id))?.focus({ preventScroll: true });\n  });\n\n  // While an item is picked up by tap or keyboard: Escape or a press elsewhere puts it down.\n  useEffect(() => {\n    if (!picked) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.preventDefault();\n      setPicked(null);\n      setMessage(`Move cancelled. ${picked.label} stays where it was.`);\n      document.querySelector<HTMLElement>(sel(picked.id))?.focus({ preventScroll: true });\n    };\n    const onDown = (e: PointerEvent) => {\n      const t = e.target as Element;\n      if (t.closest(\"[data-drop-button]\") || t.closest(sel(picked.id))) return;\n      setPicked(null);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onDown, true);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onDown, true);\n    };\n  }, [picked]);\n\n  const ctx: Ctx = {\n    flight,\n    picked,\n    over,\n    flash,\n    reduce,\n    hintId,\n    zones,\n    canDrop,\n    startPointer,\n    consumeClick: (id) => suppressClick.current === id,\n    toggle(id, label, from, via) {\n      if (picked?.id === id) {\n        setPicked(null);\n        setMessage(`${label} put down.`);\n        return;\n      }\n      setPicked({ id, label, from, via });\n      setMessage(`${label} picked up. Tab to a target and press Enter to move it there, or Escape to cancel.`);\n      if (via === \"keyboard\") {\n        // Hand focus to the first target that would take it, or to the first one (which says why not).\n        requestAnimationFrame(() =>\n          (\n            document.querySelector<HTMLElement>(\"[data-drop-button]:not([aria-disabled=true])\") ??\n            document.querySelector<HTMLElement>(\"[data-drop-button]\")\n          )?.focus(),\n        );\n      }\n    },\n    dropPicked(zone) {\n      if (!picked) return;\n      const z = zones.current.get(zone);\n      if (!z) return;\n      if (!canDrop(zone, picked.id)) {\n        flashZone(zone, \"rejected\");\n        setMessage(`${z.label} can't take ${picked.label}${z.rejectLabel ? `: ${z.rejectLabel}` : \"\"}.`);\n        return;\n      }\n      const src = document.querySelector<HTMLElement>(sel(picked.id));\n      const r = src?.getBoundingClientRect();\n      setPicked(null);\n      focusAfter.current = picked.via === \"keyboard\" ? picked.id : null;\n      if (r && !reduce) {\n        px.set(r.left);\n        py.set(r.top);\n        gw.set(r.width);\n        gh.set(r.height);\n        gScale.set(1);\n        setFlight({ id: picked.id, label: picked.label, from: picked.from, phase: \"settling\", width: r.width, height: r.height });\n      }\n      onDrop(picked.id, zone);\n      flashZone(zone, \"accepted\");\n      setMessage(`${picked.label} moved to ${z.label}.`);\n    },\n  };\n\n  return (\n    <DnDCtx.Provider value={ctx}>\n      {children}\n      <span id={hintId} className=\"sr-only\">\n        Press Enter to pick up, then Tab to a target and press Enter to drop. Escape cancels.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n      {flight &&\n        createPortal(\n          <motion.div\n            ref={ghostRef}\n            aria-hidden\n            data-phase={flight.phase}\n            className={cn(\n              \"pointer-events-none fixed left-0 top-0 z-(--z-popover) rounded-lg\",\n              \"shadow-pop transition-shadow duration-200 data-[phase=settling]:shadow-none\",\n              \"[&>*]:cursor-grabbing\",\n            )}\n            style={{ x: gx, y: gy, scale: gScale, width: gw, height: gh }}\n          />,\n          document.body,\n        )}\n    </DnDCtx.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Draggable\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DraggableProps = Omit<React.ComponentProps<\"button\">, \"id\" | \"children\"> & {\n  /** Unique across the provider. */\n  id: string;\n  /** Plain-text name for the announcements. */\n  label: string;\n  children: React.ReactNode;\n};\n\n/** Something that can be dragged, or picked up with a tap or Enter and placed on a target. */\nexport function Draggable({ id, label, disabled, className, children, onClick, onPointerDown, ...rest }: DraggableProps) {\n  const ctx = useDnD(\"Draggable\");\n  const zone = useContext(ZoneCtx)?.id ?? null;\n  const inFlight = ctx.flight?.id === id;\n  const picked = ctx.picked?.id === id;\n\n  return (\n    <button\n      type=\"button\"\n      data-draggable-id={id}\n      data-state={inFlight ? (ctx.flight?.phase === \"dragging\" ? \"dragging\" : \"landing\") : picked ? \"picked\" : \"idle\"}\n      aria-pressed={picked}\n      aria-describedby={ctx.hintId}\n      disabled={disabled}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        if (!e.defaultPrevented && !disabled) ctx.startPointer(e, id, label, zone);\n      }}\n      onClick={(e) => {\n        onClick?.(e);\n        if (e.defaultPrevented || ctx.consumeClick(id)) return;\n        // detail 0 means Enter or Space rather than a pointer.\n        ctx.toggle(id, label, zone, e.detail === 0 ? \"keyboard\" : \"pointer\");\n      }}\n      className={cn(\n        \"relative touch-none select-none outline-none [-webkit-touch-callout:none]\",\n        \"cursor-grab transition-[opacity,scale,box-shadow] duration-150 ease-out-quart active:scale-[0.97]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        // Left behind while dragging: a dashed outline of where it came from.\n        \"data-[state=dragging]:opacity-35\",\n        \"data-[state=landing]:opacity-0 data-[state=landing]:transition-none\",\n        \"data-[state=picked]:shadow-[0_0_0_1px_var(--fg-3)] data-[state=picked]:scale-[1.03]\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * DropTarget\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DropTargetState = {\n  /** Something is being dragged or is picked up. */\n  active: boolean;\n  /** This target would take what's in the air. */\n  canDrop: boolean;\n  /** What's in the air came from this target; dropping it here changes nothing. */\n  home: boolean;\n  /** The ghost is over it or inside its magnet radius. */\n  over: boolean;\n  /** Briefly true after a drop lands or is refused here. */\n  flash: \"accepted\" | \"rejected\" | null;\n};\n\nexport type DropTargetProps = Omit<React.ComponentProps<\"div\">, \"id\" | \"children\"> & {\n  id: string;\n  /** Plain-text name, used in \"Move … to <label>\" and the announcements. */\n  label: string;\n  /** Which items this target takes. Omit to take everything. */\n  accept?: (item: string) => boolean;\n  /** Why it refuses, shown while a refused item is in the air (\"Only PDFs\", \"Read-only\"). */\n  rejectLabel?: string;\n  /** How far outside its edge a release still snaps in, in px. */\n  magnet?: number;\n  children: React.ReactNode | ((state: DropTargetState) => React.ReactNode);\n};\n\n/** A place things are dropped. It lights up when it would take what you're holding and says so when it won't. */\nexport function DropTarget({ id, label, accept, rejectLabel, magnet = 32, className, children, ...rest }: DropTargetProps) {\n  const ctx = useDnD(\"DropTarget\");\n  const ref = useRef<HTMLDivElement | null>(null);\n\n  // Register with the provider; kept current on every render so accept can close over fresh state.\n  useLayoutEffect(() => {\n    ctx.zones.current.set(id, { id, label, accept, rejectLabel, magnet, el: ref.current });\n  });\n  useEffect(() => {\n    const zones = ctx.zones.current;\n    return () => {\n      zones.delete(id);\n    };\n  }, [ctx.zones, id]);\n\n  const holding = ctx.flight?.phase === \"dragging\" ? ctx.flight : ctx.picked;\n  const active = !!holding;\n  const home = holding?.from === id;\n  const can = !!holding && !home && (accept ? accept(holding.id) : true);\n  const over = ctx.over === id && ctx.flight?.phase === \"dragging\";\n  const flash = ctx.flash?.zone === id ? ctx.flash.kind : null;\n  const state: DropTargetState = { active, canDrop: can, home, over, flash };\n\n  return (\n    <ZoneCtx.Provider value={{ id, state, holding: holding?.id ?? null }}>\n      <div\n        ref={ref}\n        role=\"group\"\n        aria-label={label}\n        data-active={active ? \"\" : undefined}\n        data-can-drop={active && !home ? String(can) : undefined}\n        data-over={over ? \"\" : undefined}\n        data-flash={flash ?? undefined}\n        className={cn(\n          \"relative rounded-xl border border-line bg-raised transition-[border-color,background-color,opacity,box-shadow] duration-200 ease-out-quart\",\n          \"data-[can-drop=true]:border-dashed data-[can-drop=true]:border-fg-4\",\n          \"data-[can-drop=false]:opacity-55\",\n          \"data-over:data-[can-drop=true]:border-solid data-over:data-[can-drop=true]:border-fg-3 data-over:data-[can-drop=true]:bg-hover\",\n          \"data-over:data-[can-drop=false]:border-danger/50\",\n          \"data-[flash=accepted]:border-success/60 data-[flash=accepted]:shadow-[0_0_0_3px_var(--success-soft)]\",\n          \"data-[flash=rejected]:border-danger/60 data-[flash=rejected]:shadow-[0_0_0_3px_var(--danger-soft)]\",\n          className,\n        )}\n        {...rest}\n      >\n        {typeof children === \"function\" ? children(state) : children}\n        {/* The keyboard and tap path: while something is picked up, the whole target is one button. */}\n        {ctx.picked && !home && (\n          <button\n            type=\"button\"\n            data-drop-button=\"\"\n            aria-disabled={!can || undefined}\n            aria-label={can ? `Move ${ctx.picked.label} to ${label}` : `${label} can't take ${ctx.picked.label}${rejectLabel ? `: ${rejectLabel}` : \"\"}`}\n            onClick={() => ctx.dropPicked(id)}\n            onKeyDown={(e) => {\n              const buttons = Array.from(document.querySelectorAll<HTMLElement>(\"[data-drop-button]\"));\n              const i = buttons.indexOf(e.currentTarget);\n              const step = e.key === \"ArrowDown\" || e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowUp\" || e.key === \"ArrowLeft\" ? -1 : 0;\n              if (!step) return;\n              e.preventDefault();\n              buttons[(i + step + buttons.length) % buttons.length]?.focus();\n            }}\n            className={cn(\n              \"absolute inset-0 z-1 rounded-[inherit] outline-none\",\n              \"hover:bg-fg/[0.03] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              can ? \"cursor-copy\" : \"cursor-not-allowed\",\n            )}\n          />\n        )}\n      </div>\n    </ZoneCtx.Provider>\n  );\n}\n\n/** The state of the DropTarget it's called inside, for content that reacts to what's being held. */\nexport function useDropTarget(): DropTargetState & { holding: string | null } {\n  const zone = useContext(ZoneCtx);\n  if (!zone) throw new Error(\"useDropTarget must be used inside DropTarget\");\n  return { ...zone.state, holding: zone.holding };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/drop-target"
}
