【a11y】アクセシビリティを考慮したドロワー実装

media thumbnail

こんにちは、株式会社Gizumoでエンジニアをしている田中です。

自社サービスのフロントエンド実装の中で個人的にとくに勉強になっているアクセシビリティについて、直近で実装を担当したドロワーにどのように組み込んでいくか紹介します。

前提・対象とする読者

この記事は以下を前提として読み進めてください。

  • サンプルコードはReactTypeScriptで記述
  • CSSはemotionを使用
  • クラス名連結にはclsxを使用
  • ※今回の実装はアクセシビリティのすべてを網羅できているわけではなく、必ずしも正しいとは限らないため、あくまで参考程度の実装とする

また、対象とする読者は以下のような方を想定しています。

  • アクセシビリティに興味がある方
  • HTML・CSSを書いたことがある方
  • フロントエンド実装者

アクセシビリティとは

アクセシビリティに関してはReact公式のドキュメントに記載があるため、今回はそちらの内容をお借りします。

今回はとくにキーボード操作とスクリーンリーダーに焦点を当てています。

React公式 アクセシビリティ

Web アクセシビリティ(a11y とも呼ばれます)とは、誰にでも使えるようウェブサイトを設計・構築することです。ユーザ補助技術がウェブページを解釈できるようにするためには、サイトでアクセシビリティをサポートする必要があります。

実装したドロワー

ドロワーとはボタンのクリックイベント等をトリガーに画面の横からシュッと出てくるメニューのことです。今回の完成物は以下になります。

ドロワー:Close状態
ドロワー:Open状態

コンポーネントのソースコード

コンポーネントのソースコードを先に載せておきます。内部でカスタム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つのアクセシビリティ要件をどのように実現するよう努めたか解説します。

  1. ドロワーが開いていない時はアクセス不可能とする
  2. ドロワーが開いている時は背景裏のスクロールを無効化する
  3. ドロワー内でスクロールが発生する時はスクロールの伝播を制御する
  4. ドロワーが開く時はドロワー内の最初のフォーカス可能な要素にフォーカスを当てる
  5. ドロワーが閉じる時はドロワーを開いたボタンにフォーカスを当てる
  6. Escキーでドロワーを閉じることができる

1. ドロワーが開いていない時はアクセス不可能とする

何を以てアクセス不可能とするかは以下のように定義しています。

  • クリックできないこと
  • キーボードフォーカスできないこと
  • スクリーンリーダーに読み上げられないこと

クリックできないこと

クリックできないことは、pointer-events: noneを指定することで実現できます。

const overlayStyle = css({
  ...省略

  pointerEvents: 'none',

  ...省略
});

今回は背景の透過にopacityを使用しているため、実際に目視できない時でもクリックイベントが走ってしまいます。そのため、pointer-events: noneを指定してクリックイベントを無効化しています。

※ クリック可能な子要素を配置するとバブリングによりクリック判定が働いてしまうためpointer-events: noneを指定していても注意が必要です。

pointer-events

キーボードフォーカスできないこと

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} // ドロワーが閉じている時スクリーンリーダー読み上げ無効化
>

aria-hidden
DOMツリーとスクリーン・リーダー

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',
  ...省略
});

overscroll-behavior

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>
    </>
  );

ARIA: dialog ロール

5. ドロワーが閉じる時はドロワーを開いたボタンにフォーカスを当てる

ドロワーが閉じる時、ドロワーを開くボタンにフォーカスを当てるにはdocument.activeElementを使用し現在フォーカスが当たっている要素を取得します。処理の流れとしては以下になります。

  1. ドロワーを開くアクションを起こす
  2. フォーカスが当たっている要素をstateに格納する
  3. ドロワーが開く
  4. ドロワーを閉じるアクションを起こす
  5. 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 };
};

Document.activeElement

6. Escキーでドロワーを閉じることができる

Escキーでドロワーを閉じるには、addEventListenerkeydownに対して処理を登録しておきます。具体的な処理は以下の通りです。
重要な点としては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

最後に

なるべく多くの人が見やすく・利用しやすいサイトを作ることはとても大切なことですが、同時に実装する際に意識しないといけないことが多く、実装が難しいと感じることもあるかと思います。

私自身、自社のサービスに関わるまでとくに意識せずコーディングを行なっており、弊社のフロントメンターのレビューをきっかけとして調べるに至りまだまだ勉強中です。

今回の記事を通して、アクセシビリティについての理解・関心が深まり、実装する際の参考・調べるきっかけになれば嬉しく思います。

参考

少しでも開発にお困りの方は
相談しやすいスペシャリストにお問い合わせください

お問い合わせ
  1. breadcrumb-logo
  2. メディア
  3. 【a11y】アクセシビリティを考慮したド...