@ui5/webcomponents-react
Version:
React Wrapper for UI5 Web Components and additional components
558 lines (537 loc) • 19.5 kB
JavaScript
import { AnalyticalTableScaleWidthMode } from '../../../enums/AnalyticalTableScaleWidthMode.js';
import { ensurePluginOrder } from '../react-table/index.js';
import { getSelectionColumnWidth } from '../util/index.js';
const FALLBACK_FONT_SIZE = 14;
const ROW_SAMPLE_SIZE = 20;
const MAX_WIDTH = 700;
export const CELL_PADDING_PX = 18; /* padding left and right 0.5rem each (16px) + borders (1px) + buffer (1px) */
let measurementCanvas = null;
let measurementContext = null;
function getComputedCSSVarValue(variableName, fallback) {
if (typeof document === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement).getPropertyValue(variableName);
return value.trim() || fallback;
}
/**
* Convert fontSize string to number. Only handles `rem` and `px` values as `em` is not by design.
*/
function toPx(fontSize, rootFontSize) {
if (fontSize.endsWith('rem')) {
const rem = parseFloat(fontSize);
return rem * rootFontSize;
}
if (fontSize.endsWith('px')) {
return parseFloat(fontSize);
}
if (fontSize.endsWith('em')) {
return FALLBACK_FONT_SIZE;
}
return parseFloat(fontSize) || FALLBACK_FONT_SIZE;
}
function stringToPx(dataPoint, isHeader = false, computedCSSValues) {
if (!measurementCanvas) {
measurementCanvas = document.createElement('canvas');
measurementContext = measurementCanvas.getContext('2d');
}
if (!measurementContext) {
return FALLBACK_FONT_SIZE * (isHeader ? 0.55 : 0.5) * dataPoint.length;
}
const {
bodyFontFamily,
bodyFontSize,
headerFontFamily,
rootFontSize
} = computedCSSValues;
const fontFamily = isHeader ? headerFontFamily : bodyFontFamily;
const fontSizePx = toPx(bodyFontSize, rootFontSize);
measurementContext.font = `${fontSizePx}px ${fontFamily}`;
return Math.ceil(measurementContext.measureText(dataPoint).width);
}
function getContentPxLongest(rowSample, columnIdOrAccessor, computedCSSValues) {
return rowSample.reduce((max, item) => {
const dataPoint = item.values?.[columnIdOrAccessor];
if (dataPoint) {
const val = stringToPx(dataPoint, false, computedCSSValues) + CELL_PADDING_PX;
return Math.max(max, val);
}
return max;
}, 0);
}
function getContentPxAvg(rowSample, columnIdOrAccessor, computedCSSValues) {
return rowSample.reduce((acc, item) => {
const dataPoint = item.values?.[columnIdOrAccessor];
let val = 0;
if (dataPoint) {
val = stringToPx(dataPoint, false, computedCSSValues) + CELL_PADDING_PX;
}
return acc + val;
}, 0) / (rowSample.length || 1);
}
function calculateDefaultColumnWidths(tableWidth, columns) {
// Columns w/ external width property
const fixed = [];
// Columns w/o external width property
const dynamic = [];
let fixedTotal = 0;
// Separate fixed and dynamic columns
for (const col of columns) {
const minSize = col.minWidth ?? 0;
const maxSize = col.maxWidth ?? Infinity;
// External `width` defined
if (col.width !== undefined) {
let width = col.width;
if (width < minSize) {
width = minSize;
}
if (width > maxSize) {
width = maxSize;
}
fixedTotal += width;
fixed.push({
col,
width
});
} else {
dynamic.push({
col,
width: 0
});
}
}
// Determine remaining width for dynamic columns
const remaining = tableWidth - fixedTotal;
// Calc total min-width required by dynamic columns
let totalFlexibleMin = 0;
for (const {
col
} of dynamic) {
totalFlexibleMin += col.minWidth ?? 0;
}
if (remaining < totalFlexibleMin) {
// Not enough space - assign each dynamic column its `minSize`
for (const dc of dynamic) {
dc.width = dc.col.minWidth ?? 0;
}
} else if (dynamic.length) {
// Grant same space for each dynamic column
const initialShare = remaining / dynamic.length;
for (const dc of dynamic) {
const minSize = dc.col.minWidth ?? 0;
const maxSize = dc.col.maxWidth ?? Infinity;
let width = initialShare;
if (width < minSize) {
width = minSize;
}
if (width > maxSize) {
width = maxSize;
}
dc.width = width;
}
// Calc assigned width and remaining space
let assigned = 0;
for (const {
width
} of dynamic) {
assigned += width ?? 0;
}
/**
* - negative: table overflows
* - positive: table has white-space between last column and borderInlineEnd
*/
let remainingSpace = remaining - assigned;
// Grow or shrink columns that are still dynamic
// Grow columns
while (remainingSpace > 0) {
let expandableCount = 0;
for (const {
col,
width
} of dynamic) {
if (width < (col.maxWidth ?? Infinity)) {
expandableCount++;
}
}
if (expandableCount === 0) {
break;
}
const extra = remainingSpace / expandableCount;
let used = 0;
for (const dc of dynamic) {
const maxWidth = dc.col.maxWidth ?? Infinity;
if (dc.width < maxWidth) {
const potential = dc.width + extra;
if (potential > maxWidth) {
used += maxWidth - dc.width;
dc.width = maxWidth;
} else {
dc.width = potential;
used += extra;
}
}
}
remainingSpace -= used;
if (used === 0) {
break;
}
}
// Shrink columns
while (remainingSpace < 0) {
let shrinkableCount = 0;
for (const {
col,
width
} of dynamic) {
const min = col.minWidth ?? 0;
if (width > min) {
shrinkableCount++;
}
}
if (shrinkableCount === 0) {
break;
}
const reduction = Math.abs(remainingSpace) / shrinkableCount;
let used = 0;
for (const dc of dynamic) {
const min = dc.col.minWidth ?? 0;
if (dc.width > min) {
const potential = dc.width - reduction;
if (potential < min) {
used += dc.width - min;
dc.width = min;
} else {
dc.width = potential;
used += reduction;
}
}
}
remainingSpace += used;
if (used === 0) {
break;
}
}
}
const result = {};
for (const {
col,
width
} of [...fixed, ...dynamic]) {
const key = col.id ?? col.accessor;
result[key] = width;
}
return result;
}
const calculateSmartAndGrowColumns = (columns, instance, hiddenColumns, computedCSSValues, isGrow = false) => {
const {
rows,
state
} = instance;
const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);
const {
tableClientWidth: totalWidth
} = state;
const visibleColumns = columns.filter(column => (column.isVisible ?? true) && !hiddenColumns.includes(column.id ?? column.accessor));
const columnMeta = visibleColumns.reduce((metadata, column) => {
const columnIdOrAccessor = column.id ?? column.accessor;
if (column.id === '__ui5wcr__internal_selection_column' || column.id === '__ui5wcr__internal_highlight_column' || column.id === '__ui5wcr__internal_navigation_column') {
metadata[columnIdOrAccessor] = {
headerPx: column.width || column.minWidth || 60,
contentPxAvg: 0
};
return metadata;
}
let headerPx, contentPxLength;
if (column.scaleWidthModeOptions?.cellString) {
contentPxLength = stringToPx(column.scaleWidthModeOptions.cellString, false, computedCSSValues) + CELL_PADDING_PX;
} else {
contentPxLength = isGrow ? getContentPxLongest(rowSample, columnIdOrAccessor, computedCSSValues) : getContentPxAvg(rowSample, columnIdOrAccessor, computedCSSValues);
}
if (column.scaleWidthModeOptions?.headerString) {
headerPx = Math.max(stringToPx(column.scaleWidthModeOptions.headerString, true, computedCSSValues) + CELL_PADDING_PX, 60);
} else {
headerPx = typeof column.Header === 'string' ? Math.max(stringToPx(column.Header, true, computedCSSValues) + CELL_PADDING_PX, 60) : 60;
}
metadata[columnIdOrAccessor] = {
headerPx,
contentPxAvg: contentPxLength,
// When Grow mode is active, static max width should be applied
maxWidth: column.maxWidth ?? (isGrow ? MAX_WIDTH : undefined),
width: column.width
};
return metadata;
}, {});
let totalContentPxAvgPrio1 = 0;
let totalNumberColPrio2 = 0;
/**
* Width reserved by predefined widths or columns defined by header
* Grow: full content width or header width (if wider) if not restricted by maxWidth
*/
const reservedWidth = visibleColumns.reduce((acc, column) => {
const columnIdOrAccessor = column.id ?? column.accessor;
const {
contentPxAvg,
headerPx
} = columnMeta[columnIdOrAccessor];
if (isGrow) {
totalContentPxAvgPrio1 += columnMeta[columnIdOrAccessor].contentPxAvg;
const targetWidth = Math.min(Math.max(column.minWidth ?? 0, column.width ?? Math.max(contentPxAvg, headerPx)), column.maxWidth ?? MAX_WIDTH);
if (targetWidth !== column.maxWidth) {
totalNumberColPrio2++;
}
return acc + targetWidth;
}
if (contentPxAvg > headerPx) {
if (!column.minWidth && !column.width) {
totalContentPxAvgPrio1 += columnMeta[columnIdOrAccessor].contentPxAvg;
totalNumberColPrio2++;
return acc;
} else {
return acc + Math.max(column.minWidth || 0, column.width || 0);
}
} else {
if (!column.minWidth && !column.width) {
totalNumberColPrio2++;
}
const max = Math.max(column.minWidth || 0, column.width ?? headerPx);
columnMeta[columnIdOrAccessor].headerDefinesWidth = true;
return acc + max;
}
}, 0);
const availableWidthPrio1 = totalWidth - reservedWidth;
let availableWidthPrio2 = availableWidthPrio1;
/**
* Step 1: Give columns defined by content more space (priority 1)
* Grow: Give all columns the required space necessary to display the full content (up to the maxWidth)
*/
const visibleColumnsAdaptedPrio1 = visibleColumns.map(column => {
const columnIdOrAccessor = column.id ?? column.accessor;
const meta = columnMeta[columnIdOrAccessor];
if (meta && !column.minWidth && !column.width && !meta.headerDefinesWidth) {
let targetWidth;
const {
contentPxAvg,
headerPx
} = meta;
if (isGrow) {
targetWidth = Math.min(Math.max(contentPxAvg, headerPx), meta.maxWidth);
} else if (availableWidthPrio1 > 0) {
const factor = contentPxAvg / totalContentPxAvgPrio1;
targetWidth = Math.max(Math.min(availableWidthPrio1 * factor, contentPxAvg), headerPx);
availableWidthPrio2 -= targetWidth;
}
return {
...column,
nextWidth: targetWidth || headerPx,
maxWidth: isGrow ? column?.maxWidth ?? MAX_WIDTH : Infinity
};
}
return column;
});
let fullWidthOfAllColumns = 0;
let lessThanMaxWidthCount = 0;
/**
* Step 2: Give all columns more space (priority 2)
*/
const visibleColumnsAdaptedPrio2 = visibleColumnsAdaptedPrio1.map(column => {
const columnIdOrAccessor = column.id ?? column.accessor;
const meta = columnMeta[columnIdOrAccessor];
const {
headerPx
} = meta;
if (meta && !column.minWidth && !column.width) {
let targetWidth = column.nextWidth || headerPx;
if (availableWidthPrio2 > 0) {
targetWidth = Math.min(targetWidth + availableWidthPrio2 * (1 / totalNumberColPrio2), column.maxWidth ?? Infinity);
if (targetWidth < (column.maxWidth ?? Infinity)) {
lessThanMaxWidthCount++;
}
}
fullWidthOfAllColumns += targetWidth;
return {
...column,
width: targetWidth
};
} else {
const targetWidth = Math.max(column.width > 0 ? column.width : headerPx, column.minWidth ?? 0);
if (targetWidth < (column.maxWidth ?? Infinity) && !meta.width) {
lessThanMaxWidthCount++;
}
fullWidthOfAllColumns += targetWidth;
return {
...column,
width: targetWidth
};
}
});
// Step 3: Distribute remaining width to all columns that are not at their maxWidth
let remainingWidth = totalWidth - fullWidthOfAllColumns;
if (remainingWidth > 0) {
return visibleColumnsAdaptedPrio2.map(column => {
const columnIdOrAccessor = column.id ?? column.accessor;
const meta = columnMeta[columnIdOrAccessor];
if (column.width !== column.maxWidth && !meta.width) {
const proportionalRemainingWidth = remainingWidth / (lessThanMaxWidthCount || 1);
let proportionalWidth = column.width + proportionalRemainingWidth;
// if maxWidth is reached, distribute the remaining width to the other columns
if (proportionalWidth > column.maxWidth) {
proportionalWidth = column.maxWidth;
remainingWidth -= proportionalWidth - column.width;
if (lessThanMaxWidthCount >= 2) {
lessThanMaxWidthCount -= 1;
}
}
return {
...column,
width: proportionalWidth
};
}
return column;
});
}
return visibleColumnsAdaptedPrio2;
};
/**
* `hooks.columns` handler that captures original (pre-decorateColumn) column sizing.
*
* react-table mutates column objects in place, setting i.a. width properties with `defaultColumn` values.
* This function runs before decoration, saving the original values on the instance, so adjustColumnWidths can use them.
*/
function captureRawColumnWidths(columns, {
instance
}) {
const rawSizing = new Map();
for (const col of columns) {
const key = col.id ?? col.accessor;
if (key) {
rawSizing.set(key, {
// normalize null to undefined in case tableRef isn't ready yet
width: col.width ?? undefined,
minWidth: col.minWidth ?? undefined,
maxWidth: col.maxWidth ?? undefined
});
}
}
instance.rawColumnSizing = rawSizing;
return columns;
}
/**
* `useInstanceBeforeDimensions` hook that mutates header widths in-place.
*
* This function follows `react-table`'s own `useResizeColumns` pattern: mutate `header.width` directly, to prevent cascading updates and rerenders.
*/
const adjustColumnWidths = instance => {
const {
flatHeaders,
state,
rows,
data,
webComponentsReactProperties,
plugins
} = instance;
ensurePluginOrder(plugins, ['useColumnResizing'], 'useDynamicColumnWidths');
const {
scaleWidthMode,
loading,
fontsReady
} = webComponentsReactProperties;
const {
hiddenColumns,
tableClientWidth: totalWidth,
tableColResized,
columnResizing
} = state;
if (!state || !rows || flatHeaders.length === 0 || !totalWidth || !AnalyticalTableScaleWidthMode[scaleWidthMode] || !fontsReady || tableColResized || columnResizing?.isResizingColumn ||
// After user resize, `columnResizing.columnWidths` stores the resized column widths. To preserve the user's resize skip recalculation.
// The values are cleared by `TABLE_RESIZE` action (`retainColumnWidth` is `false` or `undefined`), or when columns change (`resetResize` action)
Object.keys(columnResizing?.columnWidths ?? {}).length > 0) {
return;
}
// raw column sizes - default or defined in column definitions
const rawSizing = instance.rawColumnSizing;
const {
tableRef
} = webComponentsReactProperties;
const selectionColumnWidth = rawSizing?.get('__ui5wcr__internal_selection_column')?.width === undefined ? getSelectionColumnWidth(tableRef) : undefined;
// Restore original width properties from rawSizing (dynamic - no defined width, fixed - defined width in column definitions),
// since `decorateColumn` has overwritten them
const visibleColDefs = flatHeaders.map(header => {
const raw = rawSizing?.get(header.id);
let width = raw?.width;
let minWidth = raw?.minWidth;
let maxWidth = raw?.maxWidth;
// `rawSizing` captured undefined for the selection column (tableRef wasn't ready). Resolve from CSS variable.
if (header.id === '__ui5wcr__internal_selection_column' && width === undefined && selectionColumnWidth) {
width = selectionColumnWidth;
minWidth = selectionColumnWidth;
maxWidth = selectionColumnWidth;
}
return {
...header,
width,
minWidth,
maxWidth
};
});
const hasData = data.length > 0;
if (scaleWidthMode === AnalyticalTableScaleWidthMode.Default || !hasData && loading) {
const calculatedWidths = calculateDefaultColumnWidths(totalWidth, visibleColDefs);
for (const header of flatHeaders) {
const calculatedWidth = calculatedWidths[header.id];
if (typeof calculatedWidth !== 'number') {
if (visibleColDefs.length === flatHeaders.length) {
console.warn('Could not determine column width!');
}
continue;
}
header.width = calculatedWidth;
// `useColumnResizing` falls back to `originalWidth` — keep it in sync to prevent reverting to 150.
header.originalWidth = calculatedWidth;
// patch columns whose raw minWidth/maxWidth was null
const raw = rawSizing?.get(header.id);
if (raw && raw.minWidth === undefined && header.minWidth == null) {
header.minWidth = calculatedWidth;
}
if (raw && raw.maxWidth === undefined && header.maxWidth == null) {
header.maxWidth = calculatedWidth;
}
}
} else if (scaleWidthMode === AnalyticalTableScaleWidthMode.Smart || scaleWidthMode === AnalyticalTableScaleWidthMode.Grow) {
const computedCSSValues = {
bodyFontFamily: getComputedCSSVarValue('--sapFontFamily', 'Arial, Helvetica, sans-serif'),
bodyFontSize: getComputedCSSVarValue('--sapFontSize', '0.875rem'),
headerFontFamily: getComputedCSSVarValue('--_ui5wcr-AnalyticalTable-HeaderFontFamily', getComputedCSSVarValue('--sapFontFamily', 'Arial, Helvetica, sans-serif')),
rootFontSize: parseFloat(getComputedStyle(document.documentElement).fontSize) || FALLBACK_FONT_SIZE
};
const isGrow = scaleWidthMode === AnalyticalTableScaleWidthMode.Grow;
const result = calculateSmartAndGrowColumns(visibleColDefs, instance, hiddenColumns, computedCSSValues, isGrow);
// apply calculated widths to headers by mutation
for (const calculated of result) {
const id = calculated.id ?? calculated.accessor;
const header = flatHeaders.find(h => h.id === id);
if (!header) {
console.warn('Could not determine column width!');
continue;
}
if (typeof calculated.width === 'number') {
header.width = calculated.width;
header.originalWidth = calculated.width;
}
if (isGrow && typeof calculated.maxWidth === 'number') {
header.maxWidth = calculated.maxWidth;
}
const raw = rawSizing?.get(id);
if (raw && raw.minWidth === undefined && header.minWidth == null) {
header.minWidth = calculated.width ?? 0;
}
if (raw && raw.maxWidth === undefined && header.maxWidth == null) {
header.maxWidth = calculated.maxWidth ?? calculated.width ?? Number.MAX_SAFE_INTEGER;
}
}
}
};
export const useDynamicColumnWidths = hooks => {
hooks.columns.push(captureRawColumnWidths);
hooks.useInstanceBeforeDimensions.push(adjustColumnWidths);
};
useDynamicColumnWidths.pluginName = 'useDynamicColumnWidths';