🪗

アコーディオンのベースのコンポーネントをRecoilを使って作る

2023/07/21に公開

はじめに

この記事では定義したアプリケーションで汎用的に使えるアコーディオンコンポーネントを紹介します。
具体的にはChakra UIMaterial UIが提供するアコーディオンのような高い再利用性を保ちつつ、スタイリングの観点における自由度が小さいものを作ります。
このように作ることで、1アプリケーションにおける様々な場面で利用可能で、デザインや体験の揺れが小さい、特定のアプリ内で利用するには最適な共通コンポーネントを作ることができます。

この記事で作成するアコーディオンはARIA Authoring Practices Guide (APG)を元に作成しました。このサイトでは他の様々なパターンの実装がアクセシビリティの観点と合わせて紹介されているので他のコンポーネントを作る際も参照するのがおすすめです。

作成するコンポーネントはAccordionAccordionItemAccordionButtonAccordionPanelの4つです。これらのコンポーネントを組み合わせてアコーディオンを作ります。
Summary1コンポーネントを持つボタンを操作することでDetail1コンポーネントの表示が切り替えられらるアコーディオンと、Summary2コンポーネントを持つボタンを操作することでDetail2コンポーネントの表示が切り替えられるアコーディオンの2つを持つ1連のコンポーネントを作る時は以下のように組み立てます。

<Accordion>
  <AccordionItem>
    <AccordionButton>
      <Summary1 />
    </AccodionButton>
    <AccordionPanel>
      <Detail1 />
    </AccodionPanel>
  </AccodionItem>
  <AccordionItem>
    <AccordionButton>
      <Summary2 />
    </AccodionButton>
    <AccordionPanel>
      <Detail2 />
    </AccodionPanel>
  </AccodionItem>
</Accodion>

実際にこれから作るコンポーネントを利用して以下のようにアコーディオンを表現できます。

利用する技術

アコーディオンで用いる状態管理はrecoilを用いました。ReactのContextでも実装可能ですが、アプリケーション全体の状態管理としてrecoilを用いることが好きなのでここでも用いました。recoilが持つ特別な機能を用いているわけではないので、自由に置き換えてご覧ください。
スタイリングにはtailwindcssを、iconにはheroiconsを利用しました。
tailwindcssは指定するclassの量が大きく読みづらいことがあるので、見やすさのためにclsxも利用しました。

Accordion

一連のアコーディオンをまとめる役割を持つコンポーネントです。このコンポーネントが持つアコーディオンがフォーカスされているとき、このコンポーネント自身の見た目も変化させることでよりフォーカスされている箇所を視覚に訴えかけます。

import { FC, ReactNode } from 'react';
import clsx from 'clsx';

export const Accordion: FC<{ children: ReactNode }> = ({ children }) => {
  return (
    <div
      className={clsx(
        'rounded-md border-2 p-2',
        'focus-within:border-transparent focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-500',
      )}
    >
      {children}
    </div>
  );
};

ここでは記述しませんでしたが、このコンポーネントが持つアコーディオンのうち一つしか開くようにできない制約を設ける場合など、一連のアコーディオンが互いに依存して動きを作り出すための状態はこのコンポーネントで管理します。

AccordionItem

単一のアコーディオンを管理するコンポーネントです。開閉の状態のようなそれぞれのアコーディオンが持つ基本的な状態を管理します。具体的にはRecoilRootを設けて状態をこのコンポーネント内にスコープさせます。これによってコンポーネントの呼び出しごとにrecoilの状態が分割され、それぞれで独立した状態として扱えます。Accordionで管理する状態がある場合はRecoilRootの引数にoverrideを調整するなどの調整が必要になります。

'use client';

import { FC, PropsWithChildren, useId } from 'react';
import { RecoilRoot } from 'recoil';
import { itemIdState, openState } from './state';

export const AccordionItem: FC<
  PropsWithChildren<{ defaultOpen?: boolean }>
> = ({ children, defaultOpen = false }) => {
  const id = useId();
  return (
    <RecoilRoot
      initializeState={(mutableSnapshot) => {
        mutableSnapshot.set(openState, defaultOpen);
        mutableSnapshot.set(itemIdState, id);
      }}
    >
      <div className="border-b">{children}</div>
    </RecoilRoot>
  );
};

// state.ts
import { atom } from 'recoil';

