ほとんどのUIバグは見た目だけの問題です。しかし中には、ユーザーが変えるつもりのなかった数値を書き換えてしまうものがあります。そしてその数値がレバレッジ、注文サイズ、価格だったなら、そのバグはもはや見た目だけの問題ではありません。
ここで紹介するのは私たちが実際に踏んだ2つのバグです。どちらもブラウザとライブラリの挙動が原因で、技術的には仕様に書かれているのに、実際にはほとんど目に見えません。修正は小さなものです。ですが、見つけるのは簡単ではありませんでした。
要点
- フォーカスされた <input type="number"> の上でホイールをスクロールすると、値が加算または減算されます。お金を扱うフィールドでは、これが値を密かに書き換えます。
- 修正は preventDefault() ではなく blur() で行います。blur すると加算はキャンセルされ、ページとモーダルのスクロールは完全にそのまま残ります。
- document.body にポータル化されたRadixポップオーバーは、Radixダイアログの内側ではフォーカスを保持できません。ダイアログのフォーカススコープがフォーカスを外へ引き戻すからです。フィールドは生きているように見えて、すべてのキー入力を食べてしまいます。
- ダイアログ自身の要素をポップオーバーのポータルコンテナとして渡し、トラップされたサブツリーの一部にしてください。
- この修正は自作の click-outside ハンドラを無効化します。コンテンツがポータル化されると、それはもう、あなたが基準にしていたラッパーのDOM子孫ではなくなるからです。
1. ホイールは編集操作であり、それを誰もユーザーに伝えていない
ネイティブの数値入力には、仕様にまでさかのぼる挙動があります。フィールドがフォーカスされているとき、その上でマウスホイールをスクロールすると、値が step 属性のぶんだけ上下します。
ショッピングカートの数量ピッカーなら、これは軽いいらだち程度でしょう。しかし私たちのフォームでは話がまったく違います。問題のフィールドはレバレッジ、取引額、利益分配率、出品価格だからです。ユーザーがフィールドをクリックし、フォームの残りを読もうとしてページをスクロールすると、すでに設定した数値が変わってしまいます。何も知らせてくれません。フォームのバリデーションは通ります。そしてボットは、ユーザーが選んだ覚えのない値で動き始めます。
素朴な修正は、各入力に wheel ハンドラを付けて preventDefault() を呼ぶことです。これは機能しますが、スクロールも壊します。カーソルがたまたま数値フィールドの上にあるとき、ユーザーはページをスクロールできなくなり、それはそれでまったく別の意味で壊れているように感じられます。
より良い修正は、仕様に書かれた前提条件を利用します。加算はフィールドがフォーカスされているときにだけ起きるので、フォーカスを外すだけで十分です。
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
このスニペットには、3つの意図的な選択があります。
preventDefault()ではなくblur()。 スクロールをキャンセルしているのではなく、スクロールを編集に変えている条件を取り除いているのです。ページは通常どおりスクロールし続けます。capture: true。 リスナーはイベントを下りの段階で捕捉するので、内側のコンポーネントが伝播を止めても発火します。passive: true。 これが安全なのは、まさに私たちがpreventDefault()を一切呼ばないからです。そしてリスナーをスクロールのクリティカルパスから外し続けてくれます。
一度マウントするグローバルリスナー1つで、アプリ内のすべての数値入力を、まだ存在しないものまで含めてカバーできます。もう一方の選択肢、つまりフィールドを1つずつパッチする方法は、誰かが新しいフォームを追加して付け忘れた瞬間に破綻します。
2. 入力できないダイアログ内のポップオーバー
2つ目は、あり得ないバグ報告のかたちで現れました。ドロップダウンの中の、さらにモーダルの中の検索ボックスが、見た目上はフォーカスされるのに、そこに打ち込まれたものをすべて無視するというものです。
原因は、2つの正しい挙動が衝突していることでした。
Radixのポップオーバーは、デフォルトでコンテンツを document.body にポータル化します。これは通常のページでは正しい挙動で、overflow やスタッキングコンテキストから抜け出せるからです。Radixのダイアログは自身のサブツリー内にフォーカスをトラップします。これも正しく、モーダルをモーダルたらしめているものです。
この2つを組み合わせると、ポップオーバーのコンテンツはDOM上ではダイアログの外側にあることになります。ダイアログのフォーカススコープは、入ってくるすべてのフォーカスイベントをおおよそ次のようにチェックします。
container.contains(target) ? allow : focus(lastFocusedElement)
検索ボックスはダイアログに含まれていないので、そこに着地するフォーカスイベントはすべて即座に取り消されていました。フィールドは描画され、一瞬キャレットを見せ、キー入力が登録される前にフォーカスを失いました。生きているように見えて、すべてを飲み込んでいたのです。
修正は、ポップオーバーがどこにいるかについて嘘をつくのをやめることです。Radixのポータルは container を受け取れるので、ダイアログの内側に空で高さゼロの div をレンダリングし、そこにポップオーバーをポータル化します。
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
間違えやすい2つの細かい点があります。
- ref ではなく state を使う。 ホストノードは初回レンダリングでは存在せず、それができたときにポータルには再レンダリングが必要です。
useRefはノードを保持しますが、その再レンダリングを引き起こしません。 - ホストはレイアウトに影響してはならない。 空で高さゼロなので、何もずらすことなくダイアログのマークアップ内の都合の良い場所に置けます。
3. 隣のコードを密かに壊す修正
ここが私が最も強く注意を促したい部分です。きれいな修正がリグレッションに変わる場所だからです。
ポータル化されたポップオーバーへ移す前、このドロップダウンには自作の click-outside ハンドラがありました。ラッパーに ref を持ち、document のクリックを監視し、クリックが内側でなければ閉じるというものです。
コンテンツがポータル化されると、それはもうそのラッパーのDOM子孫ではありません。視覚的にはその内側にあるのにです。だからハンドラは、ドロップダウン自身の検索ボックスへのクリックを外側のクリックとして見なし、ユーザーが使おうとしているまさにそのものを閉じてしまいます。
そのハンドラを取り除くことは片付けではなく、修正の一部でした。一般的なルールはこうです。
ポータル化は、DOMの包含関係に基づくあらゆる前提を壊します。 click-outside ハンドラ、
closest()の探索、イベント委譲、CSSの子孫セレクタは、すべて密かに意味を変えます。何かをポータル化したら、包含を前提にしていたコードを探しに行ってください。
この2つに共通するもの
どちらのバグも、「ライブラリは正しく振る舞っている」と「ユーザーは嫌な思いをしている」の隙間に座っています。何もスローされませんでした。何もログに残りませんでした。どちらの場合も、スクリーンショットではコンポーネントは正しく見えました。
私が持ち続けたい一般的な教訓はこうです。
- プラットフォームがどの操作を編集として扱うかを知る。 フォーカスされた数値入力の上のホイールはその1つです。Linuxでの中クリックのペーストもそうです。フィールドがお金を扱うなら、それらを監査してください。
- イベントをキャンセルするより、前提条件を取り除くほうを選ぶ。
blur()がpreventDefault()に勝ったのは、症状を抑えるのではなく原因を狙い、無関係な挙動に手を触れずに済んだからです。 - 失敗のモードが「誰かが忘れる」であるとき、一度、グローバルに解決する。 フィールドごとの修正には有効期限があります。
- ポータル化するときは、包含の前提を監査する。 DOMツリーと視覚的なツリーは一致しなくなり、その2つを混同していたコードはすべて、今や間違っています。
