@@ -16,6 +16,22 @@ import { Input, Switch, Checkbox, FilterBuilder, SortBuilder, ConfigRow } from '
1616import type { ConfigPanelSchema , ConfigField } from '@object-ui/components' ;
1717import type { FilterGroup , SortItem } from '@object-ui/components' ;
1818import { Eye , EyeOff , GripVertical } from 'lucide-react' ;
19+ import {
20+ DndContext ,
21+ closestCenter ,
22+ KeyboardSensor ,
23+ PointerSensor ,
24+ useSensor ,
25+ useSensors ,
26+ type DragEndEvent ,
27+ } from '@dnd-kit/core' ;
28+ import {
29+ SortableContext ,
30+ verticalListSortingStrategy ,
31+ useSortable ,
32+ arrayMove ,
33+ } from '@dnd-kit/sortable' ;
34+ import { CSS } from '@dnd-kit/utilities' ;
1935import {
2036 VIEW_TYPE_LABELS ,
2137 VIEW_TYPE_OPTIONS ,
@@ -43,6 +59,131 @@ function ExpandableWidget({ renderSummary, children }: {
4359 ) ;
4460}
4561
62+ // ---------------------------------------------------------------------------
63+ // Sortable column item — drag-to-reorder within the field selector
64+ // ---------------------------------------------------------------------------
65+
66+ /** Minimum drag distance in pixels to activate column reorder */
67+ const COLUMN_DRAG_ACTIVATION_DISTANCE = 5 ;
68+
69+ function SortableColumnItem ( { colName, label, idx, total, onToggle, onMove } : {
70+ colName : string ;
71+ label : string ;
72+ idx : number ;
73+ total : number ;
74+ onToggle : ( name : string , checked : boolean ) => void ;
75+ onMove : ( name : string , direction : 'up' | 'down' ) => void ;
76+ } ) {
77+ const {
78+ attributes,
79+ listeners,
80+ setNodeRef,
81+ transform,
82+ transition,
83+ isDragging,
84+ } = useSortable ( { id : colName } ) ;
85+
86+ const style : React . CSSProperties = {
87+ transform : CSS . Transform . toString ( transform ) ,
88+ transition,
89+ zIndex : isDragging ? 10 : undefined ,
90+ opacity : isDragging ? 0.5 : undefined ,
91+ } ;
92+
93+ return (
94+ < div
95+ ref = { setNodeRef }
96+ style = { style }
97+ data-field-name = { colName }
98+ data-field-label = { label }
99+ className = "flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1 group"
100+ >
101+ < span
102+ { ...attributes }
103+ { ...listeners }
104+ className = "shrink-0 cursor-grab touch-none"
105+ data-testid = { `col-drag-handle-${ colName } ` }
106+ >
107+ < GripVertical className = "h-3 w-3 text-muted-foreground/50" />
108+ </ span >
109+ < button
110+ type = "button"
111+ data-testid = { `col-eye-${ colName } ` }
112+ className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent shrink-0"
113+ onClick = { ( ) => onToggle ( colName , false ) }
114+ aria-label = { `Hide ${ label } ` }
115+ >
116+ < Eye className = "h-3.5 w-3.5 text-primary" />
117+ </ button >
118+ < span className = "truncate flex-1" > { label } </ span >
119+ < button
120+ type = "button"
121+ data-testid = { `col-move-up-${ colName } ` }
122+ className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
123+ disabled = { idx === 0 }
124+ onClick = { ( ) => onMove ( colName , 'up' ) }
125+ aria-label = { `Move ${ label } up` }
126+ >
127+ < GripVertical className = "h-3 w-3 rotate-90" />
128+ </ button >
129+ < button
130+ type = "button"
131+ data-testid = { `col-move-down-${ colName } ` }
132+ className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
133+ disabled = { idx === total - 1 }
134+ onClick = { ( ) => onMove ( colName , 'down' ) }
135+ aria-label = { `Move ${ label } down` }
136+ >
137+ < GripVertical className = "h-3 w-3 -rotate-90" />
138+ </ button >
139+ </ div >
140+ ) ;
141+ }
142+
143+ /** Wrapper component for drag-and-drop column selector (uses hooks for sensors) */
144+ function DraggableColumnList ( { columns, fieldOptions, onToggle, onMove, onReorder } : {
145+ columns : string [ ] ;
146+ fieldOptions : FieldOption [ ] ;
147+ onToggle : ( name : string , checked : boolean ) => void ;
148+ onMove : ( name : string , direction : 'up' | 'down' ) => void ;
149+ onReorder : ( newColumns : string [ ] ) => void ;
150+ } ) {
151+ const sensors = useSensors (
152+ useSensor ( PointerSensor , { activationConstraint : { distance : COLUMN_DRAG_ACTIVATION_DISTANCE } } ) ,
153+ useSensor ( KeyboardSensor ) ,
154+ ) ;
155+
156+ const handleDragEnd = React . useCallback ( ( event : DragEndEvent ) => {
157+ const { active, over } = event ;
158+ if ( ! over || active . id === over . id ) return ;
159+ const oldIndex = columns . indexOf ( String ( active . id ) ) ;
160+ const newIndex = columns . indexOf ( String ( over . id ) ) ;
161+ if ( oldIndex === - 1 || newIndex === - 1 ) return ;
162+ onReorder ( arrayMove ( columns , oldIndex , newIndex ) ) ;
163+ } , [ columns , onReorder ] ) ;
164+
165+ return (
166+ < DndContext sensors = { sensors } collisionDetection = { closestCenter } onDragEnd = { handleDragEnd } >
167+ < SortableContext items = { columns } strategy = { verticalListSortingStrategy } >
168+ { columns . map ( ( colName , idx ) => {
169+ const field = fieldOptions . find ( f => f . value === colName ) ;
170+ return (
171+ < SortableColumnItem
172+ key = { colName }
173+ colName = { colName }
174+ label = { field ?. label || colName }
175+ idx = { idx }
176+ total = { columns . length }
177+ onToggle = { onToggle }
178+ onMove = { onMove }
179+ />
180+ ) ;
181+ } ) }
182+ </ SortableContext >
183+ </ DndContext >
184+ ) ;
185+ }
186+
46187// ---------------------------------------------------------------------------
47188// Types
48189// ---------------------------------------------------------------------------
@@ -639,47 +780,16 @@ function buildDataSection(
639780 </ button >
640781 </ div >
641782 < div className = "space-y-0.5 max-h-48 overflow-auto" >
642- { /* Visible (selected) columns with drag handle + eye toggle */ }
783+ { /* Visible (selected) columns with drag-and-drop reorder */ }
643784 { Array . isArray ( draft . columns ) && draft . columns . length > 0 && (
644785 < div data-testid = "selected-columns" className = "space-y-0.5 pb-1 mb-1 border-b border-border/50" >
645- { draft . columns . map ( ( colName : string , idx : number ) => {
646- const field = fieldOptions . find ( f => f . value === colName ) ;
647- return (
648- < div key = { colName } data-field-name = { colName } data-field-label = { field ?. label || colName } className = "flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1 group" >
649- < GripVertical className = "h-3 w-3 text-muted-foreground/50 shrink-0 cursor-grab" />
650- < button
651- type = "button"
652- data-testid = { `col-eye-${ colName } ` }
653- className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent shrink-0"
654- onClick = { ( ) => handleColumnToggle ( colName , false ) }
655- aria-label = { `Hide ${ field ?. label || colName } ` }
656- >
657- < Eye className = "h-3.5 w-3.5 text-primary" />
658- </ button >
659- < span className = "truncate flex-1" > { field ?. label || colName } </ span >
660- < button
661- type = "button"
662- data-testid = { `col-move-up-${ colName } ` }
663- className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
664- disabled = { idx === 0 }
665- onClick = { ( ) => handleColumnMove ( colName , 'up' ) }
666- aria-label = { `Move ${ field ?. label || colName } up` }
667- >
668- < GripVertical className = "h-3 w-3 rotate-90" />
669- </ button >
670- < button
671- type = "button"
672- data-testid = { `col-move-down-${ colName } ` }
673- className = "h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
674- disabled = { idx === draft . columns . length - 1 }
675- onClick = { ( ) => handleColumnMove ( colName , 'down' ) }
676- aria-label = { `Move ${ field ?. label || colName } down` }
677- >
678- < GripVertical className = "h-3 w-3 -rotate-90" />
679- </ button >
680- </ div >
681- ) ;
682- } ) }
786+ < DraggableColumnList
787+ columns = { draft . columns }
788+ fieldOptions = { fieldOptions }
789+ onToggle = { handleColumnToggle }
790+ onMove = { handleColumnMove }
791+ onReorder = { ( newCols ) => updateField ( 'columns' , newCols ) }
792+ />
683793 </ div >
684794 ) }
685795 { /* Hidden (unselected) fields with eye-off toggle */ }
0 commit comments