@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 は商用利用に有償ライセンスが必要
選び方の目安
行グループ・ピボット・列固定など機能の幅や成熟度は専業の大規模グリッド(AG Grid 等)が先行します。voxsheet は「React で、無償で、Excel 風編集とサーバ駆動を素直に使いたい」用途に向いています。完全にフレームワーク非依存が必要なら Web Component 系(RevoGrid 等)も選択肢です。

インストール

npm install @lembryo/voxsheet

react / react-dom(v18 以上)は peer dependency です。スタイルシートのインポートも忘れずに行ってください。

import { VoxSheet } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"

クイックスタート

最小構成の例です。columnstotalRowsfetchRows の 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 を渡したときのみ現れます。

コアコンセプト

VoxSheetcontrolled かつ 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>
注意
fetchRowsuseCallback 等で安定した参照にしてください。毎レンダーで新しい関数を渡すと不要な再取得が発生します。

列定義

Column

列メタデータ。type が描画・編集・比較・オートフィルを駆動します。表示/非表示は配列から除外して表現します(controlled)。

プロパティ 説明
name必須 string 識別子兼表示名。sort / filtercolumn 参照に使う。
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 件以上のフィルタが適用されているとき、ヘッダのフィルタボタンに件数バッジが表示され、複数条件を一目で把握できます。

編集とコミット

編集はまずローカル層に溜まります(ダーティ表示、onCellChange / onDirtyChange 通知)。ホストは任意のタイミングで getLocalEdits() を読み、各 rowfetchRows で受け取った 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 / averagenull)。

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)。
  • rowHeightdensity より優先。
  • themedata-vox-theme 属性を設定(light / dark / system)。

国際化(labels)

内蔵 UI 文言は labelsPartial<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 レイアウト(dir prop)は対応予定です。

コールバック未指定のヘッダボタンは非表示になります(仕様)。

@lembryo/voxsheet · MIT License · npm · GitHub