Skip to content

feat(AnalyticalTable): add scaleWidthModeOptions column option #5188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ describe('AnalyticalTable', () => {
cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 700);

cy.findByText('Custom maxWidth').click();
cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 5008);
cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 3824);
});

it('Column Scaling: programatically change cols', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,13 @@ const styles = {
},
hiddenSmartColMeasure: {
visibility: 'hidden',
position: 'absolute',
position: 'fixed',
whiteSpace: 'nowrap',
height: 0
},
hiddenSmartColMeasureHeader: {
fontFamily: CustomThemingParameters.AnalyticalTableHeaderFontFamily
},
hiddenA11yText: {
display: 'none'
},
Expand Down
64 changes: 64 additions & 0 deletions packages/main/src/components/AnalyticalTable/Recipes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,68 @@ Please note that the internal react-table is resetting its hidden state after hi
/>
```

## How to scale custom cells?

Scaling of custom cells is not supported when used with `scaleWidthMode: AnalyticalTableScaleWidthMode.Grow` or `scaleWidthMode: AnalyticalTableScaleWidthMode.Smart`, as we're using the text of the cell for our calculation.
Since v1.22.0 you can use the column option `scaleWidthModeOptions` to pass a string for the internal width calculation of the header cell (`headerString`) and the body cells (`cellString`).

```jsx
const columns = [
// This column is automatically scaled because the header and body cells consists of text content only.
{ Header: 'Text Content', accessor: 'name' },
{
accessor: 'age',
// The header isn't included in the calculation of the column width, because it contains a custom component.
Header: () => (
<FlexBox direction={FlexBoxDirection.Column}>
<Text wrapping={false}>Long header text in a vertical layout</Text>
<Link>Click me!</Link>
</FlexBox>
),
scaleWidthModeOptions: {
headerString: 'Long header text in a vertical layout'
}
},
{
id: '1',
// The body cells aren't included in the calculation of the column width, because they contain a custom component.
Header: 'Custom cell',
Cell: () => (
<FlexBox direction={FlexBoxDirection.Column}>
<Text wrapping={false}>Long body cell text in a vertical layout</Text>
<Link>Click me!</Link>
</FlexBox>
),
scaleWidthModeOptions: {
cellString: 'Long body cell text in a vertical layout'
}
},
{
id: '2',
// Neither the header nor the body cells are included in the calculation of the column width,
// because they contain a custom component.
Header: () => (
<FlexBox direction={FlexBoxDirection.Column}>
<Text wrapping={false}>Long header text in a vertical layout</Text>
<Link>Click me!</Link>
</FlexBox>
),
Cell: () => (
<FlexBox direction={FlexBoxDirection.Column}>
<Text wrapping={false}>Long body cell text in a vertical layout</Text>
<Link>Click me!</Link>
</FlexBox>
),
scaleWidthModeOptions: {
headerString: 'Long header text in a vertical layout',
cellString: 'Long body cell text in a vertical layout'
}
}
];
```

```jsx
<AnalyticalTable data={props.data} columns={columns} scaleWidthMode={AnalyticalTableScaleWidthMode.Smart} />
```

<Footer />
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,40 @@ import { DEFAULT_COLUMN_WIDTH } from '../defaults/Column/index.js';
import type { AnalyticalTableColumnDefinition } from '../index.js';

const ROW_SAMPLE_SIZE = 20;
const DEFAULT_HEADER_NUM_CHAR = 10;
const MAX_WIDTH = 700;
const CELL_PADDING_PX = 18; /* padding left and right 0.5rem each (16px) + borders (1px) + buffer (1px) */

// a function, which approximates header px sizes given a character length
const approximateHeaderPxFromCharLength = (charLength) =>
charLength < 15 ? Math.sqrt(charLength * 1500) : 8 * charLength;
const approximateContentPxFromCharLength = (charLength) => 8 * charLength;
function findLongestString(str1, str2) {
if (typeof str1 !== 'string' || typeof str2 !== 'string') {
return str1 || str2 || undefined;
}

return str1.length > str2.length ? str1 : str2;
}

function getContentPxAvg(rowSample, columnIdOrAccessor, uniqueId) {
return (
rowSample.reduce((acc, item) => {
const dataPoint = item.values?.[columnIdOrAccessor];

let val = 0;
if (dataPoint) {
val = stringToPx(dataPoint, uniqueId) + CELL_PADDING_PX;
}
return acc + val;
}, 0) / (rowSample.length || 1)
);
}

function stringToPx(dataPoint, id, isHeader = false) {
const elementId = isHeader ? 'scaleModeHelperHeader' : 'scaleModeHelper';
const ruler = document.getElementById(`${elementId}-${id}`);
if (ruler) {
ruler.innerHTML = `${dataPoint}`;
return ruler.scrollWidth;
}
return 0;
}

const columnsDeps = (
deps,
Expand Down Expand Up @@ -52,14 +78,7 @@ interface IColumnMeta {
headerPx: number;
headerDefinesWidth?: boolean;
}
const stringToPx = (dataPoint, id) => {
const ruler = document.getElementById(`smartScaleModeHelper-${id}`);
if (ruler) {
ruler.innerHTML = `${dataPoint}`;
return ruler.offsetWidth;
}
return 0;
};

const smartColumns = (columns: AnalyticalTableColumnDefinition[], instance, hiddenColumns) => {
const { rows, state, webComponentsReactProperties } = instance;
const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);
Expand All @@ -84,22 +103,31 @@ const smartColumns = (columns: AnalyticalTableColumnDefinition[], instance, hidd
return metadata;
}

const contentPxAvg =
rowSample.reduce((acc, item) => {
const dataPoint = item.values?.[columnIdOrAccessor];
let val = 0;
if (dataPoint) {
val = stringToPx(dataPoint, webComponentsReactProperties.uniqueId) + CELL_PADDING_PX;
}
return acc + val;
}, 0) / (rowSample.length || 1);
let headerPx, contentPxAvg;

metadata[columnIdOrAccessor] = {
headerPx:
if (column.scaleWidthModeOptions?.cellString) {
contentPxAvg =
stringToPx(column.scaleWidthModeOptions.cellString, webComponentsReactProperties.uniqueId) + CELL_PADDING_PX;
} else {
contentPxAvg = getContentPxAvg(rowSample, columnIdOrAccessor, webComponentsReactProperties.uniqueId);
}

if (column.scaleWidthModeOptions?.headerString) {
headerPx = Math.max(
stringToPx(column.scaleWidthModeOptions.headerString, webComponentsReactProperties.uniqueId, true) +
CELL_PADDING_PX,
60
);
} else {
headerPx =
typeof column.Header === 'string'
? Math.max(stringToPx(column.Header, webComponentsReactProperties.uniqueId) + CELL_PADDING_PX, 60)
: 60,
contentPxAvg: contentPxAvg
? Math.max(stringToPx(column.Header, webComponentsReactProperties.uniqueId, true) + CELL_PADDING_PX, 60)
: 60;
}

metadata[columnIdOrAccessor] = {
headerPx,
contentPxAvg
};
return metadata;
},
Expand Down Expand Up @@ -142,9 +170,10 @@ const smartColumns = (columns: AnalyticalTableColumnDefinition[], instance, hidd
if (meta && !column.minWidth && !column.width && !meta.headerDefinesWidth) {
let targetWidth;
const { contentPxAvg, headerPx } = meta;

if (availableWidthPrio1 > 0) {
const factor = contentPxAvg / totalContentPxAvgPrio1;
targetWidth = Math.min(availableWidthPrio1 * factor, contentPxAvg);
targetWidth = Math.max(Math.min(availableWidthPrio1 * factor, contentPxAvg), headerPx);
availableWidthPrio2 -= targetWidth;
}
return {
Expand Down Expand Up @@ -183,7 +212,7 @@ const columns = (columns: AnalyticalTableColumnDefinition[], { instance }) => {
}
const { rows, state } = instance;
const { hiddenColumns, tableClientWidth: totalWidth } = state;
const { scaleWidthMode, loading } = instance.webComponentsReactProperties;
const { scaleWidthMode, loading, uniqueId } = instance.webComponentsReactProperties;

if (columns.length === 0 || !totalWidth || !AnalyticalTableScaleWidthMode[scaleWidthMode]) {
return columns;
Expand Down Expand Up @@ -296,52 +325,41 @@ const columns = (columns: AnalyticalTableColumnDefinition[], { instance }) => {
const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);

const columnMeta = visibleColumns.reduce((acc, column) => {
const columnIdOrAccessor = (column.id ?? column.accessor) as string;
if (
column.id === '__ui5wcr__internal_selection_column' ||
column.id === '__ui5wcr__internal_highlight_column' ||
column.id === '__ui5wcr__internal_navigation_column'
) {
acc[column.id ?? column.accessor] = {
acc[columnIdOrAccessor] = {
minHeaderWidth: column.width,
fullWidth: column.width,
contentCharAvg: 0
fullWidth: column.width
};
return acc;
}

const headerLength = typeof column.Header === 'string' ? column.Header.length : DEFAULT_HEADER_NUM_CHAR;

// max character length
const contentMaxCharLength = Math.max(
headerLength,
...rowSample.map((row) => {
const dataPoint = row.values?.[column.id ?? column.accessor];
if (dataPoint) {
if (typeof dataPoint === 'string') return dataPoint.length;
if (typeof dataPoint === 'number') return (dataPoint + '').length;
}
return 0;
})
const smartWidth = findLongestString(
column.scaleWidthModeOptions?.headerString,
column.scaleWidthModeOptions?.cellString
);

// avg character length
const contentCharAvg =
rowSample.reduce((acc, item) => {
const dataPoint = item.values?.[column.id ?? column.accessor];
let val = 0;
if (dataPoint) {
if (typeof dataPoint === 'string') val = dataPoint.length;
if (typeof dataPoint === 'number') val = (dataPoint + '').length;
}
return acc + val;
}, 0) / rowSample.length;
if (smartWidth) {
const width = Math.max(stringToPx(smartWidth, uniqueId) + CELL_PADDING_PX, 60);
acc[columnIdOrAccessor] = {
minHeaderWidth: width,
fullWidth: width
};
return acc;
}

const minHeaderWidth = approximateHeaderPxFromCharLength(headerLength);
const minHeaderWidth =
typeof column.Header === 'string'
? stringToPx(column.Header, uniqueId, true) + CELL_PADDING_PX
: DEFAULT_COLUMN_WIDTH;

acc[column.id ?? column.accessor] = {
acc[columnIdOrAccessor] = {
minHeaderWidth,
fullWidth: Math.max(minHeaderWidth, approximateContentPxFromCharLength(contentMaxCharLength)),
contentCharAvg
fullWidth: Math.max(minHeaderWidth, getContentPxAvg(rowSample, columnIdOrAccessor, uniqueId))
};
return acc;
}, {});
Expand All @@ -367,6 +385,7 @@ const columns = (columns: AnalyticalTableColumnDefinition[], { instance }) => {
return columns.map((column) => {
const isColumnVisible = (column.isVisible ?? true) && !hiddenColumns.includes(column.id ?? column.accessor);
const meta = columnMeta[column.id ?? (column.accessor as string)];

if (isColumnVisible && meta) {
const { minHeaderWidth } = meta;

Expand All @@ -378,7 +397,6 @@ const columns = (columns: AnalyticalTableColumnDefinition[], { instance }) => {
minWidth: column.minWidth ?? minHeaderWidth
};
}

return column;
});
}
Expand Down
46 changes: 44 additions & 2 deletions packages/main/src/components/AnalyticalTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ import { TitleBar } from './TitleBar/index.js';
import { getRowHeight, getSubRowsByString, tagNamesWhichShouldNotSelectARow } from './util/index.js';
import { VerticalResizer } from './VerticalResizer.js';

interface ScaleWidthModeOptions {
/**
* Defines the string used for internal width calculation of custom header cells (e.g. `Header: () => <Link>Click me!</Link>`).
*
* You can find out more about it [here](https://sap.github.io/ui5-webcomponents-react/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells).
*
* __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`.
*/
headerString?: string;
/**
* Defines the string used for internal width calculation of the longest cell inside the body of the table (e.g. `Cell: () => <Link>Click me!</Link>`).
*
* You can find out more about it [here](https://sap.github.io/ui5-webcomponents-react/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells).
*
* __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`.
*/
cellString?: string;
}

