Google Sheetsの選択・編集・フォーカスをどう見分けるか
非同期command完了が新しいユーザー操作のfocusを奪わないよう、selection・DOM focus・result UI ownershipを分離する。
Google Sheets上にキーボード操作を追加するChrome拡張Spexを作っています。
この種の拡張で意外と壊れやすいのが、DOM focusです。
コマンド自体は成功しているのに、その直後から文字入力できない。ダイアログを開いたのに、少し遅れてセルへfocusが戻る。マウスで別セルを触ったのに、古い非同期処理が元のセルへfocusを奪い返す。
どれも一見小さい不具合ですが、Spreadsheetのようにキーボード操作が連続するUIでは致命的です。
Spexでは最終的に、focusを単なる「処理後に戻す対象」ではなく、interactionごとに所有権を持つresourceとして扱うようにしました。
最初の実装:finallyでfocusを戻す
最初に考えやすいのは次の実装です。
const previous = document.activeElement;
try {
await executeCommand();
} finally {
if (previous instanceof HTMLElement) {
previous.focus();
}
}
単独の同期処理なら、これでも動きます。
問題はユーザー操作と非同期処理が並行するときです。
例えば、
A: セルの書式変更を開始
A: 結果確認を待つ
B: ユーザーが別のコマンドでダイアログを開く
B: ダイアログへfocus
A: 遅れてverification完了
A: finallyで古いセルへfocus() ← 事故
Aから見れば「元へ戻した」だけですが、ユーザーから見ると新しい操作を古い処理が破壊したことになります。
FocusとSelectionは別物だった
もう一つ混乱しやすかったのが、Google SheetsではDOM focusとSpreadsheet selectionが必ずしも一致しないことです。
- セル選択は残っているが
document.activeElementがBODYへ落ちる - ダイアログを開けば、selectionは残っていてもfocusは結果UIへ移る
- ユーザーが別セルへ移動すれば、古いselection snapshotへ戻してはいけない
そのためSpexでは、
DOM Focus
Spreadsheet Selection
Result UI ownership
を分けて考えています。
「前のactiveElementへ戻す」では意味が足りません。
Commandごとにfocus semanticsを持たせる
コマンドには少なくとも3種類あります。
preserve-cell
Bold、Italic、行挿入など、操作後もSpreadsheetで連続入力したいもの。
execute -> verify -> cell keyboard contextへ戻る
transfer-result
ダイアログ、picker、sidebar、rename editorなど、結果として開いたUIを次に操作するもの。
execute -> result UI open -> result UIへownershipを渡す
leave-current
focusを積極的に操作しない方が安全な特殊ケース。
この区別をCommand contractとして持たせると、adapterの最後で何となく focus() を呼ぶ必要がなくなります。
Focus Lease
Spexでは、セル選択からinteractionを開始したとき、その時点のkeyboard contextを「Focus Lease」として扱います。
概念的には次の情報を持ちます。
type FocusLease = {
interactionId: number;
ownerDocument: Document;
targetIdentity: unknown;
captureReason: string;
isStillValid: () => boolean;
};
重要なのはsnapshotそのものより、今もそのleaseを使ってよいかを検証することです。
復元前には少なくとも、
- このleaseが最新interactionのものか
- targetがまだconnectedか
- selection/contextが変わっていないか
- commandがresult UIへfocus transferしていないか
- command実行中にユーザーが別操作を開始していないか
を確認します。
interactionIdで古い完了を無効化する
Focus Leaseとセットで使っているのが、単調増加する interactionId です。
新しい操作を開始するたびにgenerationを進めます。
interaction 41: Bold開始
interaction 42: Dialogを開く
interaction 41: verification完了
41が完了した時点で最新が42なら、41のcompletionはstaleです。
内部ログへ残すことはできますが、
- focusを戻す
- 最新Toastを書き換える
- 新しいKeyTips stateを消す
といったユーザー向け副作用は禁止します。
この仕組みはfocusだけでなく、Toastやresult handoffのorderingにも使えます。
Pointer handoffも同じ所有権問題
keyboard-first UIを作っていても、ユーザーが最後までkeyboardだけを使うとは限りません。
典型的には、
AltでSpexを開始
-> native Sheets popupを開く
-> 最後はmouseで項目をclick
という操作があります。
このときSpexがpointerdownを「操作キャンセル」としてnative popupまで閉じると不自然です。
逆にSpexのoverlayがpointerを遮断すると、クリックできません。
そこで、
Keyboard -> Pointer
Keyboard -> native popup keyboard
Keyboard -> Escape
を正式なownership handoffとして扱います。
trusted pointerdown が来たら、Spex自身のnavigation stateとKeyTipsは解放する一方、Google Sheetsが開いたnative surfaceは残し、pointer eventをSheetsへ渡します。
「Spexのinteractionを終える」ことと「host UIを閉じる」ことを同一視しないのがポイントでした。
Overlayは原則focusableにしない
Focusのバグは、復元処理だけでなくSpex自身のUIからも起こります。
そのためCore overlayについては原則として、
autofocusしない- 表示だけの要素へ不要な
tabindexを付けない - 表示のためだけに
focus()しない - overlay hostでpointer captureしない
という制約を置いています。
KeyTipsは見える必要がありますが、見えることとfocusを取ることは別です。
テストするのは「最終activeElement」だけではない
Focus preservationのテストでは、単純に最終的なactiveElementだけを見ると競合を取り逃します。
例えば次のシナリオを分けます。
- cell mutation成功後はcell keyboard contextへ戻る
- dialog command成功後はcellへ戻らない
- command中にユーザーが別セルへ移動したら古いcellへ戻らない
- pointer handoff後に古いasync completionがfocusを奪わない
- failure後にresult UIがなければ安全にcell contextへ戻る
特に「古いcompletionが何もしない」ことをテストケースとして持つのが重要でした。
一般化すると「非同期UIに世代番号を持たせる」話だった
SpexではFocus Leaseと呼んでいますが、考え方自体はChrome拡張に限りません。
検索補完、非同期validation、editor extension、command paletteなどでも、
古い処理が新しいUI stateを上書きする
問題は起きます。
対策は「awaitのあとにとりあえずUIを更新する」のではなく、その結果がまだ現在のinteractionに属しているか確認することです。
focusは特に破壊力の大きい副作用なので、ownershipを明示しておく価値がありました。
Spexで実際に使っています
Spexでは、このfocus / interaction ownershipをKeyTips、command execution、native popup handoffなどで共通利用しています。
- Spex: https://spex.kikuta.dev/ja
- Chrome Web Store: https://chromewebstore.google.com/detail/gahnolkboklnjhnaahjdkingoejbepcc
既存WebアプリへUIを差し込むextensionやuserscriptを作る場合、「どうfocusを戻すか」より先に「今focusを戻す権利が誰にあるか」を考えると設計しやすくなると思います。