📝

自作コンポーネントのススメ

2023/04/13に公開

ヘッダ固定のテーブルコンポーネントを作る

経緯

長らくテーブル表示にreact-bootstrap-table-nextを使っていたのですが、ヘッダをstickyで固定する機能がなく、最近メンテナンスもされていないということで乗り換えを考えていました。

乗り換え先のライブラリをいくつか検討したのですが、

  • これまで作ったテーブル設定をそのまま引き継ぎたい
  • これまでの機能を維持したい
  • ヘッダを上部に固定(sticky)したい

という希望に合致するものがなかなかなく、ちょっと作ってみるかと思い立ちました。

コンポーネントの仕様

  • react-bootstrap-table-nextの設定を引き継げる
  • ヘッダを固定できる
  • ソートできる
    • ソートしたときの表示をわかりやすくする

propsの決定

data
ソートなどを行う対象のデータ(配列)

columns
ヘッダの項目などの設定。

  • dataField
    dataにアクセスするプロパティ。data.idなど。
  • text
    thに表示する項目名
  • sort
    ソート可否(boolean)
  • formatter
    セル(td)に表示する内容をカスタマイズする関数
 React.FC<{
  data: any[];
  columns: {
    dataField: string;
    text: string;
    formatter?: (cell: any, row: any) => JSX.Element;
    sort?: boolean;
  }[];
}

stateの決定

主にソート機能だけなのですが、

  • どの列が選択されているか
  • asc/descどちらにソートするか

という状態を持つのがよいと思いました。
また、propでもらったdataをソートして返すためにuseMemoを使います。thにonClickイベントを仕込み、クリックされたら選択状態とasc/descの向きを更新するように実装します。また、ソート不可の場合は処理をスキップします。

style

テーブルのスタイルは元々グローバルCSSで定義しているので、不足分のみ今回のコンポーネントにscopedで定義しています。
caretのアイコンのみ外部のライブラリに依存しています。

最終的なコード

import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useMemo, useState } from "react";

const StickyTable: React.FC<{
  data: any[];
  columns: {
    dataField: string;
    text: string;
    classes: string;
    formatter?: (cell: any, row: any) => JSX.Element;
    sort?: boolean;
  }[];
}> = ({ columns, data }) => {
  const [sortKey, setSortKey] = useState<string>();
  const [sortMode, setSortMode] = useState<"asc" | "desc" | "none">("none");
  const processedData: any[] = useMemo(() => {
    if (sortKey) {
      return data.sort((a, b) => {
        switch (sortMode) {
          case "asc":
            return a[sortKey] - b[sortKey] > 0 ? 1 : -1;
          case "desc":
            return b[sortKey] - a[sortKey] > 0 ? 1 : -1;
          default:
            return null;
        }
      });
    }
    return data;
  }, [data, sortKey, sortMode]);

  const isActive = useCallback(
    (dataField: string) => {
      return dataField === sortKey;
    },
    [sortKey]
  );

  return (
    <div
      style={{
        overflow: "scroll",
        maxHeight: "70vh",
      }}
    >
      <table>
        <thead>
          {columns.map((column) => (
            <th
              className={`${isActive(column.dataField) && "active"}`}
              onClick={() => {
                if (!column.sort) return;
                setSortKey(column.dataField);
                setSortMode(sortMode === "asc" ? "desc" : "asc");
              }}
            >
              {column.text}
              {column.sort && (
                <div className="order">
                  {!isActive(column.dataField) && (
                    <>
                      <div>
                        <FontAwesomeIcon icon={faCaretDown} color="gray" />
                      </div>
                      <div>
                        <FontAwesomeIcon icon={faCaretUp} color="gray" />
                      </div>
                    </>
                  )}
                  {isActive(column.dataField) && (
                    <>
                      {sortMode === "desc" && (
                        <div>
                          <FontAwesomeIcon icon={faCaretDown} />
                        </div>
                      )}
                      {sortMode === "asc" && (
                        <div>
                          <FontAwesomeIcon icon={faCaretUp} />
                        </div>
                      )}
                    </>
                  )}
                </div>
              )}
            </th>
          ))}
        </thead>
        <tbody>
          {processedData.map((row) => (
            <tr>
              {columns.map((col) => (
                <>
                  {col.formatter ? (
                    <td>{col.formatter(row[col.dataField], row)}</td>
                  ) : (
                    <td>{row[col.dataField]}</td>
                  )}
                </>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <style jsx>
        {`
          thead th {
            background-color: white;
            color: #555;
            position: -webkit-sticky;
            position: sticky;
            top: 0;
            z-index: 1;
          }
          th.active {
            background-color: #4ab9e6 !important;
            color: white !important;
          }
          thead th:first-child {
            z-index: 2;
          }
        `}
      </style>
    </div>
  );
};
export default StickyTable;

参考リンク

CSSのposition: stickyでテーブルのヘッダー行・列を固定する

xtone tech blog

Discussion