Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

430 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@jeyabbalas/data-table

A client-side TypeScript library for interactive, explorable data tables. Built on DuckDB WASM — all analytics run entirely in the browser, so no data ever leaves the user's machine.

  • Per-column visualizations (histograms, value counts, date/time histograms) with brush/click crossfilter
  • Manual filter UI per column + raw-SQL WHERE filters
  • Pin / hide / reorder / resize columns; virtual scrolling
  • Derived columns (SQL expressions or JS-provided value vectors), with same-name dependency-aware replacement
  • Stable synthetic __rowid__ + read-only column export (actions.getColumnValues) for app-side row alignment and chart feeds
  • Programmatic row / column / cell annotations — severity tiers, intersection popover, JSON round-trip, session-persisted
  • Programmatic column-header tooltips — XSS-safe structured popover (title / description / items + enum chips)
  • Custom column-stats panels — replace the built-in two-line stats display in a column header with your own DOM and DuckDB queries via BaseStatsPanel + per-instance StatsPanelRegistry
  • Public CodeMirror SQL editor primitives — embed a schema- and DuckDB-aware SQL editor anywhere in your own UI shell via createSqlExtensions / buildCompletionContext (filter-preset composers, derived-column wizards, query-template editors); live or literal schema, optional library theme
  • Filter presets (import/export JSON)
  • Undo/redo and IndexedDB session persistence
  • CSV / JSON / Parquet export
  • Automatic light/dark mode via CSS custom properties

Install

npm install @jeyabbalas/data-table \
  @duckdb/duckdb-wasm \
  @codemirror/autocomplete @codemirror/commands @codemirror/lang-sql \
  @codemirror/language @codemirror/state @codemirror/view @lezer/highlight

@duckdb/duckdb-wasm is a required peer dependency. The @codemirror/* and @lezer/* packages are optional peers — install them only if you use the default SQL expression editor (for derived columns and raw-SQL filters). If you supply your own editorFactory or disable those features, you can omit them.

Quick start

Give the container a bounded height — the table virtualizes against it, and without one virtualization is silently defeated. See Sizing the container.

<div id="my-table" style="height: 600px"></div>
import { createDataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';

const table = await createDataTable({
  container: document.getElementById('my-table')!,
  source: myCsvFileOrUrl, // File | string URL | ArrayBuffer | Blob
});

table.on('filterChange', ({ filters, filteredRowCount }) => {
  console.log(`${filters.length} filters, ${filteredRowCount} rows match`);
});

// When unmounting (e.g., route change in an SPA):
await table.destroy();

For file pickers, URL inputs, or "swap dataset" flows, mount once and load on user action — no destroy()/recreate dance needed:

const table = await createDataTable({
  container: document.getElementById('my-table')!,
});

document.getElementById('file-picker')!.addEventListener('change', async (e) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (file) await table.loadData(file);
});

loadData(source) accepts a File, an absolute URL (https://…, http://…, file:, data:, blob:), a protocol-relative URL (//host/…), a root-relative path (/data.csv), a dot-prefixed relative path (./data.csv, ../data.csv), an ArrayBuffer, a Blob, or inline CSV/JSON content (multi-line text, or a string starting with [ / {). Relative URLs resolve against window.location — the same way <img src> and fetch behave. Ambiguous strings (a single-line sample.csv with no leading slash, for example) throw LoadError with code SOURCE_AMBIGUOUS rather than silently parsing the literal text as CSV content.

Probe required browser APIs before mounting:

import { checkBrowserSupport } from '@jeyabbalas/data-table';

const support = checkBrowserSupport();
if (!support.supported) {
  renderUnsupportedScreen(support.missing); // ['Worker', 'WebAssembly', …]
  return;
}
// safe to call createDataTable(...) here

The library is ESM-only since v0.4.0. Use import syntax (every modern bundler — Vite, webpack 5, Rollup, esbuild, Bun — resolves the ESM build by default). It is browser-only and not safe to evaluate during SSR — see Framework integration below for client-side mounting patterns.

Sizing the container

The mount container must have a bounded height. This is a requirement, not a style preference — it is what makes the table fast.

The table is virtualized: it renders only the rows that fit in view, about ⌈containerHeight / rowHeight⌉ + 10 of them. It learns how many that is by measuring the container. The container's height is therefore the knob that caps how much work every scroll, filter, and sort has to do — a 600 px container renders ~29 rows whether the dataset has 1 thousand rows or 10 million.

Give it a height in one of two ways:

<!-- 1. An explicit height — simplest, and always correct -->
<div id="my-table" style="height: 600px"></div>
/* 2. A flex or grid child that fills the space left over */
html,
body {
  height: 100%;
  margin: 0;
}
body {
  display: flex;
  flex-direction: column;
}
#my-table {
  flex: 1;
  min-height: 0; /* required — see below */
}

