@drivenets/design-system 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/index.cjs +15850 -10821
  3. package/dist/index.d.cts +785 -239
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +785 -239
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15835 -10822
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.css +1 -1
  10. package/package.json +13 -13
  11. package/src/components/ds-bot-button/ds-bot-button.figma.ts +38 -0
  12. package/src/components/ds-bot-button/ds-bot-button.module.scss +41 -0
  13. package/src/components/ds-bot-button/ds-bot-button.tsx +48 -0
  14. package/src/components/ds-bot-button/ds-bot-button.types.ts +33 -0
  15. package/src/components/ds-bot-button/index.ts +2 -0
  16. package/src/components/ds-bot-button/net-gen-logo.tsx +46 -0
  17. package/src/components/ds-bulk-actions/components/action-button.tsx +42 -0
  18. package/src/components/ds-bulk-actions/components/menu-entries.tsx +76 -0
  19. package/src/components/ds-bulk-actions/components/overflow.tsx +75 -0
  20. package/src/components/ds-bulk-actions/ds-bulk-actions.figma.ts +28 -0
  21. package/src/components/ds-bulk-actions/ds-bulk-actions.module.scss +199 -0
  22. package/src/components/ds-bulk-actions/ds-bulk-actions.tsx +220 -0
  23. package/src/components/ds-bulk-actions/ds-bulk-actions.types.ts +170 -0
  24. package/src/components/ds-bulk-actions/ds-bulk-actions.unit.test.ts +195 -0
  25. package/src/components/ds-bulk-actions/ds-bulk-actions.utils.ts +91 -0
  26. package/src/components/ds-bulk-actions/ds-bulk-actions.utils.unit.test.ts +134 -0
  27. package/src/components/ds-bulk-actions/hooks/compute-bulk-actions-overflow.ts +85 -0
  28. package/src/components/ds-bulk-actions/hooks/use-bulk-actions-overflow.ts +153 -0
  29. package/src/components/ds-bulk-actions/index.ts +18 -0
  30. package/src/components/ds-button-v3/ds-button-v3.module.scss +2 -2
  31. package/src/components/ds-catalog-layout/ds-catalog-layout.figma.ts +1 -1
  32. package/src/components/ds-dropdown-menu/ds-dropdown-menu.tsx +2 -0
  33. package/src/components/ds-empty-state/ds-empty-state.module.scss +16 -0
  34. package/src/components/ds-empty-state/ds-empty-state.tsx +45 -0
  35. package/src/components/ds-empty-state/ds-empty-state.types.ts +38 -0
  36. package/src/components/ds-empty-state/index.ts +3 -0
  37. package/src/components/ds-file-upload/ds-file-upload.tsx +4 -1
  38. package/src/components/ds-form-control/ds-form-control.tsx +18 -10
  39. package/src/components/ds-illustration/ds-illustration.figma.ts +34 -0
  40. package/src/components/ds-illustration/ds-illustration.module.scss +6 -0
  41. package/src/components/ds-illustration/ds-illustration.tsx +29 -0
  42. package/src/components/ds-illustration/ds-illustration.types.ts +45 -0
  43. package/src/components/ds-illustration/illustrations/configuration.tsx +51 -0
  44. package/src/components/ds-illustration/illustrations/device.tsx +133 -0
  45. package/src/components/ds-illustration/illustrations/done.tsx +107 -0
  46. package/src/components/{ds-catalog-layout/catalog-layout-empty-illustration.tsx → ds-illustration/illustrations/empty-inbox.tsx} +10 -14
  47. package/src/components/ds-illustration/illustrations/error.tsx +86 -0
  48. package/src/components/ds-illustration/illustrations/go-filter.tsx +51 -0
  49. package/src/components/ds-illustration/illustrations/illustration-clouds.tsx +10 -0
  50. package/src/components/ds-illustration/illustrations/index.ts +39 -0
  51. package/src/components/ds-illustration/illustrations/no-connection.tsx +54 -0
  52. package/src/components/ds-illustration/illustrations/no-credit-card.tsx +48 -0
  53. package/src/components/ds-illustration/illustrations/no-device.tsx +61 -0
  54. package/src/components/ds-illustration/illustrations/no-documents.tsx +52 -0
  55. package/src/components/ds-illustration/illustrations/no-gps.tsx +57 -0
  56. package/src/components/ds-illustration/illustrations/no-images.tsx +82 -0
  57. package/src/components/ds-illustration/illustrations/no-items-cart.tsx +86 -0
  58. package/src/components/ds-illustration/illustrations/no-messages.tsx +51 -0
  59. package/src/components/ds-illustration/illustrations/no-search-result.tsx +76 -0
  60. package/src/components/ds-illustration/illustrations/no-tasks.tsx +61 -0
  61. package/src/components/ds-illustration/illustrations/site.tsx +80 -0
  62. package/src/components/ds-illustration/index.ts +3 -0
  63. package/src/components/ds-main-menu/components/expanded-item/expanded-item.tsx +108 -0
  64. package/src/components/ds-main-menu/components/expanded-item/expanded-item.types.ts +8 -0
  65. package/src/components/ds-main-menu/components/expanded-item/index.ts +1 -0
  66. package/src/components/ds-main-menu/components/menu-graphic/index.ts +1 -0
  67. package/src/components/ds-main-menu/components/menu-graphic/menu-graphic.tsx +16 -0
  68. package/src/components/ds-main-menu/components/menu-graphic/menu-graphic.types.ts +7 -0
  69. package/src/components/ds-main-menu/components/tile-link/index.ts +1 -0
  70. package/src/components/ds-main-menu/components/tile-link/tile-link.tsx +85 -0
  71. package/src/components/ds-main-menu/components/tile-link/tile-link.types.ts +8 -0
  72. package/src/components/ds-main-menu/components/utility-link/index.ts +1 -0
  73. package/src/components/ds-main-menu/components/utility-link/utility-link.tsx +37 -0
  74. package/src/components/ds-main-menu/components/utility-link/utility-link.types.ts +7 -0
  75. package/src/components/ds-main-menu/ds-main-menu.figma.ts +17 -0
  76. package/src/components/ds-main-menu/ds-main-menu.module.scss +349 -0
  77. package/src/components/ds-main-menu/ds-main-menu.tsx +102 -0
  78. package/src/components/ds-main-menu/ds-main-menu.types.ts +86 -0
  79. package/src/components/ds-main-menu/ds-main-menu.utils.ts +40 -0
  80. package/src/components/ds-main-menu/index.ts +9 -0
  81. package/src/components/ds-modal/figma/ds-modal-footer.figma.ts +1 -1
  82. package/src/components/ds-popover/ds-popover.tsx +2 -1
  83. package/src/components/ds-popover/ds-popover.types.ts +7 -0
  84. package/src/components/ds-site-node/ds-site-node.tsx +2 -2
  85. package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.module.scss +12 -2
  86. package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +2 -3
  87. package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.module.scss +1 -6
  88. package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.tsx +6 -3
  89. package/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss +18 -7
  90. package/src/components/ds-table/components/ds-table-header/ds-table-header.tsx +27 -21
  91. package/src/components/ds-table/components/ds-table-header-label.module.scss +12 -0
  92. package/src/components/ds-table/components/ds-table-header-label.tsx +28 -0
  93. package/src/components/ds-table/components/ds-table-row/ds-table-row.module.scss +0 -1
  94. package/src/components/ds-table/context/ds-table-context.tsx +8 -0
  95. package/src/components/ds-table/ds-table.module.scss +26 -16
  96. package/src/components/ds-table/ds-table.tsx +46 -14
  97. package/src/components/ds-table/ds-table.types.ts +33 -3
  98. package/src/components/ds-table/hooks/use-column-resize.ts +114 -11
  99. package/src/components/ds-table/hooks/use-scrollbar-spacer.ts +108 -0
  100. package/src/components/ds-table/hooks/use-scrollbar-spacer.unit.test.ts +26 -0
  101. package/src/components/ds-table/styles/_variables.scss +0 -2
  102. package/src/components/ds-table/utils/column-size.ts +274 -1
  103. package/src/components/ds-table/utils/column-size.unit.test.ts +157 -0
  104. package/src/components/ds-table/utils/constants.ts +13 -5
  105. package/src/components/ds-table/utils/table-columns.tsx +27 -13
  106. package/src/components/ds-table/utils/table-columns.unit.test.ts +44 -0
  107. package/src/components/ds-tag-filter/ds-tag-filter.module.scss +7 -35
  108. package/src/components/ds-tag-filter/ds-tag-filter.tsx +34 -57
  109. package/src/components/ds-tag-filter/ds-tag-filter.types.ts +2 -1
  110. package/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts +13 -18
  111. package/src/components/ds-toggle/ds-toggle.module.scss +18 -0
  112. package/src/components/ds-toggle/ds-toggle.tsx +15 -13
  113. package/src/components/ds-tooltip/ds-tooltip.tsx +6 -2
  114. package/src/components/ds-tooltip/ds-tooltip.types.ts +17 -0
  115. package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.figma.ts +35 -0
  116. package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.module.scss +150 -0
  117. package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.tsx +161 -0
  118. package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.types.ts +117 -0
  119. package/src/components/ds-top-bar-navigation/index.ts +2 -0
  120. package/src/index.ts +6 -0
  121. package/src/utils/use-is-horizontally-overflowing.ts +35 -0
  122. package/src/components/ds-site-node/hooks/use-is-overflowing.ts +0 -31
  123. package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.module.scss +0 -112
  124. package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.tsx +0 -67
  125. package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.types.ts +0 -41
  126. package/src/components/ds-table/components/ds-table-bulk-actions/index.ts +0 -2
