Skip to content

Commit 88a98ce

Browse files
authored
feat(AnalyticalTable): add experimental feature to determine column widths based on content (#295)
1 parent b592b68 commit 88a98ce

File tree

11 files changed

+420
-261
lines changed

11 files changed

+420
-261
lines changed

packages/main/src/components/AnalyticalTable/__snapshots__/AnalyticalTable.test.tsx.snap

Lines changed: 208 additions & 208 deletions
Large diffs are not rendered by default.

packages/main/src/components/AnalyticalTable/demo/demo.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TextAlign } from '@ui5/webcomponents-react/lib/TextAlign';
77
import { Title } from '@ui5/webcomponents-react/lib/Title';
88
import React from 'react';
99
import generateData from './generateData';
10+
import { TableScaleWidthMode } from '../../../enums/TableScaleWidthMode';
1011

1112
const columns = [
1213
{
@@ -25,7 +26,7 @@ const columns = [
2526
accessor: 'friend.name'
2627
},
2728
{
28-
Header: () => <span>Friend Age</span>, // Custom header components!
29+
Header: () => <span>Friend Age</span>,
2930
accessor: 'friend.age',
3031
hAlign: TextAlign.End,
3132
filter: (rows, accessor, filterValue) => {
@@ -76,12 +77,13 @@ export const defaultTable = () => {
7677
TableSelectionMode,
7778
TableSelectionMode.SINGLE_SELECT
7879
)}
80+
scaleWidthMode={select<TableScaleWidthMode>('scaleWidthMode', TableScaleWidthMode, TableScaleWidthMode.Default)}
7981
onRowSelected={action('onRowSelected')}
8082
onSort={action('onSort')}
8183
onGroup={action('onGroup')}
8284
onRowExpandChange={action('onRowExpandChange')}
8385
groupBy={array('groupBy', [])}
84-
rowHeight={number('rowHeight', 60)}
86+
rowHeight={number('rowHeight', 44)}
8587
selectedRowIds={object('selectedRowIds', { 3: true })}
8688
onColumnsReordered={action('onColumnsReordered')}
8789
/>

packages/main/src/components/AnalyticalTable/demo/generateData.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ const makeTreeEntry = () => ({
259259

260260
const makeEntry = () => ({
261261
name: getRandomName(),
262+
longColumn: 'Really really long column content... don´t crop please',
262263
age: getRandomNumber(18, 65),
263264
friend: {
264265
name: getRandomName(),
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const useColumnsDependencies = (hooks) => {
2+
hooks.columnsDeps.push((deps, { instance: { state, webComponentsReactProperties } }) => {
3+
return [state.tableClientWidth, state.hiddenColumns, webComponentsReactProperties.scaleWidthMode];
4+
});
5+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { DEFAULT_COLUMN_WIDTH } from '../defaults/Column';
2+
import { TableScaleWidthMode } from '@ui5/webcomponents-react/lib/TableScaleWidthMode';
3+
4+
const ROW_SAMPLE_SIZE = 20;
5+
const DEFAULT_HEADER_NUM_CHAR = 10;
6+
const MAX_WIDTH = 700;
7+
8+
// a function, which approximates header px sizes given a character length
9+
const approximateHeaderPxFromCharLength = (charLength) =>
10+
charLength < 15 ? Math.sqrt(charLength * 1500) : 8 * charLength;
11+
const approximateContentPxFromCharLength = (charLength) => 8 * charLength;
12+
13+
export const useDynamicColumnWidths = (hooks) => {
14+
hooks.columns.push((columns, { instance }) => {
15+
if(!instance.state || !instance.rows) {
16+
return columns;
17+
}
18+
19+
const { rows, state } = instance;
20+
21+
const { hiddenColumns, tableClientWidth: totalWidth } = state;
22+
const { scaleWidthMode } = instance.webComponentsReactProperties;
23+
24+
const visibleColumns = columns.filter(Boolean).filter((item) => {
25+
return (item.isVisible ?? true) && !hiddenColumns.includes(item.accessor);
26+
});
27+
28+
const calculateDefaultTableWidth = () => {
29+
const columnsWithFixedWidth = visibleColumns.filter(({ width }) => width ?? false).map(({ width }) => width);
30+
const fixedWidth = columnsWithFixedWidth.reduce((acc, val) => acc + val, 0);
31+
32+
const defaultColumnsCount = visibleColumns.length - columnsWithFixedWidth.length;
33+
34+
//check if columns are visible and table has width
35+
if (visibleColumns.length > 0 && totalWidth > 0) {
36+
//set fixedWidth as defaultWidth if visible columns have fixed value
37+
if (visibleColumns.length === columnsWithFixedWidth.length) {
38+
return fixedWidth / visibleColumns.length;
39+
}
40+
//spread default columns
41+
if (totalWidth >= fixedWidth + defaultColumnsCount * DEFAULT_COLUMN_WIDTH) {
42+
return (totalWidth - fixedWidth) / defaultColumnsCount;
43+
} else {
44+
//set defaultWidth for default columns if table is overflowing
45+
return DEFAULT_COLUMN_WIDTH;
46+
}
47+
} else {
48+
return DEFAULT_COLUMN_WIDTH;
49+
}
50+
};
51+
52+
if (columns.length === 0 || !totalWidth) return columns;
53+
54+
if (scaleWidthMode === TableScaleWidthMode.Default) {
55+
const defaultWidth = calculateDefaultTableWidth();
56+
return columns.map((column) => ({ ...column, width: column.width ?? defaultWidth }));
57+
}
58+
59+
const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);
60+
61+
const columnMeta = visibleColumns.reduce((acc, column) => {
62+
const headerLength = typeof column.Header === 'string' ? column.Header.length : DEFAULT_HEADER_NUM_CHAR;
63+
64+
// max character length
65+
const contentMaxCharLength = Math.max(
66+
headerLength,
67+
...rowSample.map((row) => {
68+
const dataPoint = row.values?.[column.accessor];
69+
if (dataPoint) {
70+
if (typeof dataPoint === 'string') return dataPoint.length;
71+
if (typeof dataPoint === 'number') return (dataPoint + '').length;
72+
}
73+
return 0;
74+
})
75+
);
76+
77+
// avg character length
78+
const contentCharAvg =
79+
rowSample.reduce((acc, item) => {
80+
const dataPoint = item.values?.[column.accessor];
81+
let val = 0;
82+
if (dataPoint) {
83+
if (typeof dataPoint === 'string') val = dataPoint.length;
84+
if (typeof dataPoint === 'number') val = (dataPoint + '').length;
85+
}
86+
return acc + val;
87+
}, 0) / rowSample.length;
88+
89+
const minHeaderWidth = approximateHeaderPxFromCharLength(headerLength);
90+
91+
acc[column.accessor] = {
92+
minHeaderWidth,
93+
fullWidth: Math.max(minHeaderWidth, approximateContentPxFromCharLength(contentMaxCharLength)),
94+
contentCharAvg
95+
};
96+
return acc;
97+
}, {});
98+
99+
const totalCharNum = Object.values(columnMeta).reduce(
100+
(acc: number, item: any) => acc + item.contentCharAvg,
101+
0
102+
) as number;
103+
104+
let reservedWidth = visibleColumns.reduce((acc, column) => {
105+
const { minHeaderWidth, fullWidth } = columnMeta[column.accessor];
106+
return (
107+
acc +
108+
Math.max(
109+
column.minWidth || 0,
110+
column.width || 0,
111+
minHeaderWidth || 0,
112+
scaleWidthMode === TableScaleWidthMode.Grow ? fullWidth : 0
113+
) || 0
114+
);
115+
}, 0);
116+
117+
let availableWidth = totalWidth - reservedWidth;
118+
119+
if (scaleWidthMode === TableScaleWidthMode.Smart || availableWidth > 0) {
120+
if (scaleWidthMode === TableScaleWidthMode.Grow) {
121+
reservedWidth = visibleColumns.reduce((acc, column) => {
122+
const { minHeaderWidth } = columnMeta[column.accessor];
123+
return acc + Math.max(column.minWidth || 0, column.width || 0, minHeaderWidth || 0) || 0;
124+
}, 0);
125+
availableWidth = totalWidth - reservedWidth;
126+
}
127+
128+
return columns.map((column) => {
129+
const isColumnVisible = (column.isVisible ?? true) && !hiddenColumns.includes(column.accessor);
130+
if (totalCharNum > 0 && isColumnVisible) {
131+
const { minHeaderWidth, contentCharAvg } = columnMeta[column.accessor];
132+
const targetWidth = (contentCharAvg / totalCharNum) * availableWidth + minHeaderWidth;
133+
134+
return {
135+
...column,
136+
width: column.width ?? targetWidth,
137+
minWidth: column.minWidth ?? minHeaderWidth
138+
};
139+
}
140+
141+
return column;
142+
});
143+
}
144+
145+
// TableScaleWidthMode Grow
146+
return columns.map((column) => {
147+
const isColumnVisible = (column.isVisible ?? true) && !hiddenColumns.includes(column.accessor);
148+
if (isColumnVisible) {
149+
const { fullWidth } = columnMeta[column.accessor];
150+
return {
151+
...column,
152+
width: column.width ?? fullWidth,
153+
maxWidth: MAX_WIDTH
154+
};
155+
}
156+
return column;
157+
});
158+
});
159+
};

packages/main/src/components/AnalyticalTable/hooks/useTableRowStyling.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TableSelectionMode } from '@ui5/webcomponents-react/lib/TableSelectionM
44
const ROW_SELECTION_ATTRIBUTE = 'data-is-selected';
55

66
export const useTableRowStyling = (hooks) => {
7+
78
hooks.getRowProps.push((passedRowProps, { instance, row }) => {
89
const { classes, selectionMode, onRowSelected } = instance.webComponentsReactProperties;
910
const isEmptyRow = row.original?.emptyRow;

packages/main/src/components/AnalyticalTable/index.tsx

Lines changed: 31 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Device } from '@ui5/webcomponents-react-base/lib/Device';
21
import { Event } from '@ui5/webcomponents-react-base/lib/Event';
32
import { StyleClassHelper } from '@ui5/webcomponents-react-base/lib/StyleClassHelper';
43
import { usePassThroughHtmlProps } from '@ui5/webcomponents-react-base/lib/usePassThroughHtmlProps';
@@ -16,8 +15,7 @@ import React, {
1615
useCallback,
1716
useEffect,
1817
useMemo,
19-
useRef,
20-
useState
18+
useRef
2119
} from 'react';
2220
import { createUseStyles } from 'react-jss';
2321
import {
@@ -37,7 +35,7 @@ import { CommonProps } from '../../interfaces/CommonProps';
3735
import { JSSTheme } from '../../interfaces/JSSTheme';
3836
import styles from './AnayticalTable.jss';
3937
import { ColumnHeader } from './ColumnHeader';
40-
import { DEFAULT_COLUMN_WIDTH, DefaultColumn } from './defaults/Column';
38+
import { DefaultColumn } from './defaults/Column';
4139
import { DefaultLoadingComponent } from './defaults/LoadingComponent';
4240
import { TablePlaceholder } from './defaults/LoadingComponent/TablePlaceholder';
4341
import { DefaultNoDataComponent } from './defaults/NoDataComponent';
@@ -52,6 +50,9 @@ import { useToggleRowExpand } from './hooks/useToggleRowExpand';
5250
import { stateReducer } from './tableReducer/stateReducer';
5351
import { TitleBar } from './TitleBar';
5452
import { VirtualTableBody } from './virtualization/VirtualTableBody';
53+
import { useDynamicColumnWidths } from './hooks/useDynamicColumnWidths';
54+
import { TableScaleWidthMode } from '../../enums/TableScaleWidthMode';
55+
import {useColumnsDependencies} from "./hooks/useColumnsDependencies";
5556

5657
export interface ColumnConfiguration extends Column {
5758
accessor?: string;
@@ -100,6 +101,7 @@ export interface TableProps extends CommonProps {
100101
groupable?: boolean;
101102
groupBy?: string[];
102103
selectionMode?: TableSelectionMode;
104+
scaleWidthMode?: TableScaleWidthMode;
103105
columnOrder?: object[];
104106

105107
// events
@@ -157,7 +159,8 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
157159
minRows,
158160
isTreeTable,
159161
alternateRowColor,
160-
overscanCount
162+
overscanCount,
163+
scaleWidthMode
161164
} = props;
162165

163166
const classes = useStyles({ rowHeight: props.rowHeight });
@@ -167,18 +170,6 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
167170

168171
const getSubRows = useCallback((row) => row[subRowsKey] || [], [subRowsKey]);
169172

170-
const [columnWidth, setColumnWidth] = useState(null);
171-
172-
const defaultColumn = useMemo(() => {
173-
if (columnWidth) {
174-
return {
175-
width: columnWidth,
176-
...DefaultColumn
177-
};
178-
}
179-
return DefaultColumn;
180-
}, [columnWidth]);
181-
182173
const data = useMemo(() => {
183174
if (minRows > props.data.length) {
184175
const missingRows = minRows - props.data.length;
@@ -190,6 +181,7 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
190181
return props.data;
191182
}, [props.data, minRows]);
192183

184+
193185
const {
194186
getTableProps,
195187
headerGroups,
@@ -204,15 +196,17 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
204196
{
205197
columns,
206198
data,
207-
defaultColumn,
199+
defaultColumn: DefaultColumn,
208200
getSubRows,
209201
stateReducer,
210202
webComponentsReactProperties: {
211203
selectionMode,
212204
classes,
213205
onRowSelected,
214206
onRowExpandChange,
215-
isTreeTable
207+
isTreeTable,
208+
// tableClientWidth,
209+
scaleWidthMode
216210
},
217211
...reactTableOptions
218212
},
@@ -228,45 +222,30 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
228222
useTableHeaderGroupStyling,
229223
useTableHeaderStyling,
230224
useTableRowStyling,
225+
useDynamicColumnWidths,
226+
useColumnsDependencies,
231227
useTableCellStyling,
232228
useToggleRowExpand,
233229
...tableHooks
234230
);
235231

236-
const updateTableSizes = useCallback(() => {
237-
const visibleColumns = columns.filter(Boolean).filter((item) => {
238-
return (item.isVisible ?? true) && !tableState.hiddenColumns.includes(item.accessor);
239-
});
240-
const columnsWithFixedWidth = visibleColumns.filter(({ width }) => width ?? false).map(({ width }) => width);
241-
const fixedWidth = columnsWithFixedWidth.reduce((acc, val) => acc + val, 0);
242-
243-
const tableClientWidth = tableRef.current.clientWidth;
244-
const defaultColumnsCount = visibleColumns.length - columnsWithFixedWidth.length;
245-
246-
//check if columns are visible and table has width
247-
if (visibleColumns.length > 0 && tableClientWidth > 0) {
248-
//set fixedWidth as defaultWidth if visible columns have fixed value
249-
if (visibleColumns.length === columnsWithFixedWidth.length) {
250-
setColumnWidth(fixedWidth / visibleColumns.length);
251-
return;
232+
const updateTableClientWidth = useCallback(() => {
233+
requestAnimationFrame(() => {
234+
if (tableRef.current) {
235+
dispatch({ type: 'TABLE_RESIZE', payload: { tableClientWidth: tableRef.current.clientWidth }});
252236
}
253-
//spread default columns
254-
if (tableClientWidth >= fixedWidth + defaultColumnsCount * DEFAULT_COLUMN_WIDTH) {
255-
setColumnWidth((tableClientWidth - fixedWidth) / defaultColumnsCount);
256-
} else {
257-
//set defaultWidth for default columns if table is overflowing
258-
setColumnWidth(DEFAULT_COLUMN_WIDTH);
259-
}
260-
} else {
261-
setColumnWidth(DEFAULT_COLUMN_WIDTH);
262-
}
263-
}, [tableRef.current, columns, tableState.hiddenColumns, DEFAULT_COLUMN_WIDTH]);
237+
});
238+
}, []);
239+
240+
// @ts-ignore
241+
const tableWidthObserver = useRef(new ResizeObserver(updateTableClientWidth));
264242

265243
useEffect(() => {
266-
updateTableSizes();
267-
Device.resize.attachHandler(updateTableSizes, null);
268-
return () => Device.resize.detachHandler(updateTableSizes, null);
269-
}, [updateTableSizes]);
244+
tableWidthObserver.current.observe(tableRef.current);
245+
return () => {
246+
tableWidthObserver.current.disconnect();
247+
};
248+
}, [tableWidthObserver.current, tableRef.current]);
270249

271250
useEffect(() => {
272251
dispatch({ type: 'SET_GROUP_BY', payload: groupBy });
@@ -347,7 +326,7 @@ const AnalyticalTable: FC<TableProps> = forwardRef((props: TableProps, ref: Ref<
347326
{title && <TitleBar>{title}</TitleBar>}
348327
{typeof renderExtension === 'function' && <div>{renderExtension()}</div>}
349328
<div className={tableContainerClasses.valueOf()} ref={tableRef}>
350-
{columnWidth && (
329+
{(
351330
<div {...getTableProps()} role="table" aria-rowcount={rows.length}>
352331
{headerGroups.map((headerGroup) => {
353332
let headerProps = {};
@@ -430,6 +409,7 @@ AnalyticalTable.defaultProps = {
430409
filterable: false,
431410
groupable: false,
432411
selectionMode: TableSelectionMode.NONE,
412+
scaleWidthMode: TableScaleWidthMode.Default,
433413
data: [],
434414
columns: [],
435415
title: null,

0 commit comments

Comments
 (0)