編集可能データテーブルEditableDataTableBeta
Editable line-item grid: rows of consumer-rendered editor cells with add/remove row, a totals/footer row, per-cell accessible labels, and a desktop table that stacks into mobile cards. For invoices, journals, estimates, and timesheets.
プレビュー
状態とバリエーション
升目の中身は呼ぶ側が決めるので、この部品で変わるのは骨組みのほうです。行の高さ(variant)、合計の出し方(列ごとの renderFooterCell と、列に紐づかない footer)、そして行が1つも無いときの見え方(labels.empty と caption)の3つが、画面ごとに変える口になります。
行を詰める
variant="compact" は升目の余白を詰めます。1画面に何十行も並べる仕訳や棚卸のように、打ち込む回数が多くて縦の移動が負担になる表で使います。入力欄の大きさ自体は変わらないので、押しにくくはなりません。
| 勘定科目 | 借方 | 表示 |
|---|---|---|
| ¥12,800 | ||
| ¥3,400 | ||
| ¥8,600 |
列に紐づかない帯
renderFooterCell は列の下に数字を置きますが、貸借が合っているかのような判定は列の下には収まりません。footer は表の幅いっぱいに1本の帯を出す口で、デスクトップでもスマホでも同じ位置に付きます。両方を同時に使えます。
| 勘定科目 | 借方 | 貸方 |
|---|---|---|
| ¥50,000 | ¥48,000 | |
| 差額 ¥2,000 が残っています | ||
- 借方
- ¥50,000
- 貸方
- ¥48,000
行が1つも無いとき
rows が空でも表は消えず、見出しと空の知らせが残ります。既定の知らせは英語の "No rows." なので、日本語の画面では labels.empty で書き換えます。caption は表そのものの説明で、読み上げにも入ります。行を足す口(onAddRow)は空のときこそ要ります。
| 氏名 | 所属 | Actions |
|---|---|---|
| まだ誰も登録されていません。 | ||
Props
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| columns | EditableColumn<TRow>[] | - | Column defs. Each renders its cell editor via cell(row, ctx); ctx.ariaLabel is a ready-made label. |
| rows | TRow[] | - | The (controlled) row data — the consumer owns it. |
| getRowId | (row, index) => string | - | Stable row key. |
| onAddRow | () => void | - | Shows an add-row button when set. |
| onRemoveRow | (index) => void | - | Shows a per-row remove button (hidden at/below minRows). |
| minRows | number | 0 | Minimum rows kept. |
| getRowError | (row, index) => string | undefined | - | Marks a row invalid; the message is exposed to screen readers. |
| footer | ReactNode | - | Totals / balance row, rendered under the body on desktop + mobile. |
| renderRowCard | (row, ctx) => ReactNode | - | Custom mobile card body (defaults to stacking each column). Function prop — pass only from a Client Component; from a Server Component it breaks next build. Render props return JSX (no serializable alternative) — wrap in a "use client" component to pass from an RSC. (#338) |
| variant | "default" | "compact" | "default" | Density. |
| labels / rowLabel / caption / className | — | - | Add/remove/empty labels, per-row label, caption, extra classes. |
Usage
"use client";
import * as React from "react";
import {
EditableDataTable,
type EditableColumn,
Input,
NumberInput,
formatCurrency,
} from "@gunjo/ui";
type LineItem = { id: string; name: string; qty: number; price: number };
const initialRows: LineItem[] = [
{ id: "1", name: "デザイン制作", qty: 10, price: 12000 },
{ id: "2", name: "ホスティング", qty: 1, price: 5000 },
];
export function InvoiceLineItems() {
const [rows, setRows] = React.useState<LineItem[]>(initialRows);
const idRef = React.useRef(initialRows.length);
const update = (index: number, patch: Partial<LineItem>) =>
setRows((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r)));
const columns: EditableColumn<LineItem>[] = [
{
id: "name",
header: "品目",
minWidth: "12rem",
cell: (row, ctx) => {
const { ariaLabel, rowIndex } = ctx;
const { name } = row;
return (
<Input
value={name}
aria-label={ariaLabel}
onChange={(e) => update(rowIndex, { name: e.target.value })}
/>
);
},
},
{
id: "qty",
header: "数量",
align: "right",
width: "6.5rem",
cell: (row, ctx) => {
const { ariaLabel, rowIndex } = ctx;
const { qty } = row;
return (
<NumberInput
value={qty}
min={0}
aria-label={ariaLabel}
onValueChange={(v) => update(rowIndex, { qty: v })}
/>
);
},
},
{
id: "price",
header: "単価",
align: "right",
width: "8rem",
cell: (row, ctx) => {
const { ariaLabel, rowIndex } = ctx;
const { price } = row;
return (
<NumberInput
value={price}
min={0}
step={100}
aria-label={ariaLabel}
onValueChange={(v) => update(rowIndex, { price: v })}
/>
);
},
},
{
id: "amount",
header: "金額",
align: "right",
width: "8rem",
cell: (row) => (
<span className="tabular-nums">{formatCurrency(row.qty * row.price)}</span>
),
},
];
const total = rows.reduce((sum, r) => sum + r.qty * r.price, 0);
return (
<EditableDataTable
columns={columns}
rows={rows}
getRowId={(r) => r.id}
minRows={1}
onAddRow={() => {
idRef.current += 1;
setRows((rs) => [
...rs,
{ id: String(idRef.current), name: "", qty: 1, price: 0 },
]);
}}
onRemoveRow={(index) => setRows((rs) => rs.filter((_, i) => i !== index))}
getRowError={(row) =>
row.name.trim() === "" ? "品目を入力してください" : undefined
}
labels={{
addRow: "明細を追加",
removeRow: (i) => i + 1 + "行目を削除",
}}
rowLabel={(i) => i + 1 + "行目"}
renderFooterCell={(column) =>
column.id === "name" ? (
<span>合計</span>
) : column.id === "amount" ? (
<span className="tabular-nums">{formatCurrency(total)}</span>
) : null
}
/>
);
}設計の判断
- 升目の中身は部品が持たない。
columns[].cellが返した要素をそのまま置くので、入力欄でも選択でも、計算した読み取り専用の文字でもかまいません。部品が持つのは骨組み(列幅・揃え・行の追加と削除・合計行)だけです。 - 読み上げ用の名前を作って渡す。
cellの第2引数には「2行目 単価」のような文字列(ariaLabel)が入っています。呼ぶ側はこれを入力欄のaria-labelに載せるだけで済みます。名前の無い入力欄が並んだ表を作らせないための形です。 - スマホでは表をやめてカードに積み直す。デスクトップは
table、md未満は1行が1枚のカードになり、renderFooterCellの合計もそれぞれの形で出ます(#210)。入力欄が並ぶ表は横に流すと打ち込めなくなるので、資料の「スクロール耐性」をここでは「形を変える」で解きました。
一般の表の設計は UIXHERO の「テーブル」にあります。
使用コンポーネント
関連コンポーネント
いつ・なぜ使うか(UIXHERO)
「いつ・なぜ使うか」の判断は、姉妹サイト UIXHERO の記事で解説しています。