@@ -309,6 +309,16 @@ export function TableGrid({
309309 const tbodyRef = useRef < HTMLTableSectionElement > ( null )
310310 const isDraggingRef = useRef ( false )
311311 const suppressFocusScrollRef = useRef ( false )
312+ /**
313+ * Row-gutter drag-to-select. `isRowDraggingRef` is the active flag (kept
314+ * separate from the cell-drag `isDraggingRef` so the two don't cross-fire),
315+ * `rowDragAnchorRef` is the row index the drag started on, and
316+ * `rowDragBaseRef` is the materialized selection captured before the drag so
317+ * the swept range is unioned onto whatever was already selected.
318+ */
319+ const isRowDraggingRef = useRef ( false )
320+ const rowDragAnchorRef = useRef < number | null > ( null )
321+ const rowDragBaseRef = useRef < Set < string > | null > ( null )
312322
313323 const {
314324 tableData,
@@ -1058,6 +1068,44 @@ export function TableGrid({
10581068 scrollRef . current ?. focus ( { preventScroll : true } )
10591069 } , [ ] )
10601070
1071+ /** Selects every row between the drag anchor and `rowIndex`, unioned onto the base. */
1072+ const extendRowDragTo = useCallback ( ( rowIndex : number ) => {
1073+ const anchor = rowDragAnchorRef . current
1074+ if ( anchor === null ) return
1075+ const currentRows = rowsRef . current
1076+ const next = new Set ( rowDragBaseRef . current ?? [ ] )
1077+ const from = Math . min ( anchor , rowIndex )
1078+ const to = Math . max ( anchor , rowIndex )
1079+ for ( let i = from ; i <= to ; i ++ ) {
1080+ const r = currentRows [ i ]
1081+ if ( r ) next . add ( r . id )
1082+ }
1083+ setRowSelection ( next . size === 0 ? ROW_SELECTION_NONE : { kind : 'some' , ids : next } )
1084+ } , [ ] )
1085+
1086+ const handleRowMouseDown = useCallback (
1087+ ( rowIndex : number , shiftKey : boolean ) => {
1088+ // Capture the selection before the click mutates it so a drag unions the
1089+ // swept range onto the prior selection rather than the toggled result.
1090+ rowDragBaseRef . current = rowSelectionMaterialize ( rowSelectionRef . current , rowsRef . current )
1091+ handleRowToggle ( rowIndex , shiftKey )
1092+ // Shift-click extends from the last checkbox row — leave ranging to that
1093+ // path and don't begin a drag.
1094+ if ( shiftKey ) return
1095+ isRowDraggingRef . current = true
1096+ rowDragAnchorRef . current = rowIndex
1097+ } ,
1098+ [ handleRowToggle ]
1099+ )
1100+
1101+ const handleRowMouseEnter = useCallback (
1102+ ( rowIndex : number ) => {
1103+ if ( ! isRowDraggingRef . current || rowDragAnchorRef . current === null ) return
1104+ extendRowDragTo ( rowIndex )
1105+ } ,
1106+ [ extendRowDragTo ]
1107+ )
1108+
10611109 const handleClearSelection = useCallback ( ( ) => {
10621110 setSelectionAnchor ( null )
10631111 setSelectionFocus ( null )
@@ -1530,6 +1578,9 @@ export function TableGrid({
15301578 useEffect ( ( ) => {
15311579 const handleMouseUp = ( ) => {
15321580 isDraggingRef . current = false
1581+ isRowDraggingRef . current = false
1582+ rowDragAnchorRef . current = null
1583+ rowDragBaseRef . current = null
15331584 }
15341585 document . addEventListener ( 'mouseup' , handleMouseUp )
15351586 return ( ) => document . removeEventListener ( 'mouseup' , handleMouseUp )
@@ -1561,6 +1612,15 @@ export function TableGrid({
15611612 if ( pointerX === null || pointerY === null ) return
15621613 const target = document . elementFromPoint ( pointerX , pointerY )
15631614 if ( ! target ) return
1615+ if ( isRowDraggingRef . current ) {
1616+ // The gutter cell carries no coords; read the row index off any data
1617+ // cell in the same `<tr>` and extend the swept row range.
1618+ const cell = ( target as HTMLElement ) . closest ( 'tr' ) ?. querySelector ( 'td[data-row]' )
1619+ const rowIndex = Number . parseInt ( cell ?. getAttribute ( 'data-row' ) ?? '' , 10 )
1620+ if ( Number . isNaN ( rowIndex ) ) return
1621+ extendRowDragTo ( rowIndex )
1622+ return
1623+ }
15641624 const td = ( target as HTMLElement ) . closest ( 'td[data-row][data-col]' ) as HTMLElement | null
15651625 if ( ! td ) return
15661626 const rowIndex = Number . parseInt ( td . getAttribute ( 'data-row' ) ?? '' , 10 )
@@ -1572,7 +1632,7 @@ export function TableGrid({
15721632 const tick = ( ) => {
15731633 rafId = null
15741634 const el = scrollRef . current
1575- if ( ! isDraggingRef . current || ! el || pointerY === null ) return
1635+ if ( ( ! isDraggingRef . current && ! isRowDraggingRef . current ) || ! el || pointerY === null ) return
15761636 const rect = el . getBoundingClientRect ( )
15771637 const distFromTop = pointerY - rect . top
15781638 const distFromBottom = rect . bottom - pointerY
@@ -1592,7 +1652,7 @@ export function TableGrid({
15921652 }
15931653
15941654 const handleMove = ( e : MouseEvent ) => {
1595- if ( ! isDraggingRef . current ) return
1655+ if ( ! isDraggingRef . current && ! isRowDraggingRef . current ) return
15961656 pointerX = e . clientX
15971657 pointerY = e . clientY
15981658 if ( rafId === null ) rafId = requestAnimationFrame ( tick )
@@ -1614,7 +1674,7 @@ export function TableGrid({
16141674 document . removeEventListener ( 'mouseup' , handleStop )
16151675 handleStop ( )
16161676 }
1617- } , [ ] )
1677+ } , [ extendRowDragTo ] )
16181678
16191679 useEffect ( ( ) => {
16201680 // Skip during transient empty-rows state (initial load of a new sort/filter
@@ -3525,6 +3585,8 @@ export function TableGrid({
35253585 onCellMouseEnter = { handleCellMouseEnter }
35263586 isRowChecked = { rowSelectionIncludes ( rowSelection , row . id ) }
35273587 onRowToggle = { handleRowToggle }
3588+ onRowMouseDown = { handleRowMouseDown }
3589+ onRowMouseEnter = { handleRowMouseEnter }
35283590 runningCount = { runningByRowId [ row . id ] ?? 0 }
35293591 hasWorkflowColumns = { hasWorkflowColumns }
35303592 numDivWidth = { numDivWidth }
0 commit comments