ReactModal と EventListener と useCapture

react 2年目がいろいろ学んだことやハマったことの記録。
ファンクションキーのイベントリスナー登録で悩んだお話です。

## ボタンにファンクションキーを割当てる

この機能は、他の方によってすでにフック関数で実装されていました。
ファンクションキー押下の検知は、keyDownイベントリスナーを登録することで制御しています。

```tsx
export const useFunctionKey = (
  code: string, 
  disabled: boolean, 
  callback: VoidFunction
): void => {
  const fn = useCallback(
    e => {
      if (e.key === code && !disabled) {
        callback();
        e.cancelBubble = true;
        e.returnValue = false;
        e.stopImmediatePropagation();
        e.preventDefault();
      }
  }, []);

  useEffect(() => {
    document.addEventListener('keydown', fn, false);

    return () => {
      document.removeEventListener('keydown', fn, false);
    };
  });
};
```


## モーダルと親画面でファンクションキーを割り当てる

いくつかの画面でモーダルを表示する機能があり、モーダルにもファンクションキーを割り当てたボタンが配置されています。親画面と同じファンクションキーであったり、そうでないものもあります。

モーダルが表示されている状態でファンクションキーを押したら、現状どうなるのか？それを調べた上、以下の機能を満たすよう、必要な修正をすることになりました。

* モーダルが表示されている間はモーダルのファンクションキーのみ有効にする
* 上記以外のキーは、親画面に割り当てられたファンクションキーも含めて無効になる
* モーダルが閉じられたら、親画面のファンクションキーのみ有効になる（モーダルのファンクションキーは解除される）

## イベントリスナー登録・解除

モーダルもすでに作成されていましたが、ファンクションキーの制御は入ってません。
モーダル上で入力されたキーを判定してイベント伝搬を制御する処理を追加します。

```tsx
const AllFunctionKeys = ['F1',...(略)...,'F12'];

ReactModal.setAppElement('body');

const Modal: React.FC<Props> = ({
  show,
  functionKeys = [],
  children,
}) => {
  const handleModalKeyDown = React.useCallback(
  e => {
    if (!AllFunctionKeys.includes(e.key) || functionKeys.includes(e.key)) {
      // ファンクションキー以外の入力、またはボタンに割り当てたキーであればスルー
      return;
    }

    // 対象外のファンクションキーの場合はイベント伝搬を止める（キーを無効にする）
    e.cancelBubble = true;
    e.returnValue = false;
    e.stopImmediatePropagation();
    e.preventDefault();
  },
  [functionKeys],
  );

  return (
    <>
      <ReactModal
        isOpen={show}
        shouldReturnFocusAfterClose={false}
      >
        {children}
      </ReactModal>
    </>
  );
};
```

次にこのイベントリスナーをどのタイミングで登録して解除すべきか。要件は「モーダルが開いている間のみ」です。
モーダルはReactModalを用いた共通コンポーネントとして実装されています。
ReactModalには、モーダルを開いたとき・閉じたときのコールバック関数を定義できるとわかったので、モーダルが開いたタイミングでイベントリスナーを登録し、閉じたタイミングで解除する、としました。

```tsx
// モーダルを開いた時
const handleAfterOpen = React.useCallback(() => {
  document.addEventListener('keydown', handleModalKeyDown, true);
}, [handleModalKeyDown]);

// モーダルを閉じた時
const handleAfterClose = React.useCallback(() => {
  document.removeEventListener('keydown', handleModalKeyDown, true);
}, [handleModalKeyDown]);

  return (
    <>
      <ReactModal
        isOpen={show}
        shouldReturnFocusAfterClose={false}
        onAfterOpen={handleAfterOpen}    // 追加
        onAfterClose={handleAfterClose}  // 追加
      >
        {children}
      </ReactModal>
    </>
  );
```

モーダルのイベントリスナーのほうを優先してほしいため、`addEventListener`の第3引数を`true`に指定します。

## イベント伝搬の制御

上記の実装でテストしてみます。
親画面に、F1,F3を割り当てたボタンを、モーダルにF1,F2を割り当てたボタンを配置します。

