@opendata-ai/openchart-vanilla 2.0.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/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table keyboard navigation: arrow-key cell navigation, Enter to sort,
|
|
3
|
+
* Escape to clear search, and aria-activedescendant management.
|
|
4
|
+
*
|
|
5
|
+
* Designed to be wired up by table-mount.ts after render. Returns a
|
|
6
|
+
* cleanup function to remove listeners on re-render or destroy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface KeyboardNavOptions {
|
|
14
|
+
/** The wrapper element containing the whole table UI. */
|
|
15
|
+
wrapper: HTMLElement;
|
|
16
|
+
/** Callback to trigger sort on a column. */
|
|
17
|
+
onSort: (columnKey: string) => void;
|
|
18
|
+
/** Callback to clear search and return focus to the table body. */
|
|
19
|
+
onClearSearch: () => void;
|
|
20
|
+
/** Callback to announce text to screen readers via the live region. */
|
|
21
|
+
onAnnounce: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CellPosition {
|
|
25
|
+
row: number;
|
|
26
|
+
col: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Main
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attach keyboard navigation to a rendered table.
|
|
35
|
+
*
|
|
36
|
+
* @returns A cleanup function that removes all event listeners.
|
|
37
|
+
*/
|
|
38
|
+
export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
|
|
39
|
+
const { wrapper, onSort, onClearSearch, onAnnounce } = options;
|
|
40
|
+
|
|
41
|
+
let focusedCell: CellPosition = { row: -1, col: 0 };
|
|
42
|
+
|
|
43
|
+
const table = wrapper.querySelector('table');
|
|
44
|
+
if (!table) return () => {};
|
|
45
|
+
|
|
46
|
+
const tbody = table.querySelector('tbody');
|
|
47
|
+
const thead = table.querySelector('thead');
|
|
48
|
+
if (!tbody || !thead) return () => {};
|
|
49
|
+
|
|
50
|
+
// Make tbody focusable
|
|
51
|
+
tbody.setAttribute('tabindex', '0');
|
|
52
|
+
|
|
53
|
+
function getRows(): HTMLTableRowElement[] {
|
|
54
|
+
if (!tbody) return [];
|
|
55
|
+
return Array.from(tbody.querySelectorAll('tr'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getHeaderCells(): HTMLTableCellElement[] {
|
|
59
|
+
if (!thead) return [];
|
|
60
|
+
const headerRow = thead.querySelector('tr');
|
|
61
|
+
if (!headerRow) return [];
|
|
62
|
+
return Array.from(headerRow.querySelectorAll('th'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCellsInRow(tr: HTMLTableRowElement): HTMLTableCellElement[] {
|
|
66
|
+
return Array.from(tr.querySelectorAll('td'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getColCount(): number {
|
|
70
|
+
const rows = getRows();
|
|
71
|
+
if (rows.length === 0) return getHeaderCells().length;
|
|
72
|
+
return getCellsInRow(rows[0]).length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clearFocusHighlight(): void {
|
|
76
|
+
const prev = wrapper.querySelector('.viz-table-cell-focus');
|
|
77
|
+
if (prev) {
|
|
78
|
+
prev.classList.remove('viz-table-cell-focus');
|
|
79
|
+
prev.removeAttribute('id');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setFocusedCell(row: number, col: number): void {
|
|
84
|
+
clearFocusHighlight();
|
|
85
|
+
const rows = getRows();
|
|
86
|
+
const colCount = getColCount();
|
|
87
|
+
|
|
88
|
+
// Clamp values
|
|
89
|
+
if (rows.length === 0) return;
|
|
90
|
+
row = Math.max(0, Math.min(row, rows.length - 1));
|
|
91
|
+
col = Math.max(0, Math.min(col, colCount - 1));
|
|
92
|
+
|
|
93
|
+
focusedCell = { row, col };
|
|
94
|
+
|
|
95
|
+
// Highlight the cell
|
|
96
|
+
const tr = rows[row];
|
|
97
|
+
if (!tr) return;
|
|
98
|
+
const cells = getCellsInRow(tr);
|
|
99
|
+
const cell = cells[col];
|
|
100
|
+
if (!cell) return;
|
|
101
|
+
|
|
102
|
+
const cellId = `viz-cell-${row}-${col}`;
|
|
103
|
+
cell.id = cellId;
|
|
104
|
+
cell.classList.add('viz-table-cell-focus');
|
|
105
|
+
cell.setAttribute('data-row', String(row));
|
|
106
|
+
cell.setAttribute('data-col', String(col));
|
|
107
|
+
|
|
108
|
+
// Set aria-activedescendant on tbody
|
|
109
|
+
if (tbody) {
|
|
110
|
+
tbody.setAttribute('aria-activedescendant', cellId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Scroll cell into view if needed
|
|
114
|
+
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleTbodyFocus(): void {
|
|
118
|
+
// When tbody receives focus, highlight the first cell (or restore last)
|
|
119
|
+
if (focusedCell.row < 0) {
|
|
120
|
+
setFocusedCell(0, 0);
|
|
121
|
+
} else {
|
|
122
|
+
setFocusedCell(focusedCell.row, focusedCell.col);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function handleTbodyKeydown(e: KeyboardEvent): void {
|
|
127
|
+
const rows = getRows();
|
|
128
|
+
if (rows.length === 0) return;
|
|
129
|
+
|
|
130
|
+
const colCount = getColCount();
|
|
131
|
+
const { row, col } = focusedCell;
|
|
132
|
+
|
|
133
|
+
switch (e.key) {
|
|
134
|
+
case 'ArrowDown':
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
if (row < rows.length - 1) {
|
|
137
|
+
setFocusedCell(row + 1, col);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'ArrowUp':
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (row > 0) {
|
|
143
|
+
setFocusedCell(row - 1, col);
|
|
144
|
+
} else {
|
|
145
|
+
// Move focus to the header
|
|
146
|
+
focusHeaderCell(col);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case 'ArrowRight':
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
if (col < colCount - 1) {
|
|
152
|
+
setFocusedCell(row, col + 1);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
case 'ArrowLeft':
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (col > 0) {
|
|
158
|
+
setFocusedCell(row, col - 1);
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case 'Home':
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
setFocusedCell(row, 0);
|
|
164
|
+
break;
|
|
165
|
+
case 'End':
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
setFocusedCell(row, colCount - 1);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Header cell keyboard handling
|
|
173
|
+
function focusHeaderCell(col: number): void {
|
|
174
|
+
const headers = getHeaderCells();
|
|
175
|
+
if (col >= 0 && col < headers.length) {
|
|
176
|
+
clearFocusHighlight();
|
|
177
|
+
headers[col].focus();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function handleHeaderKeydown(e: KeyboardEvent): void {
|
|
182
|
+
const th = e.currentTarget as HTMLTableCellElement;
|
|
183
|
+
const headers = getHeaderCells();
|
|
184
|
+
const colIndex = headers.indexOf(th);
|
|
185
|
+
if (colIndex < 0) return;
|
|
186
|
+
|
|
187
|
+
switch (e.key) {
|
|
188
|
+
case 'ArrowRight':
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
if (colIndex < headers.length - 1) {
|
|
191
|
+
headers[colIndex + 1].focus();
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
case 'ArrowLeft':
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
if (colIndex > 0) {
|
|
197
|
+
headers[colIndex - 1].focus();
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
case 'ArrowDown':
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
// Move focus to first body row at this column
|
|
203
|
+
if (tbody) {
|
|
204
|
+
tbody.focus();
|
|
205
|
+
setFocusedCell(0, colIndex);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
case 'Enter':
|
|
209
|
+
case ' ': {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
const sortColumn = th.getAttribute('data-column');
|
|
212
|
+
const sortBtn = th.querySelector('[data-sort-column]');
|
|
213
|
+
if (sortColumn && sortBtn) {
|
|
214
|
+
onSort(sortColumn);
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Search escape handling
|
|
222
|
+
const searchInput = wrapper.querySelector('.viz-table-search input') as HTMLInputElement | null;
|
|
223
|
+
|
|
224
|
+
function handleSearchKeydown(e: KeyboardEvent): void {
|
|
225
|
+
if (e.key === 'Escape') {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
onClearSearch();
|
|
228
|
+
// Return focus to tbody
|
|
229
|
+
if (tbody) {
|
|
230
|
+
tbody.focus();
|
|
231
|
+
onAnnounce('Search cleared');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Wire up event listeners
|
|
237
|
+
tbody.addEventListener('focus', handleTbodyFocus);
|
|
238
|
+
tbody.addEventListener('keydown', handleTbodyKeydown as EventListener);
|
|
239
|
+
|
|
240
|
+
// Make header cells focusable and wire keyboard
|
|
241
|
+
const headerCells = getHeaderCells();
|
|
242
|
+
for (const th of headerCells) {
|
|
243
|
+
th.setAttribute('tabindex', '0');
|
|
244
|
+
th.addEventListener('keydown', handleHeaderKeydown as EventListener);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (searchInput) {
|
|
248
|
+
searchInput.addEventListener('keydown', handleSearchKeydown as EventListener);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Cleanup
|
|
252
|
+
return () => {
|
|
253
|
+
tbody.removeEventListener('focus', handleTbodyFocus);
|
|
254
|
+
tbody.removeEventListener('keydown', handleTbodyKeydown as EventListener);
|
|
255
|
+
|
|
256
|
+
for (const th of headerCells) {
|
|
257
|
+
th.removeEventListener('keydown', handleHeaderKeydown as EventListener);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (searchInput) {
|
|
261
|
+
searchInput.removeEventListener('keydown', handleSearchKeydown as EventListener);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
clearFocusHighlight();
|
|
265
|
+
};
|
|
266
|
+
}
|