MUIでCascaderを作ってみる

# 背景
MUIにはCascaderがありませんがコンポーネントを組み合わせことで作れるということを知りました。

https://github.com/mui/mui-x/issues/6019#issuecomment-1236151651

業務でMUIを使ってる為、勉強がてらコード書いてみようと思った為です。

# Cascader

## 定義
https://ant.design/components/cascader

ツリー構造化データの表示と選択に使用されます。
斜めに少しずつずらしながら重ねて表示していくことをカスケード表示といいます。

## 用途
- 関連するデータセットから選択する必要がある場合。県/市/区、会社レベル、物事の分類など
- 大規模なデータセットから選択する場合、多段階の分類で簡単に選択が可能
- より良いユーザーエクスペリエンスのために、1つのフロート層でカスケード項目を選択

# コンポーネントの説明
## TreeView
https://mui.com/x/react-tree-view/#typescript

階層データをノードとしてツリー状に表現するコンポーネントです。

## TreeItem
https://mui.com/x/api/tree-view/tree-item/

TreeViewと一緒に階層的なリストを構成する一つの要素として使われます。

# sampleのソース

https://codesandbox.io/p/sandbox/typescript-data-driven-treeview-dhkx2?file=%2Fsrc%2FApp.tsx

```sampleData.tsx
export interface RenderTree {
  id: string;
  name: string;
  children?: RenderTree[];
}

export const data: RenderTree = {
  id: "0",
  name: "Parent",
  children: [
    {
      id: "1",
      name: "Child - 1"
    },
    {
      id: "3",
      name: "Child - 3",
      children: [
        {
          id: "4",
          name: "Child - 4",
          children: [
            {
              id: "7",
              name: "Child - 7"
            },
            {
              id: "8",
              name: "Child - 8"
            }
          ]
        }
      ]
    },
    {
      id: "5",
      name: "Child - 5",
      children: [
        {
          id: "6",
          name: "Child - 6"
        }
      ]
    }
  ]
};

```

```App.tsx
import React from "react";
import TreeView from "@material-ui/lab/TreeView";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import { Checkbox, FormControlLabel } from "@material-ui/core";
import { RenderTree, data } from "./sampleData";

export default function RecursiveTreeView() {
  const [selected, setSelected] = React.useState<string[]>([]);

  // idの値をidにもつ自身と子のidを再起的に取り出した配列を返す
  function getChildById(node: RenderTree, id: string) {
    let array: string[] = [];

    // 自身と子のidを再起的に取り出した配列を返す
    function getAllChild(nodes: RenderTree | null) {
      if (nodes === null) return [];
      array.push(nodes.id);
      if (Array.isArray(nodes.children)) {
        nodes.children.forEach(node => {
          array = [...array, ...getAllChild(node)];
          array = array.filter((v, i) => array.indexOf(v) === i);
        });
      }
      return array;
    }

    // idから再起的にnodeを問い合わせて取得して返す
    function getNodeById(nodes: RenderTree, id: string) {
      if (nodes.id === id) {
        return nodes;
      } else if (Array.isArray(nodes.children)) {
        let result = null;
        nodes.children.forEach(node => {
          if (!!getNodeById(node, id)) {
            result = getNodeById(node, id);
          }
        });
        return result;
      }

      return null;
    }

    return getAllChild(getNodeById(node, id));
  }

  //checkされたidのリストの更新をする
  function getOnChange(checked: boolean, nodes: RenderTree) {
    const allNode: string[] = getChildById(data, nodes.id);
    let array = checked
      ? [...selected, ...allNode]
      : selected.filter(value => !allNode.includes(value));

    array = array.filter((v, i) => array.indexOf(v) === i);

    setSelected(array);
  }

  const renderTree = (nodes: RenderTree) => (
    <TreeItem
      key={nodes.id}
      nodeId={nodes.id}
      label={
        <FormControlLabel
          control={
            <Checkbox
              checked={selected.some(item => item === nodes.id)}
              onChange={event =>
                getOnChange(event.currentTarget.checked, nodes)
              }
              onClick={e => e.stopPropagation()}
            />
          }
          label={<>{nodes.name}</>}
          key={nodes.id}
        />
      }
    >
      {Array.isArray(nodes.children)
        ? nodes.children.map(node => renderTree(node))
        : null}
    </TreeItem>
  );

  return (
    <TreeView
      defaultCollapseIcon={<ExpandMoreIcon />}
      defaultExpanded={["0", "3", "4"]}
      defaultExpandIcon={<ChevronRightIcon />}
    >
      {renderTree(data)}
    </TreeView>
  );
}

```

