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

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

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 に載せるだけで済みます。名前の無い入力欄が並んだ表を作らせないための形です。
  • スマホでは表をやめてカードに積み直す。デスクトップは tablemd 未満は1行が1枚のカードになり、renderFooterCell の合計もそれぞれの形で出ます(#210)。入力欄が並ぶ表は横に流すと打ち込めなくなるので、資料の「スクロール耐性」をここでは「形を変える」で解きました。
    一般の表の設計は UIXHERO の「テーブル」にあります。 UIXHERO: テーブル(Table)

使用コンポーネント