👌

React PopOverコンテンツ内でTabのFocusをいい感じにするhook

2021/08/08に公開

OverlayやPopOverでコンテンツを表示するとき、タブでの操作性は以下のようになってると望ましいと思う。

  • Overlayコンテンツが表示中の時にタブを押すと、コンテンツにフォーカスが当たる。
  • タブを押した時Overlayコンテンツのフォーカス可能な最初の子要素にフォーカスが当たる
  • Overlayコンテンツのフォーカス可能な最後の子要素がフォーカス中であれば、最初の子要素のフォーカスが当たる。

react-popper等を使っていい感じのOverlayコンポーネントを作ったとしても、
所詮座標を調整するライブラリなのでこの手のタブの操作性に関しては自前で実装する必要がありそうです。

ということで作ったhookがこちら。(雑コメントを追記)

import { useEffect, RefObject } from 'react';

const focusableElementsSelector =
  'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]';

export const useTabLoop = ({
  ref,
  enableTabLoop,
}: {
  ref: RefObject<HTMLElement | null>;
  enableTabLoop: boolean;
}) => {
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {

      if (!ref.current || !enableTabLoop || e.key !== 'Tab') return;
    

      const focusableElements = Array.from(
        ref.current.querySelectorAll<HTMLElement>(focusableElementsSelector)
      );

      if (!focusableElements.length) return;
      // フォーカス可能な最初と最後の子要素を取得
      const firstFocusableElement = focusableElements[0];
      const lastFocusableElement =
        focusableElements[focusableElements.length - 1];

      const focusableElementNotFocused = !focusableElements.some(
        e => e === document.activeElement
      );

      // - Overlayコンテンツを表示中にTabを押した時ににOverlay上のフォーカス可能な要素にフォーカスさせる
     //  - 最初|最後の要素をフォーカス中であれば最後|最初の要素にフォーカスする(TabLoop)

      if (e.shiftKey) {
        // Shift + Tab
        if (
          focusableElementNotFocused ||
          document.activeElement === firstFocusableElement
        ) {
          e.preventDefault();
          lastFocusableElement.focus();
        }
      } else {
       // Tab
        if (
          focusableElementNotFocused ||
          document.activeElement === lastFocusableElement
        ) {
          e.preventDefault();
          firstFocusableElement.focus();
        }
      }
    };

    document.addEventListener('keydown', onKeyDown);

    return () => document.removeEventListener('keydown', onKeyDown);
  }, [ref, enableTabLoop]);
};

使い方

React.PortalやPopperを使っていい感じのOverlayを表示できるBaseOverlayコンポーネントがあるとして、
Contentにrefを渡すことでタブループを実現することができます。

export const Overlay: React.FC<{show: boolean}> = ({ show, children}) => {
  const tabLoopRef = useRef<HTMLDivElement>(null);
  // コンテンツが表示中の時にtab loopを有効にする。
  useTabLoop({ ref: tabLoopRef, enableTabLoop: show });

  return (
    <BaseOverlay  rootClose>
        <div ref={tabLoopRef}>{children}</div>
    </BaseOverlay>
  );
};

余談

react-datepickerで使われてるこちらの実装も面白いですね

https://github.com/Hacker0x01/react-datepicker/blob/c9d7107709857dd0f6136b544324dc20119248cf/src/tab_loop.jsx

tabLoopしたいコンポーネントを包んでいます。

      <div className="react-datepicker__tab-loop" ref={this.tabLoopRef}>
        <div
          className="react-datepicker__tab-loop__start"
          tabIndex="0"
          onFocus={this.handleFocusStart}
        />
        {this.props.children}
        <div
          className="react-datepicker__tab-loop__end"
          tabIndex="0"
          onFocus={this.handleFocusEnd}
        />
      </div>
    );

参考

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/tabindex

https://web.dev/control-focus-with-tabindex/

https://developers.google.com/web/fundamentals/accessibility/focus/using-tabindex?hl=ja

https://github.com/reakit/reakit/blob/f05d36daa6fbcc52c70cf2c71baea69025aa2402/packages/reakit-utils/src/tabbable.ts

Discussion