@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 modelreact / react-dom are peer dependencies.
  • No external CSS framework — self-contained via --vox-* variables and vox- 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
Choosing the right tool
For breadth of features and maturity — row grouping, pivoting, frozen columns — established full-scale grids (e.g. AG Grid) lead. voxsheet fits “React, free, with Excel-like editing and a server-driven model done simply.” If you need to be fully framework-agnostic, Web Component grids (e.g. RevoGrid) are also worth considering.

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}
        />
    )
}
Note
Header buttons appear only when the matching callback is provided. For example, the sort button shows up only when 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".
  • null represents 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>
Important
Keep 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.

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.

Local edits are discarded when the query changes
Edits are keyed by display-row index, so when 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) for totalRows / FetchResult.total to 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 (label pasteClipped). In the handler, create the rows, raise the row count without changing queryKey (that would discard the pasted edits), and call invalidate() so the affected chunk is refetched.
  • The context menu can export the selection as CSV via platform.saveFile (available in readOnly too, 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;
}
Where to override variables
The --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.
  • density switches row height and font together (compact / normal / comfortable).
  • rowHeight takes precedence over density.
  • theme sets the data-vox-theme attribute (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

Not yet available
  • Column virtualization — rows are windowed, but every column renders; keep the column count moderate for very wide tables.
  • RTL layout (the dir prop) is planned.

Header buttons whose callback is omitted are hidden (by design).

@lembryo/voxsheet · MIT License · npm · GitHub