@@ -14,8 +14,6 @@ import {
1414 Trash2 ,
1515 Eye ,
1616 EyeOff ,
17- ArrowUp ,
18- ArrowDown ,
1917 Save ,
2018 X ,
2119 Columns3 ,
@@ -32,6 +30,22 @@ import {
3230 Copy ,
3331 Clipboard ,
3432} from 'lucide-react' ;
33+ import {
34+ DndContext ,
35+ closestCenter ,
36+ KeyboardSensor ,
37+ PointerSensor ,
38+ useSensor ,
39+ useSensors ,
40+ type DragEndEvent ,
41+ } from '@dnd-kit/core' ;
42+ import {
43+ SortableContext ,
44+ verticalListSortingStrategy ,
45+ useSortable ,
46+ arrayMove ,
47+ } from '@dnd-kit/sortable' ;
48+ import { CSS } from '@dnd-kit/utilities' ;
3549import { clsx } from 'clsx' ;
3650import { twMerge } from 'tailwind-merge' ;
3751import { useDesignerHistory } from './hooks/useDesignerHistory' ;
@@ -119,10 +133,7 @@ const LABELS = {
119133 asc : 'Asc' ,
120134 desc : 'Desc' ,
121135 selectField : 'Select field...' ,
122- moveUp : 'Move up' ,
123- moveDown : 'Move down' ,
124- moveColumnUp : 'Move column up' ,
125- moveColumnDown : 'Move column down' ,
136+ dragToReorder : 'Drag to reorder' ,
126137 hideColumn : 'Hide column' ,
127138 showColumn : 'Show column' ,
128139 toggleVisibility : 'Toggle column visibility' ,
@@ -190,6 +201,104 @@ const DESIGNER_FIELD_TYPES = [
190201const COLUMN_WIDTH_MIN = 50 ;
191202const COLUMN_WIDTH_MAX = 1000 ;
192203
204+ /** Sortable column item for drag-to-reorder */
205+ function SortableColumnItem ( {
206+ col,
207+ index,
208+ isSelected,
209+ readOnly,
210+ onSelect,
211+ onToggleVisibility,
212+ onRemove,
213+ } : {
214+ col : ViewDesignerColumn ;
215+ index : number ;
216+ isSelected : boolean ;
217+ readOnly ?: boolean ;
218+ onSelect : ( ) => void ;
219+ onToggleVisibility : ( ) => void ;
220+ onRemove : ( ) => void ;
221+ } ) {
222+ const {
223+ attributes,
224+ listeners,
225+ setNodeRef,
226+ transform,
227+ transition,
228+ isDragging,
229+ } = useSortable ( { id : `col-${ index } ` , disabled : readOnly } ) ;
230+
231+ const style : React . CSSProperties = {
232+ transform : CSS . Transform . toString ( transform ) ,
233+ transition,
234+ opacity : isDragging ? 0.5 : undefined ,
235+ zIndex : isDragging ? 10 : undefined ,
236+ } ;
237+
238+ return (
239+ < div
240+ ref = { setNodeRef }
241+ style = { style }
242+ { ...attributes }
243+ className = { cn (
244+ 'flex items-center gap-2 px-3 py-2 rounded border transition-colors cursor-pointer' ,
245+ isSelected
246+ ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
247+ : 'border-border hover:border-primary/50' ,
248+ ! col . visible && 'opacity-50' ,
249+ ) }
250+ onClick = { onSelect }
251+ data-testid = { `column-${ col . field } ` }
252+ >
253+ < span
254+ { ...( readOnly ? { } : listeners ) }
255+ className = { cn (
256+ 'shrink-0 touch-none' ,
257+ ! readOnly && 'cursor-grab' ,
258+ ) }
259+ title = { readOnly ? undefined : LABELS . dragToReorder }
260+ aria-label = { readOnly ? undefined : LABELS . dragToReorder }
261+ >
262+ < GripVertical className = "h-3.5 w-3.5 text-muted-foreground" />
263+ </ span >
264+ < span className = "text-sm font-medium truncate flex-1" >
265+ { col . label || col . field }
266+ </ span >
267+ < span className = "text-xs text-muted-foreground" > { col . field } </ span >
268+ { ! readOnly && (
269+ < div className = "flex items-center gap-0.5 shrink-0" >
270+ < button
271+ onClick = { ( e ) => {
272+ e . stopPropagation ( ) ;
273+ onToggleVisibility ( ) ;
274+ } }
275+ className = "p-0.5 rounded hover:bg-accent"
276+ title = { col . visible !== false ? LABELS . hideColumn : LABELS . showColumn }
277+ aria-label = { LABELS . toggleVisibility }
278+ >
279+ { col . visible !== false ? (
280+ < Eye className = "h-3 w-3" />
281+ ) : (
282+ < EyeOff className = "h-3 w-3" />
283+ ) }
284+ </ button >
285+ < button
286+ onClick = { ( e ) => {
287+ e . stopPropagation ( ) ;
288+ onRemove ( ) ;
289+ } }
290+ className = "p-0.5 rounded hover:bg-destructive/10"
291+ title = { LABELS . removeColumn }
292+ aria-label = { LABELS . removeColumn }
293+ >
294+ < Trash2 className = "h-3 w-3 text-destructive" />
295+ </ button >
296+ </ div >
297+ ) }
298+ </ div >
299+ ) ;
300+ }
301+
193302/**
194303 * Visual designer for creating and editing list views.
195304 * Provides a 3-panel layout:
@@ -294,19 +403,38 @@ export function ViewDesigner({
294403 [ readOnly , columns , pushState ] ,
295404 ) ;
296405
297- const handleMoveColumn = useCallback (
298- ( index : number , direction : 'up' | 'down' ) => {
406+ // --- Drag-reorder sensors ---
407+ const sensors = useSensors (
408+ useSensor ( PointerSensor , { activationConstraint : { distance : 5 } } ) ,
409+ useSensor ( KeyboardSensor ) ,
410+ ) ;
411+
412+ const handleDragEnd = useCallback (
413+ ( event : DragEndEvent ) => {
299414 if ( readOnly ) return ;
300- const newIndex = direction === 'up' ? index - 1 : index + 1 ;
301- if ( newIndex < 0 || newIndex >= columns . length ) return ;
302- const updated = [ ...columns ] ;
303- const temp = updated [ index ] ;
304- updated [ index ] = updated [ newIndex ] ;
305- updated [ newIndex ] = temp ;
306- pushState ( { columns : updated } ) ;
307- setSelectedColumnIndex ( newIndex ) ;
415+ const { active, over } = event ;
416+ if ( ! over || active . id === over . id ) return ;
417+
418+ const oldIndex = columns . findIndex ( ( _ , i ) => `col-${ i } ` === active . id ) ;
419+ const newIndex = columns . findIndex ( ( _ , i ) => `col-${ i } ` === over . id ) ;
420+ if ( oldIndex === - 1 || newIndex === - 1 ) return ;
421+
422+ const reordered = arrayMove ( columns , oldIndex , newIndex ) ;
423+ pushState ( { columns : reordered } ) ;
424+
425+ // Update selection to follow the dragged column
426+ if ( selectedColumnIndex === oldIndex ) {
427+ setSelectedColumnIndex ( newIndex ) ;
428+ } else if ( selectedColumnIndex !== null ) {
429+ // Adjust selection if it was affected by the move
430+ if ( oldIndex < selectedColumnIndex && newIndex >= selectedColumnIndex ) {
431+ setSelectedColumnIndex ( selectedColumnIndex - 1 ) ;
432+ } else if ( oldIndex > selectedColumnIndex && newIndex <= selectedColumnIndex ) {
433+ setSelectedColumnIndex ( selectedColumnIndex + 1 ) ;
434+ }
435+ }
308436 } ,
309- [ readOnly , columns , pushState ] ,
437+ [ readOnly , columns , pushState , selectedColumnIndex ] ,
310438 ) ;
311439
312440 const handleAddFilter = useCallback ( ( ) => {
@@ -624,80 +752,22 @@ export function ViewDesigner({
624752 < div className = "text-xs font-medium text-muted-foreground mb-2" >
625753 { LABELS . columnsCount ( columns . length ) }
626754 </ div >
627- { columns . map ( ( col , index ) => (
628- < div
629- key = { `${ col . field } -${ index } ` }
630- className = { cn (
631- 'flex items-center gap-2 px-3 py-2 rounded border transition-colors cursor-pointer' ,
632- selectedColumnIndex === index
633- ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
634- : 'border-border hover:border-primary/50' ,
635- ! col . visible && 'opacity-50' ,
636- ) }
637- onClick = { ( ) => setSelectedColumnIndex ( index ) }
638- data-testid = { `column-${ col . field } ` }
639- >
640- < GripVertical className = "h-3.5 w-3.5 text-muted-foreground shrink-0" />
641- < span className = "text-sm font-medium truncate flex-1" >
642- { col . label || col . field }
643- </ span >
644- < span className = "text-xs text-muted-foreground" > { col . field } </ span >
645- { ! readOnly && (
646- < div className = "flex items-center gap-0.5 shrink-0" >
647- < button
648- onClick = { ( e ) => {
649- e . stopPropagation ( ) ;
650- handleMoveColumn ( index , 'up' ) ;
651- } }
652- disabled = { index === 0 }
653- className = "p-0.5 rounded hover:bg-accent disabled:opacity-30"
654- title = { LABELS . moveUp }
655- aria-label = { LABELS . moveColumnUp }
656- >
657- < ArrowUp className = "h-3 w-3" />
658- </ button >
659- < button
660- onClick = { ( e ) => {
661- e . stopPropagation ( ) ;
662- handleMoveColumn ( index , 'down' ) ;
663- } }
664- disabled = { index === columns . length - 1 }
665- className = "p-0.5 rounded hover:bg-accent disabled:opacity-30"
666- title = { LABELS . moveDown }
667- aria-label = { LABELS . moveColumnDown }
668- >
669- < ArrowDown className = "h-3 w-3" />
670- </ button >
671- < button
672- onClick = { ( e ) => {
673- e . stopPropagation ( ) ;
674- handleToggleColumnVisibility ( index ) ;
675- } }
676- className = "p-0.5 rounded hover:bg-accent"
677- title = { col . visible !== false ? LABELS . hideColumn : LABELS . showColumn }
678- aria-label = { LABELS . toggleVisibility }
679- >
680- { col . visible !== false ? (
681- < Eye className = "h-3 w-3" />
682- ) : (
683- < EyeOff className = "h-3 w-3" />
684- ) }
685- </ button >
686- < button
687- onClick = { ( e ) => {
688- e . stopPropagation ( ) ;
689- handleRemoveColumn ( index ) ;
690- } }
691- className = "p-0.5 rounded hover:bg-destructive/10"
692- title = { LABELS . removeColumn }
693- aria-label = { LABELS . removeColumn }
694- >
695- < Trash2 className = "h-3 w-3 text-destructive" />
696- </ button >
697- </ div >
698- ) }
699- </ div >
700- ) ) }
755+ < DndContext sensors = { sensors } collisionDetection = { closestCenter } onDragEnd = { handleDragEnd } >
756+ < SortableContext items = { columns . map ( ( _ , i ) => `col-${ i } ` ) } strategy = { verticalListSortingStrategy } >
757+ { columns . map ( ( col , index ) => (
758+ < SortableColumnItem
759+ key = { `${ col . field } -${ index } ` }
760+ col = { col }
761+ index = { index }
762+ isSelected = { selectedColumnIndex === index }
763+ readOnly = { readOnly }
764+ onSelect = { ( ) => setSelectedColumnIndex ( index ) }
765+ onToggleVisibility = { ( ) => handleToggleColumnVisibility ( index ) }
766+ onRemove = { ( ) => handleRemoveColumn ( index ) }
767+ />
768+ ) ) }
769+ </ SortableContext >
770+ </ DndContext >
701771 </ div >
702772 ) }
703773 </ div >
0 commit comments