@navikt/ds-react 8.4.0 → 8.5.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 (89) hide show
  1. package/cjs/accordion/Accordion.d.ts +10 -0
  2. package/cjs/accordion/Accordion.js +2 -2
  3. package/cjs/accordion/Accordion.js.map +1 -1
  4. package/cjs/data/table/helpers/table-cell.d.ts +2 -2
  5. package/cjs/data/table/helpers/table-cell.js +2 -5
  6. package/cjs/data/table/helpers/table-cell.js.map +1 -1
  7. package/cjs/data/table/helpers/table-focus.d.ts +26 -2
  8. package/cjs/data/table/helpers/table-focus.js +60 -9
  9. package/cjs/data/table/helpers/table-focus.js.map +1 -1
  10. package/cjs/data/table/helpers/table-grid-nav.d.ts +45 -9
  11. package/cjs/data/table/helpers/table-grid-nav.js +108 -24
  12. package/cjs/data/table/helpers/table-grid-nav.js.map +1 -1
  13. package/cjs/data/table/helpers/table-keyboard.d.ts +24 -3
  14. package/cjs/data/table/helpers/table-keyboard.js +30 -5
  15. package/cjs/data/table/helpers/table-keyboard.js.map +1 -1
  16. package/cjs/data/table/hooks/useGridCache.d.ts +17 -0
  17. package/cjs/data/table/hooks/useGridCache.js +65 -0
  18. package/cjs/data/table/hooks/useGridCache.js.map +1 -0
  19. package/cjs/data/table/root/DataTableRoot.d.ts +1 -1
  20. package/cjs/data/table/root/DataTableRoot.js +2 -2
  21. package/cjs/data/table/root/DataTableRoot.js.map +1 -1
  22. package/cjs/data/table/root/useTableKeyboardNav.d.ts +9 -3
  23. package/cjs/data/table/root/useTableKeyboardNav.js +53 -95
  24. package/cjs/data/table/root/useTableKeyboardNav.js.map +1 -1
  25. package/cjs/data/table/th/DataTableTh.d.ts +1 -1
  26. package/cjs/data/table/th/DataTableTh.js +3 -1
  27. package/cjs/data/table/th/DataTableTh.js.map +1 -1
  28. package/cjs/data/table/tr/DataTableTr.d.ts +6 -2
  29. package/cjs/data/table/tr/DataTableTr.js +4 -2
  30. package/cjs/data/table/tr/DataTableTr.js.map +1 -1
  31. package/cjs/link-card/LinkCard.d.ts +13 -0
  32. package/cjs/link-card/LinkCard.js +2 -2
  33. package/cjs/link-card/LinkCard.js.map +1 -1
  34. package/cjs/process/Process.d.ts +1 -1
  35. package/cjs/utils/i18n/locales/nb.d.ts +154 -75
  36. package/cjs/utils/i18n/locales/nb.js +154 -75
  37. package/cjs/utils/i18n/locales/nb.js.map +1 -1
  38. package/esm/accordion/Accordion.d.ts +10 -0
  39. package/esm/accordion/Accordion.js +2 -2
  40. package/esm/accordion/Accordion.js.map +1 -1
  41. package/esm/data/table/helpers/table-cell.d.ts +2 -2
  42. package/esm/data/table/helpers/table-cell.js +2 -5
  43. package/esm/data/table/helpers/table-cell.js.map +1 -1
  44. package/esm/data/table/helpers/table-focus.d.ts +26 -2
  45. package/esm/data/table/helpers/table-focus.js +55 -9
  46. package/esm/data/table/helpers/table-focus.js.map +1 -1
  47. package/esm/data/table/helpers/table-grid-nav.d.ts +45 -9
  48. package/esm/data/table/helpers/table-grid-nav.js +102 -23
  49. package/esm/data/table/helpers/table-grid-nav.js.map +1 -1
  50. package/esm/data/table/helpers/table-keyboard.d.ts +24 -3
  51. package/esm/data/table/helpers/table-keyboard.js +29 -4
  52. package/esm/data/table/helpers/table-keyboard.js.map +1 -1
  53. package/esm/data/table/hooks/useGridCache.d.ts +17 -0
  54. package/esm/data/table/hooks/useGridCache.js +63 -0
  55. package/esm/data/table/hooks/useGridCache.js.map +1 -0
  56. package/esm/data/table/root/DataTableRoot.d.ts +1 -1
  57. package/esm/data/table/root/DataTableRoot.js +2 -2
  58. package/esm/data/table/root/DataTableRoot.js.map +1 -1
  59. package/esm/data/table/root/useTableKeyboardNav.d.ts +9 -3
  60. package/esm/data/table/root/useTableKeyboardNav.js +58 -100
  61. package/esm/data/table/root/useTableKeyboardNav.js.map +1 -1
  62. package/esm/data/table/th/DataTableTh.d.ts +1 -1
  63. package/esm/data/table/th/DataTableTh.js +3 -1
  64. package/esm/data/table/th/DataTableTh.js.map +1 -1
  65. package/esm/data/table/tr/DataTableTr.d.ts +6 -2
  66. package/esm/data/table/tr/DataTableTr.js +4 -2
  67. package/esm/data/table/tr/DataTableTr.js.map +1 -1
  68. package/esm/link-card/LinkCard.d.ts +13 -0
  69. package/esm/link-card/LinkCard.js +2 -2
  70. package/esm/link-card/LinkCard.js.map +1 -1
  71. package/esm/process/Process.d.ts +1 -1
  72. package/esm/utils/i18n/locales/nb.d.ts +154 -75
  73. package/esm/utils/i18n/locales/nb.js +154 -75
  74. package/esm/utils/i18n/locales/nb.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/accordion/Accordion.tsx +19 -2
  77. package/src/data/table/helpers/table-cell.ts +2 -7
  78. package/src/data/table/helpers/table-focus.ts +70 -9
  79. package/src/data/table/helpers/table-grid-nav.ts +146 -31
  80. package/src/data/table/helpers/table-keyboard.test.ts +27 -27
  81. package/src/data/table/helpers/table-keyboard.ts +43 -4
  82. package/src/data/table/hooks/useGridCache.ts +73 -0
  83. package/src/data/table/root/DataTableRoot.tsx +2 -3
  84. package/src/data/table/root/useTableKeyboardNav.ts +95 -125
  85. package/src/data/table/th/DataTableTh.tsx +7 -3
  86. package/src/data/table/tr/DataTableTr.tsx +7 -3
  87. package/src/link-card/LinkCard.tsx +15 -1
  88. package/src/process/Process.tsx +1 -1
  89. package/src/utils/i18n/locales/nb.ts +83 -4