export const openState = atom<boolean>({
  key: 'open',
  default: false,
});

export const itemIdState = atom<string>({
  key: 'itemId',
  default: '',
});

このコンポーネント内で取り扱う状態は2つです。
1つはアコーディオンの開閉の状態です。これは単純に型がbooleanの値です。
もう1つはアコーディオン内で扱うidが重複しないための値です。今回実装するアコーディオンは開閉ボタンとパネルに指定されたidをもとにaria属性のやり取り行うためここで状態として扱うようにしています。

どちらもRecoilRootを呼び出す際にinitializeStateで初期化を行っています。initializeStateではrecoilで扱う状態のミュータブルなスナップショットが提供され、それを用いて状態の更新ができます。のこではopenStateをコンポーネントの引数をもとに初期化、
itemIdStateuseIdで取得した値を初期値しました。

AccordionButton

アコーディオンを開閉するためのボタンです。
ホバー時に背景色を変更したり、フォーカス時にボタンを強調させることでボタンの状態をユーザーの視覚にわかりやすく示しています。
また、aria属性のaria-expandedで開閉の状態を伝え、aria-controlsでボタンが与える影響先を指定しています。影響先として渡す文字列はこの後に紹介するAccordionPanelのもつidです。

'use client';

import {
  ChevronDownIcon,
  ChevronUpIcon,
} from '@heroicons/react/24/solid';
import { FC, PropsWithChildren } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { itemIdState, openState } from './state';
import clsx from 'clsx';

export const AccordionButton: FC<PropsWithChildren<{}>> = ({
  children,
}) => {
  const [open, setOpen] = useRecoilState(openState);
  const id = useRecoilValue(itemIdState);
  return (
    <button
      type="button"
      className={clsx(
        'flex w-full flex-row items-center justify-between rounded-md p-2',
        'hover:bg-gray-100',
        'focus:first:border-transparent focus:first:outline-none focus:first:ring-2 focus:first:ring-blue-500',
      )}
      aria-expanded={open}
      aria-controls={`${id}-panel`}
      id={`${id}-button`}
      onClick={() => setOpen((open) => !open)}
    >
      {children}
      {open ? (
        <ChevronUpIcon className="h-4 w-4" />
      ) : (
        <ChevronDownIcon className="h-4 w-4" />
      )}
    </button>
  );
};

このコンポーネントは開閉に合わせて右側に表示するアイコンを明示的に指定しています。アコーディオンの開閉を示すアイコンを自由に入れ替え可能にすると、アプリケーション内でアイコンが表す意図がぼやけてしまうので固定しています。

AccordionPanel

アコーディオンを開いたときに表示される内容を管理するコンポーネントです。

'use client';

import { FC, PropsWithChildren } from 'react';
import { useRecoilValue } from 'recoil';
import { itemIdState, openState } from './state';
import clsx from 'clsx';

export const AccordionPanel: FC<PropsWithChildren<{}>> = ({
  children,
}) => {
  const id = useRecoilValue(itemIdState);
  const open = useRecoilValue(openState);
  return (
    <div
      id={`${id}-panel`}
      role="region"
      aria-labelledby={`${id}-button`}
      hidden={!open}
      className={clsx({ hidden: !open }, 'p-2')}
    >
      {children}
    </div>
  );
};

AccrdionButtonからaria-controlを介して参照されることもあり、非表示の場合にDOMを消すのではなくCSSを用いて表示の管理を行なっております。DOMが非表示であることを示すために非表示の時は要素にhiddenを渡しています。
また、roleregionを渡してランドマーク化を行い、aria-labelledbyAccordionButtonを参照してランドマークに名付けています。

組み合わせる

最初にも紹介しましたが、これらのコンポーネントを用いてアコーディオンを形成すると以下のようになります。

キーボードで操作できることを確認してください。ボタンにフォーカスがあるときはスペースやエンターで内容の開閉ができ、タブで次のボタンに移動、シフトとタブの同時押しで前のボタンに移動するように操作できるようになっています。

さいごに

ReactでRecoilを用いてAccordionを作成する方法を紹介しました。W3Cが提供する例に従ってアクセシビリティに考慮しつつ、アプリケーション内だけで利用することを活かすことに留意してコンポーネントを作りました。
他のコンポーネントも同じように作ることでより簡単により良いアプリケーションを作成できるので参考になれば幸いです。

GitHubで編集を提案

Discussion