📝

React | ヘッダーとカラム固定ができるテーブルコンポーネント

2024/04/25に公開

モチベーション

テーブルのコンポーネントって、実務で実装すると大概要件が複雑になりがちですよね。
それだけ、テーブルというコンポーネントが期待される機能が豊富・多様ということなんだと思います。

ただ、毎回実装を考えるのは大変なので、自分のメモ代わりに汎用的なReactのコンポーネントとして実装してみました。

このコンポーネントを使うと、以下の機能を達成できます。

  • 任意のデータ構造のリストをテーブル表示できる
  • テーブルのヘッダーと任意数のカラムを固定できる
  • テーブルのセルに、ボタンやクリックイベントを付与できる

いろいろ最適化できるところは多そうですが、テーブルコンポーネントのたたき台などにも使ってみてもらえればと思います。

成果物

かんたんに言うとエクセルの固定ウィンドウ(ただしヘッダは1行固定)みたいな感じのテーブルコンポーネントです。

Gifの赤背景の部分が固定カラムとなっていて、リアクティブに変更可能です。

ソースコード(CSS)
.tableWrrapper {
  overflow: scroll;
}

.table {
  border-collapse: collapse;
}

table {
  white-space: nowrap;
  table-layout: fixed;
}

table {
  th {
    background-color: white;
    z-index: 1;
  }
  td {
    background-color: white;
  }
}

table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
}

ソースコード(テーブルコンポーネント)
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import style from "./index.module.css";

type ColProps<T extends object> = {
  index: number;
  row: T;
};

type HeadColProps<T extends object> = {
  id: keyof T;
  label: string;
  className?: string;
  template?: React.FC<ColProps<T>>;
};

type TableProps<T extends object> = {
  rows: T[];
  headCols: readonly HeadColProps<T>[];
  height?: number;
  stickyColumns?: number;
  onClickRow?: (rowInfo: T, event: React.MouseEvent<unknown>) => void;
};

type TableHeadProps<T extends object> = Pick<
  TableProps<T>,
  "headCols" | "stickyColumns"
> & {
  rowWidthList: number[];
};

type TableBodyProps<T extends object> = Pick<
  TableProps<T>,
  "rows" | "headCols" | "stickyColumns" | "onClickRow"
> & {
  rowWidthList: number[];
};

const resolveColStyle =
  <T extends object>(
    rowWidthList: number[],
    headCols: readonly HeadColProps<T>[],
    stickyColumns?: number,
    isBodyCol = false,
  ) =>
  (colIndex: number): React.CSSProperties => {
    const isSticky = colIndex < (stickyColumns || 0);
    return {
      position: isSticky ? "sticky" : undefined,
      backgroundColor: isSticky ? "#eeaaaa" : undefined, // NOTE: For debug
      left: isSticky
        ? `${
            rowWidthList.slice(0, colIndex).reduce((acc, cur) => {
              return acc + cur;
            }, 0) || 0
          }px`
        : undefined,
      zIndex: headCols.length - colIndex - (isBodyCol ? 1 : 0),
    };
  };

const TableHead = <T extends object>({
  headCols,
  stickyColumns,
  rowWidthList,
}: TableHeadProps<T>) => {
  const colStyleResolver = useMemo(() => {
    return resolveColStyle(rowWidthList, headCols, stickyColumns);
  }, [rowWidthList, headCols, stickyColumns]);

  return (
    <thead>
      <tr>
        {headCols.map((col: HeadColProps<T>, index: number) => {
          return (
            <th key={col.id as string} style={colStyleResolver(index)}>
              {col.label}
            </th>
          );
        })}
      </tr>
    </thead>
  );
};