min-height: 0 is not optional in the flex case. Flex items default to min-height: auto, which refuses to shrink below the content's intrinsic height — and this table's content is every row in the dataset. Omit it and you get the unbounded-growth failure below, in a container that looks like it was sized correctly. The same applies to grid children.

height: 100% on the container alone does nothing unless every ancestor up to the viewport also has a resolved height. That is the usual reason a container that "has a height" still behaves as if it doesn't.

What goes wrong without one

Nothing errors. The table renders, and on a small dataset it looks fine — which is exactly why this is easy to ship and painful later.

With no bounded height, the library's root element (height: 100%) resolves against an auto-height parent and becomes content-sized. The scroll area then grows to its full capped height — min(rowCount × rowHeight, 15,000,000 px) — so the "visible region" the scroller measures is that entire range. Virtualization is silently defeated: the table builds DOM for every row in the range and issues block fetches to fill all of them.

At the default 32 px row height, the cap corresponds to ~468,750 rows, so that is where the damage saturates instead of scaling with the dataset — no comfort: hundreds of thousands of DOM rows and a churn of block fetches to fill them. The tab stalls, memory climbs, and scrolling is unusable. With a bounded container the same dataset renders ~29 rows and stays interactive.

The degenerate case is the opposite one: a container that is zero-tall at mount renders no rows at all, and the library logs a console warning saying so. A container that is merely unbounded gets no warning, because it has a perfectly good non-zero height — it is just the wrong one.

Resizing after mount

Give the container its height before mounting, and prefer a height that the browser resolves through CSS (flex: 1, 100%, vh) over one you assign imperatively after the fact. The scroller recomputes its visible range on scroll and on state changes, so a container that changes height while idle can keep a stale range until the next interaction. Sizing it up front avoids the question.

Every runnable example uses the flex pattern, and docs/integrations/cdn.md has a complete copy-pasteable HTML starter.

Documentation

Full documentation lives under docs/. A quick index:

Start here

  • Quick start (above) · Runnable examples
  • Sizing the container — the bounded height the table virtualizes against; read this before reporting a performance problem
  • AGENTS.md — agent-facing guide: capability matrix, clarifying-question checklist, canonical snippets, pitfalls

Reference

  • API reference — every option, event, action, error, filter shape, derived-column type
  • Troubleshooting — 46 error codes and 26 common-issue FAQs with fix snippets

Guides

Concepts

Integrations

Source

Framework integration

The library is browser-only — it uses Web Workers, window, document, and IndexedDB directly, and is not safe to evaluate during SSR. Mount the table inside your framework's client-side lifecycle hook.

React

import { useEffect, useRef } from 'react';
import { createDataTable, type DataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';

export function Table({ source }: { source: File | string }) {
  const hostRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!hostRef.current) return;
    let cancelled = false;
    let instance: DataTable | undefined;
    void createDataTable({ container: hostRef.current, source }).then((t) => {
      if (cancelled) {
        void t.destroy();
        return;
      }
      instance = t;
    });
    return () => {
      cancelled = true;
      if (instance && !instance.isDestroyed()) void instance.destroy();
    };
  }, [source]);
  return <div ref={hostRef} style={{ height: 600 }} />;
}

