@drivenets/design-system 0.17.0 → 0.18.1

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 (98) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.cjs +1797 -805
  3. package/dist/index.d.cts +117 -53
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +117 -53
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1799 -809
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.css +1 -1
  10. package/package.json +3 -3
  11. package/src/components/ds-alert-banner/ds-alert-banner-global.figma.ts +49 -0
  12. package/src/components/ds-alert-banner/ds-alert-banner.figma.ts +51 -0
  13. package/src/components/ds-autocomplete/ds-autocomplete.module.scss +15 -4
  14. package/src/components/ds-autocomplete/ds-autocomplete.tsx +10 -3
  15. package/src/components/ds-autocomplete/ds-autocomplete.types.ts +8 -0
  16. package/src/components/ds-button-v3/ds-button-v3.figma.ts +5 -3
  17. package/src/components/ds-button-v3/ds-button-v3.module.scss +84 -17
  18. package/src/components/ds-button-v3/ds-button-v3.tsx +2 -0
  19. package/src/components/ds-button-v3/ds-button-v3.types.ts +11 -2
  20. package/src/components/ds-button-v3/ds-button-v4.figma.ts +121 -0
  21. package/src/components/ds-comment-bubble/ds-comment-bubble.figma.ts +52 -0
  22. package/src/components/ds-comment-card/ds-comment-card.figma.ts +36 -0
  23. package/src/components/ds-comment-indicator/ds-comment-indicator.figma.ts +32 -0
  24. package/src/components/ds-comments-drawer/ds-comments-drawer.figma.ts +19 -0
  25. package/src/components/ds-select/ds-select.figma.ts +2 -1
  26. package/src/components/ds-select/ds-select.tsx +6 -1
  27. package/src/components/ds-select/ds-select.types.ts +2 -1
  28. package/src/components/ds-slider/ds-slider.figma.ts +29 -0
  29. package/src/components/ds-slider/ds-slider.module.scss +104 -0
  30. package/src/components/ds-slider/ds-slider.tsx +118 -0
  31. package/src/components/ds-slider/ds-slider.types.ts +102 -0
  32. package/src/components/ds-slider/ds-slider.utils.ts +14 -0
  33. package/src/components/ds-slider/index.ts +11 -0
  34. package/src/components/ds-smart-tabs/figma/ds-smart-tabs-tab.figma.ts +83 -0
  35. package/src/components/ds-smart-tabs/figma/ds-smart-tabs.figma.ts +23 -0
  36. package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.module.scss +5 -1
  37. package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.tsx +15 -3
  38. package/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss +15 -1
  39. package/src/components/ds-table/components/ds-table-header/ds-table-header.tsx +29 -6
  40. package/src/components/ds-table/components/ds-table-resize-handle/ds-table-resize-handle.module.scss +23 -0
  41. package/src/components/ds-table/components/ds-table-resize-handle/ds-table-resize-handle.tsx +218 -0
  42. package/src/components/ds-table/components/ds-table-resize-handle/ds-table-resize-handle.types.ts +14 -0
  43. package/src/components/ds-table/components/ds-table-resize-handle/index.ts +1 -0
  44. package/src/components/ds-table/components/ds-table-resize-overlay/ds-table-resize-overlay.module.scss +18 -0
  45. package/src/components/ds-table/components/ds-table-resize-overlay/ds-table-resize-overlay.tsx +16 -0
  46. package/src/components/ds-table/components/ds-table-resize-overlay/ds-table-resize-overlay.types.ts +13 -0
  47. package/src/components/ds-table/components/ds-table-resize-overlay/index.ts +1 -0
  48. package/src/components/ds-table/components/ds-table-row/ds-table-row.module.scss +1 -1
  49. package/src/components/ds-table/components/ds-table-row/ds-table-row.tsx +9 -2
  50. package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.module.scss +2 -1
  51. package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.tsx +9 -2
  52. package/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-checkbox.tsx +2 -2
  53. package/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-date.tsx +2 -1
  54. package/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-number.tsx +2 -1
  55. package/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-select.tsx +2 -1
  56. package/src/components/ds-table/components/edit/cell-editors/ds-table-edit-cell-text.tsx +2 -1
  57. package/src/components/ds-table/components/edit/ds-table-editing-cell/ds-table-editing-cell.tsx +12 -3
  58. package/src/components/ds-table/context/ds-table-context.tsx +208 -0
  59. package/src/components/ds-table/ds-table-defaults.ts +42 -0
  60. package/src/components/ds-table/ds-table.module.scss +4 -1
  61. package/src/components/ds-table/ds-table.tsx +139 -214
  62. package/src/components/ds-table/ds-table.types.ts +53 -47
  63. package/src/components/ds-table/grouping/utils/column-group.ts +7 -5
  64. package/src/components/ds-table/hooks/use-cell-editor.ts +5 -1
  65. package/src/components/ds-table/hooks/use-column-resize.ts +385 -0
  66. package/src/components/ds-table/hooks/use-editing-state.ts +190 -0
  67. package/src/components/ds-table/index.ts +1 -0
  68. package/src/components/ds-table/styles/_variables.scss +2 -1
  69. package/src/components/ds-table/utils/column-size-meta.augmentation.ts +16 -0
  70. package/src/components/ds-table/utils/column-size.ts +213 -10
  71. package/src/components/ds-table/utils/column-size.unit.test.ts +213 -0
  72. package/src/components/ds-table/utils/constants.ts +28 -0
  73. package/src/components/ds-table/utils/frozen-body.ts +33 -0
  74. package/src/components/ds-table/utils/frozen-body.unit.test.ts +44 -0
  75. package/src/components/ds-table/utils/table-api.ts +42 -0
  76. package/src/components/ds-table/utils/table-api.unit.test.ts +82 -0
  77. package/src/components/ds-table/utils/table-columns.tsx +119 -0
  78. package/src/components/ds-table/utils/table-columns.unit.test.ts +199 -0
  79. package/src/components/ds-tag/ds-tag.figma.ts +51 -0
  80. package/src/components/ds-tag-filter/ds-tag-filter.figma.ts +16 -0
  81. package/src/components/ds-tag-filter/ds-tag-filter.module.scss +7 -35
  82. package/src/components/ds-tag-filter/ds-tag-filter.tsx +34 -57
  83. package/src/components/ds-tag-filter/ds-tag-filter.types.ts +2 -1
  84. package/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts +13 -18
  85. package/src/components/ds-text-input/ds-input-field-v02.figma.ts +29 -4
  86. package/src/components/ds-text-input/ds-text-input.figma.ts +3 -2
  87. package/src/components/ds-text-input/ds-text-input.tsx +1 -0
  88. package/src/components/ds-text-input/ds-text-input.types.ts +1 -1
  89. package/src/components/ds-tree/ds-tree.figma.ts +54 -0
  90. package/src/components/ds-workspace-layout/ds-workspace-layout.figma.ts +99 -0
  91. package/src/components/ds-workspace-layout/ds-workspace-layout.module.scss +1 -8
  92. package/src/index.ts +1 -0
  93. package/src/styles/_gradients.scss +19 -0
  94. package/src/styles/_root.scss +39 -15
  95. package/src/styles/_typography.scss +1 -0
  96. package/src/styles/shared/_input.scss +10 -0
  97. package/src/styles/styles.scss +1 -0
  98. package/src/components/ds-table/context/ds-table-context.ts +0 -112