const TableBody = <T extends object>({
  rows,
  headCols,
  stickyColumns,
  rowWidthList,
  onClickRow,
}: TableBodyProps<T>) => {
  const colStyleResolver = useMemo(() => {
    return resolveColStyle(rowWidthList, headCols, stickyColumns, true);
  }, [rowWidthList, headCols, stickyColumns]);

  return (
    <tbody>
      {rows.map((row, index) => {
        return (
          <tr key={index} onClick={ev => onClickRow?.(row, ev)}>
            {headCols.map((col: HeadColProps<T>, i: number) => {
              return (
                <td key={col.id as string} style={colStyleResolver(i)}>
                  {(() => {
                    if (col.template) {
                      return col.template({ row, index });
                    }
                    return <>{row[col.id]}</>;
                  })()}
                </td>
              );
            })}
          </tr>
        );
      })}
    </tbody>
  );
};

const Table = <T extends object>({
  height,
  rows,
  headCols,
  stickyColumns = 0,
  onClickRow,
}: TableProps<T>) => {
  const rowRef = useRef<HTMLTableElement | null>(null);
  const delayRef = useRef<number | undefined>(undefined);
  const [rowWidthList, setRowWidthList] = useState<number[]>([]);

  // Calculate each width of table columns.
  const calculateWidthList = useCallback(() => {
    const theadElement: HTMLTableElement | null = rowRef?.current;
    const thList = theadElement?.querySelectorAll("th");
    if (thList) {
      const widthList = Array.prototype.map.call(
        thList,
        (html: HTMLElement) => {
          return html.clientWidth;
        },
      );
      setRowWidthList(widthList as number[]);
    }
  }, []);

  // Delay to reduce the number of recalculations during resizing.
  const onResize = useCallback(() => {
    clearTimeout(delayRef.current);
    delayRef.current = setTimeout(() => {
      calculateWidthList();
    }, 500);
  }, [calculateWidthList]);

  // Add resize event listener.
  useEffect(() => {
    window.addEventListener("resize", onResize);
    onResize();
    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return (
    <div className={style.tableWrrapper} style={{ height: height }}>
      <table className={style.table} ref={rowRef}>
        <TableHead
          headCols={headCols}
          rowWidthList={rowWidthList}
          stickyColumns={stickyColumns}
        />
        <TableBody
          rows={rows}
          headCols={headCols}
          rowWidthList={rowWidthList}
          stickyColumns={stickyColumns}
          onClickRow={onClickRow}
        />
      </table>
    </div>
  );
};
export default Table;

ソースコードはこちら。

実装概要

ヘッダー/カラムの固定

const resolveColStyle =
  <T extends object>(
    rowWidthList: number[],
    headCols: readonly HeadColProps<T>[],
    stickyColumns?: number,
    isBodyCol = false,
  ) =>
  (colIndex: number): React.CSSProperties => {
    const isSticky = colIndex < (stickyColumns || 0);
    return {
      position: isSticky ? "sticky" : undefined,
      backgroundColor: isSticky ? "#eeaaaa" : undefined, // NOTE: For debug
      left: isSticky
        ? `${
            rowWidthList.slice(0, colIndex).reduce((acc, cur) => {
              return acc + cur;
            }, 0) || 0
          }px`
        : undefined,
      zIndex: headCols.length - colIndex - (isBodyCol ? 1 : 0),
    };
  };

ヘッダーとカラムの固定はテーブルのセル1つ1つにStyleをあてています。

上のresolveColStyleはReact.CSSProperties をリターンする関数で、position: 'sticky'left: '${xxx}px' を指定して、固定セルの左端からの絶対位置 を指定します。

xIndex については、「ヘッダーのカラム数 - そのカラムのインデックス」とすることで、左端→右に行くにつれてzIndexが小さくなるようにしています。これは、セルを固定した際に、左側のセルのzIndexが右側のセルのzIndexより大きくなることを保証するためです。

ちなみに、テーブルヘッダー以外のセルについてはzIndexをさらに - 1 することで、「その列のヘッダーセルよりzIndexが高くなることのないよう」に調整しています。

zIndexの調整をちゃんとしてあげないと、このようにスクロールした際に後ろの行や列にカブられる挙動になっちゃいます。

データはユーザー定義で設定可能

ジェネリクスとして型を指定することで、ユーザー定義のデータ構造の配列を行データとして渡せるように作りました。

テーブルコンポーネントは一度作ったらいろいろなところで同じように使いまわしたいですし、渡せる配列データは柔軟に定義できた方が便利ですよね。
(headColsのidプロパティでRowのキーを検索するという実装になっています。なので、RowとheadColsのキーに整合性の不備があればちゃんと型エラーしてくれます。)

使う側のコード

type Row = {
  label1: string;
  label2: string;
};

const headCols = [
    {
      id: "label1",
      label: "label1",
    },
    {
      id: "label2",
      label: "label2",
    },
] as const;

const rows: Row[] = [...];

<Table<Row>
  headCols={headCols}
  rows={rows}
/>

コンポーネント側の型定義コード

type ColProps<T extends object> = {
  index: number;
  row: T;
};

type HeadColProps<T extends object> = {
  id: keyof T;
  label: string;
  className?: string;
  template?: React.FC<ColProps<T>>;
};

type TableProps<T extends object> = {
  rows: T[];
  headCols: readonly HeadColProps<T>[];
  height?: number;
  stickyColumns?: number;
  onClickRow?: (rowInfo: T, event: React.MouseEvent<unknown>) => void;
};

テンプレートのレンダリングが可能

テーブルのヘッダー定義用の配列には、template パラメータが渡せるようにしてあります。

これは何かというと、JSX.Elementを返す関数を定義することで、「テーブルのセル内にReactのコンポーネントをテンプレートとしてレンダリングできるようにする」ものです。

これによって、単なる文字列情報だけでなく、例えばクリックイベントを持つボタンなどをテーブルの任意のセルに配置することができるようになります。

使う側のコード

const headCols = [
    ...,
    {
      id: "label9",
      label: "label9",
    },
    // templateにJSX.Elementを返す関数を定義することが可能
    {
      id: "label10",
      label: "label10",
      template: ({ row }: RowTemplate) => (
        <div>
          <span>{row.label10}</span>
          <button onClick={ev => onClickRowButton(row, ev)}>
            Click This!!
          </button>
        </div>
      ),
    },
  ] as const;

コンポーネント側のコード

<tr key={index} onClick={ev => onClickRow?.(row, ev)}>
    {headCols.map((col: HeadColProps<T>, i: number) => {
      return (
        <td key={col.id as string} style={colStyleResolver(i)}>
          {(() => {
            // templateプロパティがあれば、行の情報を引数に渡してレンダリング
            if (col.template && typeof col.template === 'function') {
              return col.template({ row, index });
            }
            return <>{row[col.id]}</>;
          })()}
        </td>
      );
    })}
  </tr>

画面リサイズ時の処理

画面のリサイズ時にテーブルの列幅が変わることを考慮して、resize イベントのハンドラを定義しています。

あまりテーブルの実装とは関係ないですが、一応、、、

// Calculate each width of table columns.
  const calculateWidthList = useCallback(() => {
    const theadElement: HTMLTableElement | null = rowRef?.current;
    const thList = theadElement?.querySelectorAll("th");
    if (thList) {
      const widthList = Array.prototype.map.call(
        thList,
        (html: HTMLElement) => {
          return html.clientWidth;
        },
      );
      setRowWidthList(widthList as number[]);
    }
  }, []);

  // Delay to reduce the number of recalculations during resizing.
  const onResize = useCallback(() => {
    clearTimeout(delayRef.current);
    delayRef.current = setTimeout(() => {
      calculateWidthList();
    }, 500);
  }, [calculateWidthList]);

  // Add resize event listener.
  useEffect(() => {
    window.addEventListener("resize", onResize);
    onResize();
    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);

まとめ

ヘッダーと左からNカラムが固定になるテーブルコンポーネントをReactで実装しました。

パッケージなどでいい感じのテーブルコンポーネントがあればそれを使えばいいですが、最悪これでもある程度は柔軟に対応できるのではないかと思います。

引き続き、個人的に使いまわして使いたいコンポーネントなどを備忘録がてらに実装してみようかと思います。

Discussion