React modal表示中もglobal shortcutが動く時はrefでguardする
modalを開いているのにArrow keyで背面の選択が動く、Escapeでdialogが閉じると同時にgraph selectionも消える、ということがあります。modalへstopPropagation()を追加しても直りません。
global listenerがcapture phaseに登録されている可能性があります。通常のReact onKeyDownより先に実行されるため、modal側から既に起きたstate変更を取り消せません。
global listenerがcapture phaseか確認する
次の登録を探します。
document.addEventListener("keydown", handleKeyDown, true);
document.addEventListener("keydown", handleKeyDown, { capture: true });
eventはdocumentからtargetへ向かうcapture、target、外側へ戻るbubbleの順で進みます。bubble phaseのstopPropagation()が止められるのは、それより後のbubble handlerだけです。
これはTauriアプリのCtrl+SはpreventDefaultするで扱うbrowser defaultとは別です。今回は2つのapplication handlerが競合しています。
modal stateをrefへ同期する
capture listenerが毎回再登録されなくても最新stateを読めるよう、表示にはstateを使い、値をrefへ同期します。
const [isHelpOpen, setIsHelpOpen] = useState(false);
const isHelpOpenRef = useRef(false);
useEffect(() => {
isHelpOpenRef.current = isHelpOpen;
}, [isHelpOpen]);
overlayが複数ある場合は、個別の例外を増やさず1つのflagへまとめます。
const isOverlayOpen = isHelpOpen || isSearchOpen || isSettingsOpen;
useEffect(() => {
isOverlayOpenRef.current = isOverlayOpen;
}, [isOverlayOpen]);
global shortcutのstate変更より先にreturnする
capture handlerの先頭でrefを確認します。
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isOverlayOpenRef.current) {
return;
}
if (event.key === "Escape") clearSelection();
if (event.key === "ArrowRight") moveSelection(1);
};
document.addEventListener("keydown", handleKeyDown, true);
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [clearSelection, moveSelection]);
guardはpreventDefault()、state write、analytics、shortcut dispatchより前に置きます。overlayがkeyboardを所有している間は、global layerがkeyを受け取らなかった状態にします。
EscapeとTabはmodalだけで処理する
modal固有behaviorはmodalへ残します。
<div
role="dialog"
aria-modal="true"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
}}
>
{/* focusable modal content */}
</div>
ref guardが先行するcapture actionを止め、modal handlerが後続のbubble handlerを止めます。initial focusとfocus trapは別途modal側で実装します。
openとclosedの両方をtestする
次のmatrixで確認します。
| State | Key | Expected result | |---|---|---| | Modal closed | Arrow | Background selection moves | | Modal open | Arrow | Background remains unchanged | | Modal open | Escape | Modal closes once | | Modal open | Tab | Focus stays within the modal | | Modal closed | Escape | Normal global action runs |
automated testではclearSelection、moveSelection、onCloseのcall countをassertします。見た目だけでは背面stateの変更を見逃します。
参考
まとめ
global capture listenerがmodal handlerより先に動く場合、bubble phaseのstopPropagation()では遅すぎます。overlay stateをrefへ同期し、capture handlerの先頭でreturnして、Escape・Tab・focus処理をmodalへ一本化します。