👻

カスタマイズ可能なリストコンポーネントを実装する

2023/07/23に公開

始めに

以下の記事でMUIを使ったナビゲーションドロワーを実装しました。

https://zenn.dev/wintyo/articles/f357ad57fdbc72

ドロワー内部で作成したナビゲーションリストは以下のように単一項目、グループヘッダー、グループという3つに分解し、更にdepthに応じたUIを設定していました。これを作った際にナビゲーションだけでなく単純なチェックリストとかにも流用できないのかな?と思いました。

そこで今回は前回作ったNavigationListに加えて、単純なチェックリストなどにもカスタマイズ可能なリストコンポーネントを設計してみたので、それについて記事にまとめたいと思います。

カスタマイズ可能なリストコンポーネントの設計

まず実装に当たって、どういうカスタムができるかを考えます。以下の要件を満たすように実装していきます。

  • リストに流し込む項目リストの型を自由に決められる
  • 上記で定義した項目を元にUIを自由に定義できる
  • アコーディオン開閉状態や選択状態の参照や更新処理をUI側から行える

リストに流し込む項目リストの型を自由に決められる

最初にリストに渡す項目リストの型を定義します。NavigationListではiconとかhrefとか渡していましたが、これも内容によって自由に決められると良いので、以下のように単一とグループそれぞれにAdditionalPropsを差し込められるようにします。

CustomItem.ts
/** 単一項目 */
export type CustomSingleItem<AdditionalProps = {}> = {
  /** タイトル */
  title: string;
} & AdditionalProps;

/** グループ項目 */
export type CustomGroupItem<
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
> = {
  /** タイトル */
  title: string;
  /** 子要素 */
  subs: CustomItem<AdditionalSingleProps, AdditionalGroupProps>[];
} & AdditionalGroupProps;

/** 選択項目 */
export type CustomItem<AdditionalSingleProps = {}, AdditionalGroupProps = {}> =
  | CustomSingleItem<AdditionalSingleProps>
  | CustomGroupItem<AdditionalSingleProps, AdditionalGroupProps>;

上記で定義した項目を元にUIを自由に定義できる

上記で定義した項目がpropsとして渡ってくるようにコンポーネントのPropsを用意します。

CustomListIndexesType.ts
/** リストの番地 */
export type CustomListIndexes = number[];
CustomUIProps.ts
import { CustomListIndexes } from "./CustomListIndexesType";
import { CustomSingleItem, CustomGroupItem } from "./CustomItemType";

/** 単一項目のコンポーネントProps */
export type CustomSingleProps<AdditionalSingleProps = {}> = {
  /** 単一項目 */
  item: CustomSingleItem<AdditionalSingleProps>;
  /** リストの番地 */
  listIndexes: CustomListIndexes;
};

/** グループ項目のコンポーネントProps */
export type CustomGroupProps<
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
> = {
  /** グループ項目 */
  item: CustomGroupItem<AdditionalSingleProps, AdditionalGroupProps>;
  /** リストの番地 */
  listIndexes: CustomListIndexes;
};

/** グループヘッダー部分のコンポーネントProps */
export type CustomGroupHeaderProps<
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
> = {
  /** グループ項目 */
  item: CustomGroupItem<AdditionalSingleProps, AdditionalGroupProps>;
  /** リストの番地 */
  listIndexes: CustomListIndexes;
};

上記の型を使って、単一用コンポーネントとグループ用コンポーネントを渡してリストコンポーネントを作ることができます。

CustomList.tsx
import { List } from "@mui/material";
import { FC } from "react";

import { CustomItem } from "./CustomItemType";
import { CustomSingleProps, CustomGroupProps } from "./CustomUIProps";

export type CustomListProps<
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
> = {
  /** 単一項目のカスタムコンポーネント */
  CustomSingle: FC<CustomSingleProps<AdditionalSingleProps>>;
  /** グループ項目のカスタムコンポーネント */
  CustomGroup: FC<
    CustomGroupProps<AdditionalSingleProps, AdditionalGroupProps>
  >;
  /** 項目リスト */
  items: CustomItem<AdditionalSingleProps, AdditionalGroupProps>[];
};