@@ -1,21 +1,224 @@
1
1
  import type { CSSProperties } from 'react';
2
- import { defaultColumnSizing } from '@tanstack/react-table';
2
+ import { type ColumnSizingState } from '@tanstack/react-table';
3
+ import { BUILTIN_COLUMN_IDS, RESIZE_MIN_COLUMN_WIDTH } from './constants';
4
+
5
+ const DEFAULT_MAX_COLUMN_SIZE = Number.MAX_SAFE_INTEGER;
6
+
7
+ type ColumnWidthSource = {
8
+ id: string;
9
+ columnDef: { size?: number; minSize?: number; maxSize?: number; meta?: { hasExplicitSize?: boolean } };
10
+ };
11
+
12
+ /**
13
+ * Leaf column used to resolve `minSize` / `maxSize` when clamping a sizing map.
14
+ */
15
+ export type ColumnSizeBoundsSource = {
16
+ id: string;
17
+ columnDef: { minSize?: number; maxSize?: number };
18
+ };
19
+
20
+ type SizeHeader = {
21
+ id: string;
22
+ getSize: () => number;
23
+ column: { id: string; getSize: () => number };
24
+ };
3
25
 
4
26
  /**
5
- * Generates the appropriate style object for a table column/cell based on its size
27
+ * Whether a column should use a fixed width rather than fill (`flex: 1`).
6
28
  *
7
- * Custom-sized columns get a fixed `width` plus `minWidth` and `flexShrink: 0` so
8
- * they enforce horizontal overflow on the table container. Default-sized columns
9
- * grow to fill the row evenly via `flex: 1`.
29
+ * True when the user has resized it (`columnSizing` entry), or when the raw
30
+ * column def authored `size` (`meta.hasExplicitSize`). TanStack merges a
31
+ * default size of 150 onto every column, so `columnDef.size` alone cannot
32
+ * tell authored 150px from an unsized fill column.
33
+ */
34
+ export const isExplicitColumnWidth = (
35
+ column: ColumnWidthSource,
36
+ columnSizing: ColumnSizingState,
37
+ ): boolean => {
38
+ if (columnSizing[column.id] !== undefined) {
39
+ return true;
40
+ }
41
+
42
+ return column.columnDef.meta?.hasExplicitSize === true;
43
+ };
44
+
45
+ /**
46
+ * Style for a table column/cell based on its size and whether that size is explicit.
10
47
  *
11
- * @param columnSize - The size of the column
12
- * @returns Style object for the column/cell
48
+ * Explicit columns get a fixed `width` plus `minWidth` and `flexShrink: 0` so they
49
+ * enforce horizontal overflow on the table container. Fill columns grow evenly via
50
+ * `flex: 1`.
13
51
  */
