@navikt/ds-react 8.4.1 → 8.5.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.
- 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 +40 -10
- package/cjs/data/table/helpers/table-grid-nav.js +102 -25
- 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 +25 -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.d.ts +14 -4
- package/cjs/data/table/root/DataTableRoot.js +4 -6
- package/cjs/data/table/root/DataTableRoot.js.map +1 -1
- package/cjs/data/table/root/useTableKeyboardNav.d.ts +10 -4
- package/cjs/data/table/root/useTableKeyboardNav.js +70 -99
- package/cjs/data/table/root/useTableKeyboardNav.js.map +1 -1
- package/cjs/data/token-filter/AutoSuggest.d.ts +21 -0
- package/cjs/data/token-filter/AutoSuggest.js +129 -0
- package/cjs/data/token-filter/AutoSuggest.js.map +1 -0
- package/cjs/data/token-filter/TokenFilter.d.ts +11 -0
- package/cjs/data/token-filter/TokenFilter.js +91 -0
- package/cjs/data/token-filter/TokenFilter.js.map +1 -0
- package/cjs/data/token-filter/TokenFilter.types.d.ts +46 -0
- package/cjs/data/token-filter/TokenFilter.types.js +3 -0
- package/cjs/data/token-filter/TokenFilter.types.js.map +1 -0
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.d.ts +70 -0
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.js +171 -0
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -0
- package/cjs/data/token-filter/helpers/parse-query-text.d.ts +31 -0
- package/cjs/data/token-filter/helpers/parse-query-text.js +91 -0
- package/cjs/data/token-filter/helpers/parse-query-text.js.map +1 -0
- 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/tooltip/Tooltip.js +1 -1
- package/cjs/tooltip/Tooltip.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 +40 -10
- package/esm/data/table/helpers/table-grid-nav.js +96 -24
- 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 +24 -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.d.ts +14 -4
- package/esm/data/table/root/DataTableRoot.js +4 -6
- package/esm/data/table/root/DataTableRoot.js.map +1 -1
- package/esm/data/table/root/useTableKeyboardNav.d.ts +10 -4
- package/esm/data/table/root/useTableKeyboardNav.js +75 -104
- package/esm/data/table/root/useTableKeyboardNav.js.map +1 -1
- package/esm/data/token-filter/AutoSuggest.d.ts +21 -0
- package/esm/data/token-filter/AutoSuggest.js +93 -0
- package/esm/data/token-filter/AutoSuggest.js.map +1 -0
- package/esm/data/token-filter/TokenFilter.d.ts +11 -0
- package/esm/data/token-filter/TokenFilter.js +55 -0
- package/esm/data/token-filter/TokenFilter.js.map +1 -0
- package/esm/data/token-filter/TokenFilter.types.d.ts +46 -0
- package/esm/data/token-filter/TokenFilter.types.js +2 -0
- package/esm/data/token-filter/TokenFilter.types.js.map +1 -0
- package/esm/data/token-filter/helpers/generate-autocomplete-options.d.ts +70 -0
- package/esm/data/token-filter/helpers/generate-autocomplete-options.js +169 -0
- package/esm/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -0
- package/esm/data/token-filter/helpers/parse-query-text.d.ts +31 -0
- package/esm/data/token-filter/helpers/parse-query-text.js +87 -0
- package/esm/data/token-filter/helpers/parse-query-text.js.map +1 -0
- 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/tooltip/Tooltip.js +2 -2
- package/esm/tooltip/Tooltip.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.test.ts +659 -0
- package/src/data/table/helpers/table-grid-nav.ts +128 -32
- package/src/data/table/helpers/table-keyboard.test.ts +27 -27
- package/src/data/table/helpers/table-keyboard.ts +34 -4
- package/src/data/table/hooks/useGridCache.ts +73 -0
- package/src/data/table/root/DataTableRoot.tsx +21 -11
- package/src/data/table/root/useTableKeyboardNav.ts +110 -128
- package/src/data/token-filter/AutoSuggest.tsx +179 -0
- package/src/data/token-filter/TokenFilter.tsx +124 -0
- package/src/data/token-filter/TokenFilter.types.ts +79 -0
- package/src/data/token-filter/helpers/generate-autocomplete-options.ts +244 -0
- package/src/data/token-filter/helpers/parse-query-text.test.ts +410 -0
- package/src/data/token-filter/helpers/parse-query-text.ts +148 -0
- package/src/link-card/LinkCard.tsx +15 -1
- package/src/process/Process.tsx +1 -1
- package/src/tooltip/Tooltip.tsx +3 -3
|
@@ -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,62 +49,160 @@ 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 (contains focusable elements).
|
|
89
|
+
* Type guard that narrows Element | undefined to Element.
|
|
90
|
+
*/
|
|
91
|
+
function isCellFocusable(cell: Element | undefined): cell is Element {
|
|
92
|
+
if (!cell) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return !!findFocusableElementInCell(cell);
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
/**
|
|
83
99
|
* Finds the next cell in the given direction, starting from the current position.
|
|
84
|
-
* Skips over cells that are not focusable
|
|
100
|
+
* Skips over cells that are not focusable.
|
|
85
101
|
* Returns null if no next cell is found in the given direction.
|
|
86
102
|
*/
|
|
87
|
-
function
|
|
103
|
+
function findNextFocusableCell(
|
|
88
104
|
grid: (Element | undefined)[][],
|
|
89
105
|
currentPos: { x: number; y: number },
|
|
90
106
|
delta: { x: number; y: number },
|
|
91
107
|
currentCell: Element,
|
|
92
|
-
maxCols: number,
|
|
93
108
|
): Element | null {
|
|
94
|
-
let
|
|
95
|
-
let y = currentPos.y + delta.y;
|
|
109
|
+
let position = currentPos;
|
|
96
110
|
|
|
97
|
-
|
|
111
|
+
while (true) {
|
|
112
|
+
const nextPos = getNextGridPosition(grid, position, delta);
|
|
113
|
+
if (!nextPos) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
const cell = grid[nextPos.y][nextPos.x];
|
|
118
|
+
|
|
119
|
+
/* We check against current cell to avoid returning the same cell in cases of rowspan/colspan. */
|
|
120
|
+
if (cell !== currentCell && isCellFocusable(cell)) {
|
|
121
|
+
return cell;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
position = nextPos;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Finds the first focusable cell in the given row.
|
|
130
|
+
*/
|
|
131
|
+
function findFirstCellInRow(
|
|
132
|
+
grid: (Element | undefined)[][],
|
|
133
|
+
rowIndex: number,
|
|
134
|
+
): Element | null {
|
|
135
|
+
const row = grid[rowIndex] ?? [];
|
|
136
|
+
for (let x = 0; x < row.length; x += 1) {
|
|
137
|
+
const cell = row[x];
|
|
138
|
+
if (isCellFocusable(cell)) {
|
|
139
|
+
return cell;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Finds the last focusable cell in the given row.
|
|
148
|
+
*/
|
|
149
|
+
function findLastCellInRow(
|
|
150
|
+
grid: (Element | undefined)[][],
|
|
151
|
+
rowIndex: number,
|
|
152
|
+
): Element | null {
|
|
153
|
+
const row = grid[rowIndex] ?? [];
|
|
154
|
+
for (let x = row.length - 1; x >= 0; x -= 1) {
|
|
101
155
|
const cell = row[x];
|
|
102
|
-
if (
|
|
156
|
+
if (isCellFocusable(cell)) {
|
|
103
157
|
return cell;
|
|
104
158
|
}
|
|
105
|
-
x += delta.x;
|
|
106
|
-
y += delta.y;
|
|
107
159
|
}
|
|
108
160
|
|
|
109
161
|
return null;
|
|
110
162
|
}
|
|
111
163
|
|
|
112
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Finds the first focusable cell in the entire table.
|
|
166
|
+
*/
|
|
167
|
+
function findFirstCell(grid: (Element | undefined)[][]): Element | null {
|
|
168
|
+
for (let y = 0; y < grid.length; y += 1) {
|
|
169
|
+
const row = grid[y] ?? [];
|
|
170
|
+
for (let x = 0; x < row.length; x += 1) {
|
|
171
|
+
const cell = row[x];
|
|
172
|
+
if (isCellFocusable(cell)) {
|
|
173
|
+
return cell;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Finds the last focusable cell in the entire table.
|
|
183
|
+
*/
|
|
184
|
+
function findLastCell(grid: (Element | undefined)[][]): Element | null {
|
|
185
|
+
for (let y = grid.length - 1; y >= 0; y -= 1) {
|
|
186
|
+
const row = grid[y] ?? [];
|
|
187
|
+
for (let x = row.length - 1; x >= 0; x -= 1) {
|
|
188
|
+
const cell = row[x];
|
|
189
|
+
if (isCellFocusable(cell)) {
|
|
190
|
+
return cell;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export {
|
|
199
|
+
buildTableGridMap,
|
|
200
|
+
findFirstCell,
|
|
201
|
+
findFirstCellInRow,
|
|
202
|
+
findLastCell,
|
|
203
|
+
findLastCellInRow,
|
|
204
|
+
findNextFocusableCell,
|
|
205
|
+
getNextGridPosition,
|
|
206
|
+
isCellFocusable,
|
|
207
|
+
};
|
|
208
|
+
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,45 @@ 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
|
+
if (key === "Home") {
|
|
31
|
+
return event.ctrlKey || event.metaKey
|
|
32
|
+
? { type: "tableStart" }
|
|
33
|
+
: { type: "home" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (key === "End") {
|
|
37
|
+
return event.ctrlKey || event.metaKey
|
|
38
|
+
? { type: "tableEnd" }
|
|
39
|
+
: { type: "end" };
|
|
14
40
|
}
|
|
15
41
|
|
|
16
42
|
return null;
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/**
|
|
46
|
+
* Determines if keyboard navigation should be blocked based on the current focus context.
|
|
47
|
+
* Allows for custom blocking logic via an optional callback.
|
|
48
|
+
*
|
|
20
49
|
* Tries to make assumptions of what the user is currently doing inside a table cell
|
|
21
50
|
* Should block navigation if:
|
|
22
51
|
* - Input has selection, caret is not at start/end
|
|
@@ -24,7 +53,7 @@ function getDeltaFromKey(key: string): Delta | null {
|
|
|
24
53
|
* - User is navigating inside multiline textarea
|
|
25
54
|
* - contenteditable attrb is in use
|
|
26
55
|
*/
|
|
27
|
-
function
|
|
56
|
+
function shouldBlockNavigation(event: KeyboardEvent): boolean {
|
|
28
57
|
const key = event.key;
|
|
29
58
|
if (!(key in keyToCoord)) {
|
|
30
59
|
return false;
|
|
@@ -123,4 +152,5 @@ function isTextInputType(type: string): boolean {
|
|
|
123
152
|
}
|
|
124
153
|
}
|
|
125
154
|
|
|
126
|
-
export {
|
|
155
|
+
export { getNavigationAction, shouldBlockNavigation };
|
|
156
|
+
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 };
|
|
@@ -25,17 +25,27 @@ import { useTableKeyboardNav } from "./useTableKeyboardNav";
|
|
|
25
25
|
interface DataTableProps extends React.HTMLAttributes<HTMLTableElement> {
|
|
26
26
|
children: React.ReactNode;
|
|
27
27
|
rowDensity?: "condensed" | "normal" | "spacious";
|
|
28
|
+
/**
|
|
29
|
+
* Zebra striped table
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
zebraStripes?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Truncate content in cells and show ellipsis for overflowed text.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
truncateContent?: boolean;
|
|
28
38
|
/**
|
|
29
39
|
* Enables keyboard navigation for table rows and cells.
|
|
30
40
|
* @default false
|
|
31
41
|
*/
|
|
32
42
|
withKeyboardNav?: boolean;
|
|
33
43
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
44
|
+
* Custom callback to determine if navigation should be blocked.
|
|
45
|
+
* Called before default blocking logic.
|
|
46
|
+
* Requires `withKeyboardNav` to be `true`.
|
|
36
47
|
*/
|
|
37
|
-
|
|
38
|
-
truncateContent?: boolean;
|
|
48
|
+
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
|
|
@@ -141,6 +151,7 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
|
|
141
151
|
withKeyboardNav = false,
|
|
142
152
|
zebraStripes = false,
|
|
143
153
|
truncateContent = true,
|
|
154
|
+
shouldBlockNavigation,
|
|
144
155
|
...rest
|
|
145
156
|
},
|
|
146
157
|
forwardedRef,
|
|
@@ -148,8 +159,9 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
|
|
148
159
|
const [tableRef, setTableRef] = useState<HTMLTableElement | null>(null);
|
|
149
160
|
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
|
150
161
|
|
|
151
|
-
const {
|
|
162
|
+
const { tabIndex } = useTableKeyboardNav(tableRef, {
|
|
152
163
|
enabled: withKeyboardNav,
|
|
164
|
+
shouldBlockNavigation,
|
|
153
165
|
});
|
|
154
166
|
|
|
155
167
|
return (
|
|
@@ -158,13 +170,11 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
|
|
158
170
|
<table
|
|
159
171
|
{...rest}
|
|
160
172
|
ref={mergedRef}
|
|
161
|
-
className={cl("aksel-data-table", className
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
})}
|
|
173
|
+
className={cl("aksel-data-table", className)}
|
|
174
|
+
data-zebra-stripes={zebraStripes}
|
|
175
|
+
data-truncate-content={truncateContent}
|
|
165
176
|
data-density={rowDensity}
|
|
166
|
-
tabIndex={
|
|
167
|
-
onFocus={onFocus}
|
|
177
|
+
tabIndex={tabIndex}
|
|
168
178
|
/>
|
|
169
179
|
</div>
|
|
170
180
|
</div>
|