@navikt/ds-react 8.10.1 → 8.10.2

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 (71) hide show
  1. package/cjs/data/table/column-header/DataTableColumnHeader.d.ts +1 -1
  2. package/cjs/data/table/column-header/DataTableColumnHeader.js +13 -10
  3. package/cjs/data/table/column-header/DataTableColumnHeader.js.map +1 -1
  4. package/cjs/data/table/column-header/useTableColumnResize.d.ts +5 -3
  5. package/cjs/data/table/column-header/useTableColumnResize.js +128 -53
  6. package/cjs/data/table/column-header/useTableColumnResize.js.map +1 -1
  7. package/cjs/data/table/helpers/collectTableRowEntries.d.ts +16 -0
  8. package/cjs/data/table/helpers/collectTableRowEntries.js +27 -0
  9. package/cjs/data/table/helpers/collectTableRowEntries.js.map +1 -0
  10. package/cjs/data/table/helpers/table-keyboard.js +0 -3
  11. package/cjs/data/table/helpers/table-keyboard.js.map +1 -1
  12. package/cjs/data/table/hooks/useTableExpansion.d.ts +9 -6
  13. package/cjs/data/table/hooks/useTableExpansion.js +36 -15
  14. package/cjs/data/table/hooks/useTableExpansion.js.map +1 -1
  15. package/cjs/data/table/hooks/useTableItems.d.ts +29 -0
  16. package/cjs/data/table/hooks/useTableItems.js +63 -0
  17. package/cjs/data/table/hooks/useTableItems.js.map +1 -0
  18. package/cjs/data/table/hooks/useTableKeyboardNav.js +3 -3
  19. package/cjs/data/table/hooks/useTableKeyboardNav.js.map +1 -1
  20. package/cjs/data/table/root/DataTableAuto.d.ts +18 -0
  21. package/cjs/data/table/root/DataTableAuto.js +71 -29
  22. package/cjs/data/table/root/DataTableAuto.js.map +1 -1
  23. package/cjs/data/table/root/DataTableRoot.context.d.ts +5 -3
  24. package/cjs/data/table/root/DataTableRoot.context.js.map +1 -1
  25. package/cjs/data/table/root/DataTableRoot.js +7 -4
  26. package/cjs/data/table/root/DataTableRoot.js.map +1 -1
  27. package/cjs/data/table/tr/DataTableTr.js +30 -32
  28. package/cjs/data/table/tr/DataTableTr.js.map +1 -1
  29. package/esm/data/table/column-header/DataTableColumnHeader.d.ts +1 -1
  30. package/esm/data/table/column-header/DataTableColumnHeader.js +14 -11
  31. package/esm/data/table/column-header/DataTableColumnHeader.js.map +1 -1
  32. package/esm/data/table/column-header/useTableColumnResize.d.ts +5 -3
  33. package/esm/data/table/column-header/useTableColumnResize.js +129 -54
  34. package/esm/data/table/column-header/useTableColumnResize.js.map +1 -1
  35. package/esm/data/table/helpers/collectTableRowEntries.d.ts +16 -0
  36. package/esm/data/table/helpers/collectTableRowEntries.js +25 -0
  37. package/esm/data/table/helpers/collectTableRowEntries.js.map +1 -0
  38. package/esm/data/table/helpers/table-keyboard.js +0 -3
  39. package/esm/data/table/helpers/table-keyboard.js.map +1 -1
  40. package/esm/data/table/hooks/useTableExpansion.d.ts +9 -6
  41. package/esm/data/table/hooks/useTableExpansion.js +36 -16
  42. package/esm/data/table/hooks/useTableExpansion.js.map +1 -1
  43. package/esm/data/table/hooks/useTableItems.d.ts +29 -0
  44. package/esm/data/table/hooks/useTableItems.js +58 -0
  45. package/esm/data/table/hooks/useTableItems.js.map +1 -0
  46. package/esm/data/table/hooks/useTableKeyboardNav.js +3 -3
  47. package/esm/data/table/hooks/useTableKeyboardNav.js.map +1 -1
  48. package/esm/data/table/root/DataTableAuto.d.ts +18 -0
  49. package/esm/data/table/root/DataTableAuto.js +72 -30
  50. package/esm/data/table/root/DataTableAuto.js.map +1 -1
  51. package/esm/data/table/root/DataTableRoot.context.d.ts +5 -3
  52. package/esm/data/table/root/DataTableRoot.context.js.map +1 -1
  53. package/esm/data/table/root/DataTableRoot.js +7 -4
  54. package/esm/data/table/root/DataTableRoot.js.map +1 -1
  55. package/esm/data/table/tr/DataTableTr.js +32 -34
  56. package/esm/data/table/tr/DataTableTr.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/data/table/column-header/DataTableColumnHeader.tsx +21 -13
  59. package/src/data/table/column-header/useTableColumnResize.ts +152 -79
  60. package/src/data/table/helpers/collectTableRowEntries.ts +58 -0
  61. package/src/data/table/helpers/table-keyboard.ts +0 -4
  62. package/src/data/table/hooks/__tests__/useTableExpansion.test.tsx +115 -0
  63. package/src/data/table/hooks/__tests__/useTableItems.test.ts +131 -0
  64. package/src/data/table/hooks/useTableExpansion.tsx +63 -22
  65. package/src/data/table/hooks/useTableItems.ts +123 -0
  66. package/src/data/table/hooks/useTableKeyboardNav.ts +3 -3
  67. package/src/data/table/root/DataTableAuto.test.tsx +118 -0
  68. package/src/data/table/root/DataTableAuto.tsx +159 -49
  69. package/src/data/table/root/DataTableRoot.context.ts +4 -2
  70. package/src/data/table/root/DataTableRoot.tsx +20 -13
  71. package/src/data/table/tr/DataTableTr.tsx +48 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navikt/ds-react",
