@navikt/ds-react 8.4.1 → 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.
- package/cjs/accordion/Accordion.d.ts +10 -0
- package/cjs/accordion/Accordion.js +2 -2
- package/cjs/accordion/Accordion.js.map +1 -1
- package/cjs/data/table/helpers/table-cell.d.ts +2 -2
- package/cjs/data/table/helpers/table-cell.js +2 -5
- package/cjs/data/table/helpers/table-cell.js.map +1 -1
- package/cjs/data/table/helpers/table-focus.d.ts +26 -2
- package/cjs/data/table/helpers/table-focus.js +60 -9
- package/cjs/data/table/helpers/table-focus.js.map +1 -1
- package/cjs/data/table/helpers/table-grid-nav.d.ts +45 -9
- package/cjs/data/table/helpers/table-grid-nav.js +108 -24
- package/cjs/data/table/helpers/table-grid-nav.js.map +1 -1
- package/cjs/data/table/helpers/table-keyboard.d.ts +24 -3
- package/cjs/data/table/helpers/table-keyboard.js +30 -5
- package/cjs/data/table/helpers/table-keyboard.js.map +1 -1
- package/cjs/data/table/hooks/useGridCache.d.ts +17 -0
- package/cjs/data/table/hooks/useGridCache.js +65 -0
- package/cjs/data/table/hooks/useGridCache.js.map +1 -0
- package/cjs/data/table/root/DataTableRoot.js +2 -2
- package/cjs/data/table/root/DataTableRoot.js.map +1 -1
- package/cjs/data/table/root/useTableKeyboardNav.d.ts +9 -3
- package/cjs/data/table/root/useTableKeyboardNav.js +53 -95
- package/cjs/data/table/root/useTableKeyboardNav.js.map +1 -1
- package/cjs/link-card/LinkCard.d.ts +13 -0
- package/cjs/link-card/LinkCard.js +2 -2
- package/cjs/link-card/LinkCard.js.map +1 -1
- package/cjs/process/Process.d.ts +1 -1
- package/cjs/utils/i18n/locales/nb.d.ts +154 -75
- package/cjs/utils/i18n/locales/nb.js +154 -75
- package/cjs/utils/i18n/locales/nb.js.map +1 -1
- package/esm/accordion/Accordion.d.ts +10 -0
- package/esm/accordion/Accordion.js +2 -2
- package/esm/accordion/Accordion.js.map +1 -1
- package/esm/data/table/helpers/table-cell.d.ts +2 -2
- package/esm/data/table/helpers/table-cell.js +2 -5
- package/esm/data/table/helpers/table-cell.js.map +1 -1
- package/esm/data/table/helpers/table-focus.d.ts +26 -2
- package/esm/data/table/helpers/table-focus.js +55 -9
- package/esm/data/table/helpers/table-focus.js.map +1 -1
- package/esm/data/table/helpers/table-grid-nav.d.ts +45 -9
- package/esm/data/table/helpers/table-grid-nav.js +102 -23
- package/esm/data/table/helpers/table-grid-nav.js.map +1 -1
- package/esm/data/table/helpers/table-keyboard.d.ts +24 -3
- package/esm/data/table/helpers/table-keyboard.js +29 -4
- package/esm/data/table/helpers/table-keyboard.js.map +1 -1
- package/esm/data/table/hooks/useGridCache.d.ts +17 -0
- package/esm/data/table/hooks/useGridCache.js +63 -0
- package/esm/data/table/hooks/useGridCache.js.map +1 -0
- package/esm/data/table/root/DataTableRoot.js +2 -2
- package/esm/data/table/root/DataTableRoot.js.map +1 -1
- package/esm/data/table/root/useTableKeyboardNav.d.ts +9 -3
- package/esm/data/table/root/useTableKeyboardNav.js +58 -100
- package/esm/data/table/root/useTableKeyboardNav.js.map +1 -1
- package/esm/link-card/LinkCard.d.ts +13 -0
- package/esm/link-card/LinkCard.js +2 -2
- package/esm/link-card/LinkCard.js.map +1 -1
- package/esm/process/Process.d.ts +1 -1
- package/esm/utils/i18n/locales/nb.d.ts +154 -75
- package/esm/utils/i18n/locales/nb.js +154 -75
- package/esm/utils/i18n/locales/nb.js.map +1 -1
- package/package.json +3 -3
- package/src/accordion/Accordion.tsx +19 -2
- package/src/data/table/helpers/table-cell.ts +2 -7
- package/src/data/table/helpers/table-focus.ts +70 -9
- package/src/data/table/helpers/table-grid-nav.ts +146 -31
- package/src/data/table/helpers/table-keyboard.test.ts +27 -27
- package/src/data/table/helpers/table-keyboard.ts +43 -4
- package/src/data/table/hooks/useGridCache.ts +73 -0
- package/src/data/table/root/DataTableRoot.tsx +1 -2
- package/src/data/table/root/useTableKeyboardNav.ts +95 -125
- package/src/link-card/LinkCard.tsx +15 -1
- package/src/process/Process.tsx +1 -1
- package/src/utils/i18n/locales/nb.ts +83 -4
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
55
|
+
return { grid, positions };
|
|
61
56
|
}
|
|
62
57
|
|
|
63
|
-
type
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
type GridCache = {
|
|
59
|
+
grid: ReturnType<typeof buildTableGridMap> | null;
|
|
60
|
+
dirty: boolean;
|
|
61
|
+
};
|
|
66
62
|
|
|
67
63
|
/**
|
|
68
|
-
*
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
|
95
|
-
let y = currentPos.y + delta.y;
|
|
112
|
+
let position = currentPos;
|
|
96
113
|
|
|
97
|
-
|
|
114
|
+
while (true) {
|
|
115
|
+
const nextPos = getNextGridPosition(grid, position, delta);
|
|
116
|
+
if (!nextPos) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
98
119
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
2
|
+
import { shouldBlockNavigation } from "./table-keyboard";
|
|
3
3
|
|
|
4
|
-
describe("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 };
|
|
@@ -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 {
|
|
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>
|