@@ -19,12 +19,19 @@ import {
19
19
  collectMissingLeafSizing,
20
20
  getColumnSizeCssVars,
21
21
  getResizeOriginSize,
22
+ growFillLeavesToContainer,
23
+ isExplicitColumnWidth,
22
24
  measureLeafHeaderWidths,
23
25
  omitBuiltinColumnSizing,
26
+ shiftColumnTrack,
27
+ withUtilityColumnSizing,
24
28
  type ColumnSizeBoundsSource,
25
29
  } from '../utils/column-size';
26
- import { RESIZE_MIN_COLUMN_WIDTH } from '../utils/constants';
30
+ import { RESIZE_DIVIDER_WIDTH, RESIZE_MIN_COLUMN_WIDTH } from '../utils/constants';
27
31
  import type { DsTableResizePhase } from '../context/ds-table-context';
32
+ import { useScrollbarSpacer, type ScrollbarSpacerWidth } from './use-scrollbar-spacer';
33
+
34
+ const TRACK_MATCH_PX = 3;
28
35
 
29
36
  const INITIAL_COLUMN_SIZING_INFO: ColumnSizingInfoState = {
30
37
  startOffset: null,
@@ -35,6 +42,14 @@ const INITIAL_COLUMN_SIZING_INFO: ColumnSizingInfoState = {
35
42
  columnSizingStart: [],
36
43
  };
37
44
 
45
+ const clampResizeOverlayOffset = (offset: number, tableWidth: number): number => {
46
+ if (tableWidth <= RESIZE_DIVIDER_WIDTH) {
47
+ return offset;
48
+ }
49
+
50
+ return Math.min(offset, tableWidth - RESIZE_DIVIDER_WIDTH);
51
+ };
52
+
38
53
  export interface UseColumnResizeOptions {
39
54
  enabled: boolean;
40
55
  /**
@@ -49,6 +64,15 @@ export interface UseColumnResizeOptions {
49
64
  */
50
65
  columns: unknown;
51
66
  columnVisibility: VisibilityState;
67
+ /**
68
+ * Invalidates overflow measurement (row count). Read as a dependency only.
69
+ */
70
+ overflowKey: number;
71
+ /**
72
+ * Authored widths for injected utility leaves. Overlaid last so they win
73
+ * over persist seed and live internal state.
74
+ */
75
+ utilityColumnSizing?: ColumnSizingState;
52
76
  }
53
77
 
54
78
  export interface DsTableResizeContextSlice {
@@ -64,6 +88,10 @@ export interface DsTableResizeContextSlice {
64
88
  * the persist callback, mirroring drag end.
65
89
  */
66
90
  onResizeKeyboardNudge: (columnId: string, delta: number) => void;
91
+ /**
92
+ * Overflow-only **Scrollbar spacer** width (0 or rest scrollbar size).
93
+ */
94
+ scrollbarSpacerWidth: ScrollbarSpacerWidth;
67
95
  }
68
96
 
69
97
  export interface UseColumnResizeActiveResize {
@@ -92,6 +120,7 @@ export interface UseColumnResizeResult {
92
120
  columnSizingInfo: ColumnSizingInfoState;
93
121
  };
94
122
  bind: <TData>(table: Table<TData>) => UseColumnResizeBindResult;
123
+ scrollbarSpacerWidth: ScrollbarSpacerWidth;
95
124
  }
96
125
 
97
126
  /**
@@ -108,6 +137,8 @@ export const useColumnResize = ({
108
137
  onColumnSizingChange,
109
138
  columns,
110
139
  columnVisibility,
140
+ overflowKey,
141
+ utilityColumnSizing,
111
142
  }: UseColumnResizeOptions): UseColumnResizeResult => {
112
143
  const [columnResizeState, setColumnResizeState] = useState<{
113
144
  columnSizing: ColumnSizingState;
@@ -125,10 +156,11 @@ export const useColumnResize = ({
125
156
  // drag (or seed measurement) of a persisted column still takes effect. Passed
126
157
  // to TanStack and read for CSS vars / persist so restore, drag, and persist
127
158
  // all see the same widths.
128
- const columnSizing = useMemo<ColumnSizingState>(
129
- () => (columnSizingProp ? { ...columnSizingProp, ...internalColumnSizing } : internalColumnSizing),
130
- [columnSizingProp, internalColumnSizing],
131
- );
159
+ const columnSizing = useMemo<ColumnSizingState>(() => {
160
+ const merged = columnSizingProp ? { ...columnSizingProp, ...internalColumnSizing } : internalColumnSizing;
161
+
162
+ return withUtilityColumnSizing(merged, utilityColumnSizing ?? {});
163
+ }, [columnSizingProp, internalColumnSizing, utilityColumnSizing]);
132
164
  const [resizeHover, setResizeHover] = useState<{
133
165
  columnId: string;
134
166
  offset: number;
@@ -136,6 +168,10 @@ export const useColumnResize = ({
136
168
  const dragStartOffsetRef = useRef<number | null>(null);
137
169
  const resizeOriginSizingRef = useRef<ColumnSizingState>({});
138
170
  const containerRef = useRef<HTMLDivElement>(null);
171
+ const scrollbarSpacerWidth = useScrollbarSpacer(containerRef, overflowKey);
172
+ const spacerWidthRef = useRef(scrollbarSpacerWidth);
173
+ spacerWidthRef.current = scrollbarSpacerWidth;
174
+ const prevSpacerWidthRef = useRef<ScrollbarSpacerWidth | null>(null);
139
175
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bind is generic; the ref only calls sizing APIs
140
176
  const tableRef = useRef<Table<any> | null>(null);
141
177
 
@@ -235,6 +271,8 @@ export const useColumnResize = ({
235
271
  );
236
272
 
237
273
  useLayoutEffect(() => {
274
+ prevSpacerWidthRef.current = null;
275
+
238
276
  if (!enabled) {
239
277
  return;
240
278
  }
@@ -258,6 +296,11 @@ export const useColumnResize = ({
258
296
  const leaves = table.getVisibleLeafColumns();
259
297
  const leafIds = leaves.map((column) => column.id);
260
298
  const measured = measureLeafHeaderWidths(tableEl, leafIds);
299
+ const fillableIds = leaves
300
+ .filter((column) => !isExplicitColumnWidth(column, {}))
301
+ .map((column) => column.id);
302
+ const trackWidth = container.clientWidth - spacerWidthRef.current;
303
+ const missing = collectMissingLeafSizing(measured, columnSizingRef.current, trackWidth, fillableIds);
261
304
 
262
305
  for (const column of leaves) {
263
306
  const measuredWidth = measured[column.id];
@@ -265,17 +308,31 @@ export const useColumnResize = ({
265
308
  continue;
266
309
  }
267
310
 
268
- resizeOriginSizingRef.current[column.id] = getResizeOriginSize(column, Math.round(measuredWidth));
311
+ resizeOriginSizingRef.current[column.id] = getResizeOriginSize(
312
+ column,
313
+ missing[column.id] ?? Math.round(measuredWidth),
314
+ );
269
315
  }
270
316
 
271
- const missing = collectMissingLeafSizing(measured, columnSizingRef.current);
272
317
  if (Object.keys(missing).length > 0) {
318
+ const bounds = leafSizeBoundsRef.current;
319
+ const clamped = clampColumnSizing(missing, bounds);
320
+ const grown = growFillLeavesToContainer(
321
+ clamped,
322
+ columnSizingRef.current,
323
+ Object.keys(measured),
324
+ trackWidth,
325
+ fillableIds,
326
+ bounds,
327
+ );
273
328
  handleColumnSizingChange((prev) => ({
274
- ...clampColumnSizing(missing, leafSizeBoundsRef.current),
329
+ ...grown,
275
330
  ...prev,
276
331
  }));
277
332
  }
278
333
 
334
+ prevSpacerWidthRef.current = spacerWidthRef.current;
335
+
279
336
  return true;
280
337
  };
281
338
 
@@ -293,6 +350,48 @@ export const useColumnResize = ({
293
350
  return () => observer.disconnect();
294
351
  }, [enabled, columns, columnVisibility, handleColumnSizingChange]);
295
352
 
353
+ // After seed, a vertical scrollbar can appear or disappear (Infinite Scroll,
354
+ // filter, shorter viewport). The **Scrollbar spacer** then toggles 0↔10px.
355
+ // Shrink or grow the column track by that delta so leaves + spacer still
356
+ // fill the container — internally only, never via onColumnSizingChange.
357
+ // Skip when the leaves were not filling the previous track (already
358
+ // overflowing horizontally) so authored-wide columns are left alone.
359
+ useLayoutEffect(() => {
360
+ if (!enabled) {
361
+ return;
362
+ }
363
+
364
+ const prevSpacerWidth = prevSpacerWidthRef.current;
365
+ if (prevSpacerWidth === null || prevSpacerWidth === scrollbarSpacerWidth) {
366
+ return;
367
+ }
368
+
369
+ const container = containerRef.current;
370
+ const table = tableRef.current;
371
+ if (!container || !table) {
372
+ return;
373
+ }
374
+
375
+ const leafIds = table.getVisibleLeafColumns().map((column) => column.id);
376
+ let leafTotal = 0;
377
+
378
+ for (const id of leafIds) {
379
+ leafTotal += columnSizingRef.current[id] ?? 0;
380
+ }
381
+
382
+ const prevTrack = container.clientWidth - prevSpacerWidth;
383
+ prevSpacerWidthRef.current = scrollbarSpacerWidth;
384
+
385
+ if (Math.abs(leafTotal - prevTrack) > TRACK_MATCH_PX) {
386
+ return;
387
+ }
388
+
389
+ const delta = prevSpacerWidth - scrollbarSpacerWidth;
390
+ handleColumnSizingChange(
391
+ shiftColumnTrack(columnSizingRef.current, leafIds, delta, leafSizeBoundsRef.current),
392
+ );
393
+ }, [enabled, scrollbarSpacerWidth, handleColumnSizingChange]);
394
+
296
395
  const tableOptions = useMemo<UseColumnResizeTableOptions>(
297
396
  () => ({
298
397
  enableColumnResizing: enabled,
@@ -319,7 +418,7 @@ export const useColumnResize = ({
319
418
  const columnSizeVars: CSSProperties | undefined = resizeSizingReady
320
419
  ? {
321
420
  ...getColumnSizeCssVars(table.getFlatHeaders()),
322
- width: table.getTotalSize(),
421
+ width: table.getTotalSize() + scrollbarSpacerWidth,
323
422
  }
324
423
  : undefined;
325
424
 
@@ -335,6 +434,7 @@ export const useColumnResize = ({
335
434
  onResizeDragStart: handleResizeDragStart,
336
435
  onResizeReset: handleResizeReset,
337
436
  onResizeKeyboardNudge: handleResizeKeyboardNudge,
437
+ scrollbarSpacerWidth,
338
438
  },
339
439
  };
340
440
  }
@@ -349,14 +449,15 @@ export const useColumnResize = ({
349
449
  resizingHeader && startSize !== null
350
450
  ? resizingHeader.getSize() - startSize
351
451
  : (columnSizingInfo.deltaOffset ?? 0);
452
+ const tableWidth = table.getTotalSize();
352
453
  const activeResize: UseColumnResizeActiveResize | null =
353
454
  isResizingColumn && dragStartOffsetRef.current !== null
354
455
  ? {
355
- offset: dragStartOffsetRef.current + clampedResizeDelta,
456
+ offset: clampResizeOverlayOffset(dragStartOffsetRef.current + clampedResizeDelta, tableWidth),
356
457
  phase: 'dragging',
357
458
  }
358
459
  : resizeHover
359
- ? { offset: resizeHover.offset, phase: 'hover' }
460
+ ? { offset: clampResizeOverlayOffset(resizeHover.offset, tableWidth), phase: 'hover' }
360
461
  : null;
361
462
 
362
463
  return {
@@ -370,6 +471,7 @@ export const useColumnResize = ({
370
471
  onResizeDragStart: handleResizeDragStart,
371
472
  onResizeReset: handleResizeReset,
372
473
  onResizeKeyboardNudge: handleResizeKeyboardNudge,
474
+ scrollbarSpacerWidth,
373
475
  },
374
476
  };
375
477
  };
@@ -381,5 +483,6 @@ export const useColumnResize = ({
381
483
  columnSizingInfo,
382
484
  },
383
485
  bind,
486
+ scrollbarSpacerWidth,
384
487
  };
385
488
  };
@@ -0,0 +1,108 @@
1
+ import { useLayoutEffect, useState, type RefObject } from 'react';
2
+ import { SCROLLBAR_SPACER_WIDTH } from '../utils/constants';
3
+
4
+ export type ScrollbarSpacerWidth = 0 | typeof SCROLLBAR_SPACER_WIDTH;
5
+
6
+ const BODY_SCROLLBAR_COMPENSATION_CSS_VAR = '--ds-table-body-scrollbar-compensation';
7
+
8
+ const bodyOverflowsVertically = (body: HTMLElement): boolean => body.scrollHeight > body.clientHeight;
9
+
10
+ const verticalScrollbarOccupancy = (body: HTMLElement): number =>
11
+ Math.max(0, body.offsetWidth - body.clientWidth);
12
+
13
+ /**
14
+ * Extra `padding-inline-end` on body rows so Fill columns match the header
15
+ * **Scrollbar spacer** when the vertical scrollbar occupies no layout (overlay).
16
+ * Classic scrollbars already inset `clientWidth`; do not add that occupancy twice.
17
+ */
18
+ export const getBodyScrollbarCompensation = (
19
+ occupancyPx: number,
20
+ spacerWidth: ScrollbarSpacerWidth,
21
+ ): number => Math.max(0, spacerWidth - occupancyPx);
22
+
23
+ const syncBodyScrollbarCompensation = (
24
+ container: HTMLElement,
25
+ body: HTMLElement | null,
26
+ spacerWidth: ScrollbarSpacerWidth,
27
+ ): void => {
28
+ if (!body) {
29
+ container.style.removeProperty(BODY_SCROLLBAR_COMPENSATION_CSS_VAR);
30
+ return;
31
+ }
32
+
33
+ container.style.setProperty(
34
+ BODY_SCROLLBAR_COMPENSATION_CSS_VAR,
35
+ `${String(getBodyScrollbarCompensation(verticalScrollbarOccupancy(body), spacerWidth))}px`,
36
+ );
37
+ };
38
+
39
+ /**
40
+ * Overflow-only **Scrollbar spacer** width: `SCROLLBAR_SPACER_WIDTH` while the
41
+ * body scroll container overflows vertically, otherwise 0.
42
+ *
43
+ * Observes container/tbody size and tbody children (rows added/removed). Does
44
+ * not watch `style` attributes — virtualized rows rewrite `transform` on scroll.
45
+ */
46
+ export const useScrollbarSpacer = (
47
+ containerRef: RefObject<HTMLElement | null>,
48
+ overflowKey: number,
49
+ ): ScrollbarSpacerWidth => {
50
+ const [width, setWidth] = useState<ScrollbarSpacerWidth>(0);
51
+
52
+ useLayoutEffect(() => {
53
+ const container = containerRef.current;
54
+ if (!container) {
55
+ return;
56
+ }
57
+
58
+ const read = () => {
59
+ const body = container.querySelector('tbody');
60
+ if (!(body instanceof HTMLElement)) {
61
+ setWidth(0);
62
+ syncBodyScrollbarCompensation(container, null, 0);
63
+ return;
64
+ }
65
+
66
+ const nextWidth = bodyOverflowsVertically(body) ? SCROLLBAR_SPACER_WIDTH : 0;
67
+ setWidth(nextWidth);
68
+ syncBodyScrollbarCompensation(container, body, nextWidth);
69
+ };
70
+
71
+ const resizeObserver = new ResizeObserver(read);
72
+ const bodyChildObserver = new MutationObserver(read);
73
+ resizeObserver.observe(container);
74
+
75
+ let observedBody: HTMLElement | null = null;
76
+
77
+ const bindBody = () => {
78
+ const body = container.querySelector('tbody');
79
+ if (!(body instanceof HTMLElement) || body === observedBody) {
80
+ return;
81
+ }
82
+
83
+ bodyChildObserver.disconnect();
84
+ observedBody = body;
85
+ resizeObserver.observe(body);
86
+ bodyChildObserver.observe(body, { childList: true });
87
+ read();
88
+ };
89
+
90
+ bindBody();
91
+ read();
92
+
93
+ const table = container.querySelector('table');
94
+ const tableChildObserver = new MutationObserver(bindBody);
95
+ if (table) {
96
+ tableChildObserver.observe(table, { childList: true });
97
+ }
98
+
99
+ return () => {
100
+ resizeObserver.disconnect();
101
+ bodyChildObserver.disconnect();
102
+ tableChildObserver.disconnect();
103
+ container.style.removeProperty(BODY_SCROLLBAR_COMPENSATION_CSS_VAR);
104
+ };
105
+ }, [containerRef, overflowKey]);
106
+
107
+ return width;
108
+ };
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SCROLLBAR_SPACER_WIDTH } from '../utils/constants';
3
+ import { getBodyScrollbarCompensation } from './use-scrollbar-spacer';
4
+
5
+ describe('getBodyScrollbarCompensation', () => {
6
+ it('reserves the full spacer when the scrollbar occupies no layout (overlay)', () => {
7
+ expect(getBodyScrollbarCompensation(0, SCROLLBAR_SPACER_WIDTH)).toBe(SCROLLBAR_SPACER_WIDTH);
8
+ });
9
+
10
+ it('adds nothing when a classic scrollbar already occupies the spacer width', () => {
11
+ expect(getBodyScrollbarCompensation(SCROLLBAR_SPACER_WIDTH, SCROLLBAR_SPACER_WIDTH)).toBe(0);
12
+ });
13
+
14
+ it('fills only the gap when a thinner scrollbar occupies part of the spacer', () => {
15
+ expect(getBodyScrollbarCompensation(8, SCROLLBAR_SPACER_WIDTH)).toBe(2);
16
+ });
17
+
18
+ it('does not shrink the body when occupancy is wider than the spacer', () => {
19
+ expect(getBodyScrollbarCompensation(15, SCROLLBAR_SPACER_WIDTH)).toBe(0);
20
+ });
21
+
22
+ it('adds nothing when the spacer is collapsed', () => {
23
+ expect(getBodyScrollbarCompensation(0, 0)).toBe(0);
24
+ expect(getBodyScrollbarCompensation(SCROLLBAR_SPACER_WIDTH, 0)).toBe(0);
25
+ });
26
+ });
@@ -1,12 +1,10 @@
1
1
  $border-radius: 4px;
2
2
  $border-width: 1px;
3
3
  $group-border-width: 2px;
4
- $reorder-column-width: 60px;
5
4
  $container-margin-vertical: 1rem;
6
5
  $filter-input-gap: 0.5rem;
7
6
  $filter-input-max-width: 24rem;
8
7
  $header-sort-gap: 0.5rem;
9
- $empty-state-height: 6rem;
10
8
  $resize-handle-hit-width: 12px;
11
9
  $resize-divider-width: 2px;
12
10
 
@@ -188,25 +188,257 @@ export const measureLeafHeaderWidths = (
188
188
  return widths;
189
189
  };
190
190
 
191
+ const CONTAINER_SIZE_SNAP_PX = 1;
192
+
193
+ const absorbSizingDrift = (sizing: ColumnSizingState, ids: string[], drift: number): void => {
194
+ for (const id of [...ids].reverse()) {
195
+ if (drift === 0) {
196
+ break;
197
+ }
198
+
199
+ const current = sizing[id];
200
+ if (current === undefined) {
201
+ continue;
202
+ }
203
+
204
+ const nextSize = Math.max(RESIZE_MIN_COLUMN_WIDTH, current - drift);
205
+ drift -= current - nextSize;
206
+ sizing[id] = nextSize;
207
+ }
208
+ };
209
+
210
+ const toIdSet = (ids: ReadonlySet<string> | readonly string[]): ReadonlySet<string> =>
211
+ ids instanceof Set ? ids : new Set(ids);
212
+
213
+ /**
214
+ * Grows `ids` so their combined increase equals `extra`. Shares follow each
215
+ * id's current width; leftover rounding is absorbed on the last id.
216
+ */
217
+ const distributeExtraSpace = (
218
+ sizing: ColumnSizingState,
219
+ ids: string[],
220
+ extra: number,
221
+ maxById?: ReadonlyMap<string, number>,
222
+ ): void => {
223
+ if (ids.length === 0 || extra <= 0) {
224
+ return;
225
+ }
226
+
227
+ let remaining = extra;
228
+ let open = ids.filter((id) => (sizing[id] ?? 0) < (maxById?.get(id) ?? DEFAULT_MAX_COLUMN_SIZE));
229
+
230
+ while (remaining > 0 && open.length > 0) {
231
+ let weightSum = 0;
232
+
233
+ for (const id of open) {
234
+ weightSum += sizing[id] ?? 0;
235
+ }
236
+
237
+ let assigned = 0;
238
+ const stillOpen: string[] = [];
239
+
240
+ for (const id of open) {
241
+ const current = sizing[id] ?? 0;
242
+ const max = maxById?.get(id) ?? DEFAULT_MAX_COLUMN_SIZE;
243
+ const room = max - current;
244
+ const rawShare = weightSum <= 0 ? remaining / open.length : remaining * (current / weightSum);
245
+ const share = Math.min(room, Math.round(rawShare));
246
+ sizing[id] = current + share;
247
+ assigned += share;
248
+
249
+ if (current + share < max) {
250
+ stillOpen.push(id);
251
+ }
252
+ }
253
+
254
+ let leftover = remaining - assigned;
255
+
256
+ for (const id of stillOpen) {
257
+ if (leftover <= 0) {
258
+ break;
259
+ }
260
+
261
+ const max = maxById?.get(id) ?? DEFAULT_MAX_COLUMN_SIZE;
262
+ const add = Math.min(leftover, max - (sizing[id] ?? 0));
263
+ sizing[id] = (sizing[id] ?? 0) + add;
264
+ leftover -= add;
265
+ }
266
+
267
+ if (assigned === 0 && leftover === remaining) {
268
+ break;
269
+ }
270
+
271
+ remaining = leftover;
272
+ open = stillOpen.filter((id) => (sizing[id] ?? 0) < (maxById?.get(id) ?? DEFAULT_MAX_COLUMN_SIZE));
273
+ }
274
+ };
275
+
191
276
  /**
192
277
  * Sizing entries for leaves that are not yet in `existing`. Does not overwrite
193
- * user-resized (or previously snapshotted) ids.
278
+ * user-resized (or previously snapshotted) ids. Rounds each measurement, then
279
+ * shifts leftover pixels onto the last missing leaf so the visible total
280
+ * matches the measured width (or the container, when they differ by 1px).
281
+ * When the measured total is short of the container, leftover space is shared
282
+ * across missing fill leaves in `fillableIds`.
194
283
  */
195
284
  export const collectMissingLeafSizing = (
196
285
  measured: Record<string, number>,
197
286
  existing: ColumnSizingState,
287
+ containerWidth?: number,
288
+ fillableIds?: ReadonlySet<string> | readonly string[],
198
289
  ): ColumnSizingState => {
199
290
  const next: ColumnSizingState = {};
291
+ const missingIds: string[] = [];
200
292
 
201
293
  for (const [id, width] of Object.entries(measured)) {
202
294
  if (existing[id] === undefined) {
203
295
  next[id] = Math.round(width);
296
+ missingIds.push(id);
204
297
  }
205
298
  }
206
299
 
300
+ if (missingIds.length === 0) {
301
+ return next;
302
+ }
303
+
304
+ let roundedMissingSum = 0;
305
+ let unroundedMissingSum = 0;
306
+
307
+ for (const id of missingIds) {
308
+ roundedMissingSum += next[id] ?? 0;
309
+ unroundedMissingSum += measured[id] ?? 0;
310
+ }
311
+
312
+ absorbSizingDrift(next, missingIds, roundedMissingSum - Math.round(unroundedMissingSum));
313
+
314
+ if (containerWidth === undefined || !Number.isFinite(containerWidth) || containerWidth <= 0) {
315
+ return next;
316
+ }
317
+
318
+ let projected = 0;
319
+
320
+ for (const id of Object.keys(measured)) {
321
+ projected += existing[id] ?? next[id] ?? 0;
322
+ }
323
+
324
+ const delta = projected - containerWidth;
325
+
326
+ if (Math.abs(delta) <= CONTAINER_SIZE_SNAP_PX) {
327
+ absorbSizingDrift(next, missingIds, delta);
328
+ return next;
329
+ }
330
+
331
+ if (delta < 0 && fillableIds) {
332
+ const fillable = toIdSet(fillableIds);
333
+ distributeExtraSpace(
334
+ next,
335
+ missingIds.filter((id) => fillable.has(id)),
336
+ -delta,
337
+ );
338
+ }
339
+
207
340
  return next;
208
341
  };
209
342
 
343
+ const maxSizeOf = (id: string, boundsById: Map<string, ColumnSizeBoundsSource['columnDef']>): number =>
344
+ boundsById.get(id)?.maxSize ?? DEFAULT_MAX_COLUMN_SIZE;
345
+
346
+ /**
347
+ * After clamp, grow fill leaves that still have room so the projected
348
+ * measured total matches the container. A leaf that hit `maxSize` must not
349
+ * leave a gap — leftover pixels go to siblings.
350
+ */
351
+ export const growFillLeavesToContainer = (
352
+ sizing: ColumnSizingState,
353
+ existing: ColumnSizingState,
354
+ measuredIds: readonly string[],
355
+ containerWidth: number,
356
+ fillableIds: ReadonlySet<string> | readonly string[],
357
+ bounds: ReadonlyArray<ColumnSizeBoundsSource>,
358
+ ): ColumnSizingState => {
359
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0 || measuredIds.length === 0) {
360
+ return sizing;
361
+ }
362
+
363
+ let projected = 0;
364
+
365
+ for (const id of measuredIds) {
366
+ projected += existing[id] ?? sizing[id] ?? 0;
367
+ }
368
+
369
+ const extra = containerWidth - projected;
370
+ if (extra <= 0) {
371
+ return sizing;
372
+ }
373
+
374
+ const fillable = toIdSet(fillableIds);
375
+ const growable = Object.keys(sizing).filter((id) => fillable.has(id));
376
+ if (growable.length === 0) {
377
+ return sizing;
378
+ }
379
+
380
+ const next = { ...sizing };
381
+ const boundsById = new Map(bounds.map((column) => [column.id, column.columnDef]));
382
+ distributeExtraSpace(next, growable, extra, new Map(growable.map((id) => [id, maxSizeOf(id, boundsById)])));
383
+
384
+ return next;
385
+ };
386
+
387
+ const minSizeOf = (id: string, boundsById: Map<string, ColumnSizeBoundsSource['columnDef']>): number =>
388
+ boundsById.get(id)?.minSize ?? RESIZE_MIN_COLUMN_WIDTH;
389
+
390
+ const shrinkColumnTrack = (
391
+ sizing: ColumnSizingState,
392
+ ids: readonly string[],
393
+ amount: number,
394
+ bounds: ReadonlyArray<ColumnSizeBoundsSource>,
395
+ ): void => {
396
+ const boundsById = new Map(bounds.map((column) => [column.id, column.columnDef]));
397
+ let remaining = amount;
398
+
399
+ for (const id of [...ids].reverse()) {
400
+ if (remaining <= 0) {
401
+ break;
402
+ }
403
+
404
+ const current = sizing[id];
405
+ if (current === undefined) {
406
+ continue;
407
+ }
408
+
409
+ const nextSize = Math.max(minSizeOf(id, boundsById), current - remaining);
410
+ remaining -= current - nextSize;
411
+ sizing[id] = nextSize;
412
+ }
413
+ };
414
+
415
+ /**
416
+ * Grows or shrinks `ids` so the column track changes by `delta` (positive =
417
+ * grow). Used when the **Scrollbar spacer** toggles after seed; does not
418
+ * invent ids missing from `sizing`.
419
+ */
420
+ export const shiftColumnTrack = (
421
+ sizing: ColumnSizingState,
422
+ ids: readonly string[],
423
+ delta: number,
424
+ bounds: ReadonlyArray<ColumnSizeBoundsSource>,
425
+ ): ColumnSizingState => {
426
+ if (!Number.isFinite(delta) || delta === 0 || ids.length === 0) {
427
+ return sizing;
428
+ }
429
+
430
+ const next = { ...sizing };
431
+
432
+ if (delta > 0) {
433
+ const boundsById = new Map(bounds.map((column) => [column.id, column.columnDef]));
434
+ distributeExtraSpace(next, [...ids], delta, new Map(ids.map((id) => [id, maxSizeOf(id, boundsById)])));
435
+ } else {
436
+ shrinkColumnTrack(next, ids, -delta, bounds);
437
+ }
438
+
439
+ return clampColumnSizing(next, bounds);
440
+ };
441
+
210
442
  /**
211
443
  * Copy of `columnSizing` without injected utility leaves. Use at the public
212
444
  * `onColumnSizingChange` boundary so consumers persist only their own columns.
@@ -222,3 +454,44 @@ export const omitBuiltinColumnSizing = (columnSizing: ColumnSizingState): Column
222
454
 
223
455
  return next;
224
456
  };
457
+
458
+ /**
459
+ * Authored utility-column width, or `fallback` when the value is omitted,
460
+ * non-finite, or not positive.
461
+ */
462
+ export const resolveUtilityColumnWidth = (width: number | undefined, fallback: number): number => {
463
+ if (width === undefined || !Number.isFinite(width) || width <= 0) {
464
+ return fallback;
465
+ }
466
+
467
+ return width;
468
+ };
469
+
470
+ /**
471
+ * Drops any utility ids from `columnSizing`, then applies `utilityColumnSizing`
472
+ * so those leaves always take the authored width.
473
+ */
474
+ export const withUtilityColumnSizing = (
475
+ columnSizing: ColumnSizingState,
476
+ utilityColumnSizing: ColumnSizingState,
477
+ ): ColumnSizingState => ({
478
+ ...omitBuiltinColumnSizing(columnSizing),
479
+ ...utilityColumnSizing,
480
+ });
481
+
482
+ /**
483
+ * Authored sizes for injected utility leaves, keyed by column id.
484
+ */
485
+ export const getUtilityColumnSizing = (
486
+ columns: ReadonlyArray<{ id?: string; size?: number }>,
487
+ ): ColumnSizingState => {
488
+ const next: ColumnSizingState = {};
489
+
490
+ for (const column of columns) {
491
+ if (column.id !== undefined && BUILTIN_COLUMN_IDS.has(column.id) && column.size !== undefined) {
492
+ next[column.id] = column.size;
493
+ }
494
+ }
495
+
496
+ return next;
497
+ };