# 幾つか修正

```App.tsx
import { useState } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { TreeItem, TreeView } from '@mui/x-tree-view';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';

export const RecursiveTreeView = () => {
  const [selected, setSelected] = useState<string[]>([]);

  // idから再起的にnodeを問い合わせて取得して返す
  const getNodeById = (nodes: RenderTree, id: string) => {
    if (nodes.id === id) {
      return nodes;
    } else if (Array.isArray(nodes.children)) {
      let result = null;
      nodes.children.forEach((node) => {
        if (getNodeById(node, id)) {
          result = getNodeById(node, id);
        }
      });
      return result;
    }
    return null;
  };

  // idの値をidにもつ自身と子のidを再起的に取り出した配列を返す
  const getChildById = (node: RenderTree, id: string) => {
    let array: string[] = [];
    // 自身と子のidを再起的に取り出した配列を返す
    function getAllChild(nodes: RenderTree | null) {
      if (nodes === null) return [];
      array.push(nodes.id);
      if (Array.isArray(nodes.children)) {
        const childrenIdList = nodes.children.flatMap((child) =>
          getAllChild(child),
        );
        const uniqueChildrenIdList = [...new Set(childrenIdList)];
        array.push(...uniqueChildrenIdList);
      }
      return array;
    }
    return getAllChild(getNodeById(node, id));
  };

  //checkされたidのリストの更新をする
  const getOnChange = (checked: boolean, nodes: RenderTree) => {
    const allNode: string[] = getChildById(data, nodes.id);
    const newSelectedList = checked
      ? [...selected, ...allNode]
      : selected.filter((value) => !allNode.includes(value));
    const uniqueNewSelectedList = [...new Set(newSelectedList)];
    setSelected(uniqueNewSelectedList);
  };

  const renderTree = (nodes: RenderTree) => (
    <TreeItem
      key={nodes.id}
      nodeId={nodes.id}
      label={
        <FormControlLabel
          control={
            <Checkbox
              checked={selected.includes(nodes.id)}
              onChange={(event) =>
                getOnChange(event.currentTarget.checked, nodes)
              }
              onClick={(e) => e.stopPropagation()}
            />
          }
          label={<>{nodes.name}</>}
          key={nodes.id}
        />
      }
    >
      {Array.isArray(nodes.children)
        ? nodes.children.map((node) => renderTree(node))
        : null}
    </TreeItem>
  );

  return (
    <TreeView
      defaultCollapseIcon={<ExpandMoreIcon />}
      defaultExpanded={['0', '3', '4']}
      defaultExpandIcon={<ChevronRightIcon />}
    >
      {renderTree(data)}
    </TreeView>
  );
};
```
## 変更点

package

- ```@material-ui/icon```から```@mui/icons-material```に書き換え
    ライブラリのメンテ頻度を考慮
- ```@material-ui/lab```から```@mui/x-tree-view```に書き換え
@material-ui/labがdeprecatdになっていました

https://www.npmjs.com/package/@material-ui/lab

https://mui.com/blog/lab-tree-view-to-mui-x/

mui-xもまだalpha版のようなのでどちらにするべきなのでしょうか。

- ```@material-ui/core```から```@mui/material```に書き換え
```@material-ui/core```がdeprecatdになっていました

https://www.npmjs.com/package/@material-ui/core

```
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { TreeItem, TreeView } from '@mui/x-tree-view';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
```

その他、ソースを部分的にわかりやすく書き換えました。

# 最後に
- TreeView、TreeItemとCheckboxでCascaderを作れることがわかりました
- 業務で市区町村データをElement-UIのCascaderで作っているのでそれをMUIで書き換えるのを次回のブログに書きます

# 参考
- https://www.naiveui.com/en-US/os-theme/components/cascader
- [カスケード（カスケーディング）とは - 意味をわかりやすく - IT用語辞典 e-Words](https://e-words.jp/w/%E3%82%AB%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%89.html#:~:text=%E3%82%92%E6%84%8F%E5%91%B3%E3%81%99%E3%82%8B%E3%80%82-,%E3%82%AB%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%89%E8%A1%A8%E7%A4%BA,%E3%81%95%E3%82%8C%E3%82%8B%E3%81%93%E3%81%A8%E3%82%82%E3%81%82%E3%82%8B%E3%80%82)