💊

Storybookのインタラクションテストで、ダイアログ(モーダル)のアクセシビリティを担保する

2024/05/01に公開

はじめに

アクセシビリティとStorybookのインタラクションテストを学習中です。

UIコンポーネントテストはJestでも可能ですが、Storybookのインタラクションテストの方がブラウザのDOMでテストできたりと好みに感じました。

ただ、「Storybookのインタラクションテストが遅すぎる。」という記事も見たことがあり、実務で運用したことがないので、手探り感がありますがご了承ください。

Storybookバージョン

package.json
  "devDependencies": {
    // ...省略
    "@storybook/addon-a11y": "^7.6.3",
    "@storybook/addon-actions": "^7.6.3",
    "@storybook/addon-console": "^2.0.0",
    "@storybook/addon-essentials": "^7.6.3",
    "@storybook/addon-interactions": "^7.6.3",
    "@storybook/addon-links": "^7.6.3",
    "@storybook/jest": "^0.2.3",
    "@storybook/react": "^7.6.3",
    "@storybook/react-vite": "^7.6.3",
    "@storybook/test-runner": "^0.16.0",
    "@storybook/testing-library": "^0.2.2",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^14.4.3",
    "storybook": "^7.6.3",
    // ...省略
  }

要件

以下の要件を満たしているかどうかをテストコードで実装しました。

  • Escapeで、ダイアログを閉じること
  • ダイアログ背景クリックで、モーダルが閉じること
  • ダイアログが開いた時、ダイアログ内でフォーカスが当たる要素に初期フォーカスが当たっていること
  • ダイアログが開いているとき、フォーカスはダイアログ内に留まること(フォーカストラップ)
  • ダイアログ要素にはrole="dialog"aria-modal="true"が付与されていること

ダイアログコンポーネント

Headless UIのDialogコンポーネントを使用しており、スタイルはbackdropをつけたりと、少し調整しております

Dialog.tsx
import { Dialog as HuiDialog } from '@headlessui/react';
import clsx from 'clsx';

type Props = {
  open: boolean;
  title: string;
  subTitle?: string;
  panelClassName?: string;
  children: React.ReactNode;
  onClose: () => void;
};

const Dialog = ({ open, title, children, subTitle = '', panelClassName = '', onClose }: Props) => {
  return (
    <HuiDialog open={open} onClose={onClose}>
      <div className={clsx('fixed inset-0 z-modalBack bg-black opacity-70')} aria-hidden='true' />

      <HuiDialog.Panel>
        <div
          className={clsx(
            'fixed left-[50%] top-[50%] z-modalContent translate-x-[-50%] translate-y-[-50%]',
            'p-[40px] where:max-w-[600px]',
            'rounded-modal',
            'bg-white',
            panelClassName,
          )}
        >
          <HuiDialog.Title
            as='h3'
            className={clsx('mb-[30px]', 'text-center text-[20px] font-bold text-gray-1')}
          >
            {title}
            {!!subTitle && (
              <p className={clsx('text-[14px] font-normal', 'mt-[15px]')}>{subTitle}</p>
            )}
          </HuiDialog.Title>

          {children}
        </div>
      </HuiDialog.Panel>
    </HuiDialog>
  );
};

export default Dialog;

インタラクションテスト

UIはこんな感じになります。(特にこだわりはなし)

以下ストーリーファイルです。

Dialog.stories.tsx
import { expect } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/testing-library';
import { useState } from 'react';
import Button from '../Button';
import ComDialog from '.';

const meta = {
  title: 'Dialog',
  component: ComDialog,
} satisfies Meta<typeof ComDialog>;

export default meta;

type Story = StoryObj<typeof ComDialog>;

const RenderDialog = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button theme='primary' onClick={() => setIsOpen(true)}>
        open
      </Button>

      {isOpen && (
        <ComDialog title='Dialog Title' open={isOpen} onClose={() => setIsOpen(false)}>
          <div>Dialog Content</div>
          <Button theme='outlined' onClick={() => setIsOpen(false)}>
            close
          </Button>
          <Button theme='outlined' onClick={() => undefined}>
            register
          </Button>
        </ComDialog>
      )}
    </>
  );
};