The cancelled flag handles the case where the effect re-runs before createDataTable resolves. isDestroyed() guards against double destroys when React's Strict Mode double-invokes effects in dev.

Vue 3

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { createDataTable, type DataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';

const host = ref<HTMLElement | null>(null);
let table: DataTable | undefined;

onMounted(async () => {
  if (host.value) table = await createDataTable({ container: host.value, source: props.source });
});
onBeforeUnmount(async () => {
  if (table && !table.isDestroyed()) await table.destroy();
});
</script>

<template>
  <div ref="host" style="height: 600px" />
</template>

After destroy(), the public methods (loadData, on, off, openExportDialog, clearSession, setColorScheme) throw DestroyedError. Check table.isDestroyed() in long-lived closures before calling them.

Feature toggles

All features are on by default; pass false or a config object to customize:

Option Default Notes
persistence true Auto-save filters/sort/columns to IndexedDB
presets true Show the "Presets" button for saving filter sets
undoRedo true Ctrl/Cmd+Z and Ctrl+Y keyboard shortcuts
expressionFilter true Show the "Expression" (raw SQL) filter button
derivedColumns true Show the "+" add-column button and per-header f(x) edit icon
visualizations true Auto-attach column header histograms / value counts
exportDialog true table.openExportDialog() opens a CSV/JSON/Parquet modal

For the full options surface (mounting, worker, UI, customization), see docs/api-reference.md#createdatatableoptions.

Skipping CodeMirror

When both expressionFilter: false and derivedColumns: false are set, the library no longer reaches the CodeMirror-bound modals at runtime. Consumers in this configuration can omit the @codemirror/* and @lezer/highlight peer dependencies (already marked optional in peerDependenciesMeta) — modern bundlers chunk-split the modals into separate runtime modules that the unreachable code paths never load. The programmatic API (actions.addFilter({ type: 'raw-sql' }), actions.addDerivedColumn, FilterPresetManager) keeps working in this mode. When the flags are on, the first click of an SQL or derived-column button now fetches a small modal chunk on demand instead of loading it eagerly on table mount.

Events

Subscribe with table.on(event, handler) — returns an unsubscribe function. The event bus covers ready, loadStart / loadProgress / loadComplete / loadError, filterChange, sortChange, selectionChange, columnChange, derivedChange (with a kind: 'added' | 'removed' | 'replaced' | 'updated' discriminator and the affected columnName), undoChange, destroy, plus error and warning for recoverable failure modes. Annotation mutations flow through a separate table.annotations.on('change', …) channel — see the annotations guide. For payload types see docs/api-reference.md#event-catalog.

Theming

All colors, spacing, typography, and z-indices are driven by CSS custom properties (74 --dt-* tokens — covering the full palette, sizing scale, z-index stacking ladder, annotation severity tints, and column-header tooltip layering). Override the ones you care about on :root, on a per-instance element, or at runtime.

:root {
  --dt-primary: #10b981;
  --dt-radius: 4px;
  --dt-z-modal: 1500; /* raise above your app's modal layer */
  --dt-panel-width: 420px; /* widen filter / preset / derived-edit panels */
}

Light/dark mode follows prefers-color-scheme by default. Pass colorScheme: 'light' | 'dark' | 'auto' to force a theme, or call table.setColorScheme(...) at runtime; body-portalled modals stay in sync.

See docs/guides/theming.md for the complete variable reference with light/dark defaults side-by-side, the dark-mode scoping model, stacking-ladder deep-dive, and per-instance override patterns.

CSS isolation

All selectors carry the dt- prefix. Column-drag cursor, CodeMirror autocomplete, and modal stacking are all scoped so they don't collide with host styles. For stricter isolation (two copies of the library on one page, strict brand walls), pass classPrefix: 'myapp-dt' and every selector, modal, and tooltip re-renders with that prefix.

Shadow DOM is intentionally not used — modals portal into light DOM so they can inherit --dt-* variables from :root. Wrap the library in a shadow root yourself if you need that, and forward the theme variables + portalTarget accordingly.