export interface AnalyticalTableColumnDefinition {
// base properties
/**
Expand Down Expand Up @@ -227,6 +246,16 @@ export interface AnalyticalTableColumnDefinition {
* Vertical alignment of the cell.
*/
vAlign?: VerticalAlign;
/**
* Allows passing a custom string for the internal width calculation of custom cells for `scaleWidthMode` `Grow` and `Smart`.
*
* You can find out more about it here [here](https://sap.github.io/ui5-webcomponents-react/?path=/docs/data-display-analyticaltable-recipes--docs#how-to-scale-custom-cells).
*
* __Note:__ This property has no effect when used with `AnalyticalTableScaleWidthMode.Default`.
*
* @since 1.22.0
*/
scaleWidthModeOptions: ScaleWidthModeOptions;

// usePopIn
/**
Expand Down Expand Up @@ -444,7 +473,7 @@ export interface AnalyticalTablePropTypes extends Omit<CommonProps, 'title'> {
*
* __Default:__ `"Default"`
*
* __Note:__ Custom cells with components instead of text as children are ignored by the `Smart` and `Grow` modes.
* __Note:__ Custom cells with components instead of text as children are ignored by the `Smart` and `Grow` modes. To support them you can use the `scaleWidthModeOptions` column option.
* __Note:__ For performance reasons, the `Smart` and `Grow` modes base their calculation for table cell width on a subset of column cells. If the first 20 cells of a column are significantly smaller than the rest of the column cells, the content may still not be fully displayed for all cells.
*
*/
Expand Down Expand Up @@ -1274,7 +1303,20 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
/>
)}
</div>
<Text aria-hidden="true" id={`smartScaleModeHelper-${uniqueId}`} className={classes.hiddenSmartColMeasure}>
<Text
aria-hidden="true"
id={`scaleModeHelper-${uniqueId}`}
className={classes.hiddenSmartColMeasure}
data-component-name="AnalyticalTableScaleModeHelper"
>
{''}
</Text>
<Text
aria-hidden="true"
id={`scaleModeHelperHeader-${uniqueId}`}
className={clsx(classes.hiddenSmartColMeasure, classes.hiddenSmartColMeasureHeader)}
data-component-name="AnalyticalTableScaleModeHelperHeader"
>
{''}
</Text>
</>
Expand Down