💊
Storybookのインタラクションテストで、ダイアログ(モーダル)のアクセシビリティを担保する
はじめに
アクセシビリティと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