• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SAP / ui5-webcomponents-react / 14498335329

16 Apr 2025 05:02PM CUT coverage: 88.179% (+0.4%) from 87.749%
14498335329

Pull #7227

github

web-flow
Merge 1ce93d6c0 into 287c76ecd
Pull Request #7227: Translation Delivery

3012 of 3965 branches covered (75.96%)

5289 of 5998 relevant lines covered (88.18%)

104491.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.42
/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts
1
import { useMemo } from 'react';
2
import { AnalyticalTableScaleWidthMode } from '../../../enums/index.js';
3
import { DEFAULT_COLUMN_WIDTH } from '../defaults/Column/index.js';
4
import type { AnalyticalTableColumnDefinition, ReactTableHooks, TableInstance } from '../types/index.js';
5

6
interface IColumnMeta {
7
  contentPxAvg: number;
8
  headerPx: number;
9
  headerDefinesWidth?: boolean;
10
}
11

12
const ROW_SAMPLE_SIZE = 20;
444✔
13
const MAX_WIDTH = 700;
444✔
14
export const CELL_PADDING_PX = 18; /* padding left and right 0.5rem each (16px) + borders (1px) + buffer (1px) */
444✔
15

16
function findLongestString(str1, str2) {
17
  if (typeof str1 !== 'string' || typeof str2 !== 'string') {
246!
18
    return str1 || str2 || undefined;
246✔
19
  }
20

21
  return str1.length > str2.length ? str1 : str2;
×
22
}
23

24
function getContentPxAvg(rowSample, columnIdOrAccessor, uniqueId) {
25
  return (
246✔
26
    rowSample.reduce((acc, item) => {
27
      const dataPoint = item.values?.[columnIdOrAccessor];
984✔
28

29
      let val = 0;
984✔
30
      if (dataPoint) {
984✔
31
        val = stringToPx(dataPoint, uniqueId) + CELL_PADDING_PX;
984✔
32
      }
33
      return acc + val;
984✔
34
    }, 0) / (rowSample.length || 1)
246!
35
  );
36
}
37

38
function stringToPx(dataPoint, id, isHeader = false) {
984✔
39
  const elementId = isHeader ? 'scaleModeHelperHeader' : 'scaleModeHelper';
1,230✔
40
  const ruler = document.getElementById(`${elementId}-${id}`);
1,230✔
41
  if (ruler) {
1,230✔
42
    ruler.textContent = `${dataPoint}`;
1,230✔
43
    return ruler.scrollWidth;
1,230✔
44
  }
45
  return 0;
×
46
}
47

