Sidebar DnD
The drag-and-drop context: declarative drop zones, drag-to-reorder across lists, and one discriminated union describing every drop.
Installation
npx shadcn@latest add miskibin/chat-components/sidebar-dndUsage
"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.
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
onDropfor 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
nullfor 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
edgeZonesonChatSidebar. 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 !== listIdmeans 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
resolveDropVerbreturns, 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;
ChatSidebarItemalready reads it. useSidebarDndList(listId, itemIds)void- Registers an ordered id list so item-on-item drops resolve to
from/to. Called for you byChatSidebarItemList; call it yourself only for custom lists. No-op outside a provider.