3
- "version": "8.10.1",
3
+ "version": "8.10.2",
4
4
  "description": "React components from the Norwegian Labour and Welfare Administration.",
5
5
  "author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
6
6
  "license": "MIT",
@@ -706,8 +706,8 @@
706
706
  "dependencies": {
707
707
  "@floating-ui/react": "0.27.8",
708
708
  "@floating-ui/react-dom": "^2.1.8",
709
- "@navikt/aksel-icons": "^8.10.1",
710
- "@navikt/ds-tokens": "^8.10.1",
709
+ "@navikt/aksel-icons": "^8.10.2",
710
+ "@navikt/ds-tokens": "^8.10.2",
711
711
  "date-fns": "^4.0.0",
712
712
  "react-day-picker": "9.14.0"
713
713
  },
@@ -726,15 +726,15 @@
726
726
  "fast-glob": "3.3.3",
727
727
  "jscodeshift": "17.3.0",
728
728
  "jsdom": "27.1.0",
729
- "react": "19.2.4",
730
- "react-dom": "19.2.4",
729
+ "react": "19.2.5",
730
+ "react-dom": "19.2.5",
731
731
  "react-router": "^7.13.1",
732
732
  "rimraf": "6.1.3",
733
- "swr": "^2.3.6",
733
+ "swr": "^2.4.1",
734
734
  "tsc-alias": "1.8.16",
735
735
  "tsx": "^4.20.6",
736
736
  "typescript": "6.0.2",
737
- "vitest": "4.1.0"
737
+ "vitest": "4.1.4"
738
738
  },
739
739
  "peerDependencies": {
740
740
  "@types/react": "^17.0.30 || ^18 || ^19",
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, useState } from "react";
1
+ import React, { forwardRef, useRef } from "react";
2
2
  import {
3
3
  ArrowsUpDownIcon,
4
4
  CaretLeftCircleFillIcon,
@@ -75,15 +75,12 @@ const DataTableColumnHeader = forwardRef<
75
75
  },
76
76
  forwardedRef,