@@ -1,17 +1,15 @@
1
- import { getFocusableTarget } from "./table-focus";
1
+ import { findFocusableElementInCell } from "./table-focus";
2
2
 
3
3
  /**
4
4
  * Builds a utility grid allowing for easier keyboard-navigation between cells on columns and rows
5
5
  */
6
- function buildTableGrid(tableRef: HTMLTableElement): {
6
+ function buildTableGridMap(tableRef: HTMLTableElement): {
7
7
  grid: (Element | undefined)[][];
8
8
  positions: Map<Element, { x: number; y: number }>;
9
- maxCols: number;
10
9
  } {
11
10
  const rows = tableRef.rows;
12
11
  const grid: (Element | undefined)[][] = [];
13
12
  const positions = new Map<Element, { x: number; y: number }>();
14
- let maxCols = 0;
15
13
 
16
14
  /* Walk trough each row in order */
17
15
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
@@ -51,32 +49,53 @@ function buildTableGrid(tableRef: HTMLTableElement): {
51
49
  }
52
50
 
53
51
  colIndex += colSpan;
54
- if (colIndex > maxCols) {
55
- maxCols = colIndex;
56
- }
57
52
  }
58
53
  }
59
54
 
60
- return { grid, positions, maxCols };
55
+ return { grid, positions };
61
56
  }
62
57
 
63
- type TableGrid = ReturnType<typeof buildTableGrid>;
64
-
65
- type GridCache = { grid: TableGrid | null; dirty: boolean };
58
+ type GridCache = {
59
+ grid: ReturnType<typeof buildTableGridMap> | null;
60
+ dirty: boolean;
61
+ };
66
62
 
67
63
  /**
68
- * Makes sure to keep a cached version of the table grid, and only rebuild it when necessary (when "dirty" flag is set)
64
+ * Pure function that calculates the next grid position given a current position and delta.
65
+ * Returns the position if valid, or null if out of bounds.
69
66
  */
