@lembryo/voxsheet
A React spreadsheet component that renders millions of rows smoothly with virtual scrolling. Excel-like selection, editing, autofill, clipboard, and column resizing, with host-controlled sort / filter / search.
▶ Open live demo Runs in your browser with in-browser data
Overview
VoxSheet uses DOM-based virtual scrolling to render only the cells currently in view, no
matter how many rows exist. Data fetching and domain state (sort / filter / search) are owned
by the host; the viewport, selection, edit buffer, and keyboard are owned by
the grid.
- Transport-agnostic — fetching is delegated to
fetchRows, so REST, GraphQL, or a local array all work. - TypeScript-first — the entire public API is typed.
- Peer-dependency model —
react/react-domare peer dependencies. - No external CSS framework — self-contained via
--vox-*variables andvox-classes.
Why voxsheet?
voxsheet's distinguishing trait is that it combines a free (MIT) Excel-like editing experience with a server-driven design from day one.
- Free Excel-like editing — autofill (fill handle), multi-range selection, copy & paste, and column resizing built in.
- Server-driven by design — sort, filter, search, and pagination are delegated to
your backend through
fetchRows. Even huge datasets fetch only the visible window. - React-native / DOM-based — hooks, controlled props, JSX cells. Unlike canvas grids, you extend, inspect, and handle accessibility with ordinary DOM.
- TypeScript-first, no external CSS, peer deps — easy to drop into an existing build and theme.
How it compares
Several features voxsheet ships by default require a paid plan or a separate license in other major grids (the table below lists representative examples; licenses change, so verify current terms before adopting).
| Feature | voxsheet | Representative alternatives |
|---|---|---|
| Autofill (fill handle) | Built in, free | AG Grid: Enterprise (paid); MUI X: Premium (paid) |
| Multi-range selection | Built in, free | AG Grid: Enterprise (paid) |
| Server-driven data (host owns fetching) | Built in | AG Grid: Enterprise (Server-Side Row Model) |
| License | MIT | Handsontable: paid license required for commercial use |
Installation
npm install @lembryo/voxsheet
react / react-dom (v18+) are peer dependencies. Remember to import the
stylesheet as well.
import { VoxSheet } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"
Quick start
A minimal example. columns, totalRows, and fetchRows are the
three required props.
import { useCallback, useState } from "react"
import { VoxSheet } from "@lembryo/voxsheet"
import type { Column, FetchResult, Query, SortSpec } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"
const columns: Column[] = [
{ name: "id", type: "number" },
{ name: "name", type: "string" },
{
name: "salary",
type: "number",
format: { kind: "number", options: { style: "currency", currency: "USD" } },
},
{ name: "joinedAt", type: "date" },
]
export function App() {
const [sort, setSort] = useState<SortSpec[]>([])
const [total, setTotal] = useState(0)
// The grid calls this with a Query (offset/limit + the controlled
// sort/filters/search) and an AbortSignal for cancelling stale requests.
const fetchRows = useCallback(
async (query: Query, signal: AbortSignal): Promise<FetchResult> => {
const res = await fetch("/api/rows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(query),
signal,
})
const json: FetchResult = await res.json()
if (typeof json.total === "number") setTotal(json.total)
return json
},
[],
)
return (
<VoxSheet
columns={columns}
totalRows={total}
fetchRows={fetchRows}
sort={sort}
onSortChange={setSort}
/>
)
}
onSortChange is set.
Core concepts
VoxSheet is controlled and transport-agnostic. Responsibilities split
as follows.
| Owner | Responsibilities |
|---|---|
| Host | Data source (fetchRows), domain state (sort /
filters / search), persistence (committing edits),
filter-condition UI
|
| Grid | Viewport (virtual scroll), cell selection, edit buffer, undo/redo, keyboard, clipboard |
The host holds sort/filter state (e.g. with useState); the grid only notifies
user actions via callbacks. When state changes, fetchRows is called again with
a new Query.
Data contract
These are the types exchanged between the grid and the host — the heart of working with voxsheet.
CellValue
A cell value, restricted to JSON-serializable types.
type CellValue = string | number | boolean | null
- Carry dates as an ISO 8601 string (recommended) or epoch milliseconds,
interpreted via column
type: "date". nullrepresents SQL NULL and is distinct from the empty string"".
Query
The fetch criteria the grid passes to fetchRows. offset /
limit are chosen by the grid; sort / filters /
search are the controlled values you passed in.
type Query = {
offset: number
limit: number
sort: SortSpec[] // multi-column; array order = priority
filters: FilterSpec[] // AND-combined
search?: string
}
FetchResult
The value returned by fetchRows. data[i][j] is row i, column j, aligned to
columns order.
| Property | Type | Description |
|---|---|---|
| datarequired | CellValue[][] | data[i][j] = row i, col j. Aligned to columns order. |
| idsrequired | number[] | Stable per-row id. Used to resolve row → id when committing edits. |
| ordinalsrequired | (number | null)[] | Display ordinal shown in the row-number gutter. An explicit null renders a
blank gutter for that row (e.g. a frozen header row; the cell itself is
kept). undefined falls back to offset + i + 1.
|
| totaloptional | number | Count after filters/search; syncs the scrollbar (returning it every time is recommended). |
fetchRows
A function that fetches a window of rows. The second argument, signal, lets you cancel
stale requests.
type FetchRowsFn = (query: Query, signal: AbortSignal) => Promise<FetchResult>
fetchRows a stable reference (e.g. with useCallback).
Passing a new function every render triggers unnecessary refetches.
Columns
Column
Column metadata. type drives rendering, editing, comparison, and autofill. Show/hide a
column by including or omitting it from the array (controlled).
| Property | Type | Description |
|---|---|---|
| namerequired | string | Identifier and display label. Referenced by column in sort /
filter.
|
| typeoptional | "string" | "number" | "date" | "boolean" | "select" | Default "string". Basis for formatting, alignment, parsing, comparison, and
the editor kind.
|
| alignoptional | "left" | "right" | "center" | Derived from type by default (number/date = right, boolean = center). |
| widthoptional | number | Defaults to defaultColumnWidth. User resizes are remembered by column name.
|
| formatoptional | ColumnFormat | Display formatting. See below. |
| editableoptional | boolean | ((ctx) => boolean) | Follows the parent readOnly by default. |
| validateoptional | (value, ctx) => boolean | string | Reject with false or a string (error message). |
| sortModesoptional | { id: string; label: string }[] | Sort-method choices (e.g. text vs numeric). Adds a ▾ picker; the chosen id rides on
SortSpec.mode.
|
| defaultSortModeoptional | string | Initial/default mode id (defaults to sortModes[0]). |
| optionsoptional | { value: CellValue; label?: string }[] | Choices for type: "select". See below. |
Select (dropdown) columns
Declare type: "select" with options and the cell displays the matching
option's label (or the raw value); editing opens a dropdown (choosing an option commits
immediately). Pasted / autofilled strings are resolved back to option values by
String(value) equality; unmatched input is kept as a raw string for
validate to reject. Values on the wire stay plain CellValues, so
backend sorting/filtering/persistence is unchanged.
const columns: Column[] = [
{
name: "department",
type: "select",
options: [
{ value: "eng", label: "Engineering" },
{ value: "sales", label: "Sales" },
],
},
]
Formatting
Stringify values with Intl options or an arbitrary function. Resolution order is
function > per-kind default.
type ColumnFormat =
| { kind: "number"; options: Intl.NumberFormatOptions }
| { kind: "date"; options: Intl.DateTimeFormatOptions }
| ((value: CellValue, ctx: { column: Column; row: number }) => string)
// Currency
{ name: "salary", type: "number",
format: { kind: "number", options: { style: "currency", currency: "USD" } } }
// Fully custom via a function
{ name: "status", format: (v) => (v === 1 ? "Active" : "Inactive") }
Editing & validation
Use editable for per-column / per-row edit permission, and validate for
input validation.
{
name: "email",
type: "string",
editable: ({ row }) => row > 0, // per-row permission
validate: (value) =>
typeof value === "string" && value.includes("@")
? true
: "Invalid email address", // returning a string rejects + shows the message
}
Props reference
VoxSheetProps grouped by category.
Data
| Property | Type | Description |
|---|---|---|
| columnsrequired | Column[] | Column definitions (controlled). |
| totalRowsrequired | number | Total row count. Synced via FetchResult.total. Pass 0 when
unknown — the grid fetches the first chunk to establish the real count.
|
| fetchRowsrequired | FetchRowsFn | Row fetcher (query, signal) => Promise<FetchResult>. |
| queryKeyoptional | unknown | Invalidation key for host-specific query inputs. Changing it drops the cache and refetches. |
Layout & display
| Property | Type | Description |
|---|---|---|
| rowHeightoptional | number | Row height (px). Default 28. Takes precedence over density. |
| densityoptional | "compact" | "normal" | "comfortable" | Switches font and row height together. Default "normal". |
| defaultColumnWidthoptional | number | Default column width (px). Default 120. |
| rowHeaderWidthoptional | number | Row-number gutter width (px). Auto-fits when omitted (below). Since 1.2.0. |
| autoRowHeaderWidthoptional | boolean | Auto-fit the gutter width to the digits of totalRows (clamped 52–120px).
Default true; false restores the legacy fixed 52px. Since
1.2.0.
|
| frozenRowsoptional | number | Rows frozen from the top. Default 0. The first N rows render in a fixed band above the scrolling body (only horizontal scroll is synced). Supported since 1.1.0. |
| frozenColumnsoptional | number | Columns frozen to the left. Default 0. The first N columns stay pinned during horizontal
scroll (sticky-positioned) and compose with frozenRows. Supported since
1.1.0.
|
| themeoptional | "light" | "dark" | "system" | Sets data-vox-theme. Default "system". |
| classNameoptional | string | Class on the root element. |
| styleoptional | CSSProperties | Inline style on the root element. |
Behavior (controlled domain state)
| Property | Type | Description |
|---|---|---|
| readOnlyoptional | boolean | Disables editing UI (copy / select / navigate and header sorting / filtering still work). |
| sortoptional | SortSpec[] | Multi-column sort (controlled). |
| filtersoptional | FilterSpec[] | Filters (controlled; the condition UI is owned by the host). |
| searchoptional | string | Search keyword. |
| searchHighlightsoptional | CellAddress[] | Cells to highlight. |
| currentSearchHitoptional | CellAddress | null | Current hit; scrolls into view when it changes. |
State UI & extension
| Property | Type | Description |
|---|---|---|
| renderLoadingoptional | () => ReactNode | Override the loading view. |
| renderEmptyoptional | () => ReactNode | Override the empty view. |
| labelsoptional | Partial<VoxLabels> | Override built-in UI strings (i18n). |
| iconsoptional | Icons | Override icons. |
| platformoptional | PlatformAdapter | Inject clipboard / notify / confirm / save implementations. |
Events (callbacks)
Buttons and affordances are hidden or disabled when their callback is omitted.
| Property | Type | Description |
|---|---|---|
| onSortChange | (sort: SortSpec[]) => void | Grid toggles none→asc→desc→none and notifies. A plain click replaces
the sort with that column; Shift+click adds to a multi-column sort. With
Column.sortModes, the chosen mode is carried on SortSpec.mode.
Omitting hides the sort button.
|
| onFilterButtonClick | (col: number, anchor: DOMRect) => void | Filter request (host shows a popover). Omitting hides the filter button. |
| onColumnResize | (col: number, width: number) => void | Column resize (drag / double-click auto-fit). |
| onColumnRename | (col: number, newName: string) => void | Header rename. Omitting disables rename. |
| onColumnReorder | (from: number, to: number) => void | Column drag-and-drop from the header (shows an insertion indicator). Omitting disables
dragging. Reflect the move by reordering columns (host-controlled).
|
| onAddColumn | (atCol: number) => void | Add-column request. Omitting hides the add button. |
| onCellChange | (edit: CellEdit) => void | On each local edit. |
| onDirtyChange | (hasChanges: boolean) => void | Whether there are uncommitted changes. |
| onAppendRow | (atRow: number, count?: number) => void | Promise<void> | Append rows: Enter on the last row (count = 1), or a paste that runs
past the last row (count = missing rows). Return a Promise and the grid
waits for the row count to grow before pasting the rest.
|
| onInsertRow | (atRow: number, pos: "above" | "below") => void | Insert-row request. |
| onDeleteRows | (rows: number[]) => void | Delete-rows request. |
| onAutoFill | (p: { sourceRange; direction; toEnd }) => void | Fill-handle drag (all four directions; the grid applies the repeating pattern and
notifies) / double-click (toEnd: true; the host fills to the end).
|
| onSelectionChange | (selection: Selection[]) => void | Selection changed. |
| onSelectionStats | (stats: SelectionStats | null) => void | Selection aggregates (sum / average / count). partial: true when the
selection includes rows not yet loaded.
|
| onCellKeyDown | (e, ctx: CellContext) => void | Hook before default key handling (suppress with preventDefault). |
| onError | (err, ctx: { phase: "fetch" | "commit" | "clipboard" }) => void | Fetch / commit / clipboard failure. A failed load also shows a built-in error overlay with a Retry button; if some rows are already displayed, a small retry bar appears instead and retries only the chunks that failed. |
Sorting
Sorting is controlled. The header sort button toggles none → asc → desc → none and
notifies via onSortChange; the host updates sort. A plain click
replaces the sort with that column alone; Shift+click
adds the column to a multi-column sort. Array order is the multi-column
priority.
const [sort, setSort] = useState<SortSpec[]>([])
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows}
sort={sort} onSortChange={setSort} />
Sort modes
Some columns can be ordered more than one way — as text or as a
number, say. Declare the choices with Column.sortModes and the header
gains a small ▾ picker; the chosen mode id rides along on SortSpec.mode,
and your backend decides what it means (the grid only carries the id, staying
transport-agnostic). Columns without sortModes are unchanged — no mode is
sent, so the default is your backend's plain (text) ordering.
const columns: Column[] = [
{ name: "code", sortModes: [
{ id: "text", label: "Text" },
{ id: "numeric", label: "Numeric" },
], defaultSortMode: "text" },
]
// fetchRows receives e.g. { column: "code", direction: "asc", mode: "numeric" }
// — translate mode → ORDER BY on the server.
Filtering
The filter-condition UI is owned by the host. The grid signals “I want to edit this
column's filter” via onFilterButtonClick(col, anchorRect), so the host shows a popover
near anchorRect and updates filters on confirm. Filters are AND-combined.
type FilterOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<="
| "contains" | "startsWith" | "endsWith"
| "isNull" | "notNull"
type FilterSpec = { column: string; operator: FilterOperator; value?: CellValue }
When a column has more than one active filter, the header filter button shows a small count badge, so multi-condition filters are visible at a glance.
Search
Passing a keyword to search reflects it into Query.search for fetchRows.
The host controls hit highlighting and scrolling via searchHighlights / currentSearchHit.
<VoxSheet ...
search={keyword}
searchHighlights={hits}
currentSearchHit={hits[cursor] ?? null}
/>
Editing & commit
Edits first accumulate in a local layer (dirty highlight, onCellChange
/ onDirtyChange notifications). The host reads getLocalEdits() whenever it
likes, resolves each row to a stable id via the ids received from fetchRows,
persists, then calls clearLocalEdits().
const ref = useRef<VoxSheetHandle>(null)
async function save() {
const edits = ref.current!.getLocalEdits() // CellEdit[]
// resolve row → stable id and persist (e.g. via PATCH)
await commitToServer(edits)
ref.current!.clearLocalEdits() // clear after success
}
<VoxSheet ref={ref} ... onDirtyChange={setDirty} />
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
The editor follows the column type: select / boolean edit via
a dropdown (choosing an option commits immediately; boolean cells also toggle with
Space), date uses a native date input, number gets a decimal
input mode, and everything else a text input. IME direct input is supported — starting a
composition on a cell opens the editor with the composed text. Typing (or committing a
composition) puts the caret after the inserted text; F2 and double-click select the
whole value.
A value rejected by Column.validate keeps the editor open with the text you typed, so
it can be fixed in place — Enter, Tab and clicking another cell do not leave the cell until the
value is accepted (or Esc cancels).
Edits on rows that were never fetched have no known original value: undo drops the edit (revealing
whatever the server returns) instead of writing null, and
getLocalEdits() reports oldValue: null for them.
sort / filters /
search / columns / queryKey change, the same index points at
a different record. To prevent committing edits to the wrong rows, the grid clears edits and
undo history on those changes, closes an open editor, and fires
onDirtyChange(false). To protect unsaved
edits, confirm in your UI before applying a query change. Likewise, your row → id map
(from ids) becomes stale — drop it when the query changes.
Selection & stats
Selection is an array of Excel-like rectangular ranges. onSelectionChange reports the
ranges; onSelectionStats reports count, numeric count, sum, and average
(sum / average are null when there are no numbers). Stats are
computed over loaded (cached) cells only; partial: true is set when
the selection includes unloaded rows or exceeds the 100 000-cell limit.
type CellAddress = { row: number; col: number }
type Selection = { start: CellAddress; end: CellAddress }
type SelectionStats = {
count: number
numericCount: number
sum: number | null
average: number | null
partial?: boolean // stats cover loaded cells only
}
Copy, paste & export
- Copy writes raw (unformatted) values as TSV, so pasting back never corrupts types.
- Selections spanning unloaded rows are bulk-fetched before copying. Copies above 100 000
cells ask for confirmation via
platform.confirm; a 1 000 000-cell hard cap applies. - Cut clears the cells only after the clipboard write succeeded, so a denied write never loses data.
- Paste anchors at the top-left of the active selection and tiles the clipboard data when the selection is an exact multiple of it (Excel-style). The pasted range becomes the selection.
- When the pasted block runs past the last row, the grid calls
onAppendRow(atRow, count)and waits (up to 5 s) fortotalRows/FetchResult.totalto grow before pasting the rest — the host owns the data, so it decides how rows are created. Anything that still does not fit is skipped and reported (labelpasteClipped). In the handler, create the rows, raise the row count without changingqueryKey(that would discard the pasted edits), and callinvalidate()so the affected chunk is refetched. - The context menu can export the selection as CSV via
platform.saveFile(available inreadOnlytoo, alongside Copy). Its Paste entry is disabled where the browser cannot read the clipboard; Ctrl+V still works.
Autofill
Dragging the fill handle at the bottom-right of the selection fills in any of the four
directions — the grid repeats the selection pattern and notifies via
onAutoFill. Double-clicking the handle only notifies with
toEnd: true; the extent and values of a fill-to-end are host-determined (they depend on
your server/domain).
onAutoFill={(p) => {
// p.sourceRange: the source selection, p.direction: "down"|"up"|"left"|"right"
// p.toEnd: true = double-click (the host performs the fill to the end)
}}
Keyboard
| Keys | Action |
|---|---|
| ↑ ↓ ← → / Tab / Enter | Move between cells |
| Home End / PageUp PageDown | Row start/end / page-wise move |
| Ctrl+Home / Ctrl+End | Jump to start/end of the table |
| F2 / type directly | Start editing / overwrite (IME direct input supported) |
| Delete / Backspace | Clear the whole selection / empty the active cell and start editing |
| Space | Toggle a boolean cell |
| Ctrl+A/C/X/V | Select all / copy / cut / paste (paste uses the native paste event — works on Firefox) |
| Ctrl+Z / Ctrl+Y (Ctrl+Shift+Z) | Undo / redo |
| Shift+arrows / Shift+click | Extend the selection |
| Ctrl+click | Multi-range selection |
Navigation scrolls to follow both vertically and horizontally, and drag-selection / fill auto-scroll
at the viewport edges. IME is committed on compositionend. In readOnly
mode only copy, select-all, and navigation (including Home/End/Page keys) are active — the header
sort and filter buttons stay available, since they are read operations.
Row freezing
Set frozenRows to keep the first N rows pinned in a fixed band above the scrolling body.
The band stays in place while the rest of the grid scrolls vertically; only horizontal scrolling is
synced to it, so the frozen cells stay aligned with their columns. The scrolling body covers rows
[frozenRows, total), so frozen rows are never shown twice.
Frozen rows behave like ordinary rows — they can be selected and edited, and the leading chunk that
covers them is always fetched regardless of scroll position. The default 0 disables
freezing. To freeze columns instead, see column freezing.
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenRows={2} />
Column freezing
Set frozenColumns to keep the first N columns pinned to the left. They stay visible
while the rest of the grid scrolls horizontally (implemented with sticky positioning); column widths
and row heights stay aligned with the body. Frozen columns compose with frozenRows —
the top-left intersection stays pinned in both directions.
The default 0 disables freezing.
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenColumns={1} />
Column reordering
When onColumnReorder is provided, column headers become draggable. Dragging a header
shows an insertion indicator, and dropping emits onColumnReorder(from, to). The column
order is host-controlled: reflect the move by reordering the columns
array.
Because data is positional (data[i][j] is aligned to columns), make sure
fetchRows returns each row's cells in the current column order after a reorder — return
them from the backend in that order, or remap client-side.
onColumnReorder={(from, to) => {
setColumns((cols) => {
const next = [...cols]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
})
}}
Imperative handle (ref)
Get a VoxSheetHandle via ref to drive scrolling, selection, editing, undo,
and more imperatively.
| Method | Description |
|---|---|
| scrollToRow(row) | Scroll to the given row. |
| scrollToCell(row, col) | Scroll to the given cell. |
| focusCell(row, col) | Focus the given cell. |
| getSelection() | Get the current selection (Selection[]). |
| setSelection(sel) | Set the selection. |
| startEdit(row, col) | Begin editing the given cell. |
| getLocalEdits() | Pull uncommitted edits (CellEdit[]). |
| clearLocalEdits() | Clear local edits and undo/redo after a commit. |
| undo() / redo() | Undo / redo. |
| invalidate() | Drop the cache and refetch (explicit refresh). |
Styling & theming
Styles are self-contained via vox- classes and --vox-* CSS variables.
Import @lembryo/voxsheet/styles.css and override variables or class rules to theme.
Dark mode follows prefers-color-scheme and can be forced via the theme
prop.
.vox-sheet {
--vox-row-height: 32px;
--vox-color-accent: #06c755;
}
--vox-* variables are declared on the .vox-sheet root, not
:root. Overriding them on :root has no effect — scope your overrides to
.vox-sheet (or a wrapper class), as shown above.
densityswitches row height and font together (compact/normal/comfortable).rowHeighttakes precedence overdensity.themesets thedata-vox-themeattribute (light/dark/system).
i18n (labels)
Built-in UI strings can be partially overridden via labels (Partial<VoxLabels>).
<VoxSheet ...
labels={{
loading: "Loading…",
empty: "No data",
contextCopy: "Copy",
contextPaste: "Paste",
}}
/>
See VoxLabels in the type reference for the full list of keys.
Icons
Replace the sort / filter icons. Keys you don't provide use the built-in icons.
<VoxSheet ...
icons={{
filter: ({ size }) => <MyFilterIcon width={size} />,
}}
/>
Replaceable keys: sortAscending / sortDescending /
sortUnsorted / filter / filterActive.
Platform adapter
Inject implementations for clipboard, toast notifications, confirmation dialogs, and file saving.
When omitted, the grid falls back to standard browser behavior. confirm is used for
large-copy confirmation (above 100 000 cells) and saveFile for the context
menu's CSV export. The built-in toast / modal follow the theme prop and dark mode.
<VoxSheet ...
platform={{
notify: (kind, message) => { showToast(kind, message); return id },
confirm: async ({ message }) => window.confirm(message),
clipboard: {
readText: () => navigator.clipboard.readText(),
writeText: (t) => navigator.clipboard.writeText(t),
},
}}
/>
Type reference
The main public types.
type CellValue = string | number | boolean | null
type ColumnType = "string" | "number" | "date" | "boolean" | "select"
type ColumnAlign = "left" | "right" | "center"
type SelectOption = { value: CellValue; label?: string } // choices for type "select"
type SortDirection = "asc" | "desc"
type SortMode = { id: string; label: string }
type SortSpec = { column: string; direction: SortDirection; mode?: string }
type FilterOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<="
| "contains" | "startsWith" | "endsWith"
| "isNull" | "notNull"
type FilterSpec = { column: string; operator: FilterOperator; value?: CellValue }
type CellAddress = { row: number; col: number }
type Selection = { start: CellAddress; end: CellAddress }
type SelectionStats = {
count: number; numericCount: number
sum: number | null; average: number | null
partial?: boolean // stats cover loaded cells only
}
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
type CellContext = { row: number; col: number; value: CellValue; column: Column }
// Built-in UI strings
type VoxLabels = {
loading: string; empty: string
error: string; retry: string
contextCut: string; contextCopy: string; contextPaste: string
contextInsertRowAbove: string; contextInsertRowBelow: string
contextDeleteRows: string; contextExportCsv: string
contextUndo: string; contextRedo: string
sortOptions: string; sortClear: string
confirmLargeCopyTitle: string; confirmLargeCopyMessage: string
confirmOk: string; confirmCancel: string
preparingCopy: string; selectionTooLarge: string
pasteClipped: string
}
// Icons
type IconName =
"sortAscending" | "sortDescending" | "sortUnsorted" | "filter" | "filterActive"
type IconProps = { size?: number; className?: string }
type Icons = Partial<Record<IconName, (props: IconProps) => ReactElement>>
// Platform
type ToastKind = "loading" | "success" | "error" | "info"
type PlatformAdapter = {
clipboard?: {
readText?: () => Promise<string>
writeText?: (text: string) => Promise<void>
}
notify?: (kind: ToastKind, message: string,
opts?: { id?: string; durationMs?: number }) => string
confirm?: (opts: { title?: string; message: string;
confirmLabel?: string; cancelLabel?: string }) => Promise<boolean>
saveFile?: (opts: { suggestedName: string; mimeType?: string;
data: string | Blob }) => Promise<void>
}
Limitations
- Column virtualization — rows are windowed, but every column renders; keep the column count moderate for very wide tables.
- RTL layout (the
dirprop) is planned.
Header buttons whose callback is omitted are hidden (by design).