48
function calculateDefaultColumnWidths(tableWidth: number, columns: AnalyticalTableColumnDefinition[]) {
49
  // Columns w/ external width property
50
  const fixed = [];
15,882✔
51
  // Columns w/o external width property
52
  const dynamic = [];
15,882✔
53
  let fixedTotal = 0;
15,882✔
54

55
  // Separate fixed and dynamic columns
56
  for (const col of columns) {
15,882✔
57
    const minSize = col.minWidth ?? 0;
72,636✔
58
    const maxSize = col.maxWidth ?? Infinity;
72,636✔
59

60
    // External `width` defined
61
    if (col.width !== undefined) {
72,636✔
62
      let width = col.width;
15,992✔
63
      if (width < minSize) {
15,992!
64
        width = minSize;
×
65
      }
66
      if (width > maxSize) {
15,992✔
67
        width = maxSize;
22✔
68
      }
69
      fixedTotal += width;
15,992✔
70
      fixed.push({ col, width });
15,992✔
71
    } else {
72
      dynamic.push({ col, width: 0 });
56,644✔
73
    }
74
  }
75

76
  // Determine remaining width for dynamic columns
77
  const remaining = tableWidth - fixedTotal;
15,882✔
78

79
  // Calc total min-width required by dynamic columns
80
  let totalFlexibleMin = 0;
15,882✔
81
  for (const { col } of dynamic) {
15,882✔
82
    totalFlexibleMin += col.minWidth ?? 0;
56,644✔
83
  }
84

85
  if (remaining < totalFlexibleMin) {
15,882✔
86
    // Not enough space - assign each dynamic column its `minSize`
87
    for (const dc of dynamic) {
120✔
88
      dc.width = dc.col.minWidth ?? 0;
88✔
89
    }
90
  } else if (dynamic.length) {
15,762✔
91
    // Grant same space for each dynamic column
92
    const initialShare = remaining / dynamic.length;
14,322✔
93
    for (const dc of dynamic) {
14,322✔
94
      const minSize = dc.col.minWidth ?? 0;
56,556✔
95
      const maxSize = dc.col.maxWidth ?? Infinity;
56,556✔
96
      let width = initialShare;
56,556✔
97
      if (width < minSize) {
56,556✔
98
        width = minSize;
44✔
99
      }
100
      if (width > maxSize) {
56,556✔
101
        width = maxSize;
66✔
102
      }
103
      dc.width = width;
56,556✔
104
    }
105

106
    // Calc assigned width and remaining space
107
    let assigned = 0;
14,322✔
108
    for (const { width } of dynamic) {
14,322✔
109
      assigned += width ?? 0;
56,556!
110
    }
111

112
    /**
113
     * - negative: table overflows
114
     * - positive: table has white-space between last column and borderInlineEnd
115
     */
116
    let remainingSpace = remaining - assigned;
14,322✔
117

118
    // Grow or shrink columns that are still dynamic
119

120
    // Grow columns
121
    while (remainingSpace > 0) {
14,322✔
122
      let expandableCount = 0;
64✔
123
      for (const { col, width } of dynamic) {
64✔
124
        if (width < (col.maxWidth ?? Infinity)) {
2,308✔
125
          expandableCount++;
2,132✔
126
        }
127
      }
128
      if (expandableCount === 0) {
64!
129
        break;
×
130
      }
131
      const extra = remainingSpace / expandableCount;
64✔
132
      let used = 0;
64✔
133
      for (const dc of dynamic) {
64✔
134
        const maxWidth = dc.col.maxWidth ?? Infinity;
2,308✔
135
        if (dc.width < maxWidth) {
2,308✔
136
          const potential = dc.width + extra;
2,132✔
137
          if (potential > maxWidth) {
2,132✔
138
            used += maxWidth - dc.width;
44✔
139
            dc.width = maxWidth;
44✔
140
          } else {
141
            dc.width = potential;
2,088✔
142
            used += extra;
2,088✔
143
          }
144
        }
145
      }
146
      remainingSpace -= used;
64✔
147
      if (used === 0) {
64!
148
        break;
×
149
      }
150
    }
151

152
    // Shrink columns
153
    while (remainingSpace < 0) {
14,322✔
154
      let shrinkableCount = 0;
7,280✔
155
      for (const { col, width } of dynamic) {
7,280✔
156
        const min = col.minWidth ?? 0;
93,828✔
157
        if (width > min) {
93,828✔
158
          shrinkableCount++;
93,756✔
159
        }
160
      }
161
      if (shrinkableCount === 0) {
7,280!
162
        break;
×
163
      }
164
      const reduction = Math.abs(remainingSpace) / shrinkableCount;
7,280✔
165
      let used = 0;
7,280✔
166
      for (const dc of dynamic) {
7,280✔
167
        const min = dc.col.minWidth ?? 0;
93,828✔
168
        if (dc.width > min) {
93,828✔
169
          const potential = dc.width - reduction;
93,756✔
170
          if (potential < min) {
93,756✔
171
            used += dc.width - min;
14✔
172
            dc.width = min;
14✔
173
          } else {
174
            dc.width = potential;
93,742✔
175
            used += reduction;
93,742✔
176
          }
177
        }
178
      }
179
      remainingSpace += used;
7,280✔
180
      if (used === 0) {
7,280✔
181
        break;
322✔
182
      }
183
    }
184
  }
185

186
  const result = {};
15,882✔
187
  for (const { col, width } of [...fixed, ...dynamic]) {
15,882✔
188
    const key = col.id ?? col.accessor;
72,636✔
189
    result[key] = width;
72,636✔
190
  }
191
  return result;
15,882✔
192
}
193

194
const calculateSmartColumns = (columns: AnalyticalTableColumnDefinition[], instance, hiddenColumns) => {
444✔
195
  const { rows, state, webComponentsReactProperties } = instance;
×
196
  const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);
×
197
  const { tableClientWidth: totalWidth } = state;
×
198

199
  const visibleColumns = columns.filter(
×
200
    (column) => (column.isVisible ?? true) && !hiddenColumns.includes(column.id ?? column.accessor)
×
201
  );
202

