{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban",
  "type": "registry:ui",
  "title": "Kanban",
  "description": "Cards lean into the drag, land on an insertion line and move by menu too.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/kanban.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from \"motion/react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowDown, ArrowUp, Check, MoreH } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type KanbanColumn = {\n  id: string;\n  title: string;\n  /** A work-in-progress limit. The count reads \"n/limit\" and turns to a warning when it's passed. */\n  limit?: number;\n};\n\nexport type KanbanCardData = { id: string; column: string };\n\nexport type KanbanMove<T> = { card: T; from: string; to: string; index: number };\n\nexport type KanbanProps<T extends KanbanCardData> = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  columns: KanbanColumn[];\n  /** Every card, grouped by `column`; order within a column is the order in this array. Controlled. */\n  value?: T[];\n  defaultValue?: T[];\n  /** Called with the whole new array after any move. */\n  onValueChange?: (cards: T[]) => void;\n  /** Called once per move with what moved and where. The place to save. */\n  onCardMove?: (move: KanbanMove<T>) => void;\n  /** The card's content. Keep buttons out of it: the whole card is the drag handle. The move menu sits in the top-right corner, so leave the first line about 20px of room at its end. */\n  renderCard: (card: T) => React.ReactNode;\n  /** Plain-text name for the card's menu and the announcements. */\n  getCardLabel: (card: T) => string;\n  /** Freezes the board: nothing can be dragged or moved. */\n  disabled?: boolean;\n  /** Shown in a column with no cards. */\n  emptyLabel?: string;\n};\n\n// Mouse and pen start after a few pixels so a click stays a click; touch waits for a short\n// hold so a swipe still scrolls the board.\nconst THRESHOLD = 5;\nconst HOLD_MS = 200;\nconst HOLD_SLOP = 8;\nconst EDGE = 48;\nconst GAP = 6;\n\ntype Target = { column: string; index: number };\ntype Ghost = { id: string; phase: \"dragging\" | \"settling\"; width: number };\ntype Line = { column: string; top: number } | null;\n\nconst cardClass = cn(\n  \"relative select-none rounded-lg border border-line bg-raised px-3 py-2.5 text-[13px] text-fg shadow-[var(--shadow)]\",\n  \"[-webkit-touch-callout:none]\",\n);\n\nconst q = (root: ParentNode | null | undefined, sel: string) => root?.querySelector<HTMLElement>(sel) ?? null;\nconst cardSel = (id: string) => `[data-card-id=\"${CSS.escape(id)}\"]`;\n\n/** A board of columns whose cards move by drag, by menu, or with Alt and the arrow keys. */\nexport function Kanban<T extends KanbanCardData>({\n  columns,\n  value,\n  defaultValue,\n  onValueChange,\n  onCardMove,\n  renderCard,\n  getCardLabel,\n  disabled = false,\n  emptyLabel = \"No cards\",\n  className,\n  ...rest\n}: KanbanProps<T>) {\n  const [cards, setCards] = useControllableState<T[]>({ value, defaultValue: defaultValue ?? [], onChange: onValueChange });\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const boardRef = useRef<HTMLDivElement | null>(null);\n  const [menu] = useState(() => Menu.createHandle<string>());\n\n  const [ghost, setGhost] = useState<Ghost | null>(null);\n  const [pressed, setPressed] = useState<string | null>(null);\n  const [over, setOver] = useState<string | null>(null);\n  const [line, setLine] = useState<Line>(null);\n  const [message, setMessage] = useState(\"\");\n  const [pending, setPending] = useState<{ id: string; to: string; index: number } | null>(null);\n  const focusAfter = useRef<string | null>(null);\n\n  // The ghost lives in a fixed layer above the page, so no column's scroll box can clip it.\n  const gx = useMotionValue(0);\n  const gy = useMotionValue(0);\n  const gScale = useMotionValue(1);\n  // It leans into fast horizontal moves and rights itself when the hand stops.\n  const lean = useTransform(useVelocity(gx), [-1600, 0, 1600], [-4, 0, 4], { clamp: true });\n  const rotate = useSpring(lean, { stiffness: 380, damping: 30 });\n\n  const session = useRef<{\n    id: string;\n    label: string;\n    pointerId: number;\n    startX: number;\n    startY: number;\n    grabX: number;\n    grabY: number;\n    x: number;\n    y: number;\n    started: boolean;\n    target: Target | null;\n    hold?: number;\n    frame?: number;\n    cleanup: () => void;\n  } | null>(null);\n\n  const byColumn = (list: T[], col: string) => list.filter((c) => c.column === col);\n  const titleOf = (col: string) => columns.find((c) => c.id === col)?.title ?? col;\n\n  // Moves one card and reports it. `index` counts the destination column without the card.\n  const commitMove = (id: string, to: string, index: number) => {\n    const card = cards.find((c) => c.id === id);\n    if (!card) return;\n    const without = cards.filter((c) => c.id !== id);\n    const dest = byColumn(without, to);\n    const i = Math.max(0, Math.min(index, dest.length));\n    const at = i < dest.length ? without.indexOf(dest[i]) : dest.length ? without.indexOf(dest[dest.length - 1]) + 1 : without.length;\n    const moved = { ...card, column: to } as T;\n    without.splice(at, 0, moved);\n    setCards(without);\n    onCardMove?.({ card: moved, from: card.column, to, index: i });\n    setMessage(`${getCardLabel(card)} moved to ${titleOf(to)}, position ${i + 1} of ${dest.length + 1}.`);\n  };\n\n  /* ---------------------------------------------------------------------------------------------\n   * Pointer\n   * -------------------------------------------------------------------------------------------*/\n\n  // Which column and slot the pointer is over. Cards don't move during a drag (the source stays\n  // in place, dimmed), so the rects read here are stable and there's nothing to flicker.\n  const hitTest = (x: number, y: number, id: string): Target | null => {\n    const bodies = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(\"[data-kanban-body]\") ?? []);\n    const board = boardRef.current?.getBoundingClientRect();\n    if (!board || y < board.top - EDGE || y > board.bottom + EDGE) return null;\n    let body: HTMLElement | undefined;\n    let best = Infinity;\n    for (const b of bodies) {\n      const r = b.getBoundingClientRect();\n      const d = x < r.left ? r.left - x : x > r.right ? x - r.right : 0;\n      if (d < best) {\n        best = d;\n        body = b;\n      }\n    }\n    if (!body || best > EDGE) return null;\n    const cardsIn = Array.from(body.querySelectorAll<HTMLElement>(\":scope > [data-card-id]\")).filter((el) => el.dataset.cardId !== id);\n    const index = cardsIn.filter((el) => {\n      const r = el.getBoundingClientRect();\n      return r.top + r.height / 2 < y;\n    }).length;\n    return { column: body.dataset.kanbanBody!, index };\n  };\n\n  // The insertion line sits in the gap the card would drop into, in the body's scroll coordinates.\n  // A drop that wouldn't change anything shows no line.\n  const lineFor = (t: Target | null, id: string): Line => {\n    if (!t) return null;\n    const card = cards.find((c) => c.id === id);\n    const list = byColumn(cards, t.column).filter((c) => c.id !== id);\n    if (card?.column === t.column && byColumn(cards, t.column).findIndex((c) => c.id === id) === t.index) return null;\n    const body = q(rootRef.current, `[data-kanban-body=\"${CSS.escape(t.column)}\"]`);\n    if (!body) return null;\n    const el = (cid: string) => q(body, cardSel(cid));\n    // An empty column says \"Drop here\" instead.\n    if (!list.length) return null;\n    if (t.index === 0) return { column: t.column, top: Math.max(2, (el(list[0].id)?.offsetTop ?? 0) - GAP / 2 - 1) };\n    const prev = el(list[t.index - 1].id);\n    return { column: t.column, top: prev ? prev.offsetTop + prev.offsetHeight + GAP / 2 - 1 : 6 };\n  };\n\n  const track = (t: Target | null, id: string) => {\n    const next = lineFor(t, id);\n    setOver(t?.column ?? null);\n    setLine((l) => (l?.column === next?.column && l?.top === next?.top ? l : next));\n  };\n\n  const endSession = () => {\n    const s = session.current;\n    if (!s) return;\n    window.clearTimeout(s.hold);\n    if (s.frame) cancelAnimationFrame(s.frame);\n    s.cleanup();\n    session.current = null;\n  };\n\n  useEffect(\n    () => () => {\n      const s = session.current;\n      if (!s) return;\n      window.clearTimeout(s.hold);\n      if (s.frame) cancelAnimationFrame(s.frame);\n      s.cleanup();\n    },\n    [],\n  );\n\n  useGrabbingCursor(ghost?.phase === \"dragging\");\n\n  // Near an edge, the board scrolls sideways and the column under the pointer scrolls up or down.\n  const autoScroll = () => {\n    const s = session.current;\n    if (!s?.started) return;\n    const speed = (d: number) => Math.round((1 - Math.max(0, d) / EDGE) * 14);\n    let moved = false;\n    const board = boardRef.current;\n    if (board) {\n      const r = board.getBoundingClientRect();\n      const max = board.scrollWidth - board.clientWidth;\n      const dx = s.x < r.left + EDGE && board.scrollLeft > 0 ? -speed(s.x - r.left) : s.x > r.right - EDGE && board.scrollLeft < max ? speed(r.right - s.x) : 0;\n      if (dx) {\n        board.scrollLeft += dx;\n        moved = true;\n      }\n    }\n    const body = s.target ? q(rootRef.current, `[data-kanban-body=\"${CSS.escape(s.target.column)}\"]`) : null;\n    if (body) {\n      const r = body.getBoundingClientRect();\n      const max = body.scrollHeight - body.clientHeight;\n      const near = (d: number) => d < EDGE && d > -EDGE;\n      const dy = near(s.y - r.top) && body.scrollTop > 0 ? -speed(s.y - r.top) : near(r.bottom - s.y) && body.scrollTop < max ? speed(r.bottom - s.y) : 0;\n      if (dy) {\n        body.scrollTop += dy;\n        moved = true;\n      }\n    }\n    if (moved) {\n      s.target = hitTest(s.x, s.y, s.id);\n      track(s.target, s.id);\n    }\n    s.frame = requestAnimationFrame(autoScroll);\n  };\n\n  const begin = () => {\n    const s = session.current;\n    if (!s) return;\n    const inner = q(rootRef.current, `${cardSel(s.id)} [data-card-inner]`);\n    const r = inner?.getBoundingClientRect();\n    if (!r) return;\n    s.started = true;\n    gx.set(s.x - s.grabX);\n    gy.set(s.y - s.grabY);\n    gScale.set(1);\n    if (!reduce) animate(gScale, 1.03, spring.snappy);\n    setPressed(null);\n    setGhost({ id: s.id, phase: \"dragging\", width: r.width });\n    setMessage(`${s.label} picked up.`);\n    s.target = hitTest(s.x, s.y, s.id);\n    track(s.target, s.id);\n    s.frame = requestAnimationFrame(autoScroll);\n  };\n\n  const finish = (drop: boolean) => {\n    const s = session.current;\n    if (!s) return;\n    const { id, label, target } = s;\n    endSession();\n    setOver(null);\n    setLine(null);\n    const card = cards.find((c) => c.id === id);\n    const from = card ? byColumn(cards, card.column).findIndex((c) => c.id === id) : -1;\n    const noop = !target || (card?.column === target.column && from === target.index);\n    setGhost((g) => (g ? { ...g, phase: \"settling\" } : g));\n    if (drop && target && !noop) {\n      commitMove(id, target.column, target.index);\n    } else {\n      setMessage(drop && !target ? `${label} returned. Drop it over a column to move it.` : `Move canceled. ${label} is back where it was.`);\n    }\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLElement>, id: string, label: string) => {\n    if (disabled || ghost || session.current) return;\n    if (e.pointerType === \"mouse\" && e.button !== 0) return;\n    if ((e.target as Element).closest(\"[data-move-trigger]\")) return;\n    const touch = e.pointerType === \"touch\";\n    const r = e.currentTarget.getBoundingClientRect();\n\n    const onMove = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      s.x = ev.clientX;\n      s.y = ev.clientY;\n      if (!s.started) {\n        const dist = Math.hypot(ev.clientX - s.startX, ev.clientY - s.startY);\n        if (touch) {\n          if (dist > HOLD_SLOP) {\n            endSession();\n            setPressed(null);\n          }\n          return;\n        }\n        if (dist < THRESHOLD) return;\n        begin();\n      }\n      gx.set(s.x - s.grabX);\n      gy.set(s.y - s.grabY);\n      const t = hitTest(s.x, s.y, s.id);\n      s.target = t;\n      track(t, s.id);\n    };\n    const onUp = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      if (!s.started) {\n        endSession();\n        setPressed(null);\n      } else finish(true);\n    };\n    const onCancel = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      if (!s.started) {\n        endSession();\n        setPressed(null);\n      } else finish(false);\n    };\n    const onKey = (ev: KeyboardEvent) => {\n      if (ev.key !== \"Escape\" || !session.current?.started) return;\n      ev.preventDefault();\n      finish(false);\n    };\n    const onTouchMove = (ev: TouchEvent) => {\n      if (session.current?.started) ev.preventDefault();\n    };\n    const onContext = (ev: Event) => {\n      if (session.current) ev.preventDefault();\n    };\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onCancel);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    window.addEventListener(\"contextmenu\", onContext);\n\n    session.current = {\n      id,\n      label,\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      x: e.clientX,\n      y: e.clientY,\n      started: false,\n      target: null,\n      hold: touch ? window.setTimeout(begin, HOLD_MS) : undefined,\n      cleanup: () => {\n        window.removeEventListener(\"pointermove\", onMove);\n        window.removeEventListener(\"pointerup\", onUp);\n        window.removeEventListener(\"pointercancel\", onCancel);\n        window.removeEventListener(\"keydown\", onKey);\n        window.removeEventListener(\"touchmove\", onTouchMove);\n        window.removeEventListener(\"contextmenu\", onContext);\n      },\n    };\n    setPressed(id);\n  };\n\n  // After a drop (or a menu move across columns) the ghost flies to wherever the card now lives,\n  // then hands over to it. Measured after commit so it lands on the real slot.\n  const settlingId = ghost?.phase === \"settling\" ? ghost.id : null;\n  useLayoutEffect(() => {\n    if (!settlingId) return;\n    const inner = q(rootRef.current, `${cardSel(settlingId)} [data-card-inner]`);\n    if (!inner) {\n      setGhost(null);\n      return;\n    }\n    inner.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n    const r = inner.getBoundingClientRect();\n    const done = () => setGhost((g) => (g?.id === settlingId && g.phase === \"settling\" ? null : g));\n    if (reduce) {\n      done();\n      return;\n    }\n    animate(gx, r.left, spring.soft);\n    animate(gScale, 1, spring.soft);\n    const flight = animate(gy, r.top, spring.soft);\n    flight.then(done);\n    return () => flight.stop();\n  }, [settlingId, reduce, gx, gy, gScale]);\n\n  /* ---------------------------------------------------------------------------------------------\n   * Keyboard and menu\n   * -------------------------------------------------------------------------------------------*/\n\n  // A move to another column flies the card there in the ghost layer, so the destination\n  // column's scroll box never clips it mid-flight. Moves within a column slide by layout.\n  const moveWithFlight = (id: string, to: string, index: number) => {\n    const card = cards.find((c) => c.id === id);\n    if (!card) return;\n    if (card.column !== to && !reduce) {\n      const r = q(rootRef.current, `${cardSel(id)} [data-card-inner]`)?.getBoundingClientRect();\n      if (r) {\n        gx.set(r.left);\n        gy.set(r.top);\n        gScale.set(1);\n        setGhost({ id, phase: \"settling\", width: r.width });\n      }\n    }\n    focusAfter.current = id;\n    commitMove(id, to, index);\n  };\n\n  // Keyboard focus follows the card to its new place.\n  useLayoutEffect(() => {\n    const id = focusAfter.current;\n    if (!id) return;\n    focusAfter.current = null;\n    q(rootRef.current, `${cardSel(id)} [data-move-trigger]`)?.focus({ preventScroll: true });\n  });\n\n  const onTriggerKey = (e: React.KeyboardEvent, card: T) => {\n    if (!e.altKey || disabled) return;\n    const list = byColumn(cards, card.column);\n    const i = list.findIndex((c) => c.id === card.id);\n    const col = columns.findIndex((c) => c.id === card.column);\n    if (e.key === \"ArrowUp\" && i > 0) moveWithFlight(card.id, card.column, i - 1);\n    else if (e.key === \"ArrowDown\" && i < list.length - 1) moveWithFlight(card.id, card.column, i + 1);\n    else if (e.key === \"ArrowLeft\" && col > 0) moveWithFlight(card.id, columns[col - 1].id, i);\n    else if (e.key === \"ArrowRight\" && col < columns.length - 1) moveWithFlight(card.id, columns[col + 1].id, i);\n    else return;\n    e.preventDefault();\n    // Alt+ArrowDown would otherwise also open the menu.\n    (e as React.KeyboardEvent & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();\n  };\n\n  const ghostCard = ghost ? cards.find((c) => c.id === ghost.id) : undefined;\n\n  return (\n    <div ref={rootRef} data-dragging={ghost?.phase === \"dragging\" ? \"\" : undefined} className={cn(\"relative flex min-h-0 flex-col\", className)} {...rest}>\n      <div\n        ref={boardRef}\n        className=\"flex min-h-0 flex-1 snap-x snap-mandatory scroll-px-1 gap-2 overflow-x-auto overscroll-x-contain [scrollbar-width:none]\"\n      >\n        {columns.map((col) => {\n          const list = byColumn(cards, col.id);\n          const headId = `${uid}-${col.id}`;\n          const full = col.limit !== undefined && list.length > col.limit;\n          const isOver = over === col.id && ghost?.phase === \"dragging\";\n          return (\n            <section\n              key={col.id}\n              aria-labelledby={headId}\n              data-over={isOver ? \"\" : undefined}\n              className={cn(\n                \"flex min-h-0 w-[224px] shrink-0 snap-start flex-col rounded-xl border border-line bg-frame\",\n                \"transition-[border-color,background-color] duration-150\",\n                \"data-over:border-line-2 data-over:bg-hover\",\n              )}\n            >\n              <header className=\"flex h-10 shrink-0 items-center justify-between gap-2 ps-3 pe-2.5\">\n                <h3 id={headId} className=\"truncate text-[13px] font-medium tracking-[-0.005em] text-fg\">\n                  {col.title}\n                </h3>\n                <span\n                  data-full={full ? \"\" : undefined}\n                  className=\"inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-[11px] text-fg-3 tabular transition-colors duration-200 data-full:bg-warning-soft data-full:text-warning\"\n                >\n                  <NumberFlow value={list.length} animated={!reduce} />\n                  {col.limit !== undefined && <span className=\"text-fg-4 in-data-full:text-warning/70\">/{col.limit}</span>}\n                  <span className=\"sr-only\">\n                    {` card${list.length === 1 ? \"\" : \"s\"}`}\n                    {full ? `, over the limit of ${col.limit}` : \"\"}\n                  </span>\n                </span>\n              </header>\n              <ul\n                data-kanban-body={col.id}\n                aria-labelledby={headId}\n                className=\"relative flex min-h-24 flex-1 flex-col gap-1.5 overflow-y-auto overscroll-contain px-1.5 pb-1.5 [scrollbar-width:thin]\"\n              >\n                {list.map((card) => {\n                  const label = getCardLabel(card);\n                  const source = ghost?.id === card.id;\n                  return (\n                    <motion.li\n                      key={card.id}\n                      data-card-id={card.id}\n                      layout={reduce || source ? false : \"position\"}\n                      transition={spring.soft}\n                      className=\"group/card relative shrink-0\"\n                    >\n                      <div\n                        data-card-inner=\"\"\n                        data-pressed={pressed === card.id ? \"\" : undefined}\n                        data-source={source && ghost?.phase === \"dragging\" ? \"\" : undefined}\n                        onPointerDown={(e) => onPointerDown(e, card.id, label)}\n                        className={cn(\n                          cardClass,\n                          // Opacity isn't transitioned: the hand-over from ghost to card must be a single frame.\n                          \"transition-[scale,border-color] duration-150 ease-out-quart data-pressed:scale-[0.98]\",\n                          !disabled && \"cursor-grab touch-manipulation\",\n                          // The card it came from stays put, dimmed, so nothing reflows under the pointer.\n                          \"data-source:border-dashed data-source:opacity-40 data-source:shadow-none\",\n                          // Hidden under the landing ghost, but still focusable so keyboard focus can follow it.\n                          source && ghost?.phase === \"settling\" && \"opacity-0\",\n                        )}\n                      >\n                        {renderCard(card)}\n                        <Menu.Trigger\n                          handle={menu}\n                          payload={card.id}\n                          disabled={disabled}\n                          data-move-trigger=\"\"\n                          aria-label={`Move ${label}`}\n                          aria-keyshortcuts=\"Alt+ArrowUp Alt+ArrowDown Alt+ArrowLeft Alt+ArrowRight\"\n                          onKeyDown={(e) => onTriggerKey(e, card)}\n                          className={cn(\n                            \"absolute end-1.5 top-1.5 grid size-6 place-items-center rounded-md text-fg-3 outline-none\",\n                            \"transition-[opacity,background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]\",\n                            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n                            // Shown on hover and focus with a mouse; always there on touch, where hover doesn't exist.\n                            \"opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 data-popup-open:bg-hover data-popup-open:text-fg data-popup-open:opacity-100 pointer-coarse:opacity-100\",\n                            \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n                            \"disabled:hidden\",\n                          )}\n                        >\n                          <MoreH size={14} />\n                        </Menu.Trigger>\n                      </div>\n                    </motion.li>\n                  );\n                })}\n                {!list.length && (\n                  <li\n                    className=\"grid min-h-20 flex-1 place-items-center rounded-lg border border-dashed border-line-2 text-[12px] text-fg-4 transition-colors duration-150 in-data-over:border-fg-4 in-data-over:text-fg-3\"\n                  >\n                    {isOver ? \"Drop here\" : emptyLabel}\n                  </li>\n                )}\n                <AnimatePresence>\n                  {line?.column === col.id && (\n                    <motion.li\n                      key={col.id}\n                      aria-hidden\n                      className=\"pointer-events-none absolute inset-x-2 top-0 z-10 flex h-0.5 items-center\"\n                      initial={{ opacity: 0, y: line.top, scaleX: reduce ? 1 : 0.6 }}\n                      animate={{ opacity: 1, y: line.top, scaleX: 1 }}\n                      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                      transition={reduce ? { duration: 0 } : { ...spring.follow, opacity: { duration: 0.12 } }}\n                    >\n                      <span className=\"absolute -start-1 size-1.5 rounded-full border border-fg bg-frame\" />\n                      <span className=\"h-0.5 w-full rounded-full bg-fg\" />\n                    </motion.li>\n                  )}\n                </AnimatePresence>\n              </ul>\n            </section>\n          );\n        })}\n      </div>\n\n      <Menu.Root\n        handle={menu}\n        onOpenChangeComplete={(open) => {\n          // Move after the menu has closed, so it never loses its anchor mid-exit.\n          if (open || !pending) return;\n          setPending(null);\n          moveWithFlight(pending.id, pending.to, pending.index);\n        }}\n      >\n        {({ payload }) => {\n          const card = cards.find((c) => c.id === payload);\n          if (!card) return null;\n          const list = byColumn(cards, card.column);\n          const i = list.findIndex((c) => c.id === card.id);\n          const queue = (to: string, index: number) => setPending({ id: card.id, to, index });\n          const alt = /Mac|iPhone|iPad/.test(navigator.userAgent) ? \"⌥\" : \"Alt \";\n          return (\n            <Menu.Portal>\n              <Menu.Positioner side=\"bottom\" align=\"end\" sideOffset={4} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n                <Menu.Popup className={popupClass}>\n                  <Menu.Item disabled={i <= 0} onClick={() => queue(card.column, i - 1)} className={itemClass}>\n                    <ArrowUp size={14} className=\"text-fg-3\" />\n                    <span className=\"flex-1\">Move up</span>\n                    <Kbd>{alt}↑</Kbd>\n                  </Menu.Item>\n                  <Menu.Item disabled={i >= list.length - 1} onClick={() => queue(card.column, i + 1)} className={itemClass}>\n                    <ArrowDown size={14} className=\"text-fg-3\" />\n                    <span className=\"flex-1\">Move down</span>\n                    <Kbd>{alt}↓</Kbd>\n                  </Menu.Item>\n                  <Menu.Separator className=\"-mx-1 my-1 h-px bg-line\" />\n                  <Menu.Group>\n                    <Menu.GroupLabel className=\"px-2 pb-1 pt-1.5 font-mono text-2xs uppercase tracking-[0.08em] text-fg-4\">Move to</Menu.GroupLabel>\n                    {columns.map((col) => {\n                      const current = col.id === card.column;\n                      const n = byColumn(cards, col.id).length;\n                      return (\n                        <Menu.Item key={col.id} disabled={current} onClick={() => queue(col.id, 0)} className={itemClass}>\n                          <span className=\"grid size-3.5 place-items-center\">{current && <Check size={14} />}</span>\n                          <span className=\"min-w-0 flex-1 truncate\">{col.title}</span>\n                          <span className=\"font-mono text-[11px] text-fg-4 tabular\">\n                            {n}\n                            {col.limit !== undefined && `/${col.limit}`}\n                          </span>\n                        </Menu.Item>\n                      );\n                    })}\n                  </Menu.Group>\n                </Menu.Popup>\n              </Menu.Positioner>\n            </Menu.Portal>\n          );\n        }}\n      </Menu.Root>\n\n      {ghost &&\n        ghostCard &&\n        createPortal(\n          <motion.div\n            aria-hidden\n            className=\"pointer-events-none fixed left-0 top-0 z-(--z-popover)\"\n            style={{ x: gx, y: gy, scale: gScale, rotate: reduce || ghost.phase === \"settling\" ? 0 : rotate, width: ghost.width }}\n          >\n            <div\n              data-phase={ghost.phase}\n              className={cn(\n                cardClass,\n                \"cursor-grabbing border-line-2 shadow-pop transition-[box-shadow,border-color] duration-200\",\n                \"data-[phase=settling]:border-line data-[phase=settling]:shadow-[var(--shadow)]\",\n              )}\n            >\n              {renderCard(ghostCard)}\n            </div>\n          </motion.div>,\n          document.body,\n        )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return <kbd className=\"font-mono text-[11px] tracking-[0.04em] text-fg-4\">{children}</kbd>;\n}\n\n// While a card is in the air the cursor stays a closed hand everywhere, and text can't be selected.\nfunction useGrabbingCursor(on: boolean) {\n  useEffect(() => {\n    if (!on) return;\n    const style = document.body.style;\n    const prev = { cursor: style.cursor, userSelect: style.userSelect };\n    style.cursor = \"grabbing\";\n    style.userSelect = \"none\";\n    return () => {\n      style.cursor = prev.cursor;\n      style.userSelect = prev.userSelect;\n    };\n  }, [on]);\n}\n\nconst popupClass = cn(\n  \"min-w-[200px] rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n  \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n  \"data-starting-style:scale-96 data-starting-style:opacity-0 data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n  \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n  \"motion-reduce:scale-100 motion-reduce:translate-none\",\n);\n\nconst itemClass = cn(\n  \"flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none pointer-coarse:h-10\",\n  \"transition-colors duration-100 data-highlighted:bg-fg/[0.06] data-disabled:opacity-45\",\n);\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/kanban"
}
