@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,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
|
+
}
|