Error handling

Every error extends DataTableError (which extends Error). Subscribe to the error event to route by subsystem, or to warning for non-fatal degradations like STYLESHEET_MISSING / PERSISTENCE_UNAVAILABLE:

table.on('error', ({ error, source }) => {
  if (error.code === 'PARSE_FAILED') toast('Could not read that file.');
  else if (source === 'persistence') console.warn(error);
  else reportToSentry(error);
});

For the full list of 34 error codes with triggers and fixes, see docs/troubleshooting.md.

Multiple tables, CSP, and offline

  • Multiple tables: share a WorkerBridge (and optionally a SessionStore / FilterPresetManager) to avoid spinning up two DuckDB instances. Give each table a distinct tableName so session snapshots don't clobber each other.
  • CSP / air-gapped: self-host the worker and WASM bundles and pass bridgeOptions.workerFactory + bridgeOptions.duckdbBundles.

See AGENTS.md §3(h) for a shared-bridge snippet, and docs/troubleshooting.md §4 for the CSP recipe.

Accessibility

The grid targets WCAG 2.1 AA: role="grid" with aria-rowcount / aria-colcount, a single cursor published via aria-activedescendant and driven by the arrow keys (plus F2 to reach a column's controls and Shift+F2 to resize and reorder one), a tab order that doesn't grow with column count, polite aria-live announcements on filter/sort/row-count changes, focus trap + escape-to-close on every modal, and axe-core as a CI gate. Known out-of-scope: in-cell editing, mobile touch gestures, RTL.

Custom visualizations

Subclass BaseVisualization (from /advanced) and register on a per-instance VisualizationRegistry:

import { createDataTable, VisualizationRegistry } from '@jeyabbalas/data-table';
import { BaseVisualization } from '@jeyabbalas/data-table/advanced';

class MyViz extends BaseVisualization {
  /* fetchData, render, … */
}

const registry = new VisualizationRegistry();
registry.register({
  name: 'my-viz',
  isApplicable: (t) => t === 'float',
  constructor: MyViz as any,
  priority: 10,
});
const table = await createDataTable({ container, source, visualizationRegistry: registry });

Runnable version in examples/08-custom-visualization.

Internationalization

Every user-facing string comes from a typed Strings object. Pass a DeepPartial<Strings> via messages; missing keys fall back to English defaults. Messages are resolved once at construction — recreate the table to switch locales.

await createDataTable({
  container,
  source,
  messages: {
    common: { close: 'Fermer', apply: 'Appliquer' },
    filters: { panelTitle: 'Filtres' },
  },
});

Runnable version in examples/07-i18n-french.

Browser support

API Used for
Worker DuckDB runs in a dedicated worker
WebAssembly DuckDB is compiled to Wasm
IndexedDB Session persistence (skipped when persistence: false)
ResizeObserver Column resize, responsive visualizations
BigInt DuckDB integer columns cross the worker boundary as BigInt
structuredClone Bridge snapshots result sets

Roughly Chrome/Edge 98+, Firefox 94+, Safari 15.4+. Probe at runtime with checkBrowserSupport() or opt into fail-fast init via strictBrowserCheck: true. See docs/api-reference.md#browser-support-probe.

Advanced: modular API

The root entry (@jeyabbalas/data-table) exposes the facade plus a small set of stable hooks. Power users who want to orchestrate the stack directly — custom visualization lifecycles, headless use, driving the bridge themselves — can import the building blocks from /advanced:

import {
  TableContainer,
  FilterBar,
  ExportDialog,
  BaseVisualization /* … */,
} from '@jeyabbalas/data-table/advanced';

The /advanced surface trades stability for flexibility: it is not covered by the same semver guarantees as the root entry. See docs/api-reference.md#tier-2-exports for the full symbol list.

Development

See DEVELOPMENT.md for local setup, testing, the build pipeline, and the release workflow. See CONTRIBUTING.md for how to report bugs and submit changes.

License

MIT