@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. 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
+ }