📊

React+TypeScriptでエクセルライクな入力を作る

2023/12/22に公開

個人開発の中でエクセルっぽい見た目の入力を実現したかったのでその時の試行錯誤を記します。Jspreadsheetを使った方が操作性も実装しやすさも良さそうですが、Jspreadsheetの存在に気づいたのが実装し終わってからで、悔しかったので供養として記事にします。
気が向いたらJspreadsheetで実装し直して記事にしようと思います。

ちなみにまだまだReact初心者なのでより良いやり方や、間違っている部分ありましたら指摘いただけると嬉しいです。

概要

フレームワークとしてNext.jsを使用していますが、特有の部分はあまりないと思うので他のフレームワークでも参考になるかと思います。

この記事ではReactでエクセルっぽい見た目の表形式の入力欄を作ります。
行の末尾に新しく行を追加したり、削除したりできるようにします。
また入力値を何らかの形で保存したり、サーバーへポストすることを見越してcsv形式での保存が可能なように作成します。

完成イメージはこちらです。
表イメージ
追加、削除で表の行の追加や削除を行います。
保存を押すと入力値がcsvでダウンロード、生成を押すとjson形式での処理を行う(今回は割愛します)ことを想定しています。

説明しないこと

cssや各タグの詳細については説明しません。

行のコンポーネント化

まず初めにテーブルの基本部分を作成します。
テーブルヘッダ

function MakeHeaderColumn() {
  return (
    <thead><tr>
        <th>aaa</th>
	...(中略)
        <th>fff</th>
    </tr></thead>
  );
}

入力セル

type CellInfo = {
  keyStr: string;
  defaultValue: string;
  tdClass: string | undefined;
};
const TableCell = forwardRef<HTMLTextAreaElement, CellInfo>(
  ({ keyStr, defaultValue, tdClass }: CellInfo, ref) => {
    return (
      <td key={keyStr} className={tdClass}>
        <textarea defaultValue={defaultValue} ref={ref}></textarea>
      </td>
    );
  },
);

今回はユーザー入力値のリアルタイムなバリデーションなどは実施しないため、各セルについてはuseStateを用いた状態管理は行いません。その代わり、ボタンを押したタイミングで各セル(textarea)の入力値が取得できるよう、各セルのrefを親コンポーネントから渡せるようにしておきます。

また行毎に内容は変わらないので構造体で管理したくなります。そのため以下の型を先に定義しておきます。

type rowRef = {
  name?: MutableRefObject<HTMLTextAreaElement | null>;
  leftUp?: MutableRefObject<HTMLTextAreaElement | null>;
  rightUp?: MutableRefObject<HTMLTextAreaElement | null>;
  text?: MutableRefObject<HTMLTextAreaElement | null>;
  leftDown?: MutableRefObject<HTMLTextAreaElement | null>;
  rightDown?: MutableRefObject<HTMLTextAreaElement | null>;
};

まずはTableCellを使って1行分のtbodyを作成する処理です。
TableCellに渡す情報を列ごとに定義して、forEachで1つずつTableCellに渡して作成し、tdListに追加します。
最終的にtdListに追加したものを展開し、1行を作成します。
また各セルに渡すrefはrowRefとして引数から渡して、各Cellへrefを追加します。

const createRow = (id: number, refs: rowRef): React.JSX.Element => {
  const columns: CellInfo[] = [
    { keyStr: "name", defaultValue: "name", tdClass: "w-1/5" },
    { keyStr: "leftUp", defaultValue: "", tdClass: undefined },
    { keyStr: "rightUp", defaultValue: "", tdClass: undefined },
    { keyStr: "text", defaultValue: "", tdClass: "w-2/5" },
    { keyStr: "leftDown", defaultValue: "", tdClass: undefined },
    { keyStr: "rightDown", defaultValue: "", tdClass: undefined },
  ];
  const tdList: Array<React.JSX.Element> = [];
  columns.forEach((value, index) => {
    const refKey: keyof rowRef = value.keyStr as keyof rowRef;
    const keyStr = id.toString() + index.toString();
    tdList.push(
      <TableCell
        keyStr={keyStr}
        defaultValue={value.defaultValue}
        tdClass={value.defaultValue}
        ref={refs[refKey]}
      />,
    );
  });

  return (
    <tbody key={id}>
      <tr>{...tdList}</tr>
    </tbody>
  );
};

1行の追加の処理は以上になります。

行の追加/削除