14
- export const getColumnSizeStyle = (columnSize: number): CSSProperties => {
15
- const hasCustomSize = columnSize !== defaultColumnSizing.size;
16
- if (hasCustomSize) {
52
+ export const getColumnSizeStyle = (columnSize: number, isExplicit: boolean): CSSProperties => {
53
+ if (isExplicit) {
17
54
  return { width: columnSize, minWidth: columnSize, flexShrink: 0 };
18
55
  }
19
56
 
20
57
  return { flex: 1, minWidth: 0 };
21
58
  };
59
+
60
+ export const getHeaderSizeCssVar = (headerId: string): string => `--header-${headerId}-size`;
61
+
62
+ export const getColumnSizeCssVar = (columnId: string): string => `--col-${columnId}-size`;
63
+
64
+ /**
65
+ * Pixel width via a CSS variable on an ancestor, so frozen (memoized) cells still
66
+ * track live resize without React reconciling each row.
67
+ */
68
+ export const getResizableHeaderStyle = (headerId: string): CSSProperties => ({
69
+ width: `calc(var(${getHeaderSizeCssVar(headerId)}) * 1px)`,
70
+ minWidth: `calc(var(${getHeaderSizeCssVar(headerId)}) * 1px)`,
71
+ flexShrink: 0,
72
+ });
73
+
74
+ export const getResizableColumnStyle = (columnId: string): CSSProperties => ({
75
+ width: `calc(var(${getColumnSizeCssVar(columnId)}) * 1px)`,
76
+ minWidth: `calc(var(${getColumnSizeCssVar(columnId)}) * 1px)`,
77
+ flexShrink: 0,
78
+ });
79
+
80
+ export const getHeaderCellSizeStyle = (
81
+ headerId: string,
82
+ columnSize: number,
83
+ isExplicit: boolean,
84
+ resizeSizingReady: boolean,
85
+ ): CSSProperties => {
86
+ if (resizeSizingReady) {
87
+ return getResizableHeaderStyle(headerId);
88
+ }
89
+
90
+ return getColumnSizeStyle(columnSize, isExplicit);
91
+ };
92
+
93
+ export const getBodyCellSizeStyle = (
94
+ columnId: string,
95
+ columnSize: number,
96
+ isExplicit: boolean,
97
+ resizeSizingReady: boolean,
98
+ ): CSSProperties => {
99
+ if (resizeSizingReady) {
100
+ return getResizableColumnStyle(columnId);
101
+ }
102
+
103
+ return getColumnSizeStyle(columnSize, isExplicit);
104
+ };
105
+
106
+ /**
107
+ * TanStack v8 performant-resize map: header ids for header cells, column ids for
108
+ * body cells. Values are unitless px; consumers use `calc(var(...) * 1px)`.
109
+ */
110
+ export const getColumnSizeCssVars = (headers: SizeHeader[]): Record<string, number> => {
111
+ const colSizes: Record<string, number> = {};
112
+
113
+ for (const header of headers) {
114
+ colSizes[getHeaderSizeCssVar(header.id)] = header.getSize();
115
+ colSizes[getColumnSizeCssVar(header.column.id)] = header.column.getSize();
116
+ }
117
+
118
+ return colSizes;
119
+ };
120
+
121
+ /**
122
+ * TanStack `getSize()` formula: `min(max(minSize, width), maxSize)`. Defaults
123
+ * match DsTable (`RESIZE_MIN_COLUMN_WIDTH`, unlimited max).
124
+ */
125
+ export const clampColumnSize = (
126
+ width: number,
127
+ minSize: number = RESIZE_MIN_COLUMN_WIDTH,
128
+ maxSize: number = DEFAULT_MAX_COLUMN_SIZE,
129
+ ): number => Math.min(Math.max(minSize, width), maxSize);
130
+
131
+ /**
132
+ * Clamps every finite entry in `sizing` to that leaf's `minSize` / `maxSize`.
133
+ * Non-finite widths are omitted so they do not poison state. Ids missing from
134
+ * `columns` use the table defaults.
135
+ */
136
+ export const clampColumnSizing = (
137
+ sizing: ColumnSizingState,
138
+ columns: ReadonlyArray<ColumnSizeBoundsSource>,
139
+ ): ColumnSizingState => {
140
+ const boundsById = new Map(columns.map((column) => [column.id, column.columnDef]));
141
+ const next: ColumnSizingState = {};
142
+
143
+ for (const [id, width] of Object.entries(sizing)) {
144
+ if (!Number.isFinite(width)) {
145
+ continue;
146
+ }
147
+
148
+ const bounds = boundsById.get(id);
149
+ next[id] = clampColumnSize(
150
+ width,
151
+ bounds?.minSize ?? RESIZE_MIN_COLUMN_WIDTH,
152
+ bounds?.maxSize ?? DEFAULT_MAX_COLUMN_SIZE,
153
+ );
154
+ }
155
+
156
+ return next;
157
+ };
158
+
159
+ /**
160
+ * Width to restore on double-click: explicit `columnDef.size`, otherwise the
161
+ * snapshotted measured width.
162
+ */
163
+ export const getResizeOriginSize = (column: ColumnWidthSource, measuredWidth: number): number => {
164
+ if (isExplicitColumnWidth(column, {})) {
165
+ return column.columnDef.size as number;
166
+ }
167
+
168
+ return measuredWidth;
169
+ };
170
+
171
+ /**
172
+ * Measured leaf header widths keyed by column id. Group header cells share
173
+ * `data-column-id` with a different id, so only `leafColumnIds` are read.
174
+ */
175
+ export const measureLeafHeaderWidths = (
176
+ tableEl: HTMLElement,
177
+ leafColumnIds: string[],
178
+ ): Record<string, number> => {
179
+ const widths: Record<string, number> = {};
180
+
181
+ for (const id of leafColumnIds) {
182
+ const cell = tableEl.querySelector(`thead th[data-column-id="${CSS.escape(id)}"]`);
183
+ if (cell instanceof HTMLElement) {
184
+ widths[id] = cell.getBoundingClientRect().width;
185
+ }
186
+ }
187
+
188
+ return widths;
189
+ };
190
+
191
+ /**
192
+ * Sizing entries for leaves that are not yet in `existing`. Does not overwrite
193
+ * user-resized (or previously snapshotted) ids.
194
+ */
195
+ export const collectMissingLeafSizing = (
196
+ measured: Record<string, number>,
197
+ existing: ColumnSizingState,
198
+ ): ColumnSizingState => {
199
+ const next: ColumnSizingState = {};
200
+
201
+ for (const [id, width] of Object.entries(measured)) {
202
+ if (existing[id] === undefined) {
203
+ next[id] = Math.round(width);
204
+ }
205
+ }
206
+
207
+ return next;
208
+ };
209
+
210
+ /**
211
+ * Copy of `columnSizing` without injected utility leaves. Use at the public
212
+ * `onColumnSizingChange` boundary so consumers persist only their own columns.
213
+ */
214
+ export const omitBuiltinColumnSizing = (columnSizing: ColumnSizingState): ColumnSizingState => {
215
+ const next: ColumnSizingState = {};
216
+
217
+ for (const [id, size] of Object.entries(columnSizing)) {
218
+ if (!BUILTIN_COLUMN_IDS.has(id)) {
219
+ next[id] = size;
220
+ }
221
+ }
222
+
223
+ return next;
224
+ };
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defaultColumnSizing } from '@tanstack/react-table';
3
+ import {
4
+ clampColumnSize,
5
+ clampColumnSizing,
6
+ collectMissingLeafSizing,
7
+ getBodyCellSizeStyle,
8
+ getColumnSizeCssVar,
9
+ getColumnSizeCssVars,
10
+ getColumnSizeStyle,
11
+ getHeaderCellSizeStyle,
12
+ getHeaderSizeCssVar,
13
+ getResizableColumnStyle,
14
+ getResizableHeaderStyle,
15
+ getResizeOriginSize,
16
+ isExplicitColumnWidth,
17
+ omitBuiltinColumnSizing,
18
+ } from './column-size';
19
+ import {
20
+ EXPANDER_COLUMN_ID,
21
+ REORDER_COLUMN_ID,
22
+ RESIZE_MIN_COLUMN_WIDTH,
23
+ SELECT_COLUMN_ID,
24
+ } from './constants';
25
+
26
+ describe('isExplicitColumnWidth', () => {
27
+ it('returns false for an unsized column with no sizing entry (fill column)', () => {
28
+ expect(isExplicitColumnWidth({ id: 'a', columnDef: {} }, {})).toBe(false);
29
+ });
30
+
31
+ it('returns true when size was authored, including TanStack’s default size', () => {
32
+ expect(
33
+ isExplicitColumnWidth(
34
+ { id: 'a', columnDef: { size: defaultColumnSizing.size, meta: { hasExplicitSize: true } } },
35
+ {},
36
+ ),
37
+ ).toBe(true);
38
+ });
39
+
40
+ it('returns true when columnSizing has an entry, including the default size value', () => {
41
+ expect(isExplicitColumnWidth({ id: 'a', columnDef: {} }, { a: defaultColumnSizing.size })).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe('getColumnSizeStyle', () => {
46
+ it('returns fill styles when not explicit', () => {
47
+ expect(getColumnSizeStyle(defaultColumnSizing.size, false)).toEqual({ flex: 1, minWidth: 0 });
48
+ });
49
+
50
+ it('returns fixed styles when explicit, including at the default size value', () => {
51
+ expect(getColumnSizeStyle(defaultColumnSizing.size, true)).toEqual({
52
+ width: defaultColumnSizing.size,
53
+ minWidth: defaultColumnSizing.size,
54
+ flexShrink: 0,
55
+ });
56
+ });
57
+
58
+ it('returns fixed styles for a custom explicit size', () => {
59
+ expect(getColumnSizeStyle(200, true)).toEqual({ width: 200, minWidth: 200, flexShrink: 0 });
60
+ });
61
+ });
62
+
63
+ describe('resizable CSS variable styles', () => {
64
+ it('names header and column size variables from ids', () => {
65
+ expect(getHeaderSizeCssVar('identity')).toBe('--header-identity-size');
66
+ expect(getColumnSizeCssVar('firstName')).toBe('--col-firstName-size');
67
+ });
68
+
69
+ it('uses header variables for header cells and column variables for body cells', () => {
70
+ expect(getResizableHeaderStyle('identity')).toEqual({
71
+ width: 'calc(var(--header-identity-size) * 1px)',
72
+ minWidth: 'calc(var(--header-identity-size) * 1px)',
73
+ flexShrink: 0,
74
+ });
75
+ expect(getResizableColumnStyle('firstName')).toEqual({
76
+ width: 'calc(var(--col-firstName-size) * 1px)',
77
+ minWidth: 'calc(var(--col-firstName-size) * 1px)',
78
+ flexShrink: 0,
79
+ });
80
+ });
81
+
82
+ it('switches header and body cells to CSS variables once resize sizing is ready', () => {
83
+ expect(getHeaderCellSizeStyle('a', 200, true, true)).toEqual(getResizableHeaderStyle('a'));
84
+ expect(getHeaderCellSizeStyle('a', 200, true, false)).toEqual(getColumnSizeStyle(200, true));
85
+ expect(getBodyCellSizeStyle('a', 200, true, true)).toEqual(getResizableColumnStyle('a'));
86
+ expect(getBodyCellSizeStyle('a', 150, false, false)).toEqual(getColumnSizeStyle(150, false));
87
+ });
88
+
89
+ it('maps each flat header to header and column size variables', () => {
90
+ const vars = getColumnSizeCssVars([
91
+ { id: 'identity', getSize: () => 360, column: { id: 'identity', getSize: () => 150 } },
92
+ { id: 'firstName', getSize: () => 180, column: { id: 'firstName', getSize: () => 180 } },
93
+ ]);
94
+
95
+ expect(vars).toEqual({
96
+ '--header-identity-size': 360,
97
+ '--col-identity-size': 150,
98
+ '--header-firstName-size': 180,
99
+ '--col-firstName-size': 180,
100
+ });
101
+ });
102
+ });
103
+
104
+ describe('clampColumnSize', () => {
105
+ it('returns the width when it sits inside the range', () => {
106
+ expect(clampColumnSize(120, 80, 200)).toBe(120);
107
+ });
108
+
109
+ it('clamps to minSize', () => {
110
+ expect(clampColumnSize(40, 80, 200)).toBe(80);
111
+ });
112
+
113
+ it('clamps to maxSize', () => {
114
+ expect(clampColumnSize(300, 80, 200)).toBe(200);
115
+ });
116
+
117
+ it('returns maxSize when minSize is greater than maxSize', () => {
118
+ expect(clampColumnSize(150, 200, 100)).toBe(100);
119
+ });
120
+
121
+ it('uses the table default min when minSize is omitted', () => {
122
+ expect(clampColumnSize(20)).toBe(RESIZE_MIN_COLUMN_WIDTH);
123
+ });
124
+
125
+ it('allows a minSize below the table default', () => {
126
+ expect(clampColumnSize(10, 20)).toBe(20);
127
+ expect(clampColumnSize(40, 20)).toBe(40);
128
+ });
129
+ });
130
+
131
+ describe('clampColumnSizing', () => {
132
+ const columns = [
133
+ { id: 'a', columnDef: { minSize: 80, maxSize: 200 } },
134
+ { id: 'b', columnDef: { minSize: 20 } },
135
+ ];
136
+
137
+ it('clamps each known id to its column bounds', () => {
138
+ expect(clampColumnSizing({ a: 40, b: 400 }, columns)).toEqual({ a: 80, b: 400 });
139
+ expect(clampColumnSizing({ a: 500 }, columns)).toEqual({ a: 200 });
140
+ });
141
+
142
+ it('uses table defaults for ids that are not in columns', () => {
143
+ expect(clampColumnSizing({ c: 10 }, columns)).toEqual({ c: RESIZE_MIN_COLUMN_WIDTH });
144
+ });
145
+
146
+ it('omits non-finite widths', () => {
147
+ expect(clampColumnSizing({ a: Number.NaN, b: Number.POSITIVE_INFINITY, c: 120 }, columns)).toEqual({
148
+ c: 120,
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('getResizeOriginSize', () => {
154
+ it('uses columnDef.size when size was authored', () => {
155
+ expect(
156
+ getResizeOriginSize({ id: 'a', columnDef: { size: 200, meta: { hasExplicitSize: true } } }, 198),
157
+ ).toBe(200);
158
+ });
159
+
160
+ it('uses columnDef.size when the authored size is TanStack’s default', () => {
161
+ expect(
162
+ getResizeOriginSize(
163
+ { id: 'a', columnDef: { size: defaultColumnSizing.size, meta: { hasExplicitSize: true } } },
164
+ 198,
165
+ ),
166
+ ).toBe(defaultColumnSizing.size);
167
+ });
168
+
169
+ it('uses the measured width for fill columns', () => {
170
+ expect(getResizeOriginSize({ id: 'a', columnDef: {} }, 387)).toBe(387);
171
+ });
172
+ });
173
+
174
+ describe('collectMissingLeafSizing', () => {
175
+ it('seeds only ids that are not already in columnSizing', () => {
176
+ expect(collectMissingLeafSizing({ a: 120.4, b: 200 }, { b: 180 })).toEqual({ a: 120 });
177
+ });
178
+
179
+ it('returns an empty map when every measured id already has a sizing entry', () => {
180
+ expect(collectMissingLeafSizing({ a: 100 }, { a: 90 })).toEqual({});
181
+ });
182
+ });
183
+
184
+ describe('omitBuiltinColumnSizing', () => {
185
+ it('drops select, expander, and reorder and keeps consumer ids', () => {
186
+ expect(
187
+ omitBuiltinColumnSizing({
188
+ [SELECT_COLUMN_ID]: 36,
189
+ [EXPANDER_COLUMN_ID]: 36,
190
+ [REORDER_COLUMN_ID]: 60,
191
+ firstName: 200,
192
+ lastName: 180,
193
+ }),
194
+ ).toEqual({ firstName: 200, lastName: 180 });
195
+ });
196
+
197
+ it('returns an empty map when every id is a utility column', () => {
198
+ expect(
199
+ omitBuiltinColumnSizing({
200
+ [SELECT_COLUMN_ID]: 36,
201
+ [EXPANDER_COLUMN_ID]: 36,
202
+ }),
203
+ ).toEqual({});
204
+ });
205
+
206
+ it('returns a copy when no utility ids are present', () => {
207
+ const sizing = { firstName: 200 };
208
+ const next = omitBuiltinColumnSizing(sizing);
209
+
210
+ expect(next).toEqual({ firstName: 200 });
211
+ expect(next).not.toBe(sizing);
212
+ });
213
+ });
@@ -34,7 +34,35 @@ export const REORDER_COLUMN_ID = 'reorder';
34
34
  */
35
35
  export const REORDER_COLUMN_WIDTH = 60;
36
36
 
37
+ /**
38
+ * Injected utility leaf ids (`select`, `expander`, `reorder`). Present in internal
39
+ * sizing for layout; omitted from the public `onColumnSizingChange` payload.
40
+ */
41
+ export const BUILTIN_COLUMN_IDS: ReadonlySet<string> = Object.freeze(
42
+ new Set([SELECT_COLUMN_ID, EXPANDER_COLUMN_ID, REORDER_COLUMN_ID]),
43
+ );
44
+
37
45
  /**
38
46
  * Number of placeholder skeleton rows rendered while the table is `loading`.
39
47
  */
40
48
  export const SKELETON_ROW_COUNT = 5;
49
+
50
+ /**
51
+ * Default minimum width (in px) a column can be dragged to when
52
+ * `resizableColumns` is enabled. Overridable per column via `columnDef.minSize`
53
+ * (including values below this default). There is no default max; set
54
+ * `columnDef.maxSize` on a leaf to cap it.
55
+ */
56
+ export const RESIZE_MIN_COLUMN_WIDTH = 52;
57
+
58
+ /**
59
+ * Width change (in px) applied per Arrow key press when a resize handle is
60
+ * focused.
61
+ */
62
+ export const RESIZE_KEYBOARD_STEP = 10;
63
+
64
+ /**
65
+ * Larger width change (in px) applied when Shift is held with an Arrow key on a
66
+ * focused resize handle.
67
+ */
68
+ export const RESIZE_KEYBOARD_STEP_LARGE = 40;
@@ -0,0 +1,33 @@
1
+ import type { Table as TanstackTable } from '@tanstack/react-table';
2
+
3
+ /**
4
+ * `React.memo` comparator for the table body during a column-resize drag.
5
+ *
6
+ * While a column is being resized, sizing is applied through CSS variables on
7
+ * the table element, so body rows do not need to re-render — freezing them keeps
8
+ * dragging smooth. `table` is a stable mutable instance (ref-equal across
9
+ * renders), so comparing it is meaningless and it is skipped; every other prop
10
+ * must be referentially unchanged to freeze. That way parent-driven updates mid-
11
+ * drag (`rowSelection`, `emptyState`, `onScroll`, `infiniteScroll`, ...) are not
12
+ * dropped.
13
+ */
14
+ export const areBodiesFrozen = <T>(
15
+ prev: { table: TanstackTable<T> },
16
+ next: { table: TanstackTable<T> },
17
+ ): boolean => {
18
+ if (!next.table.getState().columnSizingInfo.isResizingColumn) {
19
+ return false;
20
+ }
21
+
22
+ const prevProps = prev as unknown as Record<string, unknown>;
23
+ const nextProps = next as unknown as Record<string, unknown>;
24
+ const keys = new Set([...Object.keys(prevProps), ...Object.keys(nextProps)]);
25
+
26
+ for (const key of keys) {
27
+ if (key !== 'table' && prevProps[key] !== nextProps[key]) {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ return true;
33
+ };
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Table as TanstackTable } from '@tanstack/react-table';
3
+ import { areBodiesFrozen } from './frozen-body';
4
+
5
+ type Row = { id: string };
6
+
7
+ const tableResizing = (isResizingColumn: string | false): TanstackTable<Row> =>
8
+ ({ getState: () => ({ columnSizingInfo: { isResizingColumn } }) }) as unknown as TanstackTable<Row>;
9
+
10
+ describe('areBodiesFrozen', () => {
11
+ it('never freezes when no column is being resized', () => {
12
+ const table = tableResizing(false);
13
+ const prev = { table, rowSelection: {} };
14
+ const next = { table, rowSelection: {} };
15
+
16
+ expect(areBodiesFrozen(prev, next)).toBe(false);
17
+ });
18
+
19
+ it('freezes during a resize when every non-table prop is unchanged', () => {
20
+ const table = tableResizing('firstName');
21
+ const rowSelection = {};
22
+ const emptyState = null;
23
+ const prev = { table, rowSelection, emptyState };
24
+ const next = { table, rowSelection, emptyState };
25
+
26
+ expect(areBodiesFrozen(prev, next)).toBe(true);
27
+ });
28
+
29
+ it('does not freeze when a non-table prop changes mid-resize', () => {
30
+ const table = tableResizing('firstName');
31
+ const prev = { table, rowSelection: {} };
32
+ const next = { table, rowSelection: { '1': true } };
33
+
34
+ expect(areBodiesFrozen(prev, next)).toBe(false);
35
+ });
36
+
37
+ it('ignores the identity of the stable table instance', () => {
38
+ const rowSelection = {};
39
+ const prev = { table: tableResizing('firstName'), rowSelection };
40
+ const next = { table: tableResizing('firstName'), rowSelection };
41
+
42
+ expect(areBodiesFrozen(prev, next)).toBe(true);
43
+ });
44
+ });
@@ -0,0 +1,42 @@
1
+ import type { Table } from '@tanstack/react-table';
2
+ import type { DsTableApi } from '../ds-table.types';
3
+
4
+ export const createTableApi = <TData>(table: Table<TData>): DsTableApi<TData> => ({
5
+ selectRow: (rowId: string) => {
6
+ table.getRow(rowId).toggleSelected(true);
7
+ },
8
+ deselectRow: (rowId: string) => {
9
+ table.getRow(rowId).toggleSelected(false);
10
+ },
11
+ selectAllRows: () => {
12
+ table.toggleAllRowsSelected(true);
13
+ },
14
+ deselectAllRows: () => {
15
+ table.toggleAllRowsSelected(false);
16
+ },
17
+ selectRows: (rowIds: string[]) => {
18
+ const selection: Record<string, boolean> = {};
19
+ rowIds.forEach((id) => (selection[id] = true));
20
+ table.setRowSelection(selection);
21
+ },
22
+ getSelectedRows: () => {
23
+ return table.getFilteredSelectedRowModel().rows.map((r) => r.original);
24
+ },
25
+ expandRow: (rowId: string) => {
26
+ table.getRow(rowId).toggleExpanded(true);
27
+ },
28
+ collapseRow: (rowId: string) => {
29
+ table.getRow(rowId).toggleExpanded(false);
30
+ },
31
+ expandAllRows: () => {
32
+ table.toggleAllRowsExpanded(true);
33
+ },
34
+ collapseAllRows: () => {
35
+ table.toggleAllRowsExpanded(false);
36
+ },
37
+ expandRows: (rowIds: string[]) => {
38
+ const expansion: Record<string, boolean> = {};
39
+ rowIds.forEach((id) => (expansion[id] = true));
40
+ table.setExpanded(expansion);
41
+ },
42
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Table } from '@tanstack/react-table';
3
+ import { createTableApi } from './table-api';
4
+
5
+ type Person = { id: string; name: string };
6
+
7
+ const alice: Person = { id: 'a', name: 'Alice' };
8
+ const bob: Person = { id: 'b', name: 'Bob' };
9
+
10
+ const createMockTable = () => {
11
+ const toggleSelected = vi.fn();
12
+ const toggleExpanded = vi.fn();
13
+ const toggleAllRowsSelected = vi.fn();
14
+ const toggleAllRowsExpanded = vi.fn();
15
+ const setRowSelection = vi.fn();
16
+ const setExpanded = vi.fn();
17
+ const getRow = vi.fn(() => ({ toggleSelected, toggleExpanded }));
18
+ const getFilteredSelectedRowModel = vi.fn(() => ({
19
+ rows: [{ original: alice }, { original: bob }],
20
+ }));
21
+
22
+ const table = {
23
+ getRow,
24
+ toggleAllRowsSelected,
25
+ toggleAllRowsExpanded,
26
+ setRowSelection,
27
+ setExpanded,
28
+ getFilteredSelectedRowModel,
29
+ } as unknown as Table<Person>;
30
+
31
+ return {
32
+ table,
33
+ toggleSelected,
34
+ toggleExpanded,
35
+ toggleAllRowsSelected,
36
+ toggleAllRowsExpanded,
37
+ setRowSelection,
38
+ setExpanded,
39
+ getRow,
40
+ };
41
+ };
42
+
43
+ describe('createTableApi', () => {
44
+ it('maps selection methods onto the table instance', () => {
45
+ const mock = createMockTable();
46
+ const api = createTableApi(mock.table);
47
+
48
+ api.selectRow('a');
49
+ api.deselectRow('b');
50
+ api.selectAllRows();
51
+ api.deselectAllRows();
52
+ api.selectRows(['a', 'b']);
53
+
54
+ expect(mock.getRow).toHaveBeenCalledWith('a');
55
+ expect(mock.getRow).toHaveBeenCalledWith('b');
56
+ expect(mock.toggleSelected).toHaveBeenCalledWith(true);
57
+ expect(mock.toggleSelected).toHaveBeenCalledWith(false);
58
+ expect(mock.toggleAllRowsSelected).toHaveBeenCalledWith(true);
59
+ expect(mock.toggleAllRowsSelected).toHaveBeenCalledWith(false);
60
+ expect(mock.setRowSelection).toHaveBeenCalledWith({ a: true, b: true });
61
+ expect(api.getSelectedRows()).toEqual([alice, bob]);
62
+ });
63
+
64
+ it('maps expand methods onto the table instance', () => {
65
+ const mock = createMockTable();
66
+ const api = createTableApi(mock.table);
67
+
68
+ api.expandRow('a');
69
+ api.collapseRow('b');
70
+ api.expandAllRows();
71
+ api.collapseAllRows();
72
+ api.expandRows(['a', 'b']);
73
+
74
+ expect(mock.getRow).toHaveBeenCalledWith('a');
75
+ expect(mock.getRow).toHaveBeenCalledWith('b');
76
+ expect(mock.toggleExpanded).toHaveBeenCalledWith(true);
77
+ expect(mock.toggleExpanded).toHaveBeenCalledWith(false);
78
+ expect(mock.toggleAllRowsExpanded).toHaveBeenCalledWith(true);
79
+ expect(mock.toggleAllRowsExpanded).toHaveBeenCalledWith(false);
80
+ expect(mock.setExpanded).toHaveBeenCalledWith({ a: true, b: true });
81
+ });
82
+ });