{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drag-select",
  "type": "registry:ui",
  "title": "Drag Select",
  "description": "Rubber-band selection that scrolls at the edges and honors Shift and ⌘.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/drag-select.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Context\n * -----------------------------------------------------------------------------------------------*/\n\ntype Ctx = {\n  selected: string[];\n  setSelected: (next: string[]) => void;\n  focusId: string | null;\n  setFocusId: (id: string | null) => void;\n  disabled: boolean;\n  reduce: boolean;\n  onOpenItem?: (id: string) => void;\n  areaRef: React.RefObject<HTMLDivElement | null>;\n  anchorRef: React.RefObject<string | null>;\n  total: number;\n  first: string | null;\n  setInfo: React.Dispatch<React.SetStateAction<{ total: number; first: string | null }>>;\n};\n\nconst SelectCtx = createContext<Ctx | null>(null);\nconst useCtx = (part: string) => {\n  const ctx = useContext(SelectCtx);\n  if (!ctx) throw new Error(`${part} must be used inside DragSelect`);\n  return ctx;\n};\n\n// A press only becomes a marquee after the pointer travels this far, so clicks stay clicks.\nconst THRESHOLD = 4;\n// Within this distance of the top or bottom edge, the area scrolls itself while you drag.\nconst EDGE = 36;\n\nconst itemsIn = (area: HTMLElement | null) => Array.from(area?.querySelectorAll<HTMLElement>(\"[data-select-id]\") ?? []);\nconst idOf = (el: Element) => (el as HTMLElement).dataset.selectId!;\nconst same = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);\n\n/* -------------------------------------------------------------------------------------------------\n * DragSelect\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DragSelectProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Selected item values. Controlled. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Double-click or Enter on an item. */\n  onOpenItem?: (value: string) => void;\n  disabled?: boolean;\n};\n\n/** Holds the selection. Put a DragSelectArea (and optionally a DragSelectCount) inside. */\nexport function DragSelect({ value, defaultValue, onValueChange, onOpenItem, disabled = false, className, children, ...rest }: DragSelectProps) {\n  const [selected, setSelected] = useControllableState({ value, defaultValue: defaultValue ?? [], onChange: onValueChange });\n  const [focusId, setFocusId] = useState<string | null>(null);\n  const [{ total, first }, setInfo] = useState<{ total: number; first: string | null }>({ total: 0, first: null });\n  const reduce = !!useReducedMotion();\n  const areaRef = useRef<HTMLDivElement | null>(null);\n  const anchorRef = useRef<string | null>(null);\n\n  return (\n    <SelectCtx.Provider\n      value={{ selected, setSelected, focusId, setFocusId, disabled, reduce, onOpenItem, areaRef, anchorRef, total, first, setInfo }}\n    >\n      <div data-disabled={disabled ? \"\" : undefined} className={cn(\"flex min-h-0 flex-col\", className)} {...rest}>\n        {children}\n      </div>\n    </SelectCtx.Provider>\n  );\n}\n\n/** The selection and helpers, for toolbars that act on it. */\nexport function useDragSelect() {\n  const ctx = useCtx(\"useDragSelect\");\n  return {\n    selected: ctx.selected,\n    total: ctx.total,\n    clear: () => ctx.setSelected([]),\n    selectAll: () => ctx.setSelected(itemsIn(ctx.areaRef.current).map(idOf)),\n  };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * DragSelectArea\n * -----------------------------------------------------------------------------------------------*/\n\ntype Rect = { x: number; y: number; w: number; h: number };\n\nexport type DragSelectAreaProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Names the list for assistive tech, e.g. \"Files in Q3 planning\". */\n  label: string;\n  /** Classes for the inner grid that lays out the items. */\n  gridClassName?: string;\n  children: React.ReactNode;\n};\n\n/** The scrolling surface you drag across. Items go inside. */\nexport function DragSelectArea({ label, gridClassName, className, children, onPointerDown, onKeyDown, ...rest }: DragSelectAreaProps) {\n  const ctx = useCtx(\"DragSelectArea\");\n  const { selected, setSelected, setFocusId, disabled, reduce, areaRef, anchorRef, setInfo } = ctx;\n  const hintId = useId();\n  const [box, setBox] = useState<Rect | null>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const session = useRef<{\n    pointerId: number;\n    startX: number;\n    startY: number;\n    clientX: number;\n    clientY: number;\n    item: string | null;\n    mode: \"replace\" | \"add\" | \"toggle\";\n    base: string[];\n    rects: { id: string; x: number; y: number; w: number; h: number }[];\n    started: boolean;\n    last: string[];\n    frame?: number;\n    cleanup: () => void;\n  } | null>(null);\n\n  // Keep the item count (for DragSelectCount) and the first item (the default tab stop) in step with the DOM.\n  useEffect(() => {\n    const area = areaRef.current;\n    if (!area) return;\n    const sync = () => {\n      const els = itemsIn(area);\n      const total = els.length;\n      const first = els[0] ? idOf(els[0]) : null;\n      setInfo((prev) => (prev.total === total && prev.first === first ? prev : { total, first }));\n    };\n    const mo = new MutationObserver(sync);\n    mo.observe(area, { childList: true, subtree: true });\n    const raf = requestAnimationFrame(sync);\n    return () => {\n      mo.disconnect();\n      cancelAnimationFrame(raf);\n    };\n  }, [areaRef, setInfo]);\n\n  useEffect(\n    () => () => {\n      const s = session.current;\n      if (!s) return;\n      if (s.frame) cancelAnimationFrame(s.frame);\n      s.cleanup();\n    },\n    [],\n  );\n\n  // A point in the area's scrollable content, so the marquee stays pinned to the content as it scrolls.\n  const toContent = (clientX: number, clientY: number) => {\n    const area = areaRef.current!;\n    const r = area.getBoundingClientRect();\n    return { x: clientX - r.left - area.clientLeft + area.scrollLeft, y: clientY - r.top - area.clientTop + area.scrollTop };\n  };\n\n  const update = () => {\n    const s = session.current;\n    if (!s) return;\n    const p = toContent(s.clientX, s.clientY);\n    const rect = { x: Math.min(s.startX, p.x), y: Math.min(s.startY, p.y), w: Math.abs(p.x - s.startX), h: Math.abs(p.y - s.startY) };\n    setBox(rect);\n    const hits = s.rects\n      .filter((r) => r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y)\n      .map((r) => r.id);\n    const order = s.rects.map((r) => r.id);\n    const next =\n      s.mode === \"replace\"\n        ? hits\n        : s.mode === \"add\"\n          ? order.filter((id) => s.base.includes(id) || hits.includes(id))\n          : order.filter((id) => s.base.includes(id) !== hits.includes(id));\n    if (!same(next, s.last)) {\n      s.last = next;\n      setSelected(next);\n    }\n  };\n\n  const autoScroll = () => {\n    const s = session.current;\n    const area = areaRef.current;\n    if (!s?.started || !area) return;\n    const r = area.getBoundingClientRect();\n    const speed = (d: number) => Math.ceil((1 - Math.max(0, d) / EDGE) * 16);\n    const max = area.scrollHeight - area.clientHeight;\n    const dy =\n      s.clientY < r.top + EDGE && area.scrollTop > 0 ? -speed(s.clientY - r.top) : s.clientY > r.bottom - EDGE && area.scrollTop < max ? speed(r.bottom - s.clientY) : 0;\n    if (dy) {\n      area.scrollTop += dy;\n      update();\n    }\n    s.frame = requestAnimationFrame(autoScroll);\n  };\n\n  const finish = (cancel: boolean) => {\n    const s = session.current;\n    if (!s) return;\n    if (s.frame) cancelAnimationFrame(s.frame);\n    s.cleanup();\n    session.current = null;\n    setBox(null);\n    setDragging(false);\n    if (cancel) setSelected(s.base);\n  };\n\n  const clickItem = (id: string, e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }, touch: boolean) => {\n    const order = itemsIn(areaRef.current).map(idOf);\n    const toggle = e.metaKey || e.ctrlKey || touch;\n    if (e.shiftKey && anchorRef.current && order.includes(anchorRef.current)) {\n      const [a, b] = [order.indexOf(anchorRef.current), order.indexOf(id)].sort((x, y) => x - y);\n      const range = order.slice(a, b + 1);\n      setSelected(toggle ? order.filter((v) => selected.includes(v) || range.includes(v)) : range);\n    } else if (toggle) {\n      setSelected(selected.includes(id) ? selected.filter((v) => v !== id) : order.filter((v) => v === id || selected.includes(v)));\n      anchorRef.current = id;\n    } else {\n      setSelected([id]);\n      anchorRef.current = id;\n    }\n    setFocusId(id);\n    itemsIn(areaRef.current)\n      .find((el) => idOf(el) === id)\n      ?.focus({ preventScroll: true });\n  };\n\n  return (\n    <div\n      ref={areaRef}\n      data-dragging={dragging ? \"\" : undefined}\n      data-has-selection={selected.length ? \"\" : undefined}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        if (e.defaultPrevented || disabled) return;\n        const touch = e.pointerType === \"touch\";\n        if (!touch && e.button !== 0) return;\n        // Leave the scrollbar alone.\n        const area = e.currentTarget;\n        const r = area.getBoundingClientRect();\n        if (e.clientX > r.left + area.clientLeft + area.clientWidth) return;\n        const itemEl = (e.target as Element).closest<HTMLElement>(\"[data-select-id]\");\n        const item = itemEl ? idOf(itemEl) : null;\n        const start = toContent(e.clientX, e.clientY);\n        const mode = e.shiftKey ? \"add\" : e.metaKey || e.ctrlKey ? \"toggle\" : \"replace\";\n        if (!touch) e.preventDefault();\n\n        const onMove = (ev: PointerEvent) => {\n          const s = session.current;\n          if (!s || ev.pointerId !== s.pointerId) return;\n          s.clientX = ev.clientX;\n          s.clientY = ev.clientY;\n          if (!s.started) {\n            // A finger dragging over the grid is a scroll; marquee is for mouse and pen.\n            if (touch) return;\n            const p = toContent(ev.clientX, ev.clientY);\n            if (Math.hypot(p.x - s.startX, p.y - s.startY) < THRESHOLD) return;\n            s.started = true;\n            setDragging(true);\n            s.frame = requestAnimationFrame(autoScroll);\n          }\n          update();\n        };\n        const onUp = (ev: PointerEvent) => {\n          const s = session.current;\n          if (!s || ev.pointerId !== s.pointerId) return;\n          const wasClick = !s.started;\n          finish(false);\n          if (!wasClick) return;\n          if (s.item) clickItem(s.item, ev, touch);\n          else if (!touch && s.mode === \"replace\") setSelected([]);\n        };\n        const onCancel = (ev: PointerEvent) => {\n          if (session.current?.pointerId === ev.pointerId) finish(true);\n        };\n        const onKey = (ev: KeyboardEvent) => {\n          if (ev.key !== \"Escape\" || !session.current?.started) return;\n          ev.preventDefault();\n          ev.stopPropagation();\n          finish(true);\n        };\n        window.addEventListener(\"pointermove\", onMove);\n        window.addEventListener(\"pointerup\", onUp);\n        window.addEventListener(\"pointercancel\", onCancel);\n        window.addEventListener(\"keydown\", onKey, true);\n\n        // Item boxes in content coordinates, read once: nothing moves while you drag.\n        const rects = itemsIn(area).map((el) => {\n          const b = el.getBoundingClientRect();\n          const p = toContent(b.left, b.top);\n          return { id: idOf(el), x: p.x, y: p.y, w: b.width, h: b.height };\n        });\n        session.current = {\n          pointerId: e.pointerId,\n          startX: start.x,\n          startY: start.y,\n          clientX: e.clientX,\n          clientY: e.clientY,\n          item,\n          mode,\n          base: selected,\n          rects,\n          started: false,\n          last: selected,\n          cleanup: () => {\n            window.removeEventListener(\"pointermove\", onMove);\n            window.removeEventListener(\"pointerup\", onUp);\n            window.removeEventListener(\"pointercancel\", onCancel);\n            window.removeEventListener(\"keydown\", onKey, true);\n          },\n        };\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented || disabled) return;\n        handleKey(e, ctx);\n      }}\n      className={cn(\n        \"relative min-h-0 flex-1 select-none overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [-webkit-touch-callout:none]\",\n        \"data-dragging:cursor-crosshair\",\n        className,\n      )}\n      {...rest}\n    >\n      <div\n        role=\"listbox\"\n        aria-multiselectable\n        aria-label={label}\n        aria-describedby={hintId}\n        aria-disabled={disabled || undefined}\n        className={cn(\"grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-1 p-2\", gridClassName)}\n      >\n        {children}\n      </div>\n      <AnimatePresence>\n        {box && (box.w > 0 || box.h > 0) && (\n          <motion.div\n            aria-hidden\n            className=\"pointer-events-none absolute left-0 top-0 z-10 rounded-[3px] border border-fg-3/60 bg-fg/[0.06]\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.12, ease: ease.out } }}\n            transition={{ duration: reduce ? 0 : 0.08 }}\n            style={{ x: box.x, y: box.y, width: box.w, height: box.h }}\n          />\n        )}\n      </AnimatePresence>\n      <span id={hintId} className=\"sr-only\">\n        Arrow keys move, Space selects, Shift with an arrow extends the selection, Control or Command A selects all, Escape clears.\n      </span>\n    </div>\n  );\n}\n\n// The listbox keyboard model, in a grid: arrows move in two dimensions and Shift extends from the anchorRef.\nfunction handleKey(e: React.KeyboardEvent, ctx: Ctx) {\n  const { selected, setSelected, focusId, setFocusId, anchorRef, onOpenItem, areaRef } = ctx;\n  const els = itemsIn(areaRef.current);\n  if (!els.length) return;\n  const order = els.map(idOf);\n  const current = focusId && order.includes(focusId) ? order.indexOf(focusId) : 0;\n  const top = els[0].offsetTop;\n  const cols = Math.max(1, els.filter((el) => Math.abs(el.offsetTop - top) < 2).length);\n  const focus = (i: number) => {\n    const id = order[i];\n    setFocusId(id);\n    els[i].focus({ preventScroll: true });\n    els[i].scrollIntoView({ block: \"nearest\" });\n    return id;\n  };\n\n  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"a\") {\n    e.preventDefault();\n    setSelected(order);\n    return;\n  }\n  const delta =\n    e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowLeft\" ? -1 : e.key === \"ArrowDown\" ? cols : e.key === \"ArrowUp\" ? -cols : 0;\n  const jump = e.key === \"Home\" ? 0 : e.key === \"End\" ? order.length - 1 : null;\n  if (delta || jump !== null) {\n    e.preventDefault();\n    const to = jump ?? Math.max(0, Math.min(order.length - 1, current + delta));\n    const id = focus(to);\n    if (e.shiftKey) {\n      const from = anchorRef.current && order.includes(anchorRef.current) ? order.indexOf(anchorRef.current) : current;\n      if (!anchorRef.current) anchorRef.current = order[current];\n      const [a, b] = [from, to].sort((x, y) => x - y);\n      setSelected(order.slice(a, b + 1));\n    } else {\n      anchorRef.current = id;\n    }\n  } else if (e.key === \" \") {\n    e.preventDefault();\n    const id = order[current];\n    anchorRef.current = id;\n    setSelected(selected.includes(id) ? selected.filter((v) => v !== id) : order.filter((v) => v === id || selected.includes(v)));\n  } else if (e.key === \"Enter\" && onOpenItem) {\n    e.preventDefault();\n    onOpenItem(order[current]);\n  } else if (e.key === \"Escape\" && selected.length) {\n    e.preventDefault();\n    e.stopPropagation();\n    setSelected([]);\n  }\n}\n\n/* -------------------------------------------------------------------------------------------------\n * DragSelectItem\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DragSelectItemProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Unique within the area. */\n  value: string;\n  /** Accessible name, e.g. the file name. */\n  label: string;\n  children: React.ReactNode;\n};\n\nexport function DragSelectItem({ value, label, className, children, onDoubleClick, ...rest }: DragSelectItemProps) {\n  const ctx = useCtx(\"DragSelectItem\");\n  const checked = ctx.selected.includes(value);\n  // Before anything has focus, the first item holds the tab stop.\n  const tabbable = ctx.focusId ? ctx.focusId === value : ctx.first === value;\n\n  return (\n    <div\n      role=\"option\"\n      aria-selected={checked}\n      aria-label={label}\n      tabIndex={tabbable ? 0 : -1}\n      data-select-id={value}\n      data-selected={checked ? \"\" : undefined}\n      onFocus={() => ctx.setFocusId(value)}\n      onDoubleClick={(e) => {\n        onDoubleClick?.(e);\n        ctx.onOpenItem?.(value);\n      }}\n      className={cn(\n        \"group/item relative min-w-0 rounded-lg p-2 outline-none\",\n        \"transition-[background-color,box-shadow] duration-150 ease-out-quart\",\n        \"hover:bg-fg/[0.04] in-data-dragging:hover:bg-transparent\",\n        \"data-selected:bg-fg/[0.07] data-selected:shadow-[inset_0_0_0_1px_var(--line-2)] data-selected:hover:bg-fg/[0.08]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <Check checked={checked} reduce={ctx.reduce} />\n      {children}\n    </div>\n  );\n}\n\n// The corner check: present on hover, focus and whenever anything is selected; always there on touch.\nfunction Check({ checked, reduce }: { checked: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"absolute left-1.5 top-1.5 z-1 grid size-4 place-items-center rounded-full border transition-[opacity,background-color,border-color,scale] duration-150\",\n        checked\n          ? \"scale-100 border-fg bg-fg text-frame opacity-100\"\n          : \"border-fg-4 bg-raised text-transparent opacity-0 group-hover/item:opacity-100 group-focus-visible/item:opacity-100 pointer-coarse:opacity-100 in-[[data-has-selection]]:opacity-100\",\n      )}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\">\n        <motion.path\n          d=\"M2.25 5.25 4.25 7.25 7.75 3\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0 }}\n          transition={{ duration: reduce ? 0 : checked ? 0.22 : 0.08, ease: ease.out }}\n        />\n      </svg>\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * DragSelectCount\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DragSelectCountProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** What's being counted, singular and plural. */\n  noun?: [string, string];\n};\n\n/** \"3 files selected\", rolling as the marquee grows. Falls back to the total when nothing is selected. */\nexport function DragSelectCount({ noun = [\"item\", \"items\"], className, ...rest }: DragSelectCountProps) {\n  const ctx = useCtx(\"DragSelectCount\");\n  const n = ctx.selected.length;\n  const shown = n || ctx.total;\n  return (\n    <span role=\"status\" aria-live=\"polite\" className={cn(\"inline-flex items-baseline gap-1 text-[12.5px] tabular\", className)} {...rest}>\n      <NumberFlow value={shown} animated={!ctx.reduce} className={n ? \"font-medium text-fg\" : \"text-fg-2\"} />\n      <span className={n ? \"text-fg-2\" : \"text-fg-3\"}>\n        {shown === 1 ? noun[0] : noun[1]}\n        {n ? \" selected\" : \"\"}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/drag-select"
}