77
77
  ) => {
78
- const [isOverflowing, setIsOverflowing] = React.useState(false);
79
78
  const contentRef = React.useRef<HTMLDivElement>(null);
80
- const [thRefState, setThRefState] = useState<HTMLTableCellElement | null>(
81
- null,
82
- );
83
- const mergedRef = useMergeRefs(forwardedRef, setThRefState);
79
+ const thRef = useRef<HTMLTableCellElement>(null);
80
+ const mergedRef = useMergeRefs(forwardedRef, thRef);
84
81
 
85
82
  const resizeResult = useTableColumnResize({
86
- ref: thRefState,
83
+ thRef,
87
84
  width,
88
85
  defaultWidth,
89
86
  minWidth,
@@ -104,12 +101,6 @@ const DataTableColumnHeader = forwardRef<
104
101
  data-sortable={sortable}
105
102
  style={resizeResult.style}
106
103
  aria-sort={sortable ? getAriaSort(sortDirection) : undefined}
107
- onPointerEnter={() => {
108
- const el = contentRef.current;
109
- setIsOverflowing(el ? el.scrollWidth > el.offsetWidth : false);
110
- console.info("is overflowing", isOverflowing);
111
- }}
112
- onPointerLeave={() => setIsOverflowing(false)}
113
104
  UNSAFE_isSelection={UNSAFE_isSelection}
114
105
  colSpan={colSpan}
115
106
  rowSpan={rowSpan}
@@ -146,9 +137,26 @@ const DataTableColumnHeader = forwardRef<
146
137
  <button
147
138
  {...resizeResult.resizeHandlerProps}
148
139
  className="aksel-data-table__th-resize-handle"
140
+ aria-label={
141
+ resizeResult.isResizingWithKeyboard
142
+ ? "Bruk pil venstre/høyre"
143
+ : "Endre bredde"
144
+ } // TODO Translate
149
145
  data-active={resizeResult.isResizingWithKeyboard}
150
146
  data-disable-keyboard-nav={resizeResult.isResizingWithKeyboard}
151
147
  data-block-keyboard-nav
148
+ role="slider"
149
+ aria-valuenow={
150
+ typeof resizeResult.style.width === "number"
151
+ ? resizeResult.style.width
152
+ : 0
153
+ }
154
+ aria-valuetext={
155
+ typeof resizeResult.style.width === "number" &&
156
+ resizeResult.isResizingWithKeyboard
157
+ ? resizeResult.style.width.toString()
158
+ : "" // Needs to be blank when not in keyboard resizing mode to avoid NVDA announcing the value as part of the column heading
159
+ } // Need either this or aria-valuemax to get SR (at least NVDA) to announce the value
152
160
  >
153
161
  {resizeResult.isResizingWithKeyboard && (
154
162
  <>
@@ -1,11 +1,10 @@
1
- import { type DOMAttributes, useCallback, useState } from "react";
1
+ import { type DOMAttributes, useCallback, useRef, useState } from "react";
2
2
  import { useControllableState } from "../../../utils/hooks";
3
3
  import { useDataTableContext } from "../root/DataTableRoot.context";
4
4
 
5
5
  type ColumnWidth = number | string;
6
6
 
7
7
  type ResizeProps = {
8
- ref: HTMLTableCellElement | null;
9
8
  /**
10
9
  * Controlled width of the column.
11
10
  *
@@ -42,17 +41,20 @@ type ResizeProps = {
42
41
  colSpan?: number;
43
42
  };
44
43
 
45
- type TableColumnResizeArgs = ResizeProps & {};
44
+ type TableColumnResizeArgs = ResizeProps & {
45
+ thRef: React.RefObject<HTMLTableCellElement | null>;
46
+ };
46
47
 
47
48
  type TableColumnResizeResult =
48
49
  | {
49
- style?: React.CSSProperties;
50
+ style: React.CSSProperties;
50
51
  resizeHandlerProps: {
51
52
  onMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"];
52
53
  onTouchStart: DOMAttributes<HTMLButtonElement>["onTouchStart"];
53
54
  onKeyDown: DOMAttributes<HTMLButtonElement>["onKeyDown"];
54
55
  onBlur: DOMAttributes<HTMLButtonElement>["onBlur"];
55
56
  onDoubleClick: DOMAttributes<HTMLButtonElement>["onDoubleClick"];
57
+ onClick: DOMAttributes<HTMLButtonElement>["onClick"];
56
58
  };
57
59
  isResizingWithKeyboard: boolean;
58
60
  enabled: true;
@@ -72,7 +74,7 @@ function useTableColumnResize(
72
74
  args: TableColumnResizeArgs,
73
75
  ): TableColumnResizeResult {
74
76
  const {
75
- ref,
77
+ thRef,
76
78
  width: userWidth,
77
79
  defaultWidth,
78
80
  onWidthChange,
@@ -84,6 +86,9 @@ function useTableColumnResize(
84
86
 
85
87
  const tableContext = useDataTableContext();
86
88
 
89
+ const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
90
+ const ignoreNextOnClick = useRef(false);
91
+
87
92
  const [width, _setWidth] = useControllableState({
88
93
  value: userWidth,
89
94
  defaultValue: defaultWidth ?? (colSpan ?? 1) * 140,
@@ -95,69 +100,85 @@ function useTableColumnResize(
95
100
  onChange: onWidthChange,
96
101
  });
97
102
 
98
- const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
99
- const [, setIsResizingWithMouse] = useState(false);
100
-
101
103
  const setWidth = useCallback(
102
104
  (newWidth: number) => {
103
- const currentWidth = ref?.offsetWidth;
104
- if (!currentWidth) {
105
- return;
106
- }
107
-
108
105
  const min = parseWidth(minWidth) ?? 0;
109
106
  const max = parseWidth(maxWidth) ?? Infinity;
110
107
  const clamped = Math.min(Math.max(newWidth, min), max);
108
+ _setWidth(clamped);
109
+ },
110
+ [minWidth, maxWidth, _setWidth],
111
+ );
111
112
 
112
- if (newWidth <= currentWidth && newWidth > max) {
113
- _setWidth(newWidth);
114
- return;
115
- }
113
+ const handleOnClick: DOMAttributes<HTMLButtonElement>["onClick"] =
114
+ useCallback(() => {
115
+ // We need to use the onClick event in order to support screen readers properly,
116
+ // since some of them only send a mouse click when pressing enter/space.
117
+ // We detect a "screen reader click" by checking if we had a mouseUp event right before.
116
118
 
117
- if (newWidth >= currentWidth && newWidth > max) {
118
- _setWidth(currentWidth);
119
+ if (ignoreNextOnClick.current) {
120
+ ignoreNextOnClick.current = false;
119
121
  return;
120
122
  }
121
123
 
122
- _setWidth(clamped);
123
- },
124
- [minWidth, maxWidth, _setWidth, ref],
125
- );
124
+ setIsResizingWithKeyboard((prev) => !prev);
125
+ }, []);
126
126
 
127
127
  const handleKeyDown: DOMAttributes<HTMLButtonElement>["onKeyDown"] =
128
128
  useCallback(
129
129
  (event) => {
130
- if (event.key === "Enter" || event.key === " ") {
131
- setIsResizingWithKeyboard((prev) => !prev);
132
- return;
133
- }
134
-
135
130
  if (!isResizingWithKeyboard) {
136
131
  return;
137
132
  }
133
+ const currentWidth = thRef.current?.offsetWidth ?? 0;
138
134
 
139
135
  if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
140
136
  event.preventDefault();
141
-
142
- const th = (event.target as HTMLElement).closest(
143
- "th",
144
- ) as HTMLTableCellElement;
145
- const startWidth = th.offsetWidth;
146
-
147
137
  const delta = event.key === "ArrowRight" ? 20 : -20;
148
- setWidth(startWidth + delta);
138
+ setWidth(currentWidth + delta);
139
+ return;
140
+ }
141
+ if (event.key === "Home") {
142
+ event.preventDefault();
143
+ setWidth(0); // will fall back to minWidth
144
+ return;
145
+ }
146
+ if (event.key === "End") {
147
+ event.preventDefault();
148
+ const autoWidth = getAutoColumnWidth(thRef);
149
+ if (autoWidth && autoWidth > currentWidth) {
150
+ setWidth(autoWidth);
151
+ }
152
+ return;
153
+ }
154
+ if (event.key === "Escape") {
155
+ setIsResizingWithKeyboard(false);
149
156
  }
150
157
  },
151
- [isResizingWithKeyboard, setWidth],
158
+ [isResizingWithKeyboard, setWidth, thRef],
152
159
  );
153
160
 
154
161
  const startResize = useCallback(
155
- (th: HTMLTableCellElement, startX: number) => {
156
- setIsResizingWithMouse(true);
157
- const startWidth = th.offsetWidth;
162
+ (startX: number) => {
163
+ const startWidth = thRef.current?.offsetWidth ?? 0;
158
164
 
159
165
  function onPointerMove(clientX: number) {
160
- setWidth(startWidth + (clientX - startX));
166
+ const currentWidth = thRef.current?.offsetWidth ?? 0;
167
+ const newWidth = startWidth + (clientX - startX);
168
+
169
+ const min = parseWidth(minWidth) ?? 0;
170
+ const max = parseWidth(maxWidth) ?? Infinity;
171
+
172
+ if (newWidth > max) {
173
+ setWidth(newWidth < currentWidth ? newWidth : currentWidth);
174
+ return;
175
+ }
176
+ if (newWidth < min) {
177
+ setWidth(newWidth > currentWidth ? newWidth : currentWidth);
178
+ return;
179
+ }
180
+
181
+ setWidth(newWidth);
161
182
  }
162
183
 
163
184
  function onMouseMove(e: MouseEvent) {
@@ -172,28 +193,26 @@ function useTableColumnResize(
172
193
  function cleanup() {
173
194
  document.removeEventListener("mousemove", onMouseMove);
174
195
  document.removeEventListener("touchmove", onTouchMove);
175
- document.removeEventListener("mouseup", cleanup);
176
- document.removeEventListener("touchend", cleanup);
177
- document.removeEventListener("touchcancel", cleanup);
178
- setIsResizingWithMouse(false);
196
+ setIsResizingWithKeyboard(false);
197
+
198
+ // We only want onClick to trigger when using the keyboard
199
+ // (we use onClick b.c. keyDown doesn't fire when using a screen reader)
200
+ ignoreNextOnClick.current = true;
179
201
  }
180
202
 
181
203
  document.addEventListener("mousemove", onMouseMove);
182
204
  document.addEventListener("touchmove", onTouchMove, { passive: false });
183
- document.addEventListener("mouseup", cleanup);
184
- document.addEventListener("touchend", cleanup);
185
- document.addEventListener("touchcancel", cleanup);
205
+ document.addEventListener("mouseup", cleanup, { once: true });
206
+ document.addEventListener("touchend", cleanup, { once: true });
207
+ document.addEventListener("touchcancel", cleanup, { once: true });
186
208
  },
187
- [setWidth],
209
+ [maxWidth, minWidth, setWidth, thRef],
188
210
  );
189
211
 
190
212
  const handleMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"] =
191
213
  useCallback(
192
214
  (event) => {
193
- const th = (event.target as HTMLElement).closest(
194
- "th",
195
- ) as HTMLTableCellElement;
196
- startResize(th, event.clientX);
215
+ startResize(event.clientX);
197
216
  },
198
217
  [startResize],
199
218
  );
@@ -201,38 +220,19 @@ function useTableColumnResize(
201
220
  const handleTouchStart: DOMAttributes<HTMLButtonElement>["onTouchStart"] =
202
221
  useCallback(
203
222
  (event) => {
204
- const th = (event.target as HTMLElement).closest(
205
- "th",
206
- ) as HTMLTableCellElement;
207
- startResize(th, event.touches[0].clientX);
223
+ startResize(event.touches[0].clientX);
208
224
  },
209
225
  [startResize],
210
226
  );
211
227
 
212
- /**
213
- * TODO: Do we even want this?
214
- * - + 32px padding is hardcoded now, fix this
215
- * - Need to find widest element in column, not the header itself.
216
- * - Should doubleclick just reset to defaultWidth? Or add a autoWidth prop.
217
- */
228
+ // Auto-size column to fit content on double click. NB: Doesn't work with block content!
218
229
  const handleDoubleClick: DOMAttributes<HTMLButtonElement>["onDoubleClick"] =
219
- useCallback(
220
- (event) => {
221
- const th = (event.target as HTMLElement).closest(
222
- "th",
223
- ) as HTMLTableCellElement;
224
-
225
- const contentEl = th.getElementsByClassName(
226
- "aksel-data-table__th-content",
227
- )[0];
228
- const range = document.createRange();
229
- range.selectNodeContents(contentEl);
230
- const contentWidth = range.getBoundingClientRect().width;
231
-
232
- setWidth(contentWidth + 32);
233
- },
234
- [setWidth],
235
- );
230
+ useCallback(() => {
231
+ const newColumnWidth = getAutoColumnWidth(thRef);
232
+ if (newColumnWidth) {
233
+ setWidth(newColumnWidth);
234
+ }
235
+ }, [setWidth, thRef]);
236
236
 
237
237
  if (tableContext.layout !== "fixed") {
238
238
  return {
@@ -252,6 +252,7 @@ function useTableColumnResize(
252
252
  onKeyDown: handleKeyDown,
253
253
  onBlur: () => setIsResizingWithKeyboard(false),
254
254
  onDoubleClick: handleDoubleClick,
255
+ onClick: handleOnClick,
255
256
  },
256
257
  isResizingWithKeyboard,
257
258
  enabled: true,
@@ -272,5 +273,77 @@ function parseWidth(width: ColumnWidth | undefined): number | undefined {
272
273
  return undefined;
273
274
  }
274
275
 
276
+ function getAutoColumnWidth(
277
+ thRef: React.RefObject<HTMLTableCellElement | null>,
278
+ ) {
279
+ const th = thRef.current!;
280
+ const thContent = th.querySelector(".aksel-data-table__th-content");
281
+ const thPaddingEl = th.querySelector("div");
282
+ const rows = th.closest("table")?.querySelectorAll("tbody tr, tfoot tr");
283
+ if (!thContent || !thPaddingEl || !rows) {
284
+ return;
285
+ }
286
+
287
+ // Find needed width for header cell
288
+ const contentWidth = thContent.scrollWidth;
289
+ const paddingElStyle = window.getComputedStyle(thPaddingEl);
290
+ const thInlinePadding =
291
+ parseInt(paddingElStyle.paddingLeft, 10) +
292
+ parseInt(paddingElStyle.paddingRight, 10);
293
+ let newColumnWidth = contentWidth + thInlinePadding;
294
+
295
+ // Find column position
296
+ let columnPosition = 1;
297
+ let prevSibling = th.previousElementSibling;
298
+ while (prevSibling) {
299
+ columnPosition += (prevSibling as HTMLTableCellElement).colSpan;
300
+ prevSibling = prevSibling.previousElementSibling;
301
+ }
302
+
303
+ // Find needed width for each cell in column in tbody and tfoot
304
+ const range = document.createRange();
305
+ let skipRows = 0;
306
+ for (const row of rows) {
307
+ // Skip rows where the cell in this column is covered by a rowspan from a previous row
308
+ if (skipRows > 0) {
309
+ skipRows--;
310
+ continue;
311
+ }
312
+
313
+ // Find cell
314
+ let cell = row.firstChild as HTMLTableCellElement;
315
+ let currentPosition = cell.colSpan;
316
+ while (columnPosition > currentPosition && cell.nextElementSibling) {
317
+ cell = cell.nextElementSibling as HTMLTableCellElement;
318
+ currentPosition += cell.colSpan;
319
+ }
320
+ skipRows = cell.rowSpan - 1;
321
+
322
+ // Find needed width
323
+ const cellContent = cell.firstChild as HTMLElement;
324
+ range.selectNodeContents(cellContent);
325
+ const cellContentWidth = range.getBoundingClientRect().width;
326
+ const contentElStyle = window.getComputedStyle(cellContent);
327
+ const inlinePadding =
328
+ parseInt(contentElStyle.paddingLeft, 10) +
329
+ parseInt(contentElStyle.paddingRight, 10);
330
+ const widthNeededForThisCell =
331
+ (cellContentWidth + inlinePadding) / cell.colSpan;
332
+ if (widthNeededForThisCell > newColumnWidth) {
333
+ newColumnWidth = widthNeededForThisCell;
334
+ }
335
+ }
336
+
337
+ // Make sure new width is not wider than the table container since that would be impractical
338
+ const container = th.closest("div");
339
+ const maxColWidth =
340
+ (container?.offsetWidth || document.documentElement.clientWidth * 0.9) *
341
+ 0.95;
342
+
343
+ return newColumnWidth > maxColWidth
344
+ ? Math.floor(maxColWidth)
345
+ : Math.ceil(newColumnWidth);
346
+ }
347
+
275
348
  export { useTableColumnResize };
276
349
  export type { ResizeProps };
@@ -0,0 +1,58 @@
1
+ type TableRowEntryId = string | number;
2
+
3
+ type CollectTableRowEntriesArgs<T> = {
4
+ items: T[];
5
+ getRowId?: (rowData: T, index: number) => TableRowEntryId;
6
+ getSubRows?: (rowData: T) => T[];
7
+ isSubRowExpandable?: (rowData: T) => boolean;
8
+ };
9
+
10
+ interface ItemDetail<T> {
11
+ id: string | number;
12
+ level: number;
13
+ parent: null | T;
14
+ children: readonly T[];
15
+ }
16
+
17
+ function collectTableRowEntries<T>({
18
+ items,
19
+ getRowId,
20
+ getSubRows,
21
+ isSubRowExpandable,
22
+ }: CollectTableRowEntriesArgs<T>): Map<T, ItemDetail<T>> {
23
+ const itemDetailsMap = new Map<T, ItemDetail<T>>();
24
+
25
+ const traverseRows = (
26
+ rows: T[],
27
+ level: number,
28
+ parent: T | null,
29
+ parentId?: TableRowEntryId,
30
+ ) => {
31
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
32
+ const rowData = rows[rowIndex];
33
+ const rowId =
34
+ getRowId?.(rowData, rowIndex) ??
35
+ (parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
36
+ const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
37
+ const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
38
+
39
+ itemDetailsMap.set(rowData, {
40
+ id: rowId,
41
+ level,
42
+ parent,
43
+ children,
44
+ });
45
+
46
+ if (children.length > 0) {
47
+ traverseRows(children, level + 1, rowData, rowId);
48
+ }
49
+ }
50
+ };
51
+
52
+ traverseRows(items, 0, null);
53
+
54
+ return itemDetailsMap;
55
+ }
56
+
57
+ export { collectTableRowEntries };
58
+ export type { CollectTableRowEntriesArgs, TableRowEntryId, ItemDetail };
@@ -55,10 +55,6 @@ function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
55
55
  */
56
56
  function shouldBlockNavigation(event: KeyboardEvent): boolean {
57
57
  const key = event.key;
58
- if (!(key in keyToCoord)) {
59
- return false;
60
- }
61
-
62
58
  const el = event.target as HTMLElement | null;
63
59
  if (!el) {
64
60
  return false;
@@ -0,0 +1,115 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import React from "react";
3
+ import { describe, expect, test, vi } from "vitest";
4
+ import {
5
+ DataTableExpansionProvider,
6
+ useDataTableExpansion,
7
+ } from "../useTableExpansion";
8
+ import { useTableItems } from "../useTableItems";
9
+
10
+ type TestRow = {
11
+ id: number;
12
+ subRows?: TestRow[];
13
+ };
14
+
15
+ function createWrapper(
16
+ options: {
17
+ onDetailsPanelChange?: (ids: (string | number)[]) => void;
18
+ isDetailsPanelExpandable?: (row: TestRow) => boolean;
19
+ } = {},
20
+ ) {
21
+ const rows: TestRow[] = [{ id: 1, subRows: [{ id: 10 }] }, { id: 2 }];
22
+
23
+ return function Wrapper({ children }: { children: React.ReactNode }) {
24
+ const tableItems = useTableItems({
25
+ items: rows,
26
+ getRowId: (row) => row.id,
27
+ getSubRows: (row) => row.subRows ?? [],
28
+ });
29
+
30
+ return (
31
+ <DataTableExpansionProvider<TestRow>
32
+ itemDetails={tableItems.itemDetails}
33
+ getDetailsPanelContent={(row) => row.id}
34
+ isDetailsPanelExpandable={options.isDetailsPanelExpandable}
35
+ onDetailsPanelChange={options.onDetailsPanelChange}
36
+ >
37
+ {children}
38
+ </DataTableExpansionProvider>
39
+ );
40
+ };
41
+ }
42
+
43
+ describe("useTableExpansion", () => {
44
+ test("does not allow toggling rows that are not expandable", () => {
45
+ const onDetailsPanelChange = vi.fn();
46
+
47
+ const { result } = renderHook(() => useDataTableExpansion(), {
48
+ wrapper: createWrapper({
49
+ onDetailsPanelChange,
50
+ isDetailsPanelExpandable: (row) => row.id === 1,
51
+ }),
52
+ });
53
+
54
+ expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
55
+ expect(result.current.isDetailsPanelExpandable(2)).toBe(false);
56
+ expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
57
+
58
+ act(() => {
59
+ result.current.toggleExpansion(2);
60
+ });
61
+
62
+ expect(result.current.isExpanded(2)).toBe(false);
63
+ expect(onDetailsPanelChange).not.toHaveBeenCalled();
64
+
65
+ act(() => {
66
+ result.current.toggleExpansion(1);
67
+ });
68
+
69
+ expect(result.current.isExpanded(1)).toBe(true);
70
+ expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
71
+ });
72
+
73
+ test("expand all only expands expandable rows", () => {
74
+ const onDetailsPanelChange = vi.fn();
75
+
76
+ const { result } = renderHook(() => useDataTableExpansion(), {
77
+ wrapper: createWrapper({
78
+ onDetailsPanelChange,
79
+ isDetailsPanelExpandable: (row) => row.id === 1,
80
+ }),
81
+ });
82
+
83
+ act(() => {
84
+ result.current.toggleAll();
85
+ });
86
+
87
+ expect(result.current.isExpanded(1)).toBe(true);
88
+ expect(result.current.isExpanded(2)).toBe(false);
89
+ expect(result.current.isAllExpanded).toBe(true);
90
+ expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
91
+ });
92
+
93
+ test("expand all only targets top-level table items", () => {
94
+ const onDetailsPanelChange = vi.fn();
95
+
96
+ const { result } = renderHook(() => useDataTableExpansion(), {
97
+ wrapper: createWrapper({
98
+ onDetailsPanelChange,
99
+ }),
100
+ });
101
+
102
+ expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
103
+ expect(result.current.isDetailsPanelExpandable(2)).toBe(true);
104
+ expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
105
+
106
+ act(() => {
107
+ result.current.toggleAll();
108
+ });
109
+
110
+ expect(result.current.isExpanded(1)).toBe(true);
111
+ expect(result.current.isExpanded(2)).toBe(true);
112
+ expect(result.current.isExpanded(10)).toBe(false);
113
+ expect(onDetailsPanelChange).toHaveBeenCalledWith([1, 2]);
114
+ });
115
+ });