行のコンポーネント化で作成した関数を用いて行の追加を行います。
行の追加/削除を実現するために、行の追加/削除時に各セルの参照を保持する配列の更新と、現在のテーブル本体の表示部分を更新する必要があります。そのためにuseStateを用います。

const [tableRefs, setTableRefs] = useState<Array<rowRef>>([]); // 参照を保持
const [rowElements, setRowElements] = React.useState<React.JSX.Element[]>([]); // 表示情報を保持

このstateを用いることで行の追加は以下のようにかけます。

function createInitialRefs(): rowRef {
  return {
    name: React.createRef(),
    leftUp: React.createRef(),
    rightUp: React.createRef(),
    text: React.createRef(),
    leftDown: React.createRef(),
    rightDown: React.createRef(),
  };
}

const addRow = () => {
  const refs: rowRef = createInitialRefs();
  const newRow = createRow(rowElements.length, refs);
  setRowElements([...rowElements, newRow]);
  setTableRefs([...tableRefs, refs]);
};

削除については以下のように書いてRowElementsとTableRefsの末尾を削除します。
また行全てが削除されてしまわないようにここでガードも追加しておきます。

const removeRow = () => {
  if (rowElements.length === 1) {
    return;
  }
  const new_row = [...rowElements];
  new_row.pop();
  setRowElements(new_row);
  const newTableRefs = [...tableRefs];
  newTableRefs.pop();
  setTableRefs(newTableRefs);
};

csv化

次にCSV化です。tableRefsで保持しいている参照をもとに現在の値を取得しにいきます。
取得後にダウンロードリンク作成、クリックの動作を記述してcsv形式としてダウンロードされます。

const handleSave = () => {
  let csv = "aaa,bbb,ccc,ddd,eee,fff\n";
  tableRefs.forEach((value) => {
    const arr = [
      value.aaa?.current?.value || "",
      value.bbb?.current?.value || "",
      value.ccc?.current?.value || "",
      value.ddd?.current?.value || "",
      value.eee?.current?.value || "",
      value.fff?.current?.value || "",
    ];
    csv += arr + "\n";
  });
  const blob = new Blob([csv], { type: "text/plain" });
  const url = URL.createObjectURL(blob);
  const downloadLink = document.createElement("a");
  downloadLink.href = url;
  downloadLink.download = "data.csv";
  downloadLink.click();
  URL.revokeObjectURL(url);
};

json化もテキスト作成部分が変わるのみかと思うので省略します。

最終形

"use client";
import React, {
  useState,
  MutableRefObject,
  forwardRef,
  useEffect,
} from "react";

function MakeHeaderColumn() {
  return (
    <thead>
      <tr>
        <th>aaa</th>
        <th>bbb</th>
        <th>ccc</th>
        <th>ddd</th>
        <th>eee</th>
        <th>fff</th>
      </tr>
    </thead>
  );
}

type rowRef = {
  aaa?: MutableRefObject<HTMLTextAreaElement | null>;
  bbb?: MutableRefObject<HTMLTextAreaElement | null>;
  ccc?: MutableRefObject<HTMLTextAreaElement | null>;
  ddd?: MutableRefObject<HTMLTextAreaElement | null>;
  eee?: MutableRefObject<HTMLTextAreaElement | null>;
  fff?: MutableRefObject<HTMLTextAreaElement | null>;
};

