{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drag-select","type":"registry:component","title":"Drag select","description":"Draw a box across files to select them, like the desktop.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/drag-select.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DragSelectItem = { id: string; name: string; meta: string; kind: Kind };\ntype Kind = \"folder\" | \"doc\" | \"image\" | \"sheet\" | \"code\" | \"video\" | \"audio\" | \"archive\";\ntype Box = { left: number; top: number; right: number; bottom: number };\n\n// Movement below this is a click on empty space, not the start of a marquee.\nconst DRAG_THRESHOLD = 3;\n// Within this distance of the top or bottom edge the list scrolls on its own,\n// faster the closer the pointer gets, up to MAX_SCROLL px per frame.\nconst SCROLL_EDGE = 40;\nconst MAX_SCROLL = 14;\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\ntype Drag = {\n  pointerId: number;\n  // Start point in content coordinates, so it stays pinned while scrolling.\n  x: number;\n  y: number;\n  clientX: number;\n  clientY: number;\n  // What was selected before the drag, kept when Shift or Cmd is held.\n  base: Set<number> | null;\n  active: boolean;\n  boxes: Box[];\n};\n\nexport function DragSelect({\n  items,\n  label,\n  className,\n}: {\n  items: DragSelectItem[];\n  label: string;\n  className?: string;\n}) {\n  const [selected, setSelected] = useState<Set<number>>(() => new Set());\n  const [focusIndex, setFocusIndex] = useState(0);\n  const selectedRef = useRef(selected);\n  const anchor = useRef<number | null>(null);\n  const lastPointer = useRef(\"mouse\");\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const marqueeRef = useRef<HTMLDivElement>(null);\n  const tiles = useRef<(HTMLDivElement | null)[]>([]);\n  const drag = useRef<Drag | null>(null);\n  const frame = useRef(0);\n  const instructions = useId();\n\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const commit = (next: Set<number>) => {\n    const prev = selectedRef.current;\n    // Marquee moves fire constantly; only re-render when membership changes.\n    if (next.size === prev.size && [...next].every((i) => prev.has(i))) return;\n    selectedRef.current = next;\n    setSelected(next);\n  };\n\n  const range = (from: number, to: number) => {\n    const set = new Set<number>();\n    for (let i = Math.min(from, to); i <= Math.max(from, to); i++) set.add(i);\n    return set;\n  };\n\n  const focusTile = (index: number) => {\n    setFocusIndex(index);\n    const tile = tiles.current[index];\n    tile?.focus({ preventScroll: true });\n    tile?.scrollIntoView({ block: \"nearest\" });\n  };\n\n  // Tiles flow into however many columns fit, so count them from layout.\n  const columns = () => {\n    const top = tiles.current[0]?.offsetTop;\n    return Math.max(1, tiles.current.filter((t) => t?.offsetTop === top).length);\n  };\n\n  const paintMarquee = () => {\n    const d = drag.current;\n    const content = contentRef.current;\n    const marquee = marqueeRef.current;\n    if (!d || !content || !marquee) return;\n    const box = content.getBoundingClientRect();\n    // Clamped to the content, so the marquee never widens the scroll area.\n    const x = Math.min(Math.max(d.clientX - box.left, 0), content.offsetWidth);\n    const y = Math.min(Math.max(d.clientY - box.top, 0), content.offsetHeight);\n    const left = Math.min(d.x, x);\n    const top = Math.min(d.y, y);\n    const right = Math.max(d.x, x);\n    const bottom = Math.max(d.y, y);\n    marquee.style.transform = `translate(${left}px, ${top}px)`;\n    marquee.style.width = `${right - left}px`;\n    marquee.style.height = `${bottom - top}px`;\n\n    const next = new Set(d.base);\n    d.boxes.forEach((b, i) => {\n      if (b.left < right && b.right > left && b.top < bottom && b.bottom > top) {\n        next.add(i);\n      }\n    });\n    commit(next);\n  };\n\n  const autoscroll = () => {\n    const d = drag.current;\n    const scroller = scrollerRef.current;\n    if (!d?.active || !scroller) return;\n    const box = scroller.getBoundingClientRect();\n    const fromTop = d.clientY - box.top;\n    const fromBottom = box.bottom - d.clientY;\n    let speed = 0;\n    if (fromTop < SCROLL_EDGE) speed = -MAX_SCROLL * (1 - Math.max(fromTop, 0) / SCROLL_EDGE);\n    else if (fromBottom < SCROLL_EDGE)\n      speed = MAX_SCROLL * (1 - Math.max(fromBottom, 0) / SCROLL_EDGE);\n    if (speed) {\n      const before = scroller.scrollTop;\n      scroller.scrollTop += speed;\n      if (scroller.scrollTop !== before) paintMarquee();\n    }\n    frame.current = requestAnimationFrame(autoscroll);\n  };\n\n  const endDrag = () => {\n    const d = drag.current;\n    drag.current = null;\n    cancelAnimationFrame(frame.current);\n    const marquee = marqueeRef.current;\n    if (!d?.active || !marquee) return;\n    // Fades out quickly; it never holds up the next drag, which resets it.\n    marquee.style.transition = `opacity 120ms ${EASE_OUT}`;\n    marquee.style.opacity = \"0\";\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    lastPointer.current = e.pointerType;\n    if (e.button !== 0 || drag.current) return;\n    // Touch keeps native scrolling; tapping tiles toggles them instead.\n    if (e.pointerType === \"touch\") return;\n    if ((e.target as Element).closest(\"[role=option]\")) return;\n    const content = e.currentTarget;\n    // Stops text selection and keeps focus in the list for shortcuts.\n    e.preventDefault();\n    tiles.current[focusIndex]?.focus({ preventScroll: true });\n    content.setPointerCapture(e.pointerId);\n    const box = content.getBoundingClientRect();\n    const additive = e.shiftKey || e.metaKey || e.ctrlKey;\n    drag.current = {\n      pointerId: e.pointerId,\n      x: e.clientX - box.left,\n      y: e.clientY - box.top,\n      clientX: e.clientX,\n      clientY: e.clientY,\n      base: additive ? new Set(selectedRef.current) : null,\n      active: false,\n      // Measured once per drag; positions inside the content never change.\n      boxes: tiles.current.map((t) =>\n        t\n          ? {\n              left: t.offsetLeft,\n              top: t.offsetTop,\n              right: t.offsetLeft + t.offsetWidth,\n              bottom: t.offsetTop + t.offsetHeight,\n            }\n          : { left: 0, top: 0, right: 0, bottom: 0 },\n      ),\n    };\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.pointerId) return;\n    d.clientX = e.clientX;\n    d.clientY = e.clientY;\n    if (!d.active) {\n      const box = e.currentTarget.getBoundingClientRect();\n      const moved = Math.hypot(e.clientX - box.left - d.x, e.clientY - box.top - d.y);\n      if (moved < DRAG_THRESHOLD) return;\n      d.active = true;\n      const marquee = marqueeRef.current;\n      if (marquee) {\n        // Appears instantly: it is drawn by the hand, not by the interface.\n        marquee.style.transition = \"none\";\n        marquee.style.opacity = \"1\";\n      }\n      frame.current = requestAnimationFrame(autoscroll);\n    }\n    paintMarquee();\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.pointerId) return;\n    // A plain click on empty space clears, like the desktop.\n    if (!d.active && !d.base) {\n      commit(new Set());\n      anchor.current = null;\n    }\n    endDrag();\n  };\n\n  const onTileClick = (index: number, e: React.MouseEvent) => {\n    const set = new Set(selectedRef.current);\n    if (lastPointer.current === \"touch\") {\n      // No modifier keys on touch, so every tap toggles.\n      if (set.has(index)) set.delete(index);\n      else set.add(index);\n      anchor.current = index;\n    } else if (e.shiftKey) {\n      const from = anchor.current ?? index;\n      const span = range(from, index);\n      commit(e.metaKey || e.ctrlKey ? new Set([...set, ...span]) : span);\n      setFocusIndex(index);\n      return;\n    } else if (e.metaKey || e.ctrlKey) {\n      if (set.has(index)) set.delete(index);\n      else set.add(index);\n      anchor.current = index;\n    } else {\n      set.clear();\n      set.add(index);\n      anchor.current = index;\n    }\n    commit(set);\n    setFocusIndex(index);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const last = items.length - 1;\n    const cols = columns();\n    const moves: Record<string, number> = {\n      ArrowLeft: focusIndex - 1,\n      ArrowRight: focusIndex + 1,\n      ArrowUp: focusIndex - cols,\n      ArrowDown: focusIndex + cols,\n      Home: 0,\n      End: last,\n    };\n    if (e.key in moves) {\n      e.preventDefault();\n      const next = moves[e.key];\n      if (next < 0 || next > last) return;\n      if (e.shiftKey) {\n        // Shift extends from the anchor, the same as Shift-clicking.\n        anchor.current ??= focusIndex;\n        commit(range(anchor.current, next));\n      }\n      focusTile(next);\n      return;\n    }\n    if (e.key === \" \") {\n      e.preventDefault();\n      if (e.shiftKey && anchor.current !== null) {\n        commit(new Set([...selectedRef.current, ...range(anchor.current, focusIndex)]));\n        return;\n      }\n      const set = new Set(selectedRef.current);\n      if (set.has(focusIndex)) set.delete(focusIndex);\n      else set.add(focusIndex);\n      anchor.current = focusIndex;\n      commit(set);\n      return;\n    }\n    if (e.key.toLowerCase() === \"a\" && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      commit(range(0, last));\n      return;\n    }\n    if (e.key === \"Escape\" && selectedRef.current.size > 0) {\n      e.preventDefault();\n      commit(new Set());\n      anchor.current = null;\n    }\n  };\n\n  const count = selected.size;\n  const toggleAll = () => {\n    commit(count > 0 ? new Set() : range(0, items.length - 1));\n    anchor.current = null;\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex h-[360px] w-[min(520px,100%)] flex-col overflow-hidden rounded-2xl bg-muted shadow-raised select-none\",\n        className,\n      )}\n    >\n      <div className=\"flex h-11 shrink-0 items-center justify-between border-b border-border px-4\">\n        <p className=\"text-sm font-medium text-foreground\">{label}</p>\n        <p className=\"text-xs text-muted-foreground tabular-nums\">{items.length} items</p>\n      </div>\n\n      <div\n        ref={scrollerRef}\n        className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]\"\n      >\n        <div\n          ref={contentRef}\n          className=\"relative p-3\"\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={endDrag}\n          onLostPointerCapture={endDrag}\n        >\n          <div\n            role=\"listbox\"\n            aria-label={label}\n            aria-multiselectable\n            aria-describedby={instructions}\n            onKeyDown={onKeyDown}\n            className=\"grid grid-cols-[repeat(auto-fill,minmax(104px,1fr))] gap-1\"\n          >\n            {items.map((item, index) => {\n              const on = selected.has(index);\n              return (\n                <div\n                  key={item.id}\n                  ref={(el) => {\n                    tiles.current[index] = el;\n                  }}\n                  role=\"option\"\n                  aria-selected={on}\n                  tabIndex={index === focusIndex ? 0 : -1}\n                  onPointerDown={(e) => {\n                    lastPointer.current = e.pointerType;\n                  }}\n                  onClick={(e) => onTileClick(index, e)}\n                  onFocus={() => setFocusIndex(index)}\n                  className=\"group flex cursor-default flex-col items-center gap-1.5 rounded-xl px-1 pt-2.5 pb-2 outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n                >\n                  <span\n                    className={cn(\n                      \"flex size-14 items-center justify-center rounded-[10px] text-foreground transition-[background-color] duration-100 ease-out\",\n                      on && \"bg-foreground/[0.08]\",\n                    )}\n                  >\n                    <FileIcon kind={item.kind} />\n                  </span>\n                  <span\n                    className={cn(\n                      \"max-w-full truncate rounded-md px-1.5 py-0.5 text-sm transition-[background-color,color] duration-100 ease-out\",\n                      on ? \"bg-foreground text-background\" : \"text-foreground\",\n                    )}\n                  >\n                    {item.name}\n                  </span>\n                  <span className=\"text-xs text-muted-foreground tabular-nums\">{item.meta}</span>\n                </div>\n              );\n            })}\n          </div>\n          <div\n            ref={marqueeRef}\n            aria-hidden\n            className=\"pointer-events-none absolute top-0 left-0 rounded-[3px] border border-foreground/30 bg-foreground/[0.06] opacity-0\"\n          />\n        </div>\n      </div>\n\n      <div className=\"flex h-11 shrink-0 items-center justify-between border-t border-border pr-1.5 pl-4\">\n        <p className=\"text-sm text-muted-foreground tabular-nums\" aria-live=\"polite\">\n          <span className=\"text-foreground\">{count}</span> selected\n        </p>\n        <button\n          type=\"button\"\n          onClick={toggleAll}\n          className=\"grid h-8 touch-manipulation items-center rounded-lg px-2.5 text-sm text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\"\n        >\n          {/* Both labels share one cell, so the button never changes width. */}\n          <Swap visible={count === 0}>Select all</Swap>\n          <Swap visible={count > 0}>Clear</Swap>\n        </button>\n      </div>\n\n      <p id={instructions} className=\"sr-only\">\n        Arrow keys move, Space toggles, Shift with arrows extends the selection,\n        Control or Command A selects all, Escape clears.\n      </p>\n    </div>\n  );\n}\n\nfunction Swap({ visible, children }: { visible: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className={cn(\n        \"col-start-1 row-start-1 text-center transition-[opacity,filter] duration-150 ease-out motion-reduce:transition-[opacity]\",\n        !visible && \"opacity-0 blur-[4px]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nconst ICONS: Record<Kind, React.ReactNode> = {\n  folder: <path d=\"M3.5 7.5a2 2 0 0 1 2-2h3.6l2 2.2h7.4a2 2 0 0 1 2 2v7.8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2Z\" />,\n  doc: (\n    <>\n      <path d=\"M6.5 3.5h7l4 4v11a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-13a2 2 0 0 1 2-2Z\" />\n      <path d=\"M13.5 3.5v4h4M8.5 12.5h7M8.5 16h5\" />\n    </>\n  ),\n  image: (\n    <>\n      <rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2\" />\n      <circle cx=\"9\" cy=\"9.5\" r=\"1.5\" />\n      <path d=\"m4 17.5 4.5-4.5 3.5 3.5 2.5-2.5 5.5 5\" />\n    </>\n  ),\n  sheet: (\n    <>\n      <rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2\" />\n      <path d=\"M3.5 9.5h17M3.5 14.5h17M9.5 9.5v10\" />\n    </>\n  ),\n  code: (\n    <>\n      <path d=\"M6.5 3.5h7l4 4v11a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-13a2 2 0 0 1 2-2Z\" />\n      <path d=\"m10 11.5-2 2 2 2M14 11.5l2 2-2 2\" />\n    </>\n  ),\n  video: (\n    <>\n      <rect x=\"3.5\" y=\"5.5\" width=\"17\" height=\"13\" rx=\"2\" />\n      <path d=\"m10.5 9.5 4 2.5-4 2.5Z\" />\n    </>\n  ),\n  audio: (\n    <>\n      <path d=\"M9.5 17.5v-11l9-2v11\" />\n      <circle cx=\"7.5\" cy=\"17.5\" r=\"2\" />\n      <circle cx=\"16.5\" cy=\"15.5\" r=\"2\" />\n    </>\n  ),\n  archive: (\n    <>\n      <rect x=\"4.5\" y=\"3.5\" width=\"15\" height=\"17\" rx=\"2\" />\n      <path d=\"M12 3.5v2M12 7.5v2M12 11.5v2M10.5 15.5h3v2.5h-3Z\" />\n    </>\n  ),\n};\n\nfunction FileIcon({ kind }: { kind: Kind }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      // Drawn at 36px, so a 1.25 stroke lands near 2px, matching the weight\n      // of the 14px label below it.\n      className=\"size-9\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      {ICONS[kind]}\n    </svg>\n  );\n}\n\nconst FILES: DragSelectItem[] = [\n  { id: \"1\", name: \"Projects\", meta: \"12 items\", kind: \"folder\" },\n  { id: \"2\", name: \"Invoices\", meta: \"48 items\", kind: \"folder\" },\n  { id: \"3\", name: \"Brief.pdf\", meta: \"240 KB\", kind: \"doc\" },\n  { id: \"4\", name: \"Roadmap.md\", meta: \"8 KB\", kind: \"doc\" },\n  { id: \"5\", name: \"Cover.png\", meta: \"2.1 MB\", kind: \"image\" },\n  { id: \"6\", name: \"Budget.csv\", meta: \"36 KB\", kind: \"sheet\" },\n  { id: \"7\", name: \"index.ts\", meta: \"4 KB\", kind: \"code\" },\n  { id: \"8\", name: \"Intro.mp4\", meta: \"84 MB\", kind: \"video\" },\n  { id: \"9\", name: \"Theme.wav\", meta: \"9.6 MB\", kind: \"audio\" },\n  { id: \"10\", name: \"Backup.zip\", meta: \"1.2 GB\", kind: \"archive\" },\n  { id: \"11\", name: \"Sketch.png\", meta: \"860 KB\", kind: \"image\" },\n  { id: \"12\", name: \"Notes.txt\", meta: \"2 KB\", kind: \"doc\" },\n  { id: \"13\", name: \"Metrics.csv\", meta: \"120 KB\", kind: \"sheet\" },\n  { id: \"14\", name: \"server.go\", meta: \"11 KB\", kind: \"code\" },\n  { id: \"15\", name: \"Demo.mov\", meta: \"210 MB\", kind: \"video\" },\n  { id: \"16\", name: \"Assets\", meta: \"96 items\", kind: \"folder\" },\n];\n\nexport default function DragSelectDemo() {\n  return <DragSelect items={FILES} label=\"Documents\" />;\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/drag-select"}