203
  const columnMeta: Record<string, IColumnMeta> = visibleColumns.reduce(
×
204
    (metadata: Record<string, IColumnMeta>, column) => {
205
      const columnIdOrAccessor = (column.id ?? column.accessor) as string;
×
206
      if (
×
207
        column.id === '__ui5wcr__internal_selection_column' ||
×
208
        column.id === '__ui5wcr__internal_highlight_column' ||
209
        column.id === '__ui5wcr__internal_navigation_column'
210
      ) {
211
        metadata[columnIdOrAccessor] = {
×
212
          headerPx: column.width || column.minWidth || 60,
×
213
          contentPxAvg: 0
214
        };
215
        return metadata;
×
216
      }
217

218
      let headerPx, contentPxAvg;
219

220
      if (column.scaleWidthModeOptions?.cellString) {
×
221
        contentPxAvg =
×
222
          stringToPx(column.scaleWidthModeOptions.cellString, webComponentsReactProperties.uniqueId) + CELL_PADDING_PX;
223
      } else {
224
        contentPxAvg = getContentPxAvg(rowSample, columnIdOrAccessor, webComponentsReactProperties.uniqueId);
×
225
      }
226

227
      if (column.scaleWidthModeOptions?.headerString) {
×
228
        headerPx = Math.max(
×
229
          stringToPx(column.scaleWidthModeOptions.headerString, webComponentsReactProperties.uniqueId, true) +
230
            CELL_PADDING_PX,
231
          60
232
        );
233
      } else {
234
        headerPx =
×
235
          typeof column.Header === 'string'
×
236
            ? Math.max(stringToPx(column.Header, webComponentsReactProperties.uniqueId, true) + CELL_PADDING_PX, 60)
237
            : 60;
238
      }
239

240
      metadata[columnIdOrAccessor] = {
×
241
        headerPx,
242
        contentPxAvg
243
      };
244
      return metadata;
×
245
    },
246
    {}
247
  );
248

249
  let totalContentPxAvgPrio1 = 0;
×
250
  let totalNumberColPrio2 = 0;
×
251

252
  // width reserved by predefined widths or columns defined by header
253
  const reservedWidth: number = visibleColumns.reduce((acc, column) => {
×
254
    const columnIdOrAccessor = (column.id ?? column.accessor) as string;
×
255
    const { contentPxAvg, headerPx } = columnMeta[columnIdOrAccessor];
×
256

257
    if (contentPxAvg > headerPx) {
×
258
      if (!column.minWidth && !column.width) {
×
259
        totalContentPxAvgPrio1 += columnMeta[columnIdOrAccessor].contentPxAvg;
×
260
        totalNumberColPrio2++;
×
261
        return acc;
×
262
      } else {
263
        return acc + Math.max(column.minWidth || 0, column.width || 0);
×
264
      }
265
    } else {
266
      if (!column.minWidth && !column.width) {
×
267
        totalNumberColPrio2++;
×
268
      }
269
      const max = Math.max(column.minWidth || 0, column.width || 0, headerPx);
×
270
      columnMeta[columnIdOrAccessor].headerDefinesWidth = true;
×
271
      return acc + max;
×
272
    }
273
  }, 0);
274

275
  const availableWidthPrio1 = totalWidth - reservedWidth;
×
276
  let availableWidthPrio2 = availableWidthPrio1;
×
277

278
  // Step 1: Give columns defined by content more space (priority 1)
279
  const visibleColumnsAdaptedPrio1 = visibleColumns.map((column) => {
×
280
    const columnIdOrAccessor = (column.id ?? column.accessor) as string;
×
281
    const meta = columnMeta[columnIdOrAccessor];
×
282
    if (meta && !column.minWidth && !column.width && !meta.headerDefinesWidth) {
×
283
      let targetWidth;
284
      const { contentPxAvg, headerPx } = meta;
×
285

286
      if (availableWidthPrio1 > 0) {
×
287
        const factor = contentPxAvg / totalContentPxAvgPrio1;
×
288
        targetWidth = Math.max(Math.min(availableWidthPrio1 * factor, contentPxAvg), headerPx);
×
289
        availableWidthPrio2 -= targetWidth;
×
290
      }
291
      return {
×
292
        ...column,
293
        nextWidth: targetWidth || headerPx
×
294
      };
295
    }
296
    return column;
×
297
  });
298
  // Step 2: Give all columns more space (priority 2)
