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:
@@ -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,138 +125,168 @@ export function StepList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
{steps.map((step) => {
|
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||||
if (step.type === 'procedure_end') {
|
<div className="space-y-2">
|
||||||
return (
|
{steps.map((step) => {
|
||||||
<div
|
if (step.type === 'procedure_end') {
|
||||||
key={step.id}
|
// procedure_end: non-draggable, always last
|
||||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
|
return (
|
||||||
>
|
<div
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
|
key={step.id}
|
||||||
<input
|
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
|
||||||
type="text"
|
>
|
||||||
value={step.title}
|
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
|
||||||
onChange={(e) => updateStep(step.id, { title: e.target.value })}
|
<input
|
||||||
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
|
type="text"
|
||||||
placeholder="Procedure Complete"
|
value={step.title}
|
||||||
/>
|
onChange={(e) => updateStep(step.id, { title: e.target.value })}
|
||||||
<span className="text-[10px] text-muted-foreground">END</span>
|
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
|
||||||
</div>
|
placeholder="Procedure Complete"
|
||||||
)
|
/>
|
||||||
}
|
<span className="text-[10px] text-muted-foreground">END</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Section header rendering
|
// Section header rendering
|
||||||
if (step.type === 'section_header') {
|
if (step.type === 'section_header') {
|
||||||
const isExpanded = expandedStepId === step.id
|
const isExpanded = expandedStepId === step.id
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<SortableStepWrapper key={step.id} id={step.id} disabled>
|
||||||
|
{() => (
|
||||||
|
<div ref={scrollTargetRef}>
|
||||||
|
<StepEditor
|
||||||
|
step={step}
|
||||||
|
stepNumber={0}
|
||||||
|
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||||
|
onCollapse={() => setExpandedStepId(null)}
|
||||||
|
availableVariables={intakeForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableStepWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableStepWrapper key={step.id} id={step.id}>
|
||||||
|
{({ dragHandleProps }) => (
|
||||||
|
<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" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
{step.title || 'Untitled Section'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeStep(step.id)}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableStepWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular procedure step
|
||||||
|
stepCounter++
|
||||||
|
const stepNumber = stepCounter
|
||||||
|
const isExpanded = expandedStepId === step.id
|
||||||
|
const contentType = step.content_type || 'action'
|
||||||
|
const config = contentTypeConfig[contentType]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<SortableStepWrapper key={step.id} id={step.id} disabled>
|
||||||
|
{() => (
|
||||||
|
<div ref={scrollTargetRef}>
|
||||||
|
<StepEditor
|
||||||
|
step={step}
|
||||||
|
stepNumber={stepNumber}
|
||||||
|
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||||
|
onCollapse={() => setExpandedStepId(null)}
|
||||||
|
availableVariables={intakeForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableStepWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
return (
|
return (
|
||||||
<div key={step.id} ref={expandedStepId === step.id ? scrollTargetRef : undefined}>
|
<SortableStepWrapper key={step.id} id={step.id}>
|
||||||
<StepEditor
|
{({ dragHandleProps }) => (
|
||||||
step={step}
|
<div
|
||||||
stepNumber={0}
|
className={cn(
|
||||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||||
onCollapse={() => setExpandedStepId(null)}
|
'hover:border-primary/30 hover:bg-accent/50'
|
||||||
availableVariables={intakeForm}
|
)}
|
||||||
/>
|
>
|
||||||
</div>
|
<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">
|
||||||
|
{stepNumber}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={cn('shrink-0', config.color)}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
|
||||||
|
onClick={() => setExpandedStepId(step.id)}
|
||||||
|
>
|
||||||
|
{step.title || 'Untitled step'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{step.estimated_minutes && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
~{step.estimated_minutes}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedStepId(step.id)}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => removeStep(step.id)}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableStepWrapper>
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
return (
|
</SortableContext>
|
||||||
<div
|
</DndContext>
|
||||||
key={step.id}
|
|
||||||
className="group flex items-center gap-2 border-b border-border pb-1 pt-3"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
|
|
||||||
<span
|
|
||||||
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)}
|
|
||||||
>
|
|
||||||
{step.title || 'Untitled Section'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => removeStep(step.id)}
|
|
||||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular procedure step
|
|
||||||
stepCounter++
|
|
||||||
const stepNumber = stepCounter
|
|
||||||
const isExpanded = expandedStepId === step.id
|
|
||||||
const contentType = step.content_type || 'action'
|
|
||||||
const config = contentTypeConfig[contentType]
|
|
||||||
const Icon = config.icon
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
return (
|
|
||||||
<div key={step.id} ref={expandedStepId === step.id ? scrollTargetRef : undefined}>
|
|
||||||
<StepEditor
|
|
||||||
step={step}
|
|
||||||
stepNumber={stepNumber}
|
|
||||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
|
||||||
onCollapse={() => setExpandedStepId(null)}
|
|
||||||
availableVariables={intakeForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={step.id}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover: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}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={cn('shrink-0', config.color)}>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
|
|
||||||
onClick={() => setExpandedStepId(step.id)}
|
|
||||||
>
|
|
||||||
{step.title || 'Untitled step'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{step.estimated_minutes && (
|
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
||||||
~{step.estimated_minutes}m
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedStepId(step.id)}
|
|
||||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => removeStep(step.id)}
|
|
||||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add step button at bottom */}
|
{/* Add step button at bottom */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user