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
}, []);
参考サイト