feat: add drag-to-reorder steps with @dnd-kit

Wrap step list in DndContext + SortableContext. Each step/section header
gets a SortableStepWrapper with useSortable. Drag handles have accessible
labels and keyboard support. procedure_end stays non-draggable and always
last. Expanded steps are disabled for dragging. Array-index reorder only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-19 03:19:14 -05:00
parent aa3651ebfe
commit 80e7831e90

View File

@@ -1,5 +1,9 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect, useCallback, type ReactNode } from 'react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react' import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { StepContentType } from '@/types' import type { StepContentType } from '@/types'
import { StepEditor } from './StepEditor' import { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore' import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -12,6 +16,29 @@ const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: stri
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: 'Warning' }, warning: { icon: AlertTriangle, color: 'text-yellow-400', label: 'Warning' },
} }
function SortableStepWrapper({
id,
disabled,
children,
}: {
id: string
disabled?: boolean
children: (props: { dragHandleProps: Record<string, unknown> }) => ReactNode
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style} className={cn(isDragging && 'z-10 opacity-50')}>
{children({ dragHandleProps: { ...attributes, ...listeners } })}
</div>
)
}
export function StepList() { export function StepList() {
const { const {
steps, steps,
@@ -22,6 +49,7 @@ export function StepList() {
addSectionHeader, addSectionHeader,
removeStep, removeStep,
updateStep, updateStep,
moveStep,
} = useProceduralEditorStore() } = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step') const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
@@ -35,7 +63,6 @@ export function StepList() {
useEffect(() => { useEffect(() => {
if (steps.length > prevStepCount.current) { if (steps.length > prevStepCount.current) {
// Scroll the newly expanded step into view
setTimeout(() => { setTimeout(() => {
scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 50) }, 50)
@@ -43,6 +70,30 @@ export function StepList() {
prevStepCount.current = steps.length prevStepCount.current = steps.length
}, [steps.length]) }, [steps.length])
// DnD setup
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = steps.findIndex(s => s.id === active.id)
const newIndex = steps.findIndex(s => s.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
// Don't allow moving past the procedure_end step
const endIndex = steps.findIndex(s => s.type === 'procedure_end')
if (newIndex >= endIndex) return
moveStep(oldIndex, newIndex)
}, [steps, moveStep])
// Sortable items: everything except procedure_end
const sortableItems = steps.filter(s => s.type !== 'procedure_end').map(s => s.id)
let stepCounter = 0 let stepCounter = 0
return ( return (
@@ -74,9 +125,12 @@ export function StepList() {
</div> </div>
</div> </div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step) => { {steps.map((step) => {
if (step.type === 'procedure_end') { if (step.type === 'procedure_end') {
// procedure_end: non-draggable, always last
return ( return (
<div <div
key={step.id} key={step.id}
@@ -101,7 +155,9 @@ export function StepList() {
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={step.id} ref={expandedStepId === step.id ? scrollTargetRef : undefined}> <SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor <StepEditor
step={step} step={step}
stepNumber={0} stepNumber={0}
@@ -110,15 +166,23 @@ export function StepList() {
availableVariables={intakeForm} availableVariables={intakeForm}
/> />
</div> </div>
)}
</SortableStepWrapper>
) )
} }
return ( return (
<div <SortableStepWrapper key={step.id} id={step.id}>
key={step.id} {({ dragHandleProps }) => (
className="group flex items-center gap-2 border-b border-border pb-1 pt-3" <div className="group flex items-center gap-2 border-b border-border pb-1 pt-3">
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder section: ${step.title || 'Untitled Section'}`}
{...dragHandleProps}
> >
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" /> <GripVertical className="h-4 w-4" />
</button>
<span <span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground" className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
onClick={() => setExpandedStepId(step.id)} onClick={() => setExpandedStepId(step.id)}
@@ -132,6 +196,8 @@ export function StepList() {
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
)}
</SortableStepWrapper>
) )
} }
@@ -145,7 +211,9 @@ export function StepList() {
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={step.id} ref={expandedStepId === step.id ? scrollTargetRef : undefined}> <SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor <StepEditor
step={step} step={step}
stepNumber={stepNumber} stepNumber={stepNumber}
@@ -154,18 +222,28 @@ export function StepList() {
availableVariables={intakeForm} availableVariables={intakeForm}
/> />
</div> </div>
)}
</SortableStepWrapper>
) )
} }
return ( return (
<div key={step.id}> <SortableStepWrapper key={step.id} id={step.id}>
{({ dragHandleProps }) => (
<div <div
className={cn( className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors', 'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50' 'hover:border-primary/30 hover:bg-accent/50'
)} )}
> >
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" /> <button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber} {stepNumber}
@@ -202,10 +280,13 @@ export function StepList() {
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> )}
</SortableStepWrapper>
) )
})} })}
</div> </div>
</SortableContext>
</DndContext>
{/* Add step button at bottom */} {/* Add step button at bottom */}
<button <button