299
  return visibleColumnsAdaptedPrio1.map((column) => {
×
300
    const columnIdOrAccessor = (column.id ?? column.accessor) as string;
×
301
    const meta = columnMeta[columnIdOrAccessor];
×
302
    const { headerPx } = meta;
×
303
    if (meta && !column.minWidth && !column.width) {
×
304
      let targetWidth = column.nextWidth || headerPx;
×
305
      if (availableWidthPrio2 > 0) {
×
306
        targetWidth = targetWidth + availableWidthPrio2 * (1 / totalNumberColPrio2);
×
307
      }
308
      return {
×
309
        ...column,
310
        width: targetWidth
311
      };
312
    } else {
313
      return {
×
314
        ...column,
315
        width: Math.max(column.width || 0, 60, headerPx)
×
316
      };
317
    }
318
  });
319
};
320

321
const columnsDeps = (
444✔
322
  deps,
323
  { instance: { state, webComponentsReactProperties, visibleColumns, data, rows, columns } }
324
) => {
325
  const isLoadingPlaceholder = !data?.length && webComponentsReactProperties.loading;
98,999✔
326
  const hasRows = rows?.length > 0;
98,999✔
327
  // eslint-disable-next-line react-hooks/rules-of-hooks
328
  const colsEqual = useMemo(() => {
98,999✔
329
    return visibleColumns
25,266✔
330
      ?.filter(
331
        (col) =>
332
          col.id !== '__ui5wcr__internal_selection_column' &&
85,929✔
333
          col.id !== '__ui5wcr__internal_highlight_column' &&
334
          col.id !== '__ui5wcr__internal_navigation_column'
335
      )
336
      .every((visCol) => {
337
        const id = visCol.id ?? visCol.accessor;
79,165!
338
        return columns.some((item) => {
79,165✔
339
          return item.accessor === id || item.id === id;
1,202,121✔
340
        });
341
      });
342
  }, [visibleColumns, columns]);
343

344
  return [
98,999✔
345
    ...deps,
346
    hasRows,
347
    colsEqual,
348
    visibleColumns?.length,
349
    !state.tableColResized && state.tableClientWidth,
197,998✔
350
    state.hiddenColumns.length,
351
    webComponentsReactProperties.scaleWidthMode,
352
    isLoadingPlaceholder
353
  ];
354
};
355

