-
- {log.map((entry, i) =>
{entry}
)}
+
+ {log.map((entry, i) => (
+
{entry}
+ ))}
diff --git a/packages/site/src/components/demos/ConditionalFormatDemo.tsx b/packages/site/src/components/demos/ConditionalFormatDemo.tsx
index 1fd660f..1136d6d 100644
--- a/packages/site/src/components/demos/ConditionalFormatDemo.tsx
+++ b/packages/site/src/components/demos/ConditionalFormatDemo.tsx
@@ -51,33 +51,52 @@ export function ConditionalFormatDemo() {
const plugin = new ConditionalFormattingPlugin();
engine.installPlugin(plugin);
- plugin.addRule(ConditionalFormattingPlugin.createGradientScale(
- { startRow: 0, startCol: 1, endRow: 9, endCol: 1 },
- [
- { value: 0, color: '#ef4444' },
- { value: 50, color: '#eab308' },
- { value: 100, color: '#22c55e' },
- ]
- ));
+ plugin.addRule(
+ ConditionalFormattingPlugin.createGradientScale(
+ { startRow: 0, startCol: 1, endRow: 9, endCol: 1 },
+ [
+ { value: 0, color: '#ef4444' },
+ { value: 50, color: '#eab308' },
+ { value: 100, color: '#22c55e' },
+ ],
+ ),
+ );
- plugin.addRule(ConditionalFormattingPlugin.createDataBar(
- { startRow: 0, startCol: 2, endRow: 9, endCol: 2 },
- '#3b82f6'
- ));
+ plugin.addRule(
+ ConditionalFormattingPlugin.createDataBar(
+ { startRow: 0, startCol: 2, endRow: 9, endCol: 2 },
+ '#3b82f6',
+ ),
+ );
- plugin.addRule(ConditionalFormattingPlugin.createIconSet(
- { startRow: 0, startCol: 3, endRow: 9, endCol: 3 },
- 'arrows'
- ));
+ plugin.addRule(
+ ConditionalFormattingPlugin.createIconSet(
+ { startRow: 0, startCol: 3, endRow: 9, endCol: 3 },
+ 'arrows',
+ ),
+ );
engine.requestRender();
setStatus('Gradient: Math | Data Bars: Science | Icons: English');
}, []);
return (
-
+
-
+
{status}
diff --git a/packages/site/src/components/demos/ContextMenuDemo.tsx b/packages/site/src/components/demos/ContextMenuDemo.tsx
index 345e06f..87a72ea 100644
--- a/packages/site/src/components/demos/ContextMenuDemo.tsx
+++ b/packages/site/src/components/demos/ContextMenuDemo.tsx
@@ -10,7 +10,9 @@ const data = generateEmployees(50);
export function ContextMenuDemo() {
const { witTheme } = useSiteTheme();
const tableRef = useRef
(null);
- const [lastAction, setLastAction] = useState('Right-click any cell, header, or row number to see the context menu.');
+ const [lastAction, setLastAction] = useState(
+ 'Right-click any cell, header, or row number to see the context menu.',
+ );
useEffect(() => {
const engine = tableRef.current?.getInstance();
@@ -51,9 +53,21 @@ export function ContextMenuDemo() {
}, []);
return (
-
+
-
+
{lastAction}
diff --git a/packages/site/src/components/demos/DemoButton.tsx b/packages/site/src/components/demos/DemoButton.tsx
index 94e73a8..2dd0def 100644
--- a/packages/site/src/components/demos/DemoButton.tsx
+++ b/packages/site/src/components/demos/DemoButton.tsx
@@ -29,7 +29,10 @@ const base: CSSProperties = {
whiteSpace: 'nowrap',
};
-const variants: Record
= {
+const variants: Record<
+ DemoButtonVariant,
+ { normal: CSSProperties; hover: CSSProperties; active?: CSSProperties }
+> = {
default: {
normal: {},
hover: { background: 'var(--sl-color-gray-5)' },
@@ -57,7 +60,14 @@ const variants: Record
+
Demo error: {this.state.error.message}
);
@@ -37,40 +45,52 @@ export function DemoWrapper({ title, description, height = 400, children }: Demo
const descColor = 'var(--sl-color-gray-2)';
return (
-
+
{(title || description) && (
-
+
{title && (
-
{title}
+
+ {title}
+
)}
{description && (
-
{description}
+
+ {description}
+
)}
diff --git a/packages/site/src/components/demos/EditingDemo.tsx b/packages/site/src/components/demos/EditingDemo.tsx
index 4d3ade7..d22366b 100644
--- a/packages/site/src/components/demos/EditingDemo.tsx
+++ b/packages/site/src/components/demos/EditingDemo.tsx
@@ -6,20 +6,34 @@ import { generateEmployees, employeeColumns } from './generate-data';
import { useSiteTheme } from './useSiteTheme';
const data = generateEmployees(30);
-const editableColumns = employeeColumns.map(col => ({ ...col, editable: true }));
+const editableColumns = employeeColumns.map((col) => ({ ...col, editable: true }));
export function EditingDemo() {
const { witTheme } = useSiteTheme();
const [lastEdit, setLastEdit] = useState('Double-click or press F2 to edit a cell');
const handleCellChange = (event: CellChangeEvent) => {
- setLastEdit(`Edited row ${event.row}, col ${event.col}: "${event.oldValue}" → "${event.value}"`);
+ setLastEdit(
+ `Edited row ${event.row}, col ${event.col}: "${event.oldValue}" → "${event.value}"`,
+ );
};
return (
-
+
-
+
{lastEdit}
diff --git a/packages/site/src/components/demos/EventBusDemo.tsx b/packages/site/src/components/demos/EventBusDemo.tsx
index 9379808..220964c 100644
--- a/packages/site/src/components/demos/EventBusDemo.tsx
+++ b/packages/site/src/components/demos/EventBusDemo.tsx
@@ -7,7 +7,7 @@ import { generateEmployees, employeeColumns } from './generate-data';
import { useSiteTheme } from './useSiteTheme';
const data = generateEmployees(30);
-const sortableColumns = employeeColumns.map(col => ({ ...col, sortable: true }));
+const sortableColumns = employeeColumns.map((col) => ({ ...col, sortable: true }));
const EVENT_COLORS: Record
= {
cellClick: '#2563eb',
@@ -38,18 +38,24 @@ export function EventBusDemo() {
const logEvent = (name: string, detail: string) => {
const time = new Date().toLocaleTimeString('en', { hour12: false });
- setEvents(prev => [...prev.slice(-49), { time, name, detail }]);
+ setEvents((prev) => [...prev.slice(-49), { time, name, detail }]);
};
const unsubs = [
bus.on('cellClick', (e: any) => logEvent('cellClick', `row:${e.row} col:${e.col}`)),
- bus.on('cellChange', (e: any) => logEvent('cellChange', `[${e.row},${e.col}] "${e.oldValue}" → "${e.newValue}"`)),
- bus.on('selectionChange', (e: any) => logEvent('selectionChange', `row:${e.selection.activeRow} col:${e.selection.activeCol}`)),
- bus.on('scroll', (e: any) => logEvent('scroll', `top:${Math.round(e.scrollTop)} left:${Math.round(e.scrollLeft)}`)),
+ bus.on('cellChange', (e: any) =>
+ logEvent('cellChange', `[${e.row},${e.col}] "${e.oldValue}" → "${e.newValue}"`),
+ ),
+ bus.on('selectionChange', (e: any) =>
+ logEvent('selectionChange', `row:${e.selection.activeRow} col:${e.selection.activeCol}`),
+ ),
+ bus.on('scroll', (e: any) =>
+ logEvent('scroll', `top:${Math.round(e.scrollTop)} left:${Math.round(e.scrollLeft)}`),
+ ),
bus.on('sortChange', (e: any) => logEvent('sortChange', `${e.sortColumns.length} column(s)`)),
];
- return () => unsubs.forEach(fn => fn());
+ return () => unsubs.forEach((fn) => fn());
}, []);
useEffect(() => {
@@ -59,7 +65,11 @@ export function EventBusDemo() {
}, [events]);
return (
-
+
-
-
Event Log ({events.length})
+
+
+ Event Log ({events.length})
+
Clear
{events.length === 0 && (
-
No events yet. Click a cell, edit a value, or scroll the table.
+
+ No events yet. Click a cell, edit a value, or scroll the table.
+
)}
{events.map((evt, i) => (
-
+
[{evt.time}]{' '}
- {evt.name}
+
+ {evt.name}
+
{': '}
{evt.detail}
diff --git a/packages/site/src/components/demos/ExcelDemo.tsx b/packages/site/src/components/demos/ExcelDemo.tsx
index 18186b8..6a9a3bd 100644
--- a/packages/site/src/components/demos/ExcelDemo.tsx
+++ b/packages/site/src/components/demos/ExcelDemo.tsx
@@ -30,7 +30,9 @@ export function ExcelDemo() {
try {
setStatus('Exporting...');
const buffer = await pluginRef.current.exportExcel({ sheetName: 'Employees' });
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -58,7 +60,11 @@ export function ExcelDemo() {
};
return (
-
+
({ ...col, filterable: true, sortable: true }));
+const filterableColumns = employeeColumns.map((col) => ({
+ ...col,
+ filterable: true,
+ sortable: true,
+}));
export function FilteringDemo() {
const { witTheme } = useSiteTheme();
@@ -19,9 +23,21 @@ export function FilteringDemo() {
};
return (
-
+
-
+
{filterInfo}
diff --git a/packages/site/src/components/demos/FormulaDemo.tsx b/packages/site/src/components/demos/FormulaDemo.tsx
index 77e0e68..4a6eb24 100644
--- a/packages/site/src/components/demos/FormulaDemo.tsx
+++ b/packages/site/src/components/demos/FormulaDemo.tsx
@@ -40,13 +40,20 @@ export function FormulaDemo() {
const visibleCols = columns;
for (let row = 0; row < data.length; row++) {
const r = row + 1; // 1-based cell references
- const formulas: [number, string][] = [[2, `=A${r}+B${r}`], [3, `=A${r}*B${r}`]];
+ const formulas: [number, string][] = [
+ [2, `=A${r}+B${r}`],
+ [3, `=A${r}*B${r}`],
+ ];
for (const [col, formula] of formulas) {
engine.setCell(row, col, formula);
engine.getEventBus().emit('cellChange', {
- row, col, value: formula,
+ row,
+ col,
+ value: formula,
column: visibleCols[col],
- oldValue: '', newValue: formula, source: 'edit' as const,
+ oldValue: '',
+ newValue: formula,
+ source: 'edit' as const,
});
}
}
@@ -61,14 +68,25 @@ export function FormulaDemo() {
height={380}
>
-
-
+
+
{active ? 'Formula Engine Active' : 'Initializing…'}
diff --git a/packages/site/src/components/demos/FrozenPanesDemo.tsx b/packages/site/src/components/demos/FrozenPanesDemo.tsx
index ea1b056..e5d549f 100644
--- a/packages/site/src/components/demos/FrozenPanesDemo.tsx
+++ b/packages/site/src/components/demos/FrozenPanesDemo.tsx
@@ -8,7 +8,11 @@ const data = generateEmployees(100);
export function FrozenPanesDemo() {
const { witTheme } = useSiteTheme();
return (
-
+
+
+
+
c.key);
-const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'David', 'Eva', 'Frank', 'Grace', 'Henry', 'Iris', 'Jack', 'Karen', 'Leo', 'Mona', 'Nick', 'Olivia', 'Paul', 'Quinn', 'Rita', 'Sam', 'Tina'];
-const LAST_NAMES = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor'];
-const DEPARTMENTS = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Design', 'Support', 'Legal'];
+const FIRST_NAMES = [
+ 'Alice',
+ 'Bob',
+ 'Carol',
+ 'David',
+ 'Eva',
+ 'Frank',
+ 'Grace',
+ 'Henry',
+ 'Iris',
+ 'Jack',
+ 'Karen',
+ 'Leo',
+ 'Mona',
+ 'Nick',
+ 'Olivia',
+ 'Paul',
+ 'Quinn',
+ 'Rita',
+ 'Sam',
+ 'Tina',
+];
+const LAST_NAMES = [
+ 'Smith',
+ 'Johnson',
+ 'Williams',
+ 'Brown',
+ 'Jones',
+ 'Davis',
+ 'Miller',
+ 'Wilson',
+ 'Moore',
+ 'Taylor',
+];
+const DEPARTMENTS = [
+ 'Engineering',
+ 'Marketing',
+ 'Sales',
+ 'HR',
+ 'Finance',
+ 'Design',
+ 'Support',
+ 'Legal',
+];
const CITIES = ['New York', 'London', 'Tokyo', 'Berlin', 'Paris', 'Sydney', 'Toronto', 'Singapore'];
// Stable reference — prevents React useEffect from wiping CellStore on re-renders
@@ -122,10 +170,30 @@ export function MillionRowsDemo({ height = 500 }: MillionRowsDemoProps) {
if (!active) {
return (
-
-
-
1,000,000
-
rows rendered at 60 FPS
+
+
+
+ 1,000,000
+
+
+ rows rendered at 60 FPS
+
▶ Load 1M Rows
-
+
Only visible rows are drawn on canvas. Scroll through a million rows with zero jank.
@@ -142,16 +218,13 @@ export function MillionRowsDemo({ height = 500 }: MillionRowsDemoProps) {
}
const fmt = new Intl.NumberFormat('en-US');
- const desc = loadTime !== null
- ? `Loaded in ${loadTime}ms · ${fmt.format(TARGET_ROWS)} rows · ${fps !== null ? fps + ' FPS' : 'Measuring...'}`
- : `Loading ${fmt.format(loadedRows)} / ${fmt.format(TARGET_ROWS)} (${progress}%) · ${fps !== null ? fps + ' FPS' : ''}`;
+ const desc =
+ loadTime !== null
+ ? `Loaded in ${loadTime}ms · ${fmt.format(TARGET_ROWS)} rows · ${fps !== null ? fps + ' FPS' : 'Measuring...'}`
+ : `Loading ${fmt.format(loadedRows)} / ${fmt.format(TARGET_ROWS)} (${progress}%) · ${fps !== null ? fps + ' FPS' : ''}`;
return (
-
+
+
How It Works
- @witqq/spreadsheet uses canvas-based virtual scrolling — only the rows visible in the viewport are
- rendered on each frame. The engine calculates which rows are visible based on scroll position and row heights,
- then draws only those cells on a single <canvas> element.
+ @witqq/spreadsheet uses canvas-based virtual scrolling — only the rows
+ visible in the viewport are rendered on each frame. The engine calculates which rows are
+ visible based on scroll position and row heights, then draws only those cells on a single{' '}
+ <canvas> element.
The ProgressiveLoaderPlugin streams data in time-budgeted chunks using
- scheduler.yield() (with MessageChannel fallback). Each chunk runs for ~50ms then yields
- to the browser. The table is interactive immediately — you can scroll, sort, and filter
- while remaining data loads. A progress overlay shows loading status.
+ scheduler.yield() (with MessageChannel fallback). Each chunk runs
+ for ~50ms then yields to the browser. The table is interactive immediately — you can scroll,
+ sort, and filter while remaining data loads. A progress overlay shows loading status.
Why it's fast:
- - O(viewport) rendering — drawing cost is proportional to visible rows (~30-50), not total rows (1M)
- - Canvas 2D API — GPU-accelerated text and shape rendering, no DOM node creation per cell
- - Float64Array layout — cumulative row positions in typed arrays for O(1) cell rect lookups and O(log n) scroll-to-row
- - rAF coalescing — multiple changes within a frame produce a single render, via
requestAnimationFrame
- - Text measurement cache — LRU cache (10K entries) avoids redundant
ctx.measureText() calls
- - Progressive loading — data streams in chunks without blocking the UI thread
+ -
+ O(viewport) rendering — drawing cost is proportional to visible rows
+ (~30-50), not total rows (1M)
+
+ -
+ Canvas 2D API — GPU-accelerated text and shape rendering, no DOM node
+ creation per cell
+
+ -
+ Float64Array layout — cumulative row positions in typed arrays for O(1)
+ cell rect lookups and O(log n) scroll-to-row
+
+ -
+ rAF coalescing — multiple changes within a frame produce a single render,
+ via
requestAnimationFrame
+
+ -
+ Text measurement cache — LRU cache (10K entries) avoids redundant{' '}
+
ctx.measureText() calls
+
+ -
+ Progressive loading — data streams in chunks without blocking the UI
+ thread
+
- The data array itself lives in memory (~200-400MB for 1M rows), but rendering performance is independent
- of dataset size. Scroll at any speed — the frame budget stays under 16ms.
+ The data array itself lives in memory (~200-400MB for 1M rows), but rendering performance is
+ independent of dataset size. Scroll at any speed — the frame budget stays under 16ms.
);
diff --git a/packages/site/src/components/demos/PluginShowcaseDemo.tsx b/packages/site/src/components/demos/PluginShowcaseDemo.tsx
index afe4590..9bf4aef 100644
--- a/packages/site/src/components/demos/PluginShowcaseDemo.tsx
+++ b/packages/site/src/components/demos/PluginShowcaseDemo.tsx
@@ -55,9 +55,13 @@ export function PluginShowcaseDemo() {
const formula = `=A${row + 1}+B${row + 1}`;
engine.setCell(row, 2, formula);
engine.getEventBus().emit('cellChange', {
- row, col: 2, value: formula,
+ row,
+ col: 2,
+ value: formula,
column: columns[2],
- oldValue: '', newValue: formula, source: 'edit' as const,
+ oldValue: '',
+ newValue: formula,
+ source: 'edit' as const,
});
}
engine.requestRender();
@@ -83,8 +87,8 @@ export function PluginShowcaseDemo() {
{ value: 0, color: '#ef4444' },
{ value: 50, color: '#eab308' },
{ value: 100, color: '#22c55e' },
- ]
- )
+ ],
+ ),
);
condFormatPluginRef.current = plugin;
engine.requestRender();
@@ -100,31 +104,23 @@ export function PluginShowcaseDemo() {
>
-
+
Formula Plugin: {formulaEnabled ? 'ON' : 'OFF'}
-
+
Conditional Formatting: {condFormatEnabled ? 'ON' : 'OFF'}
+ theme={witTheme}
+ ref={tableRef}
+ columns={columns}
+ data={initialData}
+ editable={true}
+ showRowNumbers={true}
+ style={{ width: '100%', height: '100%' }}
+ />
diff --git a/packages/site/src/components/demos/PrintSupportDemo.tsx b/packages/site/src/components/demos/PrintSupportDemo.tsx
index a4da8ae..f3be328 100644
--- a/packages/site/src/components/demos/PrintSupportDemo.tsx
+++ b/packages/site/src/components/demos/PrintSupportDemo.tsx
@@ -14,7 +14,11 @@ export function PrintSupportDemo() {
const tableRef = useRef(null);
return (
-
+
tableRef.current?.print()}>🖨️ Print Table
diff --git a/packages/site/src/components/demos/ResizeDemo.tsx b/packages/site/src/components/demos/ResizeDemo.tsx
index c6cd149..a420c4f 100644
--- a/packages/site/src/components/demos/ResizeDemo.tsx
+++ b/packages/site/src/components/demos/ResizeDemo.tsx
@@ -8,7 +8,14 @@ const data = generateEmployees(50);
const resizableColumns: ColumnDef[] = [
{ key: 'id', title: 'ID', width: 60, type: 'number', resizable: false },
- { key: 'name', title: 'Name (resizable)', width: 160, minWidth: 80, maxWidth: 400, resizable: true },
+ {
+ key: 'name',
+ title: 'Name (resizable)',
+ width: 160,
+ minWidth: 80,
+ maxWidth: 400,
+ resizable: true,
+ },
{ key: 'department', title: 'Department', width: 130, minWidth: 60, resizable: true },
{ key: 'salary', title: 'Salary', width: 100, type: 'number', minWidth: 60, resizable: true },
{ key: 'city', title: 'City', width: 120, minWidth: 60, maxWidth: 300, resizable: true },
@@ -19,7 +26,11 @@ const resizableColumns: ColumnDef[] = [
export function ResizeDemo() {
const { witTheme } = useSiteTheme();
return (
-
+
(null);
- const [info, setInfo] = useState('3 groups: Electronics (3 items), Clothing (4 items), Food (2 items)');
+ const [info, setInfo] = useState(
+ '3 groups: Electronics (3 items), Clothing (4 items), Food (2 items)',
+ );
useEffect(() => {
const engine = tableRef.current?.getInstance();
@@ -57,13 +59,18 @@ export function RowGroupingDemo() {
const engine = tableRef.current?.getInstance();
if (!engine) return;
const rgm = engine.getRowGroupManager();
- if (expand) rgm.expandAll(); else rgm.collapseAll();
+ if (expand) rgm.expandAll();
+ else rgm.collapseAll();
engine.requestRender();
setInfo(expand ? 'All groups expanded' : 'All groups collapsed');
};
return (
-
+
handleToggleAll(true)}>➕ Expand All
diff --git a/packages/site/src/components/demos/SelectionDemo.tsx b/packages/site/src/components/demos/SelectionDemo.tsx
index 1602fa7..fb2abe5 100644
--- a/packages/site/src/components/demos/SelectionDemo.tsx
+++ b/packages/site/src/components/demos/SelectionDemo.tsx
@@ -19,9 +19,21 @@ export function SelectionDemo() {
};
return (
-
+
-
+
{selection}
diff --git a/packages/site/src/components/demos/SortingDemo.tsx b/packages/site/src/components/demos/SortingDemo.tsx
index e9caf12..4d7d7fb 100644
--- a/packages/site/src/components/demos/SortingDemo.tsx
+++ b/packages/site/src/components/demos/SortingDemo.tsx
@@ -7,7 +7,7 @@ import { generateEmployees, employeeColumns } from './generate-data';
import { useSiteTheme } from './useSiteTheme';
const data = generateEmployees(100);
-const sortableColumns = employeeColumns.map(col => ({ ...col, sortable: true }));
+const sortableColumns = employeeColumns.map((col) => ({ ...col, sortable: true }));
export function SortingDemo() {
const { witTheme } = useSiteTheme();
@@ -19,15 +19,29 @@ export function SortingDemo() {
if (cols.length === 0) {
setSortInfo('No sort applied');
} else {
- const desc = cols.map(s => `${sortableColumns[s.col]?.title ?? `col ${s.col}`} (${s.direction})`).join(', ');
+ const desc = cols
+ .map((s) => `${sortableColumns[s.col]?.title ?? `col ${s.col}`} (${s.direction})`)
+ .join(', ');
setSortInfo(`Sorted by: ${desc}`);
}
};
return (
-
+