@lembryo/voxsheet
仮想スクロールで数百万行を滑らかに描画する React 製スプレッドシートコンポーネント。Excel 風の選択・編集・オートフィル・コピペ・列リサイズ、ホスト制御のソート/フィルタ/検索に対応します。
▶ ライブデモを開く ブラウザ内データで実際に動かせます
概要
VoxSheet は DOM ベースの仮想スクロールにより、行数が膨大でも表示中のセルだけを描画します。データ取得とドメイン状態(ソート・フィルタ・検索)はホストが所有し、ビューポート・選択・編集バッファ・キーボードはグリッドが所有します。
- Transport 非依存 — 取得手段は
fetchRowsに委ねるため、REST / GraphQL / ローカル配列どれでも可。 - TypeScript ファースト — 公開 API はすべて型付き。
- ピア依存モデル —
react/react-domは peer dependency。 - 外部 CSS フレームワーク不使用 —
--vox-*変数とvox-クラスで完結。
なぜ voxsheet?
voxsheet の特徴は、「無償(MIT)で Excel 風の編集体験」と「サーバ駆動を前提にした設計」を最初から両立している点です。
- 無償で Excel 風編集 — オートフィル(フィルハンドル)・複数レンジ選択・コピー&ペースト・列リサイズを標準装備。
- サーバ駆動が前提 — ソート・フィルタ・検索・ページングを
fetchRows経由でバックエンドに委譲。巨大データでも可視範囲だけを取得します。 - React ネイティブ / DOM ベース — hooks・controlled props・JSX セル。Canvas 実装と異なり、通常の DOM で拡張・検査・アクセシビリティを扱えます。
- TypeScript ファースト・外部 CSS 不使用・peer 依存 — 既存のビルドやテーマに溶け込みやすい。
競合との違い
voxsheet が標準提供する機能には、他の主要グリッドでは有償プランや別ライセンスが必要なものがあります(下表は代表例。各製品のライセンスは変わり得るため、採用前に最新の条件を確認してください)。
| 機能 | voxsheet | 他の代表例 |
|---|---|---|
| オートフィル(フィルハンドル) | 標準・無償 | AG Grid は Enterprise(有償)、MUI X は Premium(有償) |
| 複数レンジ選択 | 標準・無償 | AG Grid は Enterprise(有償) |
| サーバ駆動データ(取得をホストに委譲) | 標準 | AG Grid は Enterprise(Server-Side Row Model) |
| ライセンス | MIT | Handsontable は商用利用に有償ライセンスが必要 |
インストール
npm install @lembryo/voxsheet
react / react-dom(v18 以上)は peer dependency です。スタイルシートのインポートも忘れずに行ってください。
import { VoxSheet } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"
クイックスタート
最小構成の例です。columns・totalRows・fetchRows の 3 つが必須です。
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)
// グリッドは Query(offset/limit + controlled な sort/filters/search)と
// 陳腐化リクエストを中断するための AbortSignal を渡してくる。
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 を渡したときのみ現れます。
コアコンセプト
VoxSheet は controlled かつ transport 非依存です。責務は次のように分かれます。
| 所有者 | 担当 |
|---|---|
| ホスト | データソース(fetchRows)、ドメイン状態(sort /
filters / search)、永続化(編集コミット)、フィルタ条件 UI
|
| グリッド | ビューポート(仮想スクロール)、セル選択、編集バッファ、Undo/Redo、キーボード、クリップボード |
ソートやフィルタの「状態」はホストが useState 等で持ち、グリッドはユーザー操作をコールバックで通知するだけです。状態が変わると
fetchRows が新しい Query で再度呼ばれます。
データ契約
グリッドとホストの間で受け渡す型は次のとおりです。これらが voxsheet を使う上での中心になります。
CellValue
セルの値。JSON シリアライズ可能な型に限定されます。
type CellValue = string | number | boolean | null
- 日付は ISO 8601 文字列(推奨)または epoch ミリ秒で運び、列
type: "date"で解釈します。 nullは SQL の NULL を表し、空文字""と区別されます。
Query
グリッドが fetchRows に渡す取得条件。offset / limit はグリッドが決め、sort
/ filters / search はホストが渡した controlled 値がそのまま入ります。
type Query = {
offset: number
limit: number
sort: SortSpec[] // 多列。配列順 = 優先順位
filters: FilterSpec[] // AND 結合
search?: string
}
FetchResult
fetchRows が返す値。data[i][j] は i 行目・j 列目で、列順は
columns に揃えます。
| プロパティ | 型 | 説明 |
|---|---|---|
| data必須 | CellValue[][] | data[i][j] = i 行 j 列。列順は columns に整列。 |
| ids必須 | number[] | 各行の安定 ID。編集コミット時に行→ID 解決に使う。 |
| ordinals必須 | number[] | 行番号ガターに表示する連番。 |
| total任意 | number | フィルタ/検索適用後の総件数。スクロールバーと同期する(毎回返すのが望ましい)。 |
fetchRows
行データを範囲指定で取得する関数。第 2 引数の signal で陳腐化した取得を中断できます。
type FetchRowsFn = (query: Query, signal: AbortSignal) => Promise<FetchResult>
fetchRows は useCallback 等で安定した参照にしてください。毎レンダーで新しい関数を渡すと不要な再取得が発生します。
列定義
Column
列メタデータ。type が描画・編集・比較・オートフィルを駆動します。表示/非表示は配列から除外して表現します(controlled)。
| プロパティ | 型 | 説明 |
|---|---|---|
| name必須 | string | 識別子兼表示名。sort / filter の column 参照に使う。
|
| type任意 | "string" | "number" | "date" | "boolean" | 既定 "string"。整形・整列・パース・比較の基準。 |
| align任意 | "left" | "right" | "center" | 既定は type から導出(number/date=right、boolean=center)。 |
| width任意 | number | 既定 defaultColumnWidth。ユーザーリサイズは列名キーで記憶。 |
| format任意 | ColumnFormat | 表示整形。下記参照。 |
| editable任意 | boolean | ((ctx) => boolean) | 既定は親 readOnly に従う。 |
| validate任意 | (value, ctx) => boolean | string | false または文字列(エラーメッセージ)で却下。 |
| sortModes任意 | { id: string; label: string }[] | 並べ替え方式の選択肢(例: テキスト/数値)。▾ ピッカーを表示し、選択 id を SortSpec.mode
に載せる。
|
| defaultSortMode任意 | string | 初期/既定の方式 id(未指定なら sortModes[0])。 |
表示整形 format
Intl のオプション、または任意の関数で値を文字列化します。解決順は 関数 > kind
別既定 です。
type ColumnFormat =
| { kind: "number"; options: Intl.NumberFormatOptions }
| { kind: "date"; options: Intl.DateTimeFormatOptions }
| ((value: CellValue, ctx: { column: Column; row: number }) => string)
// 通貨表示
{ name: "salary", type: "number",
format: { kind: "number", options: { style: "currency", currency: "JPY" } } }
// 関数で完全カスタム
{ name: "status", format: (v) => (v === 1 ? "有効" : "無効") }
編集とバリデーション
editable で列・行単位の編集可否を、validate で入力検証を行います。
{
name: "email",
type: "string",
editable: ({ row }) => row > 0, // 行ごとに可否
validate: (value) =>
typeof value === "string" && value.includes("@")
? true
: "メールアドレスの形式が不正です", // 文字列を返すと却下+メッセージ
}
Props リファレンス
VoxSheetProps をカテゴリ別に示します。
データ
| プロパティ | 型 | 説明 |
|---|---|---|
| columns必須 | Column[] | 列定義(controlled)。 |
| totalRows必須 | number | 総行数。FetchResult.total で同期する。 |
| fetchRows必須 | FetchRowsFn | 行取得関数 (query, signal) => Promise<FetchResult>。 |
| queryKey任意 | unknown | ホスト独自クエリ入力の無効化キー。変化でキャッシュを破棄して再取得。 |
レイアウト・表示
| プロパティ | 型 | 説明 |
|---|---|---|
| rowHeight任意 | number | 行の高さ(px)。既定 28。density より優先。 |
| density任意 | "compact" | "normal" | "comfortable" | フォントと行高をセットで切替。既定 "normal"。 |
| defaultColumnWidth任意 | number | 列幅の既定(px)。既定 120。 |
| frozenRows任意 | number | 先頭から固定する行数。既定 0。先頭 N 行を本体スクロール外の固定バンドに描画します(横スクロールのみ同期)。1.1.0 で対応。 |
| frozenColumns任意 | number | 先頭から固定する列数。既定 0。先頭 N
列を横スクロール時も左に固定(sticky)。frozenRows と合成可。1.1.0 で対応。
|
| theme任意 | "light" | "dark" | "system" | data-vox-theme を設定。既定 "system"。 |
| className任意 | string | ルート要素のクラス。 |
| style任意 | CSSProperties | ルート要素のインラインスタイル。 |
振る舞い(controlled ドメイン状態)
| プロパティ | 型 | 説明 |
|---|---|---|
| readOnly任意 | boolean | 編集 UI を無効化(コピー・選択・ナビは可)。 |
| sort任意 | SortSpec[] | 多列ソート(controlled)。 |
| filters任意 | FilterSpec[] | フィルタ(controlled・条件 UI はホスト所有)。 |
| search任意 | string | 検索キーワード。 |
| searchHighlights任意 | CellAddress[] | ハイライト対象セル。 |
| currentSearchHit任意 | CellAddress | null | 現在ヒット。変化でスクロールする。 |
状態 UI・拡張
| プロパティ | 型 | 説明 |
|---|---|---|
| renderLoading任意 | () => ReactNode | ローディング表示の差し替え。 |
| renderEmpty任意 | () => ReactNode | 空表示の差し替え。 |
| labels任意 | Partial<VoxLabels> | 内蔵 UI 文言の上書き(i18n)。 |
| icons任意 | Icons | アイコンの差し替え。 |
| platform任意 | PlatformAdapter | クリップボード/通知/確認/保存の実装注入。 |
イベント(コールバック)
ボタンや操作系の affordance は、対応するコールバックが未指定だと非表示・無効になります。
| プロパティ | 型 | 説明 |
|---|---|---|
| onSortChange | (sort: SortSpec[]) => void | none→asc→desc をグリッドがトグルし通知。Column.sortModes 指定時は選択方式を
SortSpec.mode に載せる。未指定でソートボタン非表示。
|
| onFilterButtonClick | (col: number, anchor: DOMRect) => void | フィルタ操作要求(ホストがポップオーバー表示)。未指定でフィルタボタン非表示。 |
| onColumnResize | (col: number, width: number) => void | 列幅変更(ドラッグ/ダブルクリック自動フィット)。 |
| onColumnRename | (col: number, newName: string) => void | ヘッダ名編集。未指定でリネーム無効。 |
| onColumnReorder | (from: number, to: number) => void | ヘッダのドラッグで列を並べ替え(挿入インジケータ表示)。未指定でドラッグ無効。列順はホストが
columns で反映(controlled)。
|
| onAddColumn | (atCol: number) => void | 列追加要求。未指定で追加ボタン非表示。 |
| onCellChange | (edit: CellEdit) => void | ローカル編集の都度。 |
| onDirtyChange | (hasChanges: boolean) => void | 未コミット変更の有無。 |
| onAppendRow | (atRow: number) => void | 行追加(最下行 Enter/末尾)。 |
| onInsertRow | (atRow: number, pos: "above" | "below") => void | 行挿入要求。 |
| onDeleteRows | (rows: number[]) => void | 行削除要求。 |
| onAutoFill | (p: { sourceRange; direction; toEnd }) => void | フィルハンドルのドラッグ/ダブルクリック。 |
| onSelectionChange | (selection: Selection[]) => void | 選択変更。 |
| onSelectionStats | (stats: SelectionStats | null) => void | 選択集計(合計/平均/件数)。 |
| onCellKeyDown | (e, ctx: CellContext) => void | 既定キー処理前にフック(preventDefault で抑止)。 |
| onError | (err, ctx: { phase: "fetch" | "commit" }) => void | 取得/コミット失敗。 |
ソート
ソートは controlled です。グリッドはヘッダクリックで none → asc → desc をトグルし onSortChange
で通知、ホストが sort を更新します。配列の順序が多列ソートの優先順位です。
const [sort, setSort] = useState<SortSpec[]>([])
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows}
sort={sort} onSortChange={setSort} />
並べ替え方式(sortModes)
同じ列でも「テキストとして」か「数値として」かで並べ替え結果が変わる場合があります。Column.sortModes
を指定するとヘッダに小さな ▾ ピッカーが出て、選んだ方式 id が SortSpec.mode
に載ります。実際の比較はバックエンドが解釈します(グリッドは id を運ぶだけでトランスポート非依存)。sortModes
未指定の列は従来どおりで mode を送らないため、既定はバックエンドの素の(テキスト)並べ替えになります。
const columns: Column[] = [
{ name: "code", sortModes: [
{ id: "text", label: "テキスト" },
{ id: "numeric", label: "数値" },
], defaultSortMode: "text" },
]
// fetchRows には例えば { column: "code", direction: "asc", mode: "numeric" } が渡る
// → サーバ側で mode を ORDER BY に変換する。
フィルタ
フィルタ条件 UI はホストが所有します。グリッドは onFilterButtonClick(col,
anchorRect) で「この列のフィルタを編集したい」と通知するので、ホストはその
anchorRect 付近にポップオーバーを出し、確定後 filters を更新します。フィルタは
AND 結合です。
type FilterOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<="
| "contains" | "startsWith" | "endsWith"
| "isNull" | "notNull"
type FilterSpec = { column: string; operator: FilterOperator; value?: CellValue }
ある列に2 件以上のフィルタが適用されているとき、ヘッダのフィルタボタンに件数バッジが表示され、複数条件を一目で把握できます。
検索
search にキーワードを渡すと fetchRows の Query.search
に反映されます。ヒット箇所のハイライトとスクロールはホストが searchHighlights / currentSearchHit
で制御します。
<VoxSheet ...
search={keyword}
searchHighlights={hits}
currentSearchHit={hits[cursor] ?? null}
/>
編集とコミット
編集はまずローカル層に溜まります(ダーティ表示、onCellChange / onDirtyChange
通知)。ホストは任意のタイミングで getLocalEdits() を読み、各 row を fetchRows
で受け取った ids で安定 ID に解決し、永続化後に clearLocalEdits() を呼びます。
const ref = useRef<VoxSheetHandle>(null)
async function save() {
const edits = ref.current!.getLocalEdits() // CellEdit[]
// row → 安定 ID へ解決して PATCH 等で永続化
await commitToServer(edits)
ref.current!.clearLocalEdits() // 成功後にクリア
}
<VoxSheet ref={ref} ... onDirtyChange={setDirty} />
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
選択と集計
選択は Excel 風の矩形レンジの配列です。onSelectionChange で選択範囲、onSelectionStats
で件数・数値件数・合計・平均が得られます(数値が無い場合 sum / average は
null)。
type CellAddress = { row: number; col: number }
type Selection = { start: CellAddress; end: CellAddress }
type SelectionStats = {
count: number
numericCount: number
sum: number | null
average: number | null
}
オートフィル
選択範囲右下のフィルハンドルをドラッグ/ダブルクリックすると onAutoFill
が呼ばれます。実際の値生成ロジックはホストが担います。
onAutoFill={(p) => {
// p.sourceRange: 元の選択, p.direction: "down"|"up"|"left"|"right"
// p.toEnd: ダブルクリックで列末尾まで
}}
キーボード操作
| キー | 動作 |
|---|---|
| ↑ ↓ ← → / Tab / Enter | セル移動 |
| Home End / PageUp PageDown | 行頭・行末 / ページ単位移動 |
| Ctrl+Home / Ctrl+End | 表の先頭・末尾へ |
| F2 / 直接入力 / Delete | 編集開始 / 上書き入力 / クリア |
| Ctrl+A/C/X/V | 全選択 / コピー / カット / 貼り付け |
| Ctrl+Z / Ctrl+Y | Undo / Redo |
| Shift+矢印 / Shift+クリック | 選択範囲を拡張 |
| Ctrl+クリック | 複数レンジ選択 |
IME は compositionend で確定されます。readOnly 時はコピー・全選択・ナビゲーションのみ有効です。
行の固定
frozenRows を指定すると、先頭 N
行を本体スクロールの外側に置いた固定バンドに描画します。バンドはその場に留まり、残りのグリッドだけが縦スクロールします。横スクロールのみバンドに同期するため、固定セルは対応する列と揃ったままになります。本体スクロール領域は
[frozenRows, total) の行を担当するので、固定行が二重に表示されることはありません。
固定行は通常の行と同じように選択・編集でき、それらを含む先頭側のチャンクはスクロール位置に依らず常に取得されます。既定値
0 で固定は無効です。列を固定する場合は列の固定を参照してください。
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenRows={2} />
列の固定
frozenColumns を指定すると、先頭 N 列を左に固定します。残りのグリッドを横スクロールしても常に見えたままになり(sticky
で実装)、列幅や行高は本体とずれません。frozenRows と組み合わせると、左上の交差部分が縦横どちらのスクロールでも固定されます。
既定値 0 で固定は無効です。
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenColumns={1} />
列の並べ替え
onColumnReorder を指定すると、列ヘッダをドラッグして並べ替えられます。ドラッグ中は挿入位置のインジケータが出て、ドロップで
onColumnReorder(from, to) が発火します。列順はホストが所有します(columns
配列を並べ替えて反映)。
データは位置ベース(data[i][j] は columns 対応)のため、並べ替え後は fetchRows
が各行のセルを現在の列順で返すようにしてください(バックエンドがその順で返す、またはクライアントで並べ替える)。
onColumnReorder={(from, to) => {
setColumns((cols) => {
const next = [...cols]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
})
}}
命令的ハンドル(ref)
ref 経由で VoxSheetHandle を取得し、スクロール・選択・編集・Undo
などを命令的に操作できます。
| メソッド | 説明 |
|---|---|
| scrollToRow(row) | 指定行までスクロール。 |
| scrollToCell(row, col) | 指定セルまでスクロール。 |
| focusCell(row, col) | 指定セルにフォーカス。 |
| getSelection() | 現在の選択(Selection[])を取得。 |
| setSelection(sel) | 選択を設定。 |
| startEdit(row, col) | 指定セルの編集を開始。 |
| getLocalEdits() | 未コミット編集(CellEdit[])を取り出す。 |
| clearLocalEdits() | コミット後にローカル編集と Undo/Redo をクリア。 |
| undo() / redo() | 元に戻す / やり直す。 |
| invalidate() | キャッシュ破棄+再取得(明示リフレッシュ)。 |
スタイリング・テーマ
スタイルは vox- クラスと --vox-* CSS 変数で自己完結しています。@lembryo/voxsheet/styles.css
を読み込み、変数やクラスを上書きしてテーマ調整します。ダークモードは prefers-color-scheme
に追従し、theme prop で強制もできます。
.vox-sheet {
--vox-row-height: 32px;
--vox-color-accent: #06c755;
}
--vox-* 変数は :root ではなく .vox-sheet ルートに定義されています。:root
で上書きしても効きません。上書きは .vox-sheet(またはラッパークラス)にスコープしてください(上の例)。
densityで行高とフォントをまとめて切替(compact/normal/comfortable)。rowHeightはdensityより優先。themeはdata-vox-theme属性を設定(light/dark/system)。
国際化(labels)
内蔵 UI 文言は labels(Partial<VoxLabels>)で部分上書きできます。
<VoxSheet ...
labels={{
loading: "読み込み中…",
empty: "データがありません",
contextCopy: "コピー",
contextPaste: "貼り付け",
}}
/>
キー一覧は 型リファレンス の VoxLabels を参照してください。
アイコン
ソート/フィルタのアイコンを差し替えられます。指定しないキーは内蔵アイコンが使われます。
<VoxSheet ...
icons={{
filter: ({ size }) => <MyFilterIcon width={size} />,
}}
/>
差し替え可能なキー: sortAscending / sortDescending /
sortUnsorted / filter / filterActive。
プラットフォームアダプタ
クリップボード・トースト通知・確認ダイアログ・ファイル保存の実装を注入できます。未指定時はブラウザ標準の挙動にフォールバックします。
<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 CellValue = string | number | boolean | null
type ColumnType = "string" | "number" | "date" | "boolean"
type ColumnAlign = "left" | "right" | "center"
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
}
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
type CellContext = { row: number; col: number; value: CellValue; column: Column }
// 内蔵 UI 文言
type VoxLabels = {
loading: string; empty: string
contextCut: string; contextCopy: string; contextPaste: string
contextInsertRowAbove: string; contextInsertRowBelow: string
contextDeleteRows: string; contextUndo: string; contextRedo: string
sortOptions: string; sortClear: string
confirmLargeCopyTitle: string; confirmLargeCopyMessage: string
confirmOk: string; confirmCancel: string
}
// アイコン
type IconName =
"sortAscending" | "sortDescending" | "sortUnsorted" | "filter" | "filterActive"
type IconProps = { size?: number; className?: string }
type Icons = Partial<Record<IconName, (props: IconProps) => ReactElement>>
// プラットフォーム
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>
}
制限事項
- 列の仮想化 — 行はウィンドウ化しますが全列を描画します。列数の多い横長テーブルでは列数を穏当に保ってください。
- RTL レイアウト(
dirprop)は対応予定です。
コールバック未指定のヘッダボタンは非表示になります(仕様)。