編集可能データテーブル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.

プレビュー

1行目
品目
数量
単価
金額¥120,000
2行目
品目
数量
単価
金額¥5,000
品目
合計
金額
¥125,000

状態とバリエーション

升目の中身は呼ぶ側が決めるので、この部品で変わるのは骨組みのほうです。行の高さ(variant)、合計の出し方(列ごとの renderFooterCell と、列に紐づかない footer)、そして行が1つも無いときの見え方(labels.empty と caption)の3つが、画面ごとに変える口になります。

行を詰める

variant="compact" は升目の余白を詰めます。1画面に何十行も並べる仕訳や棚卸のように、打ち込む回数が多くて縦の移動が負担になる表で使います。入力欄の大きさ自体は変わらないので、押しにくくはなりません。

1行目
勘定科目
借方
表示¥12,800
2行目
勘定科目
借方
表示¥3,400
3行目
勘定科目
借方
表示¥8,600

renderFooterCell は列の下に数字を置きますが、貸借が合っているかのような判定は列の下には収まりません。footer は表の幅いっぱいに1本の帯を出す口で、デスクトップでもスマホでも同じ位置に付きます。両方を同時に使えます。

1行目
勘定科目
借方
貸方
2行目
勘定科目
借方
貸方
借方
¥50,000
貸方
¥48,000
差額 ¥2,000 が残っています

行が1つも無いとき

rows が空でも表は消えず、見出しと空の知らせが残ります。既定の知らせは英語の "No rows." なので、日本語の画面では labels.empty で書き換えます。caption は表そのものの説明で、読み上げにも入ります。行を足す口(onAddRow)は空のときこそ要ります。

まだ誰も登録されていません。

Props

表は横にスクロールできます
プロパティ型初期値説明
columnsEditableColumn<TRow>[]-Column defs. Each renders its cell editor via cell(row, ctx); ctx.ariaLabel is a ready-made label.
rowsTRow[]-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).
minRowsnumber0Minimum rows kept.
getRowError(row, index) => string | undefined-Marks a row invalid; the message is exposed to screen readers.
footerReactNode-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 の記事で解説しています。