356
const columns = (columns: TableInstance['columns'], { instance }: { instance: TableInstance }) => {
444✔
357
  if (!instance.state || !instance.rows) {
27,223✔
358
    return columns;
7,290✔
359
  }
360
  const { rows, state } = instance;
19,933✔
361
  const { hiddenColumns, tableClientWidth: totalWidth } = state;
19,933✔
362
  const { scaleWidthMode, loading, uniqueId } = instance.webComponentsReactProperties;
19,933✔
363

364
  if (columns.length === 0 || !totalWidth || !AnalyticalTableScaleWidthMode[scaleWidthMode]) {
19,933✔
365
    return columns;
3,805✔
366
  }
367

368
  // map columns to visibleColumns
369
  const visibleColumns = instance.visibleColumns
16,128✔
370
    .map((visCol) => {
371
      const column = columns.find((col) => {
73,682✔
372
        return (
861,753✔
373
          col.id === visCol.id || (col.accessor !== undefined && visCol.id !== undefined && col.accessor === visCol.id)
2,988,978✔
374
        );
375
      });
376
      if (column) {
73,682✔
377
        return column;
72,882✔
378
      }
379
      return column ?? false;
800✔
380
    })
381
    .filter(Boolean) as TableInstance['columns'];
382
  if (scaleWidthMode === AnalyticalTableScaleWidthMode.Smart) {
16,128!
383
    return calculateSmartColumns(columns, instance, hiddenColumns);
×
384
  }
385

386
  const hasData = instance.data.length > 0;
16,128✔
387

388
  if (scaleWidthMode === AnalyticalTableScaleWidthMode.Default || (!hasData && loading)) {
16,128!
389
    const calculatedWidths = calculateDefaultColumnWidths(totalWidth, visibleColumns);
15,882✔
390
    return columns.map((column) => {
15,882✔
391
      const calculatedWidth = calculatedWidths[column.id] || calculatedWidths[column.accessor];
79,403✔
392
      if (typeof calculatedWidth !== 'number') {
79,403✔
393
        console.warn('Could not determine column width!');
6,767✔
394
        return column;
6,767✔
395
      }
396
      return { ...column, width: calculatedWidth };
72,636✔
397
    });
398
  }
399

400
  // AnalyticalTableScaleWidthMode.Grow
401

402
  const rowSample = rows.slice(0, ROW_SAMPLE_SIZE);
246✔
403

404
  const columnMeta = visibleColumns.reduce((acc, column) => {
246✔
405
    const columnIdOrAccessor = (column.id ?? column.accessor) as string;
246✔
406
    if (
246!
407
      column.id === '__ui5wcr__internal_selection_column' ||
738✔
408
      column.id === '__ui5wcr__internal_highlight_column' ||
409
      column.id === '__ui5wcr__internal_navigation_column'
410
    ) {
411
      acc[columnIdOrAccessor] = {
×
412
        minHeaderWidth: column.width,
413
        fullWidth: column.width
414
      };
415
      return acc;
×
416
    }
417

418
    const smartWidth = findLongestString(
246✔
419
      column.scaleWidthModeOptions?.headerString,
420
      column.scaleWidthModeOptions?.cellString
421
    );
422

423
    if (smartWidth) {
246!
424
      const width = Math.max(stringToPx(smartWidth, uniqueId) + CELL_PADDING_PX, 60);
×
425
      acc[columnIdOrAccessor] = {
×
426
        minHeaderWidth: width,
427
        fullWidth: width
428
      };
429
      return acc;
×
430
    }
431

432
    const minHeaderWidth =
433
      typeof column.Header === 'string'
246!
434
        ? stringToPx(column.Header, uniqueId, true) + CELL_PADDING_PX
435
        : DEFAULT_COLUMN_WIDTH;
436

437
    acc[columnIdOrAccessor] = {
246✔
438
      minHeaderWidth,
439
      fullWidth: Math.max(minHeaderWidth, getContentPxAvg(rowSample, columnIdOrAccessor, uniqueId))
440
    };
441
    return acc;
246✔
442
  }, {});
443

444
  let reservedWidth = visibleColumns.reduce((acc, column) => {
246✔
445
    const { minHeaderWidth, fullWidth } = columnMeta[column.id ?? column.accessor];
246✔
446
    return acc + Math.max(column.minWidth || 0, column.width || 0, minHeaderWidth || 0, fullWidth) || 0;
246!
447
  }, 0);
448

449
  let availableWidth = totalWidth - reservedWidth;
246✔
450

451
  if (availableWidth > 0) {
246✔
452
    let notReservedCount = 0;
164✔
453
    reservedWidth = visibleColumns.reduce((acc, column) => {
164✔
454
      const reserved = Math.max(column.minWidth || 0, column.width || 0) || 0;
164✔
455
      if (!reserved) {
164✔
456
        notReservedCount++;
164✔
457
      }
458
      return acc + reserved;
164✔
459
    }, 0);
460
    availableWidth = totalWidth - reservedWidth;
164✔
461

462
    return columns.map((column) => {
164✔
463
      const isColumnVisible = (column.isVisible ?? true) && !hiddenColumns.includes(column.id ?? column.accessor);
164✔
464
      const meta = columnMeta[column.id ?? (column.accessor as string)];
164✔
465

466
      if (isColumnVisible && meta) {
164✔
467
        const { minHeaderWidth } = meta;
164✔
468

469
        const targetWidth = availableWidth / notReservedCount;
164✔
470

471
        return {
164✔
472
          ...column,
473
          width: column.width ?? Math.min(targetWidth, MAX_WIDTH),
328✔
474
          minWidth: column.minWidth ?? minHeaderWidth
328✔
475
        };
476
      }
477
      return column;
×
478
    });
479
  }
480

481
  return columns.map((column) => {
82✔
482
    const isColumnVisible = (column.isVisible ?? true) && !hiddenColumns.includes(column.id ?? column.accessor);
82✔
483
    const meta = columnMeta[column.id ?? (column.accessor as string)];
82✔
484
    if (isColumnVisible && meta) {
82✔
485
      const { fullWidth } = meta;
82✔
486
      return {
82✔
487
        ...column,
488
        width: column.width ?? fullWidth,
164✔
489
        maxWidth: column.maxWidth ?? MAX_WIDTH
82!
490
      };
491
    }
492
    return column;
×
493
  });
494
};
495

496
export const useDynamicColumnWidths = (hooks: ReactTableHooks) => {
444✔
497
  hooks.columns.push(columns);
98,999✔
498
  hooks.columnsDeps.push(columnsDeps);
98,999✔
499
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc