Sidebar DnD

The drag-and-drop context: declarative drop zones, drag-to-reorder across lists, and one discriminated union describing every drop.

Open in

Installation

npx shadcn@latest add miskibin/chat-components/sidebar-dnd

Usage

"use client"

import { arrayMove } from "@dnd-kit/sortable"
import {
  ChatSidebarDnd,
  ChatSidebarItemGhost,
  ChatSidebarItemList,
  type SidebarDndDrop,
} from "@/components/ui/chat-sidebar"

function handleDrop(drop: SidebarDndDrop) {
  if (drop.kind === "reorder") {
    setItems((prev) => arrayMove(prev, drop.from, drop.to))
    return
  }
  if (drop.action === "pin") pin(drop.itemId)
  if (drop.action === "delete") remove(drop.itemId)
}

export function Nav() {
  return (
    <ChatSidebarDnd
      onDrop={handleDrop}
      renderOverlay={(id) => {
        const item = items.find((candidate) => candidate.id === id)
        return item ? <ChatSidebarItemGhost item={item} /> : null
      }}
    >
      <ChatSidebarItemList listId="recent" items={items} sortable />
    </ChatSidebarDnd>
  )
}

Examples

Narrow the drop union

Zone drops and reorders arrive through the same callback. Check drop.kind first, then drop.action — the provider never mutates your data, it only reports what happened.

function handleDrop(drop: SidebarDndDrop) {
  if (drop.kind === "reorder") {
    const { fromListId, listId, from, to } = drop
    if (fromListId === listId) {
      setItems((prev) => arrayMove(prev, from, to))
    } else {
      moveBetweenLists(drop.itemId, fromListId, listId, to)
    }
    return
  }

  if (drop.kind === "zone") {
    switch (drop.action) {
      case "pin":
        return pin(drop.itemId)
      case "delete":
        return remove(drop.itemId)
      case "archive":
        return archive(drop.itemId)
    }
  }
}

Say what the drop will do

A drag that crosses lists is not a reorder — it pins, archives, or moves. resolveDropVerb names the one that applies and the lifted row wears it, while canDrop refuses the drops that make no sense: the archived row below cannot be dragged anywhere, and rejecting it falls back to the source instead of quietly choosing a neighbour.

Custom zones

Pin and trash are just presets over SidebarDropZoneDef. Anything you can drag onto — archive, “move to project”, a per-folder target — is one more entry in the array.

Drag a chat onto one of the zones.

Register your own list

If you render rows without ChatSidebarItemList, register their ids so item-on-item drops still resolve to indices.

function ProjectList({ projects }: { projects: Project[] }) {
  useSidebarDndList(
    "projects",
    React.useMemo(() => projects.map((project) => project.id), [projects])
  )

  return (
    <SortableContext
      items={projects.map((project) => project.id)}
      strategy={verticalListSortingStrategy}
    >
      {projects.map((project) => (
        <ChatSidebarItem key={project.id} item={project} sortable />
      ))}
    </SortableContext>
  )
}

Notes

API reference

ChatSidebarDnd

zonesSidebarDropZoneDef[] = DEFAULT_SIDEBAR_ZONES
Drop targets available while dragging. Defaults to the pin + trash pair; pass [] for reorder-only.
onDrop(drop: SidebarDndDrop) => void
Fires for every resolved drop — zone, reorder, custom.
onReorder(drop: SidebarReorderDrop) => void
Convenience hook fired in addition to onDrop for reorder drops.
renderOverlay(itemId: string) => React.ReactNode
Rendered in the dnd-kit drag overlay. Usually ChatSidebarItemGhost.
onDragStart / onDragCancel(itemId: string) => void / () => void
Drag lifecycle hooks.
canDrop(itemId: string, overId: string) => boolean
Rejects a target while it is only hovered. A rejected target falls back to the source, never to the next-nearest droppable, so sliding past a forbidden row cannot drop the item two rows further down. Answers are cached per target for the length of one drag.
resolveDropVerb(fromListId, toListId) => { label, icon? } | null
Names what a drop that crosses lists would do. The lifted row renders it as a badge in place of its meta label; return null for a move that needs no explaining. A reorder inside one list never asks.
activationDistancenumber = 6
Pixels the mouse must travel before a drag starts, so plain clicks keep working.
touchDelaynumber = 220
Long-press duration before a touch drag starts, so the list stays scrollable.
modifiers / sensorsModifier[] / SensorDescriptor[]
Passed through to dnd-kit. Providing sensors replaces the mouse + touch + keyboard defaults entirely.

SidebarDropZoneDef

id*string
Droppable id, unique within the context.
label*string
Text shown inside the zone while dragging.
iconReact.ReactNode
Leading icon.
tone"accent" | "danger" | "muted" = "accent"
Color treatment, including the drag-over state.
edge"top" | "bottom"
Pin the zone to that edge of the sidebar panel. Needs edgeZones on ChatSidebar.
actionstring = zone.id
Semantic name delivered on onDrop — this is what you switch on.
disabledboolean
Filtered out of the active zone set.

SidebarDndDrop

kind: "zone"{ action, itemId, zoneId, zone }
An item landed on a zone. Narrow with drop.action.
kind: "reorder"{ itemId, listId, fromListId, from, to, overId }
An item landed on another item. fromListId !== listId means it crossed lists.
kind: "custom"{ itemId, overId }
Landed on a droppable that is neither a zone nor a registered list item.
SidebarDropVerb{ label: string; icon?: React.ReactNode }
What resolveDropVerb returns, and what the badge on the lifted row renders.

Helpers

pinDropZone(overrides?)SidebarDropZoneDef
Preset: top edge, accent tone, action: "pin". Every field is overridable.
trashDropZone(overrides?)SidebarDropZoneDef
Preset: bottom edge, danger tone, action: "delete".
DEFAULT_SIDEBAR_ZONESSidebarDropZoneDef[]
The pin + trash pair, used when zones is omitted.
useSidebarDnd(){ activeId, zones, edgeZones, … }
Read the drag state — mount inline zones only while activeId is set.
useSidebarDropVerb()SidebarDropVerb | null
The verb for the drag in progress. Its own context, so only the badge on the lifted row re-renders as the pointer crosses lists; ChatSidebarItem already reads it.
useSidebarDndList(listId, itemIds)void
Registers an ordered id list so item-on-item drops resolve to from/to. Called for you by ChatSidebarItemList; call it yourself only for custom lists. No-op outside a provider.