export const CustomList = function <
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
>({
  CustomSingle,
  CustomGroup,
  items
}: CustomListProps<AdditionalSingleProps, AdditionalGroupProps>) {
  return (
    <List>
      {items.map((item, index) => {
        const key = `${index}`;
        const listIndexes = [index];
        if ("subs" in item) {
          return (
            <CustomGroup key={key} item={item} listIndexes={listIndexes} />
          );
        } else {
          return (
            <CustomSingle key={key} item={item} listIndexes={listIndexes} />
          );
        }
      })}
    </List>
  );
};

これで最低限な機能は完成しました。ただ、このままだとアコーディオンの開閉や選択することができません。そのやり方は次で説明します。

アコーディオン開閉状態や選択状態の参照や更新処理をUIから行える

先ほどまでの実装ではアコーディオン開閉状態や選択状態はpropsに渡していないためそういった機能を作ることができません。ただpropsで渡すと不要な時に邪魔なデータになってしまうのでpropsで渡すのは不適切です。そこで必要な場合はContextで渡す方針にしました。

アコーディオン開閉や選択状態を管理するインスタンスをContextで渡すイメージ
return (
  {/* 選択状態機能を使用するためContext経由で渡す */}
  <SelectionManagerProvider value={selectionManager}>
    {/* アコーディオン機能を使用するためContext経由で渡す */}
    <AccordionManagerProvider value={accordionManager}>
      <CustomList
        // カスタムしたコンポーネント側でContextを参照して使う
        CustomSingle={CustomSingle}
        CustomGroup={CustomGroup}
        items={items}
      />
    </AccordionManagerProvider>
  </SelectionManagerProvider>
)

Contextなので各自自由に定義すれば良いですが、アコーディオンと選択状態はよく使われるものなので事前に用意しておきます。Contextに渡す値も拡張できるようにinterfaceで定義しておきます。更にContextValueを取得する際に未定義な場合も許容するパターンもあるかもしれないので、必須で取得するかもオプションで指定できるようにします。
選択状態を管理するselectionManagerはアコーディオンの実装とほぼ同じなので割愛します。

IAccordionManager.ts
import { CustomListIndexes } from "../CustomListIndexesType";

/** アコーディオン開閉状態を管理する */
export interface IAccordionManager {
  /** 開閉状態をトグルする */
  toggle: (listIndexes: CustomListIndexes) => void;
  /** 開いているかチェック */
  isOpen: (listIndexes: CustomListIndexes) => boolean;
}
AccordionManagerProvider.ts
import { createContext, useContext } from "react";

import { IAccordionManager } from "../AccordionManager/IAccordionManager";

export type AccordionManagerContextValue = IAccordionManager;

export const AccordionManagerContext = createContext<
  AccordionManagerContextValue | undefined
>(undefined);
export const AccordionManagerProvider = AccordionManagerContext.Provider;

type UseContextValueOptions<Required extends boolean = false> = {
  required?: Required;
};

type ReturnUseContextValue<
  Required extends boolean = false
> = Required extends true
  ? AccordionManagerContextValue
  : AccordionManagerContextValue | undefined;

/**
 * アコーディオン管理情報のコンテキストを取得する
 */
export const useAccordionManagerContextValue = <
  Required extends boolean = false
>({ required }: UseContextValueOptions<Required> = {}): ReturnUseContextValue<
  Required
> => {
  const contextValue = useContext(AccordionManagerContext);

  if (required !== true) {
    // required: falseケースの型ではあるが上手く推論はできないのでキャストする
    return contextValue as ReturnUseContextValue<Required>;
  }

  if (contextValue == null) {
    throw new Error(
      "アコーディオンマネージャーのProviderがセットされていません。"
    );
  }
  return contextValue;
};

標準機能を用意する

