Skip to content

Commit 819d3e2

Browse files
authored
feat(AnalyticalTable): improve dynamic column width scaling performance (#8394)
#8392 must be merged first.
1 parent d6e03d5 commit 819d3e2

12 files changed

Lines changed: 581 additions & 175 deletions

File tree

REUSE.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ path = "packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts"
4444
precedence = "aggregate"
4545
SPDX-FileCopyrightText = "2019-2021 Tanner Linsley"
4646
SPDX-License-Identifier = "MIT"
47+
48+
[[annotations]]
49+
path = "packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts"
50+
precedence = "aggregate"
51+
SPDX-FileCopyrightText = "2019-2021 Tanner Linsley"
52+
SPDX-License-Identifier = "MIT"

packages/main/src/components/AnalyticalTable/AnalyticalTable.module.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
.th {
107107
composes: baseCellFocus;
108108

109+
position: relative;
109110
height: var(--_ui5wcr-AnalyticalTableHeaderRowHeight);
110111
color: var(--sapList_HeaderTextColor);
111112
background-color: var(--sapList_HeaderBackground);
@@ -478,7 +479,8 @@
478479
opacity: 0.5;
479480
}
480481

481-
&:active {
482+
&:active,
483+
&[data-active-resizer] {
482484
background-color: var(--sapContent_DragAndDropActiveColor);
483485
opacity: 1;
484486
}
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import type { MouseEvent, TouchEvent } from 'react';
2+
import { useCallback } from 'react';
3+
import { actions, defaultColumn, makePropGetter, useGetLatest, useMountedLayoutEffect } from 'react-table';
4+
import type { ColumnType, ReactTableHooks, TableInstance } from '../types/index.js';
5+
6+
// Default Column
7+
defaultColumn.canResize = true;
8+
9+
// Actions
10+
actions.columnStartResizing = 'columnStartResizing';
11+
actions.columnResizing = 'columnResizing';
12+
actions.columnDoneResizing = 'columnDoneResizing';
13+
actions.resetResize = 'resetResize';
14+
15+
/**
16+
* Computes the projected column width after a resize drag.
17+
*
18+
* Uses the same percentage-based formula as react-table's `columnResizing` reducer:
19+
* the drag delta is expressed as a percentage of the original column width, then
20+
* applied multiplicatively. RTL inverts the delta direction.
21+
*/
22+
export function getProjectedWidth(rawDeltaX: number, originalWidth: number, isRtl: boolean): number {
23+
const deltaX = isRtl ? -rawDeltaX : rawDeltaX;
24+
const percentageDeltaX = deltaX / originalWidth;
25+
return Math.max(originalWidth + originalWidth * percentageDeltaX, 0);
26+
}
27+
28+
/**
29+
* UI5WCR custom column resizing plugin — a fork of react-table v7's useResizeColumns hook.
30+
* Original source: https://github.com/TanStack/table/blob/v7/src/plugin-hooks/useResizeColumns.js
31+
*
32+
* This is a fork of react-table's `useResizeColumns` with the following changes:
33+
* - Deferred resize: CSS transform feedback during drag (zero renders), single state dispatch on mouseup
34+
* - RTL delta inversion inlined into the reducer (previously handled in `stateReducer.ts`)
35+
* - Removed `columnWidth` from reducer state destructuring — uses per-header width via `getProjectedWidth`
36+
* - Removed `ensurePluginOrder` check for `useAbsoluteLayout` (not used by AnalyticalTable)
37+
* - Removed `getLeafHeaders` (AnalyticalTable does not support grouped column headers)
38+
* - Moved `getHeaderProps` inline `position: relative` style to `.th` in `AnalyticalTable.module.css`
39+
* - Added `e.preventDefault()` on mousedown to prevent text selection in Firefox during drag
40+
* - Split mouse/touch into separate branches (touch needs `{ passive: false }` for `preventDefault()`)
41+
* - Added 3px dead zone and `data-active-resizer` attribute for double-click compatibility
42+
* - Removed `cursor: col-resize` style (AnalyticalTable provides its own resizer styling)
43+
* - Clamped resize width to `minWidth`/`maxWidth` in both the visual drag and the reducer (original clamps to 0)
44+
* - Fixed falsy `0` bug: replaced `||` with `??` in `useInstanceBeforeDimensions` width assignment
45+
*/
46+
export const useColumnResizing = (hooks: ReactTableHooks) => {
47+
// UI5WCR: replaced default RAF-throttled getResizerProps with deferred version
48+
hooks.getResizerProps = [deferredGetResizerProps];
49+
// UI5WCR: removed getHeaderProps push for `position: relative` — moved to CSS
50+
hooks.stateReducers.push(reducer);
51+
hooks.useInstance.push(useInstance);
52+
hooks.useInstanceBeforeDimensions.push(useInstanceBeforeDimensions);
53+
};
54+
55+
useColumnResizing.pluginName = 'useColumnResizing';
56+
57+
const deferredGetResizerProps: ReactTableHooks['getResizerProps'][0] = (props, { instance, header }) => {
58+
const { dispatch } = instance;
59+
60+
const startResize = (resizerElement: HTMLDivElement, startClientX: number, isTouchEvent: boolean) => {
61+
// UI5WCR: use header directly instead of getLeafHeaders (no grouped columns)
62+
const headerIdWidths = [[header.id, header.totalWidth, header.minWidth, header.maxWidth]];
63+
const columnWidth = header.totalWidth;
64+
const minWidth = header.minWidth;
65+
const maxWidth = header.maxWidth;
66+
const columnId = header.id;
67+
const isRtl = instance.state.isRtl;
68+
69+
dispatch({
70+
type: actions.columnStartResizing,
71+
columnId,
72+
columnWidth,
73+
headerIdWidths,
74+
clientX: startClientX,
75+
});
76+
77+
// UI5WCR: capture original transform so it can be restored on mouseup
78+
const originalTransform = resizerElement.style.transform;
79+
80+
// UI5WCR: data attribute replaces :active (which stops when cursor leaves the 5px resizer)
81+
resizerElement.dataset.activeResizer = '';
82+
let deltaX = 0;
83+
let isDragging = false;
84+
85+
const finishResize = () => {
86+
delete resizerElement.dataset.activeResizer;
87+
// UI5WCR: restore transform to keep resizer centered (prevents double-click detection issues)
88+
resizerElement.style.transform = originalTransform;
89+
90+
// UI5WCR: only dispatch columnResizing if user dragged past dead zone
91+
if (isDragging) {
92+
dispatch({ type: actions.columnResizing, clientX: startClientX + deltaX });
93+
}
94+
dispatch({ type: actions.columnDoneResizing });
95+
};
96+
97+
// UI5WCR: 3px dead zone prevents transform shifts during double-click sequences
98+
const applyDrag = (clientX: number) => {
99+
deltaX = clientX - startClientX;
100+
// UI5WCR: clamp so resizer can't be dragged past minWidth/maxWidth boundary
101+
if (isRtl) {
102+
deltaX = Math.max(deltaX, columnWidth - maxWidth);
103+
deltaX = Math.min(deltaX, columnWidth - minWidth);
104+
} else {
105+
deltaX = Math.max(deltaX, minWidth - columnWidth);
106+
deltaX = Math.min(deltaX, maxWidth - columnWidth);
107+
}
108+
if (!isDragging && Math.abs(deltaX) < 3) {
109+
return;
110+
}
111+
isDragging = true;
112+
resizerElement.style.transform = `${originalTransform} translateX(${deltaX}px)`;
113+
};
114+
115+
// UI5WCR: separate mouse/touch branches (touch needs { passive: false } for preventDefault)
116+
if (isTouchEvent) {
117+
const handleTouchMove = (moveEvent: globalThis.TouchEvent) => {
118+
if (moveEvent.cancelable) {
119+
moveEvent.preventDefault();
120+
moveEvent.stopPropagation();
121+
}
122+
applyDrag(moveEvent.touches[0].clientX);
123+
};
124+
125+
const handleTouchEnd = () => {
126+
document.removeEventListener('touchmove', handleTouchMove);
127+
document.removeEventListener('touchend', handleTouchEnd);
128+
finishResize();
129+
};
130+
131+
document.addEventListener('touchmove', handleTouchMove, { passive: false });
132+
document.addEventListener('touchend', handleTouchEnd, { passive: false });
133+
} else {
134+
const handleMouseMove = (moveEvent: globalThis.MouseEvent) => {
135+
applyDrag(moveEvent.clientX);
136+
};
137+
138+
const handleMouseUp = () => {
139+
document.removeEventListener('mousemove', handleMouseMove);
140+
document.removeEventListener('mouseup', handleMouseUp);
141+
finishResize();
142+
};
143+
144+
document.addEventListener('mousemove', handleMouseMove);
145+
document.addEventListener('mouseup', handleMouseUp);
146+
}
147+
};
148+
149+
return {
150+
...props,
151+
role: 'separator',
152+
draggable: false,
153+
'aria-hidden': 'true',
154+
onMouseDown: (e: MouseEvent<HTMLDivElement>) => {
155+
// UI5WCR: prevent text selection during drag in Firefox
156+
e.preventDefault();
157+
startResize(e.currentTarget, e.clientX, false);
158+
},
159+
onTouchStart: (e: TouchEvent<HTMLDivElement>) => {
160+
// lets not respond to multiple touches (e.g. 2 or 3 fingers)
161+
if (e.touches && e.touches.length > 1) {
162+
return;
163+
}
164+
startResize(e.currentTarget, Math.round(e.touches[0].clientX), true);
165+
},
166+
};
167+
};
168+
169+
const reducer: TableInstance['stateReducer'] = (state, action) => {
170+
if (action.type === actions.init) {
171+
return {
172+
columnResizing: {
173+
columnWidths: {},
174+
},
175+
...state,
176+
};
177+
}
178+
179+
if (action.type === actions.resetResize) {
180+
return {
181+
...state,
182+
columnResizing: {
183+
columnWidths: {},
184+
},
185+
};
186+
}
187+
188+
if (action.type === actions.columnStartResizing) {
189+
const { clientX, columnId, columnWidth, headerIdWidths } = action;
190+
191+
return {
192+
...state,
193+
columnResizing: {
194+
...state.columnResizing,
195+
startX: clientX,
196+
headerIdWidths,
197+
columnWidth,
198+
isResizingColumn: columnId,
199+
},
200+
};
201+
}
202+
203+
if (action.type === actions.columnResizing) {
204+
const { clientX } = action;
205+
// UI5WCR: removed `columnWidth` from destructuring — uses per-header width via getProjectedWidth
206+
const { startX, headerIdWidths = [] } = state.columnResizing;
207+
208+
// UI5WCR: RTL delta inversion inlined (previously in stateReducer.ts)
209+
const rawDeltaX = clientX - startX;
210+
211+
const newColumnWidths: Record<string, number> = {};
212+
213+
headerIdWidths.forEach(
214+
([headerId, headerWidth, headerMinWidth, headerMaxWidth]: [string, number, number, number]) => {
215+
// UI5WCR: clamp to minWidth/maxWidth (original only clamps to 0)
216+
const projected = getProjectedWidth(rawDeltaX, headerWidth, state.isRtl);
217+
newColumnWidths[headerId] = Math.min(Math.max(projected, headerMinWidth), headerMaxWidth);
218+
},
219+
);
220+
221+
return {
222+
...state,
223+
columnResizing: {
224+
...state.columnResizing,
225+
columnWidths: {
226+
...state.columnResizing.columnWidths,
227+
...newColumnWidths,
228+
},
229+
},
230+
};
231+
}
232+
233+
if (action.type === actions.columnDoneResizing) {
234+
return {
235+
...state,
236+
columnResizing: {
237+
...state.columnResizing,
238+
startX: null,
239+
isResizingColumn: null,
240+
},
241+
};
242+
}
243+
};
244+
245+
// Replaces react-table's internal `getFirstDefined` from `utils.js` (not publicly exported)
246+
function getFirstDefined<T>(...args: (T | undefined)[]): T | undefined {
247+
for (let i = 0; i < args.length; i += 1) {
248+
if (typeof args[i] !== 'undefined') {
249+
return args[i];
250+
}
251+
}
252+
}
253+
254+
const useInstanceBeforeDimensions = (instance: TableInstance) => {
255+
const {
256+
flatHeaders,
257+
disableResizing,
258+
getHooks,
259+
state: { columnResizing },
260+
} = instance;
261+
262+
const getInstance = useGetLatest(instance);
263+
264+
flatHeaders.forEach((header: ColumnType) => {
265+
const canResize = getFirstDefined(
266+
header.disableResizing === true ? false : undefined,
267+
disableResizing === true ? false : undefined,
268+
true,
269+
);
270+
271+
header.canResize = canResize;
272+
// UI5WCR: use ?? instead of || (original uses ||, which treats 0 as falsy)
273+
header.width = columnResizing.columnWidths[header.id] ?? header.originalWidth ?? header.width;
274+
header.isResizing = columnResizing.isResizingColumn === header.id;
275+
276+
if (canResize) {
277+
header.getResizerProps = makePropGetter(getHooks().getResizerProps, {
278+
instance: getInstance(),
279+
header,
280+
});
281+
}
282+
});
283+
};
284+
285+
function useInstance(instance: TableInstance) {
286+
const { dispatch, autoResetResize = true, columns } = instance;
287+
288+
const getAutoResetResize = useGetLatest(autoResetResize);
289+
useMountedLayoutEffect(() => {
290+
if (getAutoResetResize()) {
291+
dispatch({ type: actions.resetResize });
292+
}
293+
}, [columns]);
294+
295+
const resetResizing = useCallback(() => dispatch({ type: actions.resetResize }), [dispatch]);
296+
297+
Object.assign(instance, {
298+
resetResizing,
299+
});
300+
}

0 commit comments

Comments
 (0)