70
- function ensureTableGrid(
71
- tableRef: HTMLTableElement,
72
- tableGridCache: GridCache,
73
- ): TableGrid {
74
- if (tableGridCache.dirty || !tableGridCache.grid) {
75
- tableGridCache.grid = buildTableGrid(tableRef);
76
- tableGridCache.dirty = false;
67
+ function getNextGridPosition(
68
+ grid: (Element | undefined)[][],
69
+ currentPos: { x: number; y: number },
70
+ delta: { x: number; y: number },
71
+ ): { x: number; y: number } | null {
72
+ const x = currentPos.x + delta.x;
73
+ const y = currentPos.y + delta.y;
74
+
75
+ if (y < 0 || y >= grid.length) {
76
+ return null;
77
+ }
78
+
79
+ const row = grid[y] ?? [];
80
+ if (x < 0 || x >= row.length) {
81
+ return null;
77
82
  }
78
83
 
79
- return tableGridCache.grid;
84
+ return { x, y };
85
+ }
86
+
87
+ /**
88
+ * Checks if a cell is focusable (not the same as current cell and contains focusable elements).
89
+ * Type guard that narrows Element | undefined to Element.
90
+ */
91
+ function isCellFocusable(
92
+ cell: Element | undefined,
93
+ currentCell: Element,
94
+ ): cell is Element {
95
+ if (!cell || cell === currentCell) {
96
+ return false;
97
+ }
98
+ return !!findFocusableElementInCell(cell);
80
99
  }
81
100
 
82
101
  /**
@@ -84,29 +103,125 @@ function ensureTableGrid(
84
103
  * Skips over cells that are not focusable or are the same as the current cell.
85
104
  * Returns null if no next cell is found in the given direction.
86
105
  */
87
- function findNextCell(
106
+ function findNextFocusableCell(
88
107
  grid: (Element | undefined)[][],
89
108
  currentPos: { x: number; y: number },
90
109
  delta: { x: number; y: number },
91
110
  currentCell: Element,
92
- maxCols: number,
93
111
  ): Element | null {
94
- let x = currentPos.x + delta.x;
95
- let y = currentPos.y + delta.y;
112
+ let position = currentPos;
96
113
 
97
- const maxRows = grid.length;
114
+ while (true) {
115
+ const nextPos = getNextGridPosition(grid, position, delta);
116
+ if (!nextPos) {
117
+ return null;
118
+ }
98
119
 
99
- while (y >= 0 && y < maxRows && x >= 0 && x < maxCols) {
100
- const row = grid[y] ?? [];
120
+ const cell = grid[nextPos.y][nextPos.x];
121
+ if (isCellFocusable(cell, currentCell)) {
122
+ return cell;
123
+ }
124
+
125
+ position = nextPos;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Finds the first focusable cell in the same row as the current position.
131
+ */
132
+ function findFirstCellInRow(
133
+ grid: (Element | undefined)[][],
134
+ positions: Map<Element, { x: number; y: number }>,
135
+ currentCell: Element,
136
+ ): Element | null {
137
+ const currentPos = positions.get(currentCell);
138
+ if (!currentPos) {
139
+ return null;
140
+ }
141
+
142
+ const row = grid[currentPos.y] ?? [];
143
+ for (let x = 0; x < row.length; x += 1) {
144
+ const cell = row[x];
145
+ if (isCellFocusable(cell, currentCell)) {
146
+ return cell;
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Finds the last focusable cell in the same row as the current position.
155
+ */
156
+ function findLastCellInRow(
157
+ grid: (Element | undefined)[][],
158
+ positions: Map<Element, { x: number; y: number }>,
159
+ currentCell: Element,
160
+ ): Element | null {
161
+ const currentPos = positions.get(currentCell);
162
+ if (!currentPos) {
163
+ return null;
164
+ }
165
+
166
+ const row = grid[currentPos.y] ?? [];
167
+ for (let x = row.length - 1; x >= 0; x -= 1) {
101
168
  const cell = row[x];
102
- if (cell && cell !== currentCell && !!getFocusableTarget(cell)) {
169
+ if (isCellFocusable(cell, currentCell)) {
103
170
  return cell;
104
171
  }
105
- x += delta.x;
106
- y += delta.y;
107
172
  }
108
173
 
109
174
  return null;
110
175
  }
111
176
 
112
- export { buildTableGrid, ensureTableGrid, findNextCell, type GridCache };
177
+ /**
178
+ * Finds the first focusable cell in the entire table.
179
+ */
180
+ function findFirstCell(
181
+ grid: (Element | undefined)[][],
182
+ currentCell: Element,
183
+ ): Element | null {
184
+ for (let y = 0; y < grid.length; y += 1) {
185
+ const row = grid[y] ?? [];
186
+ for (let x = 0; x < row.length; x += 1) {
187
+ const cell = row[x];
188
+ if (isCellFocusable(cell, currentCell)) {
189
+ return cell;
190
+ }
191
+ }
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Finds the last focusable cell in the entire table.
199
+ */
200
+ function findLastCell(
201
+ grid: (Element | undefined)[][],
202
+ currentCell: Element,
203
+ ): Element | null {
204
+ for (let y = grid.length - 1; y >= 0; y -= 1) {
205
+ const row = grid[y] ?? [];
206
+ for (let x = row.length - 1; x >= 0; x -= 1) {
207
+ const cell = row[x];
208
+ if (isCellFocusable(cell, currentCell)) {
209
+ return cell;
210
+ }
211
+ }
212
+ }
213
+
214
+ return null;
215
+ }
216
+
217
+ export {
218
+ buildTableGridMap,
219
+ findFirstCell,
220
+ findFirstCellInRow,
221
+ findLastCell,
222
+ findLastCellInRow,
223
+ findNextFocusableCell,
224
+ getNextGridPosition,
225
+ isCellFocusable,
226
+ };
227
+ export type { GridCache };
@@ -1,26 +1,26 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { shouldBlockArrowKeyNavigation } from "./table-keyboard";
2
+ import { shouldBlockNavigation } from "./table-keyboard";
3
3
 
4
- describe("shouldBlockArrowKeyNavigation", () => {
4
+ describe("shouldBlockNavigation", () => {
5
5
  test("should return false for non-arrow keys", () => {
6
6
  const event = new KeyboardEvent("keydown", { key: "Enter" });
7
7
  Object.defineProperty(event, "target", {
8
8
  value: document.createElement("div"),
9
9
  });
10
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
10
+ expect(shouldBlockNavigation(event)).toBe(false);
11
11
  });
12
12
 
13
13
  test("should return false when target is null", () => {
14
14
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
15
15
  Object.defineProperty(event, "target", { value: null });
16
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
16
+ expect(shouldBlockNavigation(event)).toBe(false);
17
17
  });
18
18
 
19
19
  test("should return false for non-editable elements", () => {
20
20
  const div = document.createElement("div");
21
21
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
22
22
  Object.defineProperty(event, "target", { value: div });
23
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
23
+ expect(shouldBlockNavigation(event)).toBe(false);
24
24
  });
25
25
 
26
26
  test("should return true when target is contentEditable", () => {
@@ -30,7 +30,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
30
30
 
31
31
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
32
32
  Object.defineProperty(event, "target", { value: div });
33
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
33
+ expect(shouldBlockNavigation(event)).toBe(true);
34
34
 
35
35
  document.body.removeChild(div);
36
36
  });
@@ -44,7 +44,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
44
44
 
45
45
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
46
46
  Object.defineProperty(event, "target", { value: child });
47
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
47
+ expect(shouldBlockNavigation(event)).toBe(true);
48
48
 
49
49
  document.body.removeChild(parent);
50
50
  });
@@ -54,7 +54,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
54
54
  input.type = "checkbox";
55
55
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
56
56
  Object.defineProperty(event, "target", { value: input });
57
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
57
+ expect(shouldBlockNavigation(event)).toBe(false);
58
58
  });
59
59
 
60
60
  test("should return false for radio input", () => {
@@ -62,7 +62,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
62
62
  input.type = "radio";
63
63
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
64
64
  Object.defineProperty(event, "target", { value: input });
65
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
65
+ expect(shouldBlockNavigation(event)).toBe(false);
66
66
  });
67
67
 
68
68
  test("should return false for non-text input types", () => {
@@ -70,7 +70,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
70
70
  input.type = "button";
71
71
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
72
72
  Object.defineProperty(event, "target", { value: input });
73
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
73
+ expect(shouldBlockNavigation(event)).toBe(false);
74
74
  });
75
75
 
76
76
  test("should return true for text input when selectionStart is null", () => {
@@ -86,7 +86,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
86
86
  });
87
87
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
88
88
  Object.defineProperty(event, "target", { value: input });
89
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
89
+ expect(shouldBlockNavigation(event)).toBe(true);
90
90
  });
91
91
 
92
92
  test("should block ArrowLeft when cursor is not at start of text input", () => {
@@ -96,7 +96,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
96
96
  input.setSelectionRange(2, 2);
97
97
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
98
98
  Object.defineProperty(event, "target", { value: input });
99
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
99
+ expect(shouldBlockNavigation(event)).toBe(true);
100
100
  });
101
101
 
102
102
  test("should not block ArrowLeft when cursor is at start of text input", () => {
@@ -106,7 +106,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
106
106
  input.setSelectionRange(0, 0);
107
107
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
108
108
  Object.defineProperty(event, "target", { value: input });
109
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
109
+ expect(shouldBlockNavigation(event)).toBe(false);
110
110
  });
111
111
 
112
112
  test("should block ArrowRight when cursor is not at end of text input", () => {
@@ -116,7 +116,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
116
116
  input.setSelectionRange(2, 2);
117
117
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
118
118
  Object.defineProperty(event, "target", { value: input });
119
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
119
+ expect(shouldBlockNavigation(event)).toBe(true);
120
120
  });
121
121
 
122
122
  test("should not block ArrowRight when cursor is at end of text input", () => {
@@ -126,7 +126,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
126
126
  input.setSelectionRange(4, 4);
127
127
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
128
128
  Object.defineProperty(event, "target", { value: input });
129
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
129
+ expect(shouldBlockNavigation(event)).toBe(false);
130
130
  });
131
131
 
132
132
  test("should block ArrowLeft when text is selected in input", () => {
@@ -136,7 +136,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
136
136
  input.setSelectionRange(0, 2);
137
137
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
138
138
  Object.defineProperty(event, "target", { value: input });
139
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
139
+ expect(shouldBlockNavigation(event)).toBe(true);
140
140
  });
141
141
 
142
142
  test("should block ArrowRight when text is selected in input", () => {
@@ -146,7 +146,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
146
146
  input.setSelectionRange(1, 3);
147
147
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
148
148
  Object.defineProperty(event, "target", { value: input });
149
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
149
+ expect(shouldBlockNavigation(event)).toBe(true);
150
150
  });
151
151
 
152
152
  test("should handle various text input types", () => {
@@ -161,7 +161,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
161
161
  input.setSelectionRange(2, 2);
162
162
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
163
163
  Object.defineProperty(event, "target", { value: input });
164
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
164
+ expect(shouldBlockNavigation(event)).toBe(true);
165
165
  });
166
166
  });
167
167
 
@@ -171,7 +171,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
171
171
  textarea.setSelectionRange(5, 5);
172
172
  const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
173
173
  Object.defineProperty(event, "target", { value: textarea });
174
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
174
+ expect(shouldBlockNavigation(event)).toBe(true);
175
175
  });
176
176
 
177
177
  test("should not block ArrowUp when cursor is at start of textarea", () => {
@@ -180,7 +180,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
180
180
  textarea.setSelectionRange(0, 0);
181
181
  const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
182
182
  Object.defineProperty(event, "target", { value: textarea });
183
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
183
+ expect(shouldBlockNavigation(event)).toBe(false);
184
184
  });
185
185
 
186
186
  test("should block ArrowDown when cursor is not at end of textarea", () => {
@@ -189,7 +189,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
189
189
  textarea.setSelectionRange(5, 5);
190
190
  const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
191
191
  Object.defineProperty(event, "target", { value: textarea });
192
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
192
+ expect(shouldBlockNavigation(event)).toBe(true);
193
193
  });
194
194
 
195
195
  test("should not block ArrowDown when cursor is at end of textarea", () => {
@@ -198,35 +198,35 @@ describe("shouldBlockArrowKeyNavigation", () => {
198
198
  textarea.setSelectionRange(11, 11);
199
199
  const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
200
200
  Object.defineProperty(event, "target", { value: textarea });
201
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
201
+ expect(shouldBlockNavigation(event)).toBe(false);
202
202
  });
203
203
 
204
204
  test("should return true for select with ArrowDown", () => {
205
205
  const select = document.createElement("select");
206
206
  const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
207
207
  Object.defineProperty(event, "target", { value: select });
208
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
208
+ expect(shouldBlockNavigation(event)).toBe(true);
209
209
  });
210
210
 
211
211
  test("should return true for select with ArrowUp", () => {
212
212
  const select = document.createElement("select");
213
213
  const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
214
214
  Object.defineProperty(event, "target", { value: select });
215
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
215
+ expect(shouldBlockNavigation(event)).toBe(true);
216
216
  });
217
217
 
218
218
  test("should return false for select with ArrowLeft", () => {
219
219
  const select = document.createElement("select");
220
220
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
221
221
  Object.defineProperty(event, "target", { value: select });
222
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
222
+ expect(shouldBlockNavigation(event)).toBe(false);
223
223
  });
224
224
 
225
225
  test("should return false for select with ArrowRight", () => {
226
226
  const select = document.createElement("select");
227
227
  const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
228
228
  Object.defineProperty(event, "target", { value: select });
229
- expect(shouldBlockArrowKeyNavigation(event)).toBe(false);
229
+ expect(shouldBlockNavigation(event)).toBe(false);
230
230
  });
231
231
 
232
232
  test("should handle element inside editable parent", () => {
@@ -240,7 +240,7 @@ describe("shouldBlockArrowKeyNavigation", () => {
240
240
 
241
241
  const event = new KeyboardEvent("keydown", { key: "ArrowLeft" });
242
242
  Object.defineProperty(event, "target", { value: input });
243
- expect(shouldBlockArrowKeyNavigation(event)).toBe(true);
243
+ expect(shouldBlockNavigation(event)).toBe(true);
244
244
 
245
245
  document.body.removeChild(wrapper);
246
246
  });
@@ -7,16 +7,46 @@ const keyToCoord = {
7
7
 
8
8
  type DirectionsT = keyof typeof keyToCoord;
9
9
  type Delta = { x: number; y: number };
10
+ type NavigationAction =
11
+ | { type: "delta"; delta: Delta }
12
+ | { type: "home" }
13
+ | { type: "end" }
14
+ | { type: "tableStart" }
15
+ | { type: "tableEnd" };
10
16
 
11
- function getDeltaFromKey(key: string): Delta | null {
17
+ /**
18
+ * Maps keyboard events to navigation actions.
19
+ * Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
20
+ * and PageUp/PageDown (multi-row navigation).
21
+ */
22
+ function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
23
+ const key = event.key;
24
+
25
+ /* Arrow keys -> directional navigation */
12
26
  if (key in keyToCoord) {
13
- return keyToCoord[key as DirectionsT];
27
+ return { type: "delta", delta: keyToCoord[key as DirectionsT] };
28
+ }
29
+
30
+ // Home/End keys
31
+ if (key === "Home") {
32
+ return event.ctrlKey || event.metaKey
33
+ ? { type: "tableStart" }
34
+ : { type: "home" };
35
+ }
36
+
37
+ if (key === "End") {
38
+ return event.ctrlKey || event.metaKey
39
+ ? { type: "tableEnd" }
40
+ : { type: "end" };
14
41
  }
15
42
 
16
43
  return null;
17
44
  }
18
45
 
19
46
  /**
47
+ * Determines if keyboard navigation should be blocked based on the current focus context.
48
+ * Allows for custom blocking logic via an optional callback.
49
+ *
20
50
  * Tries to make assumptions of what the user is currently doing inside a table cell
21
51
  * Should block navigation if:
22
52
  * - Input has selection, caret is not at start/end
@@ -24,7 +54,15 @@ function getDeltaFromKey(key: string): Delta | null {
24
54
  * - User is navigating inside multiline textarea
25
55
  * - contenteditable attrb is in use
26
56
  */
27
- function shouldBlockArrowKeyNavigation(event: KeyboardEvent): boolean {
57
+ function shouldBlockNavigation(
58
+ event: KeyboardEvent,
59
+ customBlockFn?: (event: KeyboardEvent) => boolean,
60
+ ): boolean {
61
+ /* Check custom block function first */
62
+ if (customBlockFn?.(event)) {
63
+ return true;
64
+ }
65
+
28
66
  const key = event.key;
29
67
  if (!(key in keyToCoord)) {
30
68
  return false;
@@ -123,4 +161,5 @@ function isTextInputType(type: string): boolean {
123
161
  }
124
162
  }
125
163
 
126
- export { getDeltaFromKey, shouldBlockArrowKeyNavigation };
164
+ export { getNavigationAction, shouldBlockNavigation };
165
+ export type { Delta, NavigationAction };
@@ -0,0 +1,73 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useValueAsRef } from "../../../utils/hooks";
3
+ import { type GridCache, buildTableGridMap } from "../helpers/table-grid-nav";
4
+
5
+ /**
6
+ * Manages the table grid cache and observes DOM changes that require grid rebuilding.
7
+ * Watches for structural changes (rows/cells added/removed) and attribute changes
8
+ * (colspan, rowspan, hidden, style) that affect grid layout and focusability.
9
+ */
10
+ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
11
+ const gridCacheRef = useRef<GridCache>({
12
+ grid: null,
13
+ dirty: true,
14
+ });
15
+
16
+ const [activeCell, setActiveCell] = useState<Element | null>(null);
17
+ const activeCellRef = useValueAsRef(activeCell).current;
18
+ const observerRef = useRef<MutationObserver | null>(null);
19
+
20
+ useEffect(() => {
21
+ if (!tableRef || !enabled) {
22
+ return;
23
+ }
24
+
25
+ observerRef.current = new MutationObserver(() => {
26
+ gridCacheRef.current.dirty = true;
27
+ if (activeCellRef && !activeCellRef.isConnected) {
28
+ setActiveCell(null);
29
+ }
30
+ });
31
+
32
+ observerRef.current.observe(tableRef, {
33
+ subtree: true,
34
+ childList: true,
35
+ attributes: true,
36
+ attributeFilter: ["colspan", "rowspan", "hidden", "style"],
37
+ });
38
+
39
+ return () => {
40
+ if (observerRef.current) {
41
+ observerRef.current.disconnect();
42
+ observerRef.current = null;
43
+ }
44
+ };
45
+ }, [tableRef, enabled, activeCellRef]);
46
+
47
+ /**
48
+ * If keyboard-nav is re-enabled, mark grid as dirty since
49
+ * the table might have changed while it was disabled.
50
+ */
51
+ useEffect(() => {
52
+ if (enabled) {
53
+ gridCacheRef.current.dirty = true;
54
+ }
55
+ }, [enabled]);
56
+
57
+ const getTableGrid = useCallback((_tableRef: HTMLTableElement) => {
58
+ if (gridCacheRef.current.dirty || !gridCacheRef.current.grid) {
59
+ gridCacheRef.current.grid = buildTableGridMap(_tableRef);
60
+ gridCacheRef.current.dirty = false;
61
+ }
62
+
63
+ return gridCacheRef.current.grid;
64
+ }, []);
65
+
66
+ return {
67
+ getTableGrid,
68
+ activeCell,
69
+ setActiveCell,
70
+ };
71
+ }
72
+
73
+ export { useGridCache };
@@ -39,7 +39,7 @@ interface DataTableProps extends React.HTMLAttributes<HTMLTableElement> {
39
39
  }
40
40
 
41
41
  interface DataTableRootComponent extends React.ForwardRefExoticComponent<
42
- DataTableProps & React.RefAttributes<HTMLDialogElement>
42
+ DataTableProps & React.RefAttributes<HTMLTableElement>
43
43
  > {
44
44
  /**
45
45
  * @see 🏷️ {@link DataTableCaptionProps}
@@ -148,7 +148,7 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
148
148
  const [tableRef, setTableRef] = useState<HTMLTableElement | null>(null);
149
149
  const mergedRef = useMergeRefs(forwardedRef, setTableRef);
150
150
 
151
- const { onFocus, tableTabIndex } = useTableKeyboardNav(tableRef, {
151
+ const { tableTabIndex } = useTableKeyboardNav(tableRef, {
152
152
  enabled: withKeyboardNav,
153
153
  });
154
154
 
@@ -164,7 +164,6 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
164
164
  })}
165
165
  data-density={rowDensity}
166
166
  tabIndex={tableTabIndex}
167
- onFocus={onFocus}
168
167
  />
169
168
  </div>
170
169
  </div>