編集可能データテーブル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.
プレビュー
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
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: テーブル(Table)