LoginSignup
1
1

More than 3 years have passed since last update.

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

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

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

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

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

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

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

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

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

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

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には、モーダルを開いたとき・閉じたときのコールバック関数を定義できるとわかったので、モーダルが開いたタイミングでイベントリスナーを登録し、閉じたタイミングで解除する、としました。

// モーダルを開いた時
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を割り当てたボタンを配置します。

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でよいようにします。

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); // 同上
};

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

<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

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

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

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

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

    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
    }, []);

参考サイト

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1