```tsx
const EventListenerTester: React.FC = () => {
    const [show, setShow] = React.useState(false);
    const handleClick = React.useCallback((message: string) => {
        console.log(message);
    }, []);

    const handleClickModal = React.useCallback(() => {
        setShow(!show);
    }, [show]);

    return (
        <>
        <FunctionKeyButton shortcutKey={'F1'} onClick={() => handleClick('page F1')} >ボタン</FunctionKeyButton>
        <FunctionKeyButton shortcutKey={'F3'} onClick={() => handleClick('page F3')} >ボタン</FunctionKeyButton>
        <Button onClick={handleClickModal} >モーダル</Button>
        <Modal functionKeys={['F1', 'F2']} show={show}>
            <>
                <div>モーダルのテスト</div>
                <div>
                    <FunctionKeyButton shortcutKey={'F1'} onClick={() => handleClick('modal F1')} >ボタン</FunctionKeyButton>
                    <FunctionKeyButton shortcutKey={'F2'} onClick={() => handleClick('modal F2')} >ボタン</FunctionKeyButton>
                    <Button onClick={handleClickModal}>閉じる</Button>
                </div>
            </>
        </Modal>
        </>
    );
};
```

まず親画面のみ出ている状態で、F1->F2->F3の順でファンクションキーをクリックします。

```
page F1
page F3
```
これは想定通りの動きとなってます。
その後、モーダルを表示して同じようにF1->F2->F3の順でファンクションキーをクリックします。

```
page F1
modal F2
```

となりました。期待したのは両方ともモーダルで登録したほうのリスナーが実行されることでしたがF1のみNGでした。
最後のF3については正常にキャンセルされた、ということも確認できました。

モーダルで使うファンクションキーは、そのままボタンのリスナーへイベント伝搬してほしいため、スルーするよう実装しました。しかし、ボタンに登録したリスナーはデフォルトの「登録された順番で実行」されるため、親画面のほうのリスナーが最初に実行されて、イベント伝搬が止められました。そのため、モーダルのボタンで登録されたリスナーが実行されなかったということになります。

ボタンに登録する場合のリスナーも「モーダルに配置するボタンなのかどうか」を考慮してリスナーの実行順序をコントロールしないといけないということです。

フック関数に引数を追加することにします。
ファンクションキーは親画面で使うほうが多いと思われたため任意設定とし、未指定時は`false`でよいようにします。

```tsx
export const useFunctionKey = (
    code: FunctionKey,
    callback: VoidFunction,
    disabled: boolean,
    modal?: boolean,　// 追加
): void => {

// 省略

useEffect(() => {
  document.addEventListener('keydown', fn, modal); // 第3引数を修正

return () => {
  document.removeEventListener('keydown', fn, modal); // 同上
};
```

テストのほうも、モーダルに配置するボタンは「モーダル指定」に修正します。

```tsx
<div>モーダルのテスト</div>
<div>
  <FunctionKeyButton shortcutKey={'F1'} onClick={() => handleClick('modal F1')} modal>ボタン</FunctionKeyButton>
  <FunctionKeyButton shortcutKey={'F2'} onClick={() => handleClick('modal F2')} modal>ボタン</FunctionKeyButton>
  <Button onClick={handleClickModal}>閉じる</Button>
</div>

```

さきほどと同じようにF1〜F3まで順番にファンクションキーを押していきます。

```
page F1
page F3
```

さきほどと同様な動きのままで影響ありません！
そしていよいよモーダル表示時です。

```
modal F1
modal F2
```

無事にモーダルのほうのイベントリスナーが実行されました。
これで、機能要件の１つめ、２つめはOKとなりました。

最後は３つめ、モーダルを閉じたらモーダルで登録したイベントリスナーは削除し、もとの親画面のイベントリスナーが有効になることです。

実はここもNGでした。。
モーダルのイベントリスナー登録の条件が間違っており、閉じたときに正常にイベントが削除されていませんでした。
原因は、[前回の記事](https://qiita.com/rikayoshimura/items/32ad70acae178b39f8cc)と同様のため詳細は書きませんが、`useCallback`のdepsに、リスナーを指定してしまったこと。イベントリスナー登録時と解除時で中身が変わってしまうため、リスナーの解除ができてませんでした。

こちらもこのように書き換えて、機能要件３つめも無事解決となりました。

```tsx
    const handleAfterOpen = React.useCallback(() => {
        document.addEventListener('keydown', handleModalKeyDown, true);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    const handleAfterClose = React.useCallback(() => {
        document.removeEventListener('keydown', handleModalKeyDown, true);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

```


#### 参考サイト
https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener