← ./articles-ja

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が競合しています。

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ではclearSelectionmoveSelectiononCloseのcall countをassertします。見た目だけでは背面stateの変更を見逃します。

参考

まとめ

global capture listenerがmodal handlerより先に動く場合、bubble phaseのstopPropagation()では遅すぎます。overlay stateをrefへ同期し、capture handlerの先頭でreturnして、Escape・Tab・focus処理をmodalへ一本化します。