@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,532 @@
1
+ /**
2
+ * Table mount API: the main entry point for vanilla JS table usage.
3
+ *
4
+ * createTable() takes a container, spec, and options, compiles the table,
5
+ * renders it as HTML, sets up responsive resizing, sort/search/pagination
6
+ * interactivity, and returns a TableInstance with update/resize/export/destroy.
7
+ *
8
+ * Supports both controlled and uncontrolled modes:
9
+ * - Uncontrolled (default): manages sort/search/page internally
10
+ * - Controlled: reads state from externalState, fires onStateChange
11
+ */
12
+
13
+ import type {
14
+ CompileTableOptions,
15
+ DarkMode,
16
+ SortState,
17
+ TableLayout,
18
+ TableSpec,
19
+ ThemeConfig,
20
+ } from '@opendata-ai/openchart-core';
21
+ import { getBreakpoint } from '@opendata-ai/openchart-core';
22
+ import { compileTable } from '@opendata-ai/openchart-engine';
23
+ import { observeResize } from './resize-observer';
24
+ import { attachKeyboardNav } from './table-keyboard';
25
+ import { renderTable } from './table-renderer';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface TableState {
32
+ sort: SortState | null;
33
+ search: string;
34
+ page: number;
35
+ }
36
+
37
+ export interface TableMountOptions {
38
+ theme?: ThemeConfig;
39
+ darkMode?: DarkMode;
40
+ responsive?: boolean;
41
+ onRowClick?: (row: Record<string, unknown>) => void;
42
+ onStateChange?: (state: TableState) => void;
43
+ externalState?: { sort?: SortState | null; search?: string; page?: number };
44
+ }
45
+
46
+ export interface TableInstance {
47
+ update(spec: TableSpec): void;
48
+ resize(): void;
49
+ export(format: 'csv'): string;
50
+ getState(): TableState;
51
+ setState(partial: Partial<TableState>): void;
52
+ destroy(): void;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Dark mode resolution
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function resolveDarkMode(mode?: DarkMode): boolean {
60
+ if (mode === 'force') return true;
61
+ if (mode === 'off' || mode === undefined) return false;
62
+ if (typeof window !== 'undefined' && window.matchMedia) {
63
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // CSV export
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function csvEscape(value: string): string {
73
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
74
+ return `"${value.replace(/"/g, '""')}"`;
75
+ }
76
+ return value;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Sort cycling
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Cycle sort state: clicking same column cycles none -> asc -> desc -> none.
85
+ * Clicking a different column resets to asc.
86
+ */
87
+ function cycleSort(current: SortState | null, column: string): SortState | null {
88
+ if (!current || current.column !== column) {
89
+ return { column, direction: 'asc' };
90
+ }
91
+ if (current.direction === 'asc') {
92
+ return { column, direction: 'desc' };
93
+ }
94
+ // desc -> none
95
+ return null;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Main API
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Create a table instance from a spec and mount it into a container.
104
+ *
105
+ * @param container - The DOM element to render into.
106
+ * @param spec - The table spec.
107
+ * @param options - Mount options.
108
+ * @returns A TableInstance with update/resize/export/destroy methods.
109
+ */
110
+ export function createTable(
111
+ container: HTMLElement,
112
+ spec: TableSpec,
113
+ options?: TableMountOptions,
114
+ ): TableInstance {
115
+ let currentSpec = spec;
116
+ let currentLayout: TableLayout;
117
+ let wrapperElement: HTMLElement | null = null;
118
+ let disconnectResize: (() => void) | null = null;
119
+ let cleanupKeyboard: (() => void) | null = null;
120
+ let destroyed = false;
121
+
122
+ // Internal state (used in uncontrolled mode)
123
+ const internalState: TableState = {
124
+ sort: null,
125
+ search: '',
126
+ page: 0,
127
+ };
128
+
129
+ // Debounce timers
130
+ let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
131
+ let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
132
+
133
+ const isControlled = options?.externalState !== undefined;
134
+
135
+ function getState(): TableState {
136
+ if (isControlled && options?.externalState) {
137
+ return {
138
+ sort: options.externalState.sort ?? null,
139
+ search: options.externalState.search ?? '',
140
+ page: options.externalState.page ?? 0,
141
+ };
142
+ }
143
+ return { ...internalState };
144
+ }
145
+
146
+ function updateState(partial: Partial<TableState>): void {
147
+ if (isControlled) {
148
+ // In controlled mode, notify parent
149
+ const current = getState();
150
+ const next: TableState = {
151
+ sort: partial.sort !== undefined ? partial.sort : current.sort,
152
+ search: partial.search !== undefined ? partial.search : current.search,
153
+ page: partial.page !== undefined ? partial.page : current.page,
154
+ };
155
+ options?.onStateChange?.(next);
156
+ } else {
157
+ // In uncontrolled mode, update internal state
158
+ if (partial.sort !== undefined) internalState.sort = partial.sort;
159
+ if (partial.search !== undefined) internalState.search = partial.search;
160
+ if (partial.page !== undefined) internalState.page = partial.page;
161
+ options?.onStateChange?.({ ...internalState });
162
+ }
163
+ }
164
+
165
+ function compile(): TableLayout {
166
+ const state = getState();
167
+ const darkMode = resolveDarkMode(options?.darkMode);
168
+ const { width } = getContainerDimensions();
169
+
170
+ const compileOpts: CompileTableOptions = {
171
+ width,
172
+ height: 600,
173
+ theme: options?.theme,
174
+ darkMode,
175
+ sort: state.sort ?? undefined,
176
+ search: state.search || undefined,
177
+ page: state.page,
178
+ };
179
+
180
+ return compileTable(currentSpec, compileOpts);
181
+ }
182
+
183
+ function getContainerDimensions(): { width: number; height: number } {
184
+ const rect = container.getBoundingClientRect();
185
+ return {
186
+ width: Math.max(rect.width || 600, 100),
187
+ height: Math.max(rect.height || 400, 100),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Announce a message to screen readers via the live region.
193
+ */
194
+ function announce(message: string): void {
195
+ if (!wrapperElement) return;
196
+ const liveRegion = wrapperElement.querySelector('.viz-table-live-region');
197
+ if (liveRegion) {
198
+ liveRegion.textContent = message;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Apply responsive breakpoint class based on container width.
204
+ * Only auto-adds compact mode at small sizes. Never removes compact
205
+ * if the spec explicitly requests it (layout.compact === true).
206
+ */
207
+ function applyBreakpointClass(): void {
208
+ if (!wrapperElement) return;
209
+ const { width } = getContainerDimensions();
210
+ const bp = getBreakpoint(width);
211
+
212
+ if (bp === 'compact' || bp === 'medium') {
213
+ wrapperElement.classList.add('viz-table--compact');
214
+ } else if (!currentLayout?.compact) {
215
+ // Only remove compact if the spec didn't explicitly request it
216
+ wrapperElement.classList.remove('viz-table--compact');
217
+ }
218
+ }
219
+
220
+ function render(): void {
221
+ if (destroyed) return;
222
+
223
+ try {
224
+ // Clean up previous keyboard nav
225
+ if (cleanupKeyboard) {
226
+ cleanupKeyboard();
227
+ cleanupKeyboard = null;
228
+ }
229
+
230
+ // Clean up previous render
231
+ if (wrapperElement?.parentNode) {
232
+ wrapperElement.parentNode.removeChild(wrapperElement);
233
+ wrapperElement = null;
234
+ }
235
+
236
+ currentLayout = compile();
237
+ wrapperElement = renderTable(currentLayout, container);
238
+
239
+ // Apply dark mode class
240
+ const isDark = resolveDarkMode(options?.darkMode);
241
+ if (isDark) {
242
+ container.classList.add('viz-dark');
243
+ } else {
244
+ container.classList.remove('viz-dark');
245
+ }
246
+
247
+ // Apply responsive breakpoint
248
+ applyBreakpointClass();
249
+
250
+ // Add clickable class if onRowClick is provided
251
+ if (options?.onRowClick) {
252
+ wrapperElement.classList.add('viz-table--clickable');
253
+ }
254
+
255
+ // Wire up event handlers
256
+ wireEvents();
257
+
258
+ // Wire up keyboard navigation
259
+ if (wrapperElement) {
260
+ cleanupKeyboard = attachKeyboardNav({
261
+ wrapper: wrapperElement,
262
+ onSort: (columnKey: string) => {
263
+ const state = getState();
264
+ const newSort = cycleSort(state.sort, columnKey);
265
+ updateState({ sort: newSort, page: 0 });
266
+
267
+ // Announce sort change
268
+ if (newSort) {
269
+ const dir = newSort.direction === 'asc' ? 'ascending' : 'descending';
270
+ announce(`Sorted by ${columnKey} ${dir}`);
271
+ } else {
272
+ announce('Sort cleared');
273
+ }
274
+
275
+ if (!isControlled) {
276
+ rerender();
277
+ }
278
+ },
279
+ onClearSearch: () => {
280
+ updateState({ search: '', page: 0 });
281
+ if (!isControlled) {
282
+ rerender();
283
+ }
284
+ },
285
+ onAnnounce: announce,
286
+ });
287
+ }
288
+ } catch (err) {
289
+ console.error('[viz] Table render failed:', err);
290
+ }
291
+ }
292
+
293
+ function wireEvents(): void {
294
+ if (!wrapperElement) return;
295
+
296
+ // Sort click handlers on thead buttons
297
+ const sortBtns = wrapperElement.querySelectorAll('[data-sort-column]');
298
+ for (const btn of sortBtns) {
299
+ btn.addEventListener('click', handleSortClick);
300
+ }
301
+
302
+ // Search input
303
+ const searchInput = wrapperElement.querySelector(
304
+ '.viz-table-search input',
305
+ ) as HTMLInputElement | null;
306
+ if (searchInput) {
307
+ searchInput.addEventListener('input', handleSearchInput);
308
+ }
309
+
310
+ // Pagination buttons
311
+ const pageButtons = wrapperElement.querySelectorAll('[data-page-action]');
312
+ for (const btn of pageButtons) {
313
+ btn.addEventListener('click', handlePageClick);
314
+ }
315
+
316
+ // Row click
317
+ if (options?.onRowClick) {
318
+ const rows = wrapperElement.querySelectorAll('tbody tr');
319
+ for (const row of rows) {
320
+ row.addEventListener('click', handleRowClick);
321
+ }
322
+ }
323
+ }
324
+
325
+ function handleSortClick(e: Event): void {
326
+ const btn = e.currentTarget as HTMLElement;
327
+ const column = btn.getAttribute('data-sort-column');
328
+ if (!column) return;
329
+
330
+ const state = getState();
331
+ const newSort = cycleSort(state.sort, column);
332
+
333
+ updateState({ sort: newSort, page: 0 });
334
+
335
+ // Announce sort change for screen readers
336
+ if (newSort) {
337
+ const dir = newSort.direction === 'asc' ? 'ascending' : 'descending';
338
+ announce(`Sorted by ${column} ${dir}`);
339
+ } else {
340
+ announce('Sort cleared');
341
+ }
342
+
343
+ if (!isControlled) {
344
+ rerender();
345
+ }
346
+ }
347
+
348
+ function handleSearchInput(e: Event): void {
349
+ const input = e.target as HTMLInputElement;
350
+ const query = input.value;
351
+
352
+ if (searchDebounceTimer !== null) {
353
+ clearTimeout(searchDebounceTimer);
354
+ }
355
+
356
+ searchDebounceTimer = setTimeout(() => {
357
+ searchDebounceTimer = null;
358
+ updateState({ search: query, page: 0 });
359
+
360
+ if (!isControlled) {
361
+ rerender();
362
+ // Announce search result count
363
+ const rowCount = currentLayout?.rows?.length ?? 0;
364
+ if (query) {
365
+ announce(`${rowCount} result${rowCount !== 1 ? 's' : ''} found`);
366
+ }
367
+ }
368
+ }, 200);
369
+ }
370
+
371
+ function handlePageClick(e: Event): void {
372
+ const btn = e.currentTarget as HTMLElement;
373
+ const action = btn.getAttribute('data-page-action');
374
+ const state = getState();
375
+
376
+ if (action === 'prev' && state.page > 0) {
377
+ updateState({ page: state.page - 1 });
378
+ } else if (action === 'next') {
379
+ updateState({ page: state.page + 1 });
380
+ }
381
+
382
+ if (!isControlled) {
383
+ rerender();
384
+ }
385
+ }
386
+
387
+ function handleRowClick(e: Event): void {
388
+ const tr = e.currentTarget as HTMLElement;
389
+ const rowId = tr.getAttribute('data-row-id');
390
+ if (!rowId || !currentLayout) return;
391
+
392
+ const row = currentLayout.rows.find((r) => r.id === rowId);
393
+ if (row) {
394
+ options?.onRowClick?.(row.data);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Re-render the table, preserving search input focus across the DOM rebuild.
400
+ */
401
+ function rerender(): void {
402
+ if (destroyed) return;
403
+
404
+ // Capture current search input state before re-render
405
+ const searchInput = wrapperElement?.querySelector(
406
+ '.viz-table-search input',
407
+ ) as HTMLInputElement | null;
408
+ const hadFocus = searchInput && document.activeElement === searchInput;
409
+ const selectionStart = searchInput?.selectionStart ?? 0;
410
+ const selectionEnd = searchInput?.selectionEnd ?? 0;
411
+
412
+ render();
413
+
414
+ // Restore search focus after re-render
415
+ if (hadFocus) {
416
+ const newInput = wrapperElement?.querySelector(
417
+ '.viz-table-search input',
418
+ ) as HTMLInputElement | null;
419
+ if (newInput) {
420
+ newInput.focus();
421
+ newInput.setSelectionRange(selectionStart, selectionEnd);
422
+ }
423
+ }
424
+ }
425
+
426
+ function update(newSpec: TableSpec): void {
427
+ if (destroyed) return;
428
+ currentSpec = newSpec;
429
+ render();
430
+ }
431
+
432
+ function resize(): void {
433
+ if (destroyed) return;
434
+ render();
435
+ }
436
+
437
+ function doExport(format: 'csv'): string {
438
+ if (format !== 'csv') {
439
+ throw new Error(`Unsupported export format: ${format}`);
440
+ }
441
+
442
+ // Export all filtered/sorted data (not just current page)
443
+ // Re-compile without pagination
444
+ const state = getState();
445
+ const darkMode = resolveDarkMode(options?.darkMode);
446
+ const { width } = getContainerDimensions();
447
+
448
+ const fullLayout = compileTable(currentSpec, {
449
+ width,
450
+ height: 600,
451
+ theme: options?.theme,
452
+ darkMode,
453
+ sort: state.sort ?? undefined,
454
+ search: state.search || undefined,
455
+ // No page/pageSize: get all rows
456
+ });
457
+
458
+ const headers = fullLayout.columns.map((c) => c.label);
459
+ const csvRows = [headers.map(csvEscape).join(',')];
460
+
461
+ for (const row of fullLayout.rows) {
462
+ const values = row.cells.map((cell) => csvEscape(cell.formattedValue));
463
+ csvRows.push(values.join(','));
464
+ }
465
+
466
+ return csvRows.join('\n');
467
+ }
468
+
469
+ function setState(partial: Partial<TableState>): void {
470
+ if (destroyed) return;
471
+
472
+ if (partial.sort !== undefined) internalState.sort = partial.sort;
473
+ if (partial.search !== undefined) internalState.search = partial.search;
474
+ if (partial.page !== undefined) internalState.page = partial.page;
475
+
476
+ render();
477
+ }
478
+
479
+ function destroy(): void {
480
+ if (destroyed) return;
481
+ destroyed = true;
482
+
483
+ if (cleanupKeyboard) {
484
+ cleanupKeyboard();
485
+ cleanupKeyboard = null;
486
+ }
487
+ if (searchDebounceTimer !== null) {
488
+ clearTimeout(searchDebounceTimer);
489
+ searchDebounceTimer = null;
490
+ }
491
+ if (resizeDebounceTimer !== null) {
492
+ clearTimeout(resizeDebounceTimer);
493
+ resizeDebounceTimer = null;
494
+ }
495
+ if (disconnectResize) {
496
+ disconnectResize();
497
+ disconnectResize = null;
498
+ }
499
+ if (wrapperElement?.parentNode) {
500
+ wrapperElement.parentNode.removeChild(wrapperElement);
501
+ wrapperElement = null;
502
+ }
503
+ container.classList.remove('viz-dark');
504
+ }
505
+
506
+ // Initial render
507
+ render();
508
+
509
+ // Set up responsive resize with breakpoint detection
510
+ if (options?.responsive !== false) {
511
+ disconnectResize = observeResize(container, () => {
512
+ if (resizeDebounceTimer !== null) {
513
+ clearTimeout(resizeDebounceTimer);
514
+ }
515
+ resizeDebounceTimer = setTimeout(() => {
516
+ resizeDebounceTimer = null;
517
+ // Update breakpoint class without full re-render when possible
518
+ applyBreakpointClass();
519
+ resize();
520
+ }, 100);
521
+ });
522
+ }
523
+
524
+ return {
525
+ update,
526
+ resize,
527
+ export: doExport,
528
+ getState,
529
+ setState,
530
+ destroy,
531
+ };
532
+ }