@opendata-ai/openchart-vanilla 6.4.1 → 6.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.
@@ -20,6 +20,7 @@ import type {
20
20
  } from '@opendata-ai/openchart-core';
21
21
  import { getBreakpoint } from '@opendata-ai/openchart-core';
22
22
  import { compileTable } from '@opendata-ai/openchart-engine';
23
+ import { setupTableAnimationCleanup } from './animation';
23
24
  import { observeResize } from './resize-observer';
24
25
  import { attachKeyboardNav } from './table-keyboard';
25
26
  import { renderTable } from './table-renderer';
@@ -117,6 +118,8 @@ export function createTable(
117
118
  let wrapperElement: HTMLElement | null = null;
118
119
  let disconnectResize: (() => void) | null = null;
119
120
  let cleanupKeyboard: (() => void) | null = null;
121
+ let cleanupAnimations: (() => void) | null = null;
122
+ let isFirstRender = true;
120
123
  let destroyed = false;
121
124
 
122
125
  // Internal state (used in uncontrolled mode)
@@ -193,7 +196,7 @@ export function createTable(
193
196
  */
194
197
  function announce(message: string): void {
195
198
  if (!wrapperElement) return;
196
- const liveRegion = wrapperElement.querySelector('.viz-table-live-region');
199
+ const liveRegion = wrapperElement.querySelector('.oc-table-live-region');
197
200
  if (liveRegion) {
198
201
  liveRegion.textContent = message;
199
202
  }
@@ -210,10 +213,10 @@ export function createTable(
210
213
  const bp = getBreakpoint(width);
211
214
 
212
215
  if (bp === 'compact' || bp === 'medium') {
213
- wrapperElement.classList.add('viz-table--compact');
216
+ wrapperElement.classList.add('oc-table--compact');
214
217
  } else if (!currentLayout?.compact) {
215
218
  // Only remove compact if the spec didn't explicitly request it
216
- wrapperElement.classList.remove('viz-table--compact');
219
+ wrapperElement.classList.remove('oc-table--compact');
217
220
  }
218
221
  }
219
222
 
@@ -221,6 +224,12 @@ export function createTable(
221
224
  if (destroyed) return;
222
225
 
223
226
  try {
227
+ // Cancel any in-flight animations before re-rendering
228
+ if (cleanupAnimations) {
229
+ cleanupAnimations();
230
+ cleanupAnimations = null;
231
+ }
232
+
224
233
  // Clean up previous keyboard nav
225
234
  if (cleanupKeyboard) {
226
235
  cleanupKeyboard();
@@ -234,14 +243,23 @@ export function createTable(
234
243
  }
235
244
 
236
245
  currentLayout = compile();
237
- wrapperElement = renderTable(currentLayout, container);
246
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
247
+ wrapperElement = renderTable(currentLayout, container, { animate: shouldAnimate });
248
+
249
+ // Set up animation cleanup on first animated render
250
+ if (shouldAnimate && wrapperElement) {
251
+ cleanupAnimations = setupTableAnimationCleanup(wrapperElement);
252
+ }
253
+ if (isFirstRender) {
254
+ isFirstRender = false;
255
+ }
238
256
 
239
257
  // Apply dark mode class
240
258
  const isDark = resolveDarkMode(options?.darkMode);
241
259
  if (isDark) {
242
- container.classList.add('viz-dark');
260
+ container.classList.add('oc-dark');
243
261
  } else {
244
- container.classList.remove('viz-dark');
262
+ container.classList.remove('oc-dark');
245
263
  }
246
264
 
247
265
  // Apply responsive breakpoint
@@ -249,7 +267,7 @@ export function createTable(
249
267
 
250
268
  // Add clickable class if onRowClick is provided
251
269
  if (options?.onRowClick) {
252
- wrapperElement.classList.add('viz-table--clickable');
270
+ wrapperElement.classList.add('oc-table--clickable');
253
271
  }
254
272
 
255
273
  // Wire up event handlers
@@ -301,7 +319,7 @@ export function createTable(
301
319
 
302
320
  // Search input
303
321
  const searchInput = wrapperElement.querySelector(
304
- '.viz-table-search input',
322
+ '.oc-table-search input',
305
323
  ) as HTMLInputElement | null;
306
324
  if (searchInput) {
307
325
  searchInput.addEventListener('input', handleSearchInput);
@@ -403,7 +421,7 @@ export function createTable(
403
421
 
404
422
  // Capture current search input state before re-render
405
423
  const searchInput = wrapperElement?.querySelector(
406
- '.viz-table-search input',
424
+ '.oc-table-search input',
407
425
  ) as HTMLInputElement | null;
408
426
  const hadFocus = searchInput && document.activeElement === searchInput;
409
427
  const selectionStart = searchInput?.selectionStart ?? 0;
@@ -414,7 +432,7 @@ export function createTable(
414
432
  // Restore search focus after re-render
415
433
  if (hadFocus) {
416
434
  const newInput = wrapperElement?.querySelector(
417
- '.viz-table-search input',
435
+ '.oc-table-search input',
418
436
  ) as HTMLInputElement | null;
419
437
  if (newInput) {
420
438
  newInput.focus();
@@ -431,6 +449,7 @@ export function createTable(
431
449
 
432
450
  function resize(): void {
433
451
  if (destroyed) return;
452
+ if (cleanupAnimations) return; // Skip resize during entrance animation
434
453
  render();
435
454
  }
436
455
 
@@ -480,6 +499,10 @@ export function createTable(
480
499
  if (destroyed) return;
481
500
  destroyed = true;
482
501
 
502
+ if (cleanupAnimations) {
503
+ cleanupAnimations();
504
+ cleanupAnimations = null;
505
+ }
483
506
  if (cleanupKeyboard) {
484
507
  cleanupKeyboard();
485
508
  cleanupKeyboard = null;
@@ -500,7 +523,7 @@ export function createTable(
500
523
  wrapperElement.parentNode.removeChild(wrapperElement);
501
524
  wrapperElement = null;
502
525
  }
503
- container.classList.remove('viz-dark');
526
+ container.classList.remove('oc-dark');
504
527
  }
505
528
 
506
529
  // Initial render
@@ -8,8 +8,21 @@
8
8
 
9
9
  import type { ResolvedColumn, TableLayout, TableRow } from '@opendata-ai/openchart-core';
10
10
  import { BRAND_FONT_SIZE } from '@opendata-ai/openchart-core';
11
+ import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
11
12
  import { renderCell } from './renderers/table-cells';
12
13
 
14
+ /** Options for renderTable(). */
15
+ export interface TableRenderOptions {
16
+ /** Whether to apply entrance animation on this render. */
17
+ animate?: boolean;
18
+ }
19
+
20
+ /** CSS easing preset map for animation custom properties. */
21
+ const EASE_VAR_MAP: Record<string, string> = {
22
+ smooth: 'var(--oc-ease-smooth)',
23
+ snappy: 'var(--oc-ease-snappy)',
24
+ };
25
+
13
26
  // ---------------------------------------------------------------------------
14
27
  // Constants
15
28
  // ---------------------------------------------------------------------------
@@ -31,17 +44,17 @@ function renderChromeBlock(
31
44
  if (!chrome.title && !chrome.subtitle) return null;
32
45
 
33
46
  const div = document.createElement('div');
34
- div.className = 'viz-chrome';
47
+ div.className = 'oc-chrome';
35
48
 
36
49
  if (chrome.title) {
37
50
  const h = document.createElement('div');
38
- h.className = 'viz-table-title';
51
+ h.className = 'oc-table-title';
39
52
  h.textContent = chrome.title.text;
40
53
  div.appendChild(h);
41
54
  }
42
55
  if (chrome.subtitle) {
43
56
  const sub = document.createElement('div');
44
- sub.className = 'viz-table-subtitle';
57
+ sub.className = 'oc-table-subtitle';
45
58
  sub.textContent = chrome.subtitle.text;
46
59
  div.appendChild(sub);
47
60
  }
@@ -53,17 +66,17 @@ function renderChromeBlock(
53
66
  if (!chrome.source && !chrome.footer) return null;
54
67
 
55
68
  const div = document.createElement('div');
56
- div.className = 'viz-chrome viz-chrome-footer';
69
+ div.className = 'oc-chrome oc-chrome-footer';
57
70
 
58
71
  if (chrome.source) {
59
72
  const src = document.createElement('div');
60
- src.className = 'viz-table-source';
73
+ src.className = 'oc-table-source';
61
74
  src.textContent = chrome.source.text;
62
75
  div.appendChild(src);
63
76
  }
64
77
  if (chrome.footer) {
65
78
  const foot = document.createElement('div');
66
- foot.className = 'viz-table-footer-text';
79
+ foot.className = 'oc-table-footer-text';
67
80
  foot.textContent = chrome.footer.text;
68
81
  div.appendChild(foot);
69
82
  }
@@ -105,7 +118,7 @@ function renderThead(
105
118
  // Sort button
106
119
  if (col.sortable) {
107
120
  const btn = document.createElement('button');
108
- btn.className = 'viz-table-sort-btn';
121
+ btn.className = 'oc-table-sort-btn';
109
122
  btn.setAttribute('aria-label', `Sort by ${col.label}`);
110
123
  btn.setAttribute('data-sort-column', col.key);
111
124
  btn.type = 'button';
@@ -131,6 +144,7 @@ function renderTbody(rows: TableRow[], columns: ResolvedColumn[]): HTMLTableSect
131
144
  const tr = document.createElement('tr');
132
145
  tr.setAttribute('role', 'row');
133
146
  tr.setAttribute('data-row-id', row.id);
147
+ tr.style.setProperty('--oc-row-index', String(r));
134
148
 
135
149
  for (let c = 0; c < columns.length; c++) {
136
150
  const cell = row.cells[c];
@@ -156,7 +170,7 @@ function renderSearchBar(layout: TableLayout): HTMLDivElement | null {
156
170
  if (!layout.search.enabled) return null;
157
171
 
158
172
  const div = document.createElement('div');
159
- div.className = 'viz-table-search';
173
+ div.className = 'oc-table-search';
160
174
 
161
175
  const input = document.createElement('input');
162
176
  input.type = 'search';
@@ -178,10 +192,10 @@ function renderPagination(layout: TableLayout): HTMLDivElement | null {
178
192
  const { page, pageSize, totalRows, totalPages } = layout.pagination;
179
193
 
180
194
  const div = document.createElement('div');
181
- div.className = 'viz-table-pagination';
195
+ div.className = 'oc-table-pagination';
182
196
 
183
197
  const info = document.createElement('span');
184
- info.className = 'viz-table-pagination-info';
198
+ info.className = 'oc-table-pagination-info';
185
199
 
186
200
  if (totalRows === 0) {
187
201
  info.textContent = 'No results';
@@ -194,7 +208,7 @@ function renderPagination(layout: TableLayout): HTMLDivElement | null {
194
208
  div.appendChild(info);
195
209
 
196
210
  const btnGroup = document.createElement('span');
197
- btnGroup.className = 'viz-table-pagination-btns';
211
+ btnGroup.className = 'oc-table-pagination-btns';
198
212
 
199
213
  const prevBtn = document.createElement('button');
200
214
  prevBtn.setAttribute('aria-label', 'Previous page');
@@ -220,7 +234,7 @@ function renderPagination(layout: TableLayout): HTMLDivElement | null {
220
234
 
221
235
  function renderEmptyState(message: string): HTMLDivElement {
222
236
  const div = document.createElement('div');
223
- div.className = 'viz-table-empty';
237
+ div.className = 'oc-table-empty';
224
238
  div.setAttribute('aria-live', 'polite');
225
239
  div.textContent = message;
226
240
  return div;
@@ -237,53 +251,57 @@ function renderEmptyState(message: string): HTMLDivElement {
237
251
  * @param container - The container element to render into.
238
252
  * @returns The wrapper element that was created.
239
253
  */
240
- export function renderTable(layout: TableLayout, container: HTMLElement): HTMLElement {
254
+ export function renderTable(
255
+ layout: TableLayout,
256
+ container: HTMLElement,
257
+ opts?: TableRenderOptions,
258
+ ): HTMLElement {
241
259
  const wrapper = document.createElement('div');
242
- wrapper.className = 'viz-table-wrapper';
260
+ wrapper.className = 'oc-table-wrapper';
243
261
 
244
262
  // Apply theme colors as CSS custom properties so table CSS picks them up.
245
263
  // Without this, dark-background themes show invisible text since the
246
- // CSS defaults (--viz-text etc.) are light-mode values.
264
+ // CSS defaults (--oc-text etc.) are light-mode values.
247
265
  const { theme, chrome } = layout;
248
266
  if (theme) {
249
267
  const s = wrapper.style;
250
- s.setProperty('--viz-bg', theme.colors.background);
251
- s.setProperty('--viz-text', theme.colors.text);
252
- s.setProperty('--viz-text-secondary', theme.colors.axis ?? theme.colors.text);
253
- s.setProperty('--viz-text-muted', theme.colors.axis ?? theme.colors.text);
254
- s.setProperty('--viz-gridline', theme.colors.gridline);
255
- s.setProperty('--viz-border', theme.colors.gridline);
256
- s.setProperty('--viz-font-family', theme.fonts.family);
268
+ s.setProperty('--oc-bg', theme.colors.background);
269
+ s.setProperty('--oc-text', theme.colors.text);
270
+ s.setProperty('--oc-text-secondary', theme.colors.axis ?? theme.colors.text);
271
+ s.setProperty('--oc-text-muted', theme.colors.axis ?? theme.colors.text);
272
+ s.setProperty('--oc-gridline', theme.colors.gridline);
273
+ s.setProperty('--oc-border', theme.colors.gridline);
274
+ s.setProperty('--oc-font-family', theme.fonts.family);
257
275
  s.fontFamily = theme.fonts.family;
258
276
  }
259
277
 
260
278
  // Set computed chrome CSS custom properties so chrome elements pick up
261
- // theme-resolved values via CSS fallbacks (e.g. --viz-title-computed-size).
279
+ // theme-resolved values via CSS fallbacks (e.g. --oc-title-computed-size).
262
280
  {
263
281
  const s = wrapper.style;
264
282
  if (chrome.title) {
265
- s.setProperty('--viz-title-computed-size', `${chrome.title.style.fontSize}px`);
266
- s.setProperty('--viz-title-computed-weight', String(chrome.title.style.fontWeight));
267
- s.setProperty('--viz-title-computed-color', chrome.title.style.fill);
283
+ s.setProperty('--oc-title-computed-size', `${chrome.title.style.fontSize}px`);
284
+ s.setProperty('--oc-title-computed-weight', String(chrome.title.style.fontWeight));
285
+ s.setProperty('--oc-title-computed-color', chrome.title.style.fill);
268
286
  }
269
287
  if (chrome.subtitle) {
270
- s.setProperty('--viz-subtitle-computed-size', `${chrome.subtitle.style.fontSize}px`);
271
- s.setProperty('--viz-subtitle-computed-weight', String(chrome.subtitle.style.fontWeight));
272
- s.setProperty('--viz-subtitle-computed-color', chrome.subtitle.style.fill);
288
+ s.setProperty('--oc-subtitle-computed-size', `${chrome.subtitle.style.fontSize}px`);
289
+ s.setProperty('--oc-subtitle-computed-weight', String(chrome.subtitle.style.fontWeight));
290
+ s.setProperty('--oc-subtitle-computed-color', chrome.subtitle.style.fill);
273
291
  }
274
292
  if (chrome.source) {
275
- s.setProperty('--viz-source-computed-size', `${chrome.source.style.fontSize}px`);
276
- s.setProperty('--viz-source-computed-color', chrome.source.style.fill);
293
+ s.setProperty('--oc-source-computed-size', `${chrome.source.style.fontSize}px`);
294
+ s.setProperty('--oc-source-computed-color', chrome.source.style.fill);
277
295
  }
278
296
  if (chrome.footer) {
279
- s.setProperty('--viz-footer-computed-size', `${chrome.footer.style.fontSize}px`);
280
- s.setProperty('--viz-footer-computed-color', chrome.footer.style.fill);
297
+ s.setProperty('--oc-footer-computed-size', `${chrome.footer.style.fontSize}px`);
298
+ s.setProperty('--oc-footer-computed-color', chrome.footer.style.fill);
281
299
  }
282
300
  }
283
301
 
284
302
  // Apply class modifiers
285
303
  if (layout.compact) {
286
- wrapper.classList.add('viz-table--compact');
304
+ wrapper.classList.add('oc-table--compact');
287
305
  }
288
306
 
289
307
  // Header chrome
@@ -305,7 +323,7 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
305
323
  } else {
306
324
  // Scroll container
307
325
  const scroll = document.createElement('div');
308
- scroll.className = 'viz-table-scroll';
326
+ scroll.className = 'oc-table-scroll';
309
327
 
310
328
  // Table
311
329
  const table = document.createElement('table');
@@ -313,13 +331,13 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
313
331
  table.setAttribute('aria-label', layout.a11y.caption);
314
332
 
315
333
  if (layout.stickyFirstColumn) {
316
- table.classList.add('viz-table--sticky');
334
+ table.classList.add('oc-table--sticky');
317
335
  }
318
336
 
319
337
  // Caption (screen reader only – inline styles ensure hiding even without
320
338
  // the external stylesheet, e.g. CDN / esm.sh usage)
321
339
  const caption = document.createElement('caption');
322
- caption.className = 'viz-sr-only';
340
+ caption.className = 'oc-sr-only';
323
341
  caption.style.position = 'absolute';
324
342
  caption.style.width = '1px';
325
343
  caption.style.height = '1px';
@@ -356,7 +374,7 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
356
374
 
357
375
  // Live region for screen reader announcements (sort changes, search results)
358
376
  const liveRegion = document.createElement('div');
359
- liveRegion.className = 'viz-table-live-region viz-sr-only';
377
+ liveRegion.className = 'oc-table-live-region oc-sr-only';
360
378
  liveRegion.style.position = 'absolute';
361
379
  liveRegion.style.width = '1px';
362
380
  liveRegion.style.height = '1px';
@@ -374,7 +392,7 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
374
392
  // Brand watermark
375
393
  const brandColor = theme ? theme.colors.axis : '#999999';
376
394
  const brand = document.createElement('div');
377
- brand.className = 'viz-table-ref';
395
+ brand.className = 'oc-table-ref';
378
396
  brand.style.cssText = 'text-align: right; padding: 4px 8px;';
379
397
  const brandLink = document.createElement('a');
380
398
  brandLink.href = BRAND_URL;
@@ -385,6 +403,19 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
385
403
  brand.appendChild(brandLink);
386
404
  wrapper.appendChild(brand);
387
405
 
406
+ // Animation: stamp CSS custom properties and add oc-animate class BEFORE
407
+ // DOM insertion to avoid a flash of final state.
408
+ if (opts?.animate && layout.animation?.enabled) {
409
+ const anim = layout.animation;
410
+ const rowCount = layout.rows.length;
411
+ const stagger = clampStaggerDelay(anim.staggerDelay, rowCount);
412
+ const s = wrapper.style;
413
+ s.setProperty('--oc-animation-duration', `${anim.duration}ms`);
414
+ s.setProperty('--oc-animation-stagger', `${stagger}ms`);
415
+ s.setProperty('--oc-animation-ease', EASE_VAR_MAP[anim.ease] || EASE_VAR_MAP.smooth);
416
+ wrapper.classList.add('oc-animate');
417
+ }
418
+
388
419
  container.appendChild(wrapper);
389
420
  return wrapper;
390
421
  }
package/src/tooltip.ts CHANGED
@@ -32,7 +32,7 @@ const TOOLTIP_OFFSET = 12;
32
32
  */
33
33
  export function createTooltipManager(container: HTMLElement): TooltipManager {
34
34
  const tooltip = document.createElement('div');
35
- tooltip.className = 'viz-tooltip';
35
+ tooltip.className = 'oc-tooltip';
36
36
  tooltip.setAttribute('role', 'tooltip');
37
37
 
38
38
  container.style.position = container.style.position || 'relative';
@@ -63,21 +63,21 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
63
63
  // Title row: optional color dot + title text
64
64
  if (content.title) {
65
65
  const titleColor = content.fields.find((f) => f.color)?.color;
66
- html += '<div class="viz-tooltip-header">';
66
+ html += '<div class="oc-tooltip-header">';
67
67
  if (titleColor) {
68
- html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
68
+ html += `<span class="oc-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
69
69
  }
70
- html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
70
+ html += `<span class="oc-tooltip-title">${esc(content.title)}</span>`;
71
71
  html += '</div>';
72
72
  }
73
73
 
74
74
  // Field rows
75
75
  if (content.fields.length > 0) {
76
- html += '<div class="viz-tooltip-body">';
76
+ html += '<div class="oc-tooltip-body">';
77
77
  for (const field of content.fields) {
78
- html += '<div class="viz-tooltip-row">';
79
- html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
80
- html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
78
+ html += '<div class="oc-tooltip-row">';
79
+ html += `<span class="oc-tooltip-label">${esc(field.label)}</span>`;
80
+ html += `<span class="oc-tooltip-value">${esc(field.value)}</span>`;
81
81
  html += '</div>';
82
82
  }
83
83
  html += '</div>';