上記の内容で一応は完成しました。ただ、動作確認用のカスタムコンポーネントが1つもないのでさっぱりイメージがわかないのと、毎回全部カスタム定義するとなると大変です。そこで標準となるコンポーネントやhooksを用意します。

標準のUIコンポーネントを用意する

最低限動作確認ができる程度のUIコンポーネントを用意します。アコーディオンについては流石にないと悲しいのでContextで渡される前提で実装します。ただ選択は不要なケースがあっても良いと思ったので、SelectionManagerがContextで渡された時だけ選択機能が動くようにしています。
コードが多くなるので最初は全て折りたたんでおきます。

DefaultSingle
DefaultSingle.tsx
import { ListItemButton, ListItemText } from "@mui/material";
import { FC } from "react";

import { CustomSingleProps } from "../CustomUIProps";
import { useSelectionManagerContextValue } from "../providers/SelectionManagerProvider";

export const DefaultSingle: FC<CustomSingleProps> = ({ item, listIndexes }) => {
  const selectionManager = useSelectionManagerContextValue();
  const depth = listIndexes.length - 1;

  return (
    <ListItemButton
      sx={{ pl: 2 }}
      selected={selectionManager?.isSelected(listIndexes)}
      onClick={() => {
        selectionManager?.toggle(listIndexes);
      }}
    >
      <ListItemText primary={item.title} inset={depth > 0} />
    </ListItemButton>
  );
};
DefaultGroupHeader
DefaultGroupHeader.tsx
import { ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
import { ArrowDropDown as ArrowDropDownIcon } from "@mui/icons-material";
import { FC } from "react";

import { CustomGroupHeaderProps } from "../CustomUIProps";
import { useAccordionManagerContextValue } from "../providers/AccordionManagerProvider";

export const DefaultGroupHeader: FC<CustomGroupHeaderProps> = ({
  item,
  listIndexes
}) => {
  const accordionManager = useAccordionManagerContextValue({ required: true });
  const isOpen = accordionManager.isOpen(listIndexes);

  return (
    <ListItemButton
      onClick={() => {
        accordionManager.toggle(listIndexes);
      }}
    >
      <ListItemIcon>
        <ArrowDropDownIcon
          sx={{
            transform: `rotate(${isOpen ? 180 : 0}deg)`,
            transition: "transform 0.3s"
          }}
        />
      </ListItemIcon>
      <ListItemText primary={item.title} />
    </ListItemButton>
  );
};
DefaultGroup

標準のグループコンポーネントだけちょっと工夫が必要です。基本的にはアコーディオンアニメーションで開閉するだけですが、ループや再帰呼び出しするコンポーネントがカスタマイズしたCustomSingleCustomGroupHeaderを指定しないといけません。そこでこれら2つを渡して標準のグループコンポーネントを作るメソッドを用意して、それを呼び出して作ります。

createDefaultGroup.tsx
import { Collapse, List } from "@mui/material";
import { FC } from "react";

import {
  CustomSingleProps,
  CustomGroupProps,
  CustomGroupHeaderProps
} from "../CustomUIProps";
import { useAccordionManagerContextValue } from "../providers/AccordionManagerProvider";

/**
 * グループ項目の標準コンポーネントを生成する
 */
export const createDefaultGroup = function <
  AdditionalSingleProps = {},
  AdditionalGroupProps = {}
>({
  CustomSingle,
  CustomGroupHeader
}: {
  CustomSingle: FC<CustomSingleProps<AdditionalSingleProps>>;
  CustomGroupHeader: FC<
    CustomGroupHeaderProps<AdditionalSingleProps, AdditionalGroupProps>
  >;
}) {
  const GroupItem: FC<CustomGroupProps<
    AdditionalSingleProps,
    AdditionalGroupProps
  >> = ({ item, listIndexes }) => {
    const accordionManager = useAccordionManagerContextValue({
      required: true
    });

    const isOpen = accordionManager.isOpen(listIndexes);

    return (
      <>
        <CustomGroupHeader item={item} listIndexes={listIndexes} />
        <Collapse in={isOpen} timeout="auto">
          <List disablePadding>
            {item.subs.map((subItem, index) => {
              const nextTreeIndexes = [...listIndexes, index];
              const key = nextTreeIndexes.join("-");
              if ("subs" in subItem) {
                return (
                  <GroupItem
                    key={key}
                    item={subItem}
                    listIndexes={nextTreeIndexes}
                  />
                );
              } else {
                return (
                  <CustomSingle
                    key={key}
                    item={subItem}
                    listIndexes={nextTreeIndexes}
                  />
                );
              }
            })}
          </List>
        </Collapse>
      </>
    );
  };
  return GroupItem;
};
DefaultGroup.tsx
import { createDefaultGroup } from "./createDefaultGroup";
import { DefaultGroupHeader } from "./DefaultGroupHeader";
import { DefaultSingle } from "./DefaultSingle";

export const DefaultGroup = createDefaultGroup({
  CustomSingle: DefaultSingle,
  CustomGroupHeader: DefaultGroupHeader
});

※ちなみに、最初が全部開いている状態で表示する場合はアコーディオン開閉フラグすら不要になります。その辺の自由度も設けるため全ての状態管理をContextから渡すようにしています。

アコーディオン開閉状態管理と選択状態管理をする標準hooksを用意する

アコーディオン開閉状態管理と選択状態管理を行うhooksを以下のように定義します。折角なので単一のみか複数かを指定できるようにします。

useDefaultAccordionManager
useDefaultAccordionManager.ts
import { useState } from "react";

import { CustomListIndexes } from "../CustomListIndexesType";
import { IAccordionManager } from "./IAccordionManager";

/**
 * リストの番地からユニークなkey文字列を算出する
 * @param listIndexes - リストの番地
 */
const getKey = (listIndexes: CustomListIndexes) => {
  return listIndexes.join("-");
};

export type UseDefaultAccordionManagerOptions = {
  /** 複数選択を可能にするか */
  multiple?: boolean;
};

/**
 * 標準の開閉フラグを管理するhook
 */
export const useDefaultAccordionManager = ({
  multiple
}: UseDefaultAccordionManagerOptions = {}): IAccordionManager => {
  const [isOpenMap, setIsOpenMap] = useState<Record<string, boolean>>({});

  return {
    toggle: (listIndexes) => {
      const selectedKey = getKey(listIndexes);
      const newIsOpenMap = {
        ...isOpenMap,
        [selectedKey]: !isOpenMap[selectedKey]
      };
      if (!multiple) {
        // 同じグループに属さないキーは取り除く
        // 例: 1-2-3というキーを選択した場合は1, 1-2は残るようにする
        const removeKeys = Object.keys(newIsOpenMap).filter((key) => {
          return !selectedKey.startsWith(key);
        });
        removeKeys.forEach((removeKey) => {
          delete newIsOpenMap[removeKey];
        });
      }
      setIsOpenMap(newIsOpenMap);
    },
    isOpen: (listIndexes) => {
      return isOpenMap[getKey(listIndexes)] ?? false;
    }
  };
};
useDefaultSelectionManager
useDefaultSelectionManager.ts
import { useState } from "react";

import { CustomListIndexes } from "../CustomListIndexesType";
import { ISelectionManager } from "./ISelectionManager";

/**
 * リストの番地からユニークなkey文字列を算出する
 * @param listIndexes - リストの番地
 */
const getKey = (listIndexes: CustomListIndexes) => {
  return listIndexes.join("-");
};

export type UseDefaultSelectionManagerOptions = {
  /** 複数選択を可能にするか */
  multiple?: boolean;
};

/**
 * 標準の選択状態を管理するhooks
 */
export const useDefaultSelectionManager = ({
  multiple
}: UseDefaultSelectionManagerOptions = {}): ISelectionManager => {
  const [isSelectedMap, setIsSelectedMap] = useState<Record<string, boolean>>(
    {}
  );

  return {
    toggle: (listIndexes) => {
      const key = getKey(listIndexes);
      setIsSelectedMap(
        multiple
          ? {
              ...isSelectedMap,
              [key]: !isSelectedMap[key]
            }
          : {
              [key]: !isSelectedMap[key]
            }
      );
    },
    isSelected: (listIndexes) => {
      return isSelectedMap[getKey(listIndexes)] ?? false;
    }
  };
};

標準機能だけでカスタムリストを作って呼び出してみる

標準機能が出揃ったので、それらと組み合わせると以下のようなコンポーネントが作れます。ローカルで状態を管理し、選択機能の有無や複数選択のフラグなどが設定できます。

DefaultLocalSelectionList.tsx
import { FC } from "react";

import {
  CustomList,
  CustomItem,
  DefaultSingle,
  DefaultGroup,
  useDefaultAccordionManager,
  AccordionManagerProvider,
  useDefaultSelectionManager,
  SelectionManagerProvider
} from "~/lib/CustomList";

export type DefaultSelectionItem = CustomItem;

export type DefaultLocalSelectionListProps = {
  /** 項目リスト */
  items: DefaultSelectionItem[];
  /** 複数アコーディオン開閉するか */
  multipleAccordion?: boolean;
  /** 選択可能にするか */
  selectable?: boolean;
  /** 複数選択するか */
  multipleSelection?: boolean;
};

export const DefaultLocalSelectionList: FC<DefaultLocalSelectionListProps> = ({
  items,
  multipleAccordion,
  selectable,
  multipleSelection
}) => {
  const accordionManager = useDefaultAccordionManager({
    multiple: multipleAccordion
  });
  const selectionManager = useDefaultSelectionManager({
    multiple: multipleSelection
  });

  return (
    <SelectionManagerProvider value={selectable ? selectionManager : undefined}>
      <AccordionManagerProvider value={accordionManager}>
        <CustomList
          CustomSingle={DefaultSingle}
          CustomGroup={DefaultGroup}
          items={items}
        />
      </AccordionManagerProvider>
    </SelectionManagerProvider>
  );
};

selectableフラグをつけて実行するとこんな感じになります。

NavigationListをCustomListを使って作り直す

今度はNavigationListもCustomListを使って作り直したいと思います。

項目に追加の型を足す

まずはNavigationListではアイコンや遷移先を指定する必要があるのでその辺をItemに設定できるようにAdditionalPropsを定義します。カスタマイズ後のItem型は参照しやすいように定義しておきます。

NavigationAdditionalProps.ts
import { FC } from "react";

export type NavigationAdditionalSingleProps = {
  /** アイコン */
  icon?: FC;
  /** 遷移先 */
  href: string;
};

export type NavigationAdditionalGroupProps = {
  /** アイコン */
  icon?: FC;
};
NavigationItemType.ts
import {
  CustomSingleItem,
  CustomGroupItem,
  CustomItem
} from "~/lib/CustomList";

import {
  NavigationAdditionalSingleProps,
  NavigationAdditionalGroupProps
} from "./NavigationAdditionalProps";

export type NavigationSingleItem = CustomSingleItem<
  NavigationAdditionalSingleProps
>;

export type NavigationGroupItem = CustomGroupItem<
  NavigationAdditionalSingleProps,
  NavigationAdditionalGroupProps
>;

export type NavigationItem = CustomItem<
  NavigationAdditionalSingleProps,
  NavigationAdditionalGroupProps
>;

上記で指定したItemを使ってNavigation用のUIコンポーネントを作ります。accordionManagerは渡される前提で、選択状態についてはrouter側のContextがあれば良いのでそちらを参照して選択状態を表現します。

NavSingle
NavSingle.tsx
import {
  Link,
  ListItemButton,
  ListItemIcon,
  ListItemText
} from "@mui/material";
import { FC } from "react";
import { Link as RouterLink, useLocation } from "react-router-dom";

import { CustomSingleProps } from "~/lib/CustomList";
import { NavigationAdditionalSingleProps } from "../NavigationAdditionalProps";

export const NavSingle: FC<CustomSingleProps<
  NavigationAdditionalSingleProps
>> = ({ item, listIndexes }) => {
  const Icon = item.icon;
  const depth = listIndexes.length - 1;

  const { pathname } = useLocation();

  return (
    <Link
      component={RouterLink}
      to={item.href}
      underline="none"
      color="inherit"
    >
      <ListItemButton
        sx={{
          pl: Math.max(2 * depth, 2),
          minHeight: depth === 0 ? "56px" : "48px"
        }}
        selected={item.href === pathname}
      >
        {Icon && (
          <ListItemIcon>
            <Icon />
          </ListItemIcon>
        )}
        <ListItemText primary={item.title} inset={Icon == null} />
      </ListItemButton>
    </Link>
  );
};
NavGroupHeader
NavGroupHeader.tsx
import { ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
import {
  ArrowDropDown as ArrowDropDownIcon,
  ExpandMore as ExpandMoreIcon
} from "@mui/icons-material";
import { FC } from "react";

import {
  CustomGroupHeaderProps,
  useAccordionManagerContextValue
} from "~/lib/CustomList";
import {
  NavigationAdditionalSingleProps,
  NavigationAdditionalGroupProps
} from "../NavigationAdditionalProps";

export const NavGroupHeader: FC<CustomGroupHeaderProps<
  NavigationAdditionalSingleProps,
  NavigationAdditionalGroupProps
>> = ({ item, listIndexes }) => {
  const accordionManager = useAccordionManagerContextValue({ required: true });

  const isOpen = accordionManager.isOpen(listIndexes);
  const Icon = item.icon;
  const depth = listIndexes.length - 1;

  return (
    <ListItemButton
      color="primary"
      sx={{ minHeight: "56px" }}
      onClick={() => {
        accordionManager.toggle(listIndexes);
      }}
    >
      {Icon && (
        <ListItemIcon>
          <Icon />
        </ListItemIcon>
      )}
      {depth === 1 && (
        <ListItemIcon
          sx={{
            justifyContent: "center"
          }}
        >
          <ArrowDropDownIcon
            sx={{
              transform: `rotate(${isOpen ? 180 : 0}deg)`,
              transition: "transform 0.3s"
            }}
          />
        </ListItemIcon>
      )}
      <ListItemText primary={item.title} inset={depth < 1 && Icon == null} />
      {depth === 0 && (
        <ListItemIcon sx={{ minWidth: "auto" }}>
          <ExpandMoreIcon
            sx={{
              transform: `rotate(${isOpen ? 180 : 0}deg)`,
              transition: "transform 0.3s"
            }}
          />
        </ListItemIcon>
      )}
    </ListItemButton>
  );
};
NavGroup

標準のグループコンポーネントを作るメソッドを事前に用意していたため、アコーディオンの動きに特に変更がなければカスタムコンポーネントを設定して呼ぶだけになります。

NavGroup.tsx
import { createDefaultGroup } from "~/lib/CustomList";
import { NavSingle } from "./NavSingle";
import { NavGroupHeader } from "./NavGroupHeader";

export const NavGroup = createDefaultGroup({
  CustomSingle: NavSingle,
  CustomGroupHeader: NavGroupHeader
});

アコーディオンのカスタムhooksを用意する

こちらで書いた内容と実装は変わりませんが、型の設定とかで微妙に変わっているので一応載せておきます。

https://zenn.dev/wintyo/articles/f357ad57fdbc72#アコーディオン開閉フラグを管理するhooksを作成

useNavigationAccordionManager
useNavigationAccordionManager.ts
import { useState } from "react";

import { IAccordionManager } from "~/lib/CustomList";

type AccordionState = {
  /** トップ階層で開いているindex番号 */
  depth0: number[];
  /** 二階層目で開いているindex番号 */
  depth1: {
    [depth0Index: number]: number[];
  };
};

export type UseNavigationAccordionManagerOptions = {
  /** 一時的に全て閉じている状態にするか */
  isTemporaryAllClose?: boolean;
};

export const useNavigationAccordionManager = ({
  isTemporaryAllClose
}: UseNavigationAccordionManagerOptions): IAccordionManager => {
  const [accordionState, setAccordionState] = useState<AccordionState>({
    depth0: [],
    depth1: {}
  });

  return {
    toggle: (listIndexes) => {
      const [depth0Index, depth1Index] = listIndexes;
      // トップ階層のtoggleの場合
      if (depth1Index == null) {
        if (accordionState.depth0.includes(depth0Index)) {
          setAccordionState({
            ...accordionState,
            depth0: accordionState.depth0.filter(
              (openIndex) => openIndex !== depth0Index
            ),
            depth1: {}
          });
        } else {
          setAccordionState({
            ...accordionState,
            depth0: [depth0Index],
            depth1: {}
          });
        }
        return;
      }

      // 二階層目の場合
      const openIndexes = accordionState.depth1[depth0Index] || [];
      if (openIndexes.includes(depth1Index)) {
        setAccordionState({
          ...accordionState,
          depth1: {
            ...accordionState.depth1,
            [depth0Index]: openIndexes.filter(
              (openIndex) => openIndex !== depth1Index
            )
          }
        });
      } else {
        setAccordionState({
          ...accordionState,
          depth1: {
            ...accordionState.depth1,
            [depth0Index]: openIndexes.concat(depth1Index)
          }
        });
      }
    },
    isOpen: (listIndexes) => {
      if (isTemporaryAllClose) {
        return false;
      }

      const [depth0Index, depth1Index] = listIndexes;
      // トップ階層の場合
      if (depth1Index == null) {
        return accordionState.depth0.includes(depth0Index);
      }
      // 二階層目の場合
      return (accordionState.depth1[depth0Index] || []).includes(depth1Index);
    }
  };
};

全てのパーツを組み合わせてNavigationListコンポーネントを作る

あとは作ったものを組み合わせてNavigationListを作ったら完成です。

NavigationList.tsx
import { FC } from "react";

import { CustomList, AccordionManagerProvider } from "~/lib/CustomList";

import { NavigationItem } from "./NavigationItemType";
import { useNavigationAccordionManager } from "./hooks/useNavigationAccordionManager";
import { NavSingle } from "./subComponents/NavSingle";
import { NavGroup } from "./subComponents/NavGroup";

export type NavigationListProps = {
  /** 項目リスト */
  items: NavigationItem[];
  /** 強制的に閉じるか */
  forceCollapse?: boolean;
};

export const NavigationList: FC<NavigationListProps> = ({
  items,
  forceCollapse
}) => {
  const accordionManager = useNavigationAccordionManager({
    isTemporaryAllClose: forceCollapse
  });

  return (
    <AccordionManagerProvider value={accordionManager}>
      <CustomList
        CustomSingle={NavSingle}
        CustomGroup={NavGroup}
        items={items}
      />
    </AccordionManagerProvider>
  );
};

その他のカスタマイズ

かなり自由度のあるCustomListになったので例えば以下のようなものを作ることができます。記事が長くなってしまうので実装方法は割愛します。詳細が気になるかたは記事の最後に埋め込んでいるCodeSandboxの方をご参照ください。

アイコン付きチェックボックス

グループ単位で一括チェック・開閉する

検証コード

本記事で書いた内容は全て以下のCodeSandboxにありますので詳細はこちらをご参照ください。

終わりに

以上がカスタマイズ可能なリストコンポーネントの実装でした。ちょっとやり過ぎな印象ですが、かなり自由度のあるコンポーネントが作れたので、ネストされたリストのコンポーネントに関して様々な要望を受けられそうだなと思いました。ネストされたチェックリストなどの実装や、ネストされたリストのかなり自由度のあるUIを求められているときなどの参考になれれば幸いです。

Discussion