export const Dialog: Story = {
  render: () => <RenderDialog />,
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);

    // openボタンの取得
    const openButton = canvas.queryByRole('button', { name: 'open' });
    // openボタンが見つからない場合はエラーをスロー
    if (!openButton) throw new Error('open button not found');

    // ダイアログ要素の取得
    const getDialogElement = () => {
      const dialog = document.querySelector('#headlessui-portal-root');
      if (!dialog) throw new Error('Dialog not found');
      return dialog;
    };

    await step('ボタンクリック後に、モーダルが開きタイトルが取得可能なこと', async () => {
      await userEvent.click(openButton);

      const dialogElement = getDialogElement();
      const dialogCanvas = within(dialogElement as HTMLElement);

      const dialogTitle = dialogCanvas.getByText('Dialog Title');
      // ダイアログタイトルが取得できることを確認
      expect(dialogTitle).toBeInTheDocument();

      const closeButton = dialogCanvas.getByText('close');
      await userEvent.click(closeButton);
      expect(dialogTitle).not.toBeInTheDocument();
    });

    await step('Escキーが押されたら、モーダルが閉じること', async () => {
      await userEvent.click(openButton);

      const dialogElement = getDialogElement();
      const dialogCanvas = within(dialogElement as HTMLElement);

      const dialogTitle = dialogCanvas.getByText('Dialog Title');
      expect(dialogTitle).toBeInTheDocument();

      // Escキーを押してモーダルを閉じる
      await userEvent.type(dialogElement as Element, '{esc}');
      expect(dialogTitle).not.toBeInTheDocument();
    });

    await step('モーダル背景がクリックされたら、モーダルが閉じること', async () => {
      await userEvent.click(openButton);

      const dialogElement = getDialogElement();
      const dialogCanvas = within(dialogElement as HTMLElement);
      // ダイアログ背景を取得、やむを得ずクラス名で取得、「z-modalBack」はz-indexの値でかぶらないようにしている
      const dialogBackdrop = document.querySelector('.z-modalBack');

      if (!dialogBackdrop) throw new Error('dialogBackdrop not found');

      const dialogTitle = dialogCanvas.getByText('Dialog Title');
      expect(dialogTitle).toBeInTheDocument();

      await userEvent.click(dialogBackdrop);
      expect(dialogTitle).not.toBeInTheDocument();
    });

    await step(
      'モーダルが開いた時に、モーダル内の要素にフォーカスが当たる要素にフォーカスが当たっていること',
      async () => {
        await userEvent.click(openButton);

        const dialogElement = getDialogElement();
        const dialogCanvas = within(dialogElement as HTMLElement);

        const dialogTitle = dialogCanvas.getByText('Dialog Title');
        expect(dialogTitle).toBeInTheDocument();

        const closeButton = dialogCanvas.getByText('close');
        // 最初のフォーカス要素であるcloseボタンにフォーカスが当たっていることを確認
        expect(closeButton).toHaveFocus();

        await userEvent.click(closeButton);
        expect(dialogTitle).not.toBeInTheDocument();
      },
    );

    await step(
      'フォーカストラップ:ダイアログが開いているとき、フォーカスはダイアログ内に留まること',
      async () => {
        await userEvent.click(openButton);

        const dialogElement = getDialogElement();
        const dialogCanvas = within(dialogElement as HTMLElement);
        const closeButton = dialogCanvas.getByText('close');
        const registerButton = dialogCanvas.getByText('register');

        // Tabキーを押してフォーカスを移動
        // モーダルが開いた時には、モーダル内の要素にフォーカスが当たる要素にフォーカスが当たっているので、2つ目の要素にフォーカスが移動する
        await userEvent.tab();
        expect(registerButton).toHaveFocus();

        // 再度Tabキーを押してフォーカスを移動すると、フォーカスはダイアログ内に留まる
        await userEvent.tab();
        expect(closeButton).toHaveFocus();

        await userEvent.click(closeButton);
      },
    );

    await step(
      'ロールとARIA属性:ダイアログ要素にはrole="dialog"/aria-modal="true"が付与されていること',
      async () => {
        await userEvent.click(openButton);

        // ダイアログ要素を取得
        const dialogElement = getDialogElement();
        const dialogCanvas = within(dialogElement as HTMLElement);
        const closeButton = dialogCanvas.getByText('close');
        const dialogRote = dialogCanvas.queryByRole('dialog');

        // ダイアログ要素にrole="dialog"、aria-modal="true"が付与されていることを確認
        expect(dialogRote).toHaveAttribute('role', 'dialog');
        expect(dialogRote).toHaveAttribute('aria-modal', 'true');

        await userEvent.click(closeButton);
      },
    );
  },
};

無事にテストがパスしていることを確認しました。

終わりに

アクセシビリティは内容が難しく、量も多いですが、テストコードを書いて品質を担保していくしかないと改めて感じました。

Discussion