export function CellLikeInput() {
  // let tableRefs: Array<rowRef> = [];
  const [tableRefs, setTableRefs] = useState<Array<rowRef>>([]);
  const [rowElements, setRowElements] = React.useState<React.JSX.Element[]>([]);

function createInitialRefs(): rowRef {
  return {
    aaa: React.createRef(),
    bbb: React.createRef(),
    ccc: React.createRef(),
    ddd: React.createRef(),
    eee: React.createRef(),
    fff: React.createRef(),
  };
}

  const createRow = (id: number, refs: rowRef): React.JSX.Element => {
    const columns: CellInfo[] = [
      { keyStr: "aaa", defaultValue: "aaa", tdClass: "w-1/5" },
      { keyStr: "bbb", defaultValue: "", tdClass: undefined },
      { keyStr: "ccc", defaultValue: "", tdClass: undefined },
      { keyStr: "ddd", defaultValue: "", tdClass: "w-2/5" },
      { keyStr: "eee", defaultValue: "", tdClass: undefined },
      { keyStr: "fff", defaultValue: "", tdClass: undefined },
    ];
    const tdList: Array<React.JSX.Element> = [];
    columns.forEach((value, index) => {
      const refKey: keyof rowRef = value.keyStr as keyof rowRef;
      const keyStr = id.toString() + index.toString();
      tdList.push(
        <TableCell
          keyStr={keyStr}
          defaultValue={value.defaultValue}
          tdClass={value.defaultValue}
          ref={refs[refKey]}
        />,
      );
    });

    return (
      <tbody key={id}>
        <tr>{...tdList}</tr>
      </tbody>
    );
  };

  const addRow = () => {
    const refs: rowRef = createInitialRefs();
    const newRow = createRow(rowElements.length, refs);
    setRowElements([...rowElements, newRow]);
    setTableRefs([...tableRefs, refs]);
  };

  const removeRow = () => {
    if (rowElements.length === 1) {
      return;
    }
    const new_row = [...rowElements];
    new_row.pop();
    setRowElements(new_row);

    const newTableRefs = [...tableRefs];
    newTableRefs.pop();
    setTableRefs(newTableRefs);
  };
  // 初回レンダリング時に一行を追加
  useEffect(() => {
    addRow();
  }, []);

  const handleSave = () => {
    let csv = "aaa,bbb,ccc,ddd,eee,fff\n";
    tableRefs.forEach((value) => {
      const arr = [
        value.aaa?.current?.value || "",
        value.bbb?.current?.value || "",
        value.ccc?.current?.value || "",
        value.ddd?.current?.value || "",
        value.eee?.current?.value || "",
        value.fff?.current?.value || "",
      ];
      csv += arr + "\n";
    });
    const blob = new Blob([csv], { type: "text/plain" });
    const url = URL.createObjectURL(blob);
    const downloadLink = document.createElement("a");
    downloadLink.href = url;
    downloadLink.download = "data.csv";
    downloadLink.click();
    URL.revokeObjectURL(url);
  };
  const jsonSave = () => {
  };
  return (
    <>
      <table className={"card-info-table"}>
        <MakeHeaderColumn key={"header-column"} />
        {rowElements}
      </table>
      <div className={"flex flex-row justify-between"}>
        <div>
          <FlatButton
            key={"add-button"}
            handleFunc={addRow}
            text={"追加"}
            classes={"float__button positive__button"}
          />
          <FlatButton
            key={"delete-button"}
            handleFunc={removeRow}
            text={"削除"}
            classes={"float__button delete__button"}
          />
        </div>
        <div>
          <FlatButton
            key={"save-button"}
            handleFunc={handleSave}
            text={"保存"}
            classes={"float__button  normal__button mr-4"}
          />
          <FlatButton
            key={"generate-button"}
            handleFunc={jsonSave}
            text={"生成"}
            classes={"float__button  normal__button mr-4"}
          />
        </div>
      </div>
    </>
  );
}

interface ButtonVoidFunc {
  handleFunc: () => void;
  text: string;
  classes: string | undefined;
}

function FlatButton({
  handleFunc,
  text,
  classes,
}: ButtonVoidFunc): React.JSX.Element {
  return (
    <button className={classes} onClick={handleFunc}>
      {text}
    </button>
  );
}

function ButtonAdd({ handleFunc }: ButtonVoidFunc): React.JSX.Element {
  return (
    <button className={"float__button positive__button"} onClick={handleFunc}>
      追加
    </button>
  );
}

function ButtonDelete({ handleFunc }: ButtonVoidFunc): React.JSX.Element {
  return (
    <button className={"float__button delete__button"} onClick={handleFunc}>
      削除
    </button>
  );
}

function ButtonSave({ handleFunc, text }: ButtonVoidFunc): React.JSX.Element {
  return (
    <button
      className={"float__button normal__button mr-12"}
      onClick={handleFunc}
    >
      text
    </button>
  );
}

type CellInfo = {
  keyStr: string;
  defaultValue: string;
  tdClass: string | undefined;
};

const TableCell = forwardRef<HTMLTextAreaElement, CellInfo>(
  ({ keyStr, defaultValue, tdClass }: CellInfo, ref) => {
    return (
      <td key={keyStr} className={tdClass}>
        <textarea defaultValue={defaultValue} ref={ref}></textarea>
      </td>
    );
  },
);

まとめ

今回は自前でエクセルライクな見た目の入力をReactで実装しました。
僕はrefの渡し方などでつまる部分が多かったので何かの参考になれば幸いです。

Discussion