【a11y】アクセシビリティを考慮したドロワー実装
こんにちは、株式会社Gizumoでエンジニアをしている田中です。
自社サービスのフロントエンド実装の中で個人的にとくに勉強になっているアクセシビリティについて、直近で実装を担当したドロワーにどのように組み込んでいくか紹介します。
目次
前提・対象とする読者
この記事は以下を前提として読み進めてください。
- サンプルコードは
React
とTypeScript
で記述 - CSSは
emotion
を使用 - クラス名連結には
clsx
を使用 - ※今回の実装はアクセシビリティのすべてを網羅できているわけではなく、必ずしも正しいとは限らないため、あくまで参考程度の実装とする
また、対象とする読者は以下のような方を想定しています。
- アクセシビリティに興味がある方
- HTML・CSSを書いたことがある方
- フロントエンド実装者
アクセシビリティとは
アクセシビリティに関してはReact公式のドキュメントに記載があるため、今回はそちらの内容をお借りします。
今回はとくにキーボード操作とスクリーンリーダーに焦点を当てています。
Web アクセシビリティ(a11y とも呼ばれます)とは、誰にでも使えるようウェブサイトを設計・構築することです。ユーザ補助技術がウェブページを解釈できるようにするためには、サイトでアクセシビリティをサポートする必要があります。
実装したドロワー
ドロワーとはボタンのクリックイベント等をトリガーに画面の横からシュッと出てくるメニューのことです。今回の完成物は以下になります。
コンポーネントのソースコード
コンポーネントのソースコードを先に載せておきます。内部でカスタムHooksを利用していますが、後の解説で詳しく説明します。
mport './App.css';
import { Drawer } from './components/Drawer';
import { useDrawer } from './hooks/useDrawer';
function App() {
const { isOpen, onOpenDrawer, onCloseDrawer } = useDrawer();
return (
<>
<div>
<button onClick={onOpenDrawer} aria-expanded={isOpen}>open</button>
<Drawer isOpen={isOpen} onClose={onCloseDrawer} />
</div>
</>
);
}
export default App;
import { css } from '@emotion/react';
import clsx from 'clsx';
import { FC, useCallback, useEffect, useRef } from 'react';
type Props = {
isOpen: boolean;
onClose: () => void;
};
const overlayStyle = css({
position: 'fixed',
inset: 0,
opacity: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 100,
overscrollBehaviorY: 'contain',
backgroundColor: 'rgb(0 0 0 / 0.5)',
transition: 'opacity 0.2s ease-in-out',
'&.is-visible': {
opacity: 1,
pointerEvents: 'auto',
},
});
const drawerStyle = css({
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '60%',
height: '100%',
overflowY: 'auto',
pointerEvents: 'none',
backgroundColor: '#fff',
zIndex: 101,
transform: 'translateX(100%)',
transition: 'transform 0.2s ease-in-out',
'&.is-open': {
pointerEvents: 'auto',
transform: 'translateX(0)',
},
});
export const Drawer: FC<Props> = ({ isOpen, onClose }) => {
const handleKeyDownEscKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDownEscKey);
return () => {
document.removeEventListener('keydown', handleKeyDownEscKey);
};
}, [handleKeyDownEscKey]);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
buttonRef.current?.focus();
}
}, [isOpen]);
return (
<>
<div
aria-hidden
className={clsx(isOpen && 'is-visible')}
onClick={onClose}
css={overlayStyle}
/>
<div
className={clsx(isOpen && 'is-open')}
css={drawerStyle}
role="dialog"
aria-hidden={!isOpen}
>
<button type="button" ref={buttonRef} onClick={onClose}>
Close
</button>
</div>
</>
);
};
実装を元に解説
ドロワーの実装を元に、以下の6つのアクセシビリティ要件をどのように実現するよう努めたか解説します。
- ドロワーが開いていない時はアクセス不可能とする
- ドロワーが開いている時は背景裏のスクロールを無効化する
- ドロワー内でスクロールが発生する時はスクロールの伝播を制御する
- ドロワーが開く時はドロワー内の最初のフォーカス可能な要素にフォーカスを当てる
- ドロワーが閉じる時はドロワーを開いたボタンにフォーカスを当てる
- Escキーでドロワーを閉じることができる
1. ドロワーが開いていない時はアクセス不可能とする
何を以てアクセス不可能とするかは以下のように定義しています。
- クリックできないこと
- キーボードフォーカスできないこと
- スクリーンリーダーに読み上げられないこと
クリックできないこと
クリックできないことは、pointer-events: none
を指定することで実現できます。
const overlayStyle = css({
...省略
pointerEvents: 'none',
...省略
});
今回は背景の透過にopacity
を使用しているため、実際に目視できない時でもクリックイベントが走ってしまいます。そのため、pointer-events: none
を指定してクリックイベントを無効化しています。
※ クリック可能な子要素を配置するとバブリングによりクリック判定が働いてしまうためpointer-events: none
を指定していても注意が必要です。
キーボードフォーカスできないこと
div
タグはキーボードフォーカスがデフォルトで不可能なため対応はとくに必要ありません。
※ 逆にフォーカス可能へしたい時にはtabindex属性を指定することで可能になります。
スクリーンリーダーに読み上げられないこと
スクリーンリーダーに読み上げられないことは、aria-hidden
を指定することで実現できます。
ドロワーの背景は常時指定、ドロワー本体は閉じている時のみ指定としています。
<div
aria-hidden // スクリーンリーダー読み上げ無効化
className={clsx(isOpen && 'is-visible')}
onClick={onClose}
css={overlayStyle}
/>
<div
className={clsx(isOpen && 'is-open')}
css={drawerStyle}
role="dialog"
aria-hidden={!isOpen} // ドロワーが閉じている時スクリーンリーダー読み上げ無効化
>
2. ドロワーが開いている時は背景裏のスクロールを無効化する
ドロワーが開いている時のメインコンテンツはドロワーであるため、背景のスクロールを無効にすることで制御できる領域を制限します。
bodyのスクロールを無効化する
ドロワーが開いている時に背景のスクロールを無効にするには、HTMLのbody
要素にoverflow: hidden
を指定します。
import { useCallback, useState } from 'react';
export const useDrawer = () => {
const [isOpen, setIsOpen] = useState(false);
const onOpenDrawer = useCallback(() => {
setIsOpen(true);
// bodyのスクロールを無効化する
document.body.classList.add('is-disabled-scroll');
}, [setIsOpen]);
const onCloseDrawer = useCallback(() => {
setIsOpen(false);
// bodyのスクロールを有効化する
document.body.classList.remove('is-disabled-scroll');
}, [setIsOpen]);
return { isOpen, onOpenDrawer, onCloseDrawer };
};
.is-disabled-scroll {
overflow: hidden;
}
3. ドロワー内でスクロールが発生する時はスクロールの伝播を制御する
overscroll-behavior
プロパティを使用します。このプロパティは子要素にスクロールが発生しており、スクロールの限界に達した時、その後のスクロールの伝播を制御します。今回はドロワー内でスクロールが発生した場合、背景裏にスクロールが伝播するスクロール連鎖を防ぐためにcontain
を指定します。
const overlayStyle = css({
...省略
overscrollBehaviorY: 'contain',
...省略
});
4. ドロワーが開く時はドロワー内の最初のフォーカス可能な要素にフォーカスを当てる
こちらはrole='dialog'
を指定したダイアログ要素をアクセシブルにするため必要な要件となってきます。
フォーカスを当てたい要素をuseRef
で取得し、ドロワーが開いている時のみフォーカスを当てるようにします。
...省略
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
buttonRef.current?.focus();
}
}, [isOpen]);
return (
<>
...省略
<div
className={clsx(isOpen && 'is-open')}
css={drawerStyle}
role="dialog"
aria-hidden={!isOpen}
>
// refによって取得した要素にフォーカスを当てる
<button type="button" ref={buttonRef} onClick={onClose}>
Close
</button>
</div>
</>
);
5. ドロワーが閉じる時はドロワーを開いたボタンにフォーカスを当てる
ドロワーが閉じる時、ドロワーを開くボタンにフォーカスを当てるにはdocument.activeElement
を使用し現在フォーカスが当たっている要素を取得します。処理の流れとしては以下になります。
- ドロワーを開くアクションを起こす
- フォーカスが当たっている要素を
state
に格納する - ドロワーが開く
- ドロワーを閉じるアクションを起こす
- 2で
state
に格納していた要素にフォーカスを当てる
import { useCallback, useState } from 'react';
export const useBeforeFocused = () => {
const [beforeFocusedElement, setBeforeFocusedElement] =
useState<HTMLElement | null>(null);
// ドロワーを開く前にフォーカスが当たっていた要素をstateに格納しておく(処理②)
const onHoldFocusedElement = useCallback(() => {
setBeforeFocusedElement(document.activeElement as HTMLElement);
}, []);
// ドロワーを閉じた時にstateに格納しておいた要素にフォーカスを当てる(処理⑤)
const onFocusBeforeFocusedElement = useCallback(() => {
beforeFocusedElement?.focus();
setBeforeFocusedElement(null);
}, [beforeFocusedElement]);
return {
beforeFocusedElement,
onHoldFocusedElement,
onFocusBeforeFocusedElement,
};
};
import { useCallback, useState } from 'react';
import { useBeforeFocused } from './useBeforeFocused'; // 追加
export const useDrawer = () => {
const { onHoldFocusedElement, onFocusBeforeFocusedElement } =
useBeforeFocused(); // 追加
const [isOpen, setIsOpen] = useState(false);
const onOpenDrawer = useCallback(() => {
onHoldFocusedElement(); // 追加
setIsOpen(true);
document.body.classList.add('is-disabled-scroll');
}, [setIsOpen, onHoldFocusedElement]);
const onCloseDrawer = useCallback(() => {
setIsOpen(false);
onFocusBeforeFocusedElement(); // 追加
document.body.classList.remove('is-disabled-scroll');
}, [setIsOpen, onFocusBeforeFocusedElement]);
return { isOpen, onOpenDrawer, onCloseDrawer };
};
6. Escキーでドロワーを閉じることができる
Escキーでドロワーを閉じるには、addEventListener
でkeydown
に対して処理を登録しておきます。具体的な処理は以下の通りです。
重要な点としてはuseEffect
のクリーンアップ関数でremoveEventListener
を行うことです。クリーンアップを行うことでメモリリークを防ぎ、登録したイベントが残り続けることを防止できます。
export const Drawer: FC<Props> = ({ isOpen, onClose }) => {
const handleKeyDownEscKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDownEscKey);
return () => {
document.removeEventListener('keydown', handleKeyDownEscKey);
};
}, [handleKeyDownEscKey]);
...省略
}
Element: keydown イベント
Subscribing to events
最後に
なるべく多くの人が見やすく・利用しやすいサイトを作ることはとても大切なことですが、同時に実装する際に意識しないといけないことが多く、実装が難しいと感じることもあるかと思います。
私自身、自社のサービスに関わるまでとくに意識せずコーディングを行なっており、弊社のフロントメンターのレビューをきっかけとして調べるに至りまだまだ勉強中です。
今回の記事を通して、アクセシビリティについての理解・関心が深まり、実装する際の参考・調べるきっかけになれば嬉しく思います。