定義
要素を直接ドラッグして移動したりサイズを調整する UI(カスタムドラッグ・リサイズ)を作るとき、マウスではうまく動いたコードがモバイルのタッチで何度も壊れる問題を扱う記事だ。
中核となるツールは Pointer Events だ。これはマウス・タッチ・ペンを一つのイベント体系にまとめたブラウザ API で、押下は pointerdown、移動は pointermove、離しは pointerup、そしてブラウザがジェスチャーを横取りしてキャンセルすることは pointercancel として入ってくる。モバイルで安定して動作させるには、この上に四つを併せて設計する必要がある: 入力タイプ(マウスかタッチか)ごとにいつドラッグを開始するか決めるポリシー、pointercancel が来たときの状態復元、touch-action(ブラウザ既定のスクロール・ズーム動作を無効にするか決める CSS プロパティ)、そしてマウスがなく hover(ポインターを乗せておく状態)を使えないタッチ機器用の代替 UI だ。
なぜ必要か
まず問題の状況から見るとこうだ。ブラウザはタッチ画面でスクロール・ズーム・長押しで出るメニュー(ロングプレスメニュー)を既定で優先する。そのためアプリが「このハンドル領域では私がジェスチャーを取る」と宣言しないと、ドラッグ途中でブラウザがスクロールと判断し、pointerup(離し)なしで pointercancel(キャンセル)だけを送ってしまい、ドラッグが中途半端に途切れる。またマウス hover だけに依存して表示していたハンドルは、hover の概念が曖昧なタッチでちらついたり消えなかったりする。
こうした症状は原因が幾重にも重なって現れるため、以下のように原因ごと(有効化ポリシー・キャンセル処理・touch-action・hover 代替)に分けて一つずつ捉える必要がある。
動作原理
pointerdown:event.pointerTypeで"mouse"vs"touch"を分岐。マウスは小さな移動(例: 4px)で即セッション開始、タッチはロングプレス(例: 220ms)またはより大きな移動(例: 10px、ジッター許容)後に開始。- セッションの寿命:
AbortControllersignal にpointermove、pointerup、pointercancelをすべて登録。cancel はスクロールスチール・電話・マルチタッチでpointerupなしに終了するとき — コミットせず復元。 touch-action: none: ドラッグ/リサイズハンドルの CSS。ないとブラウザが縦スクロールを取る。contextmenu: ドラッグセッション中にwindowの capture-phase でstopPropagationにより Android のロングプレスメニューを遮断。マウス右クリック(button !== 0)はセッションを作らなければ影響なし。- Coarse hover:
@media (hover: none) and (pointer: coarse)で hover-リビール UI を常時表示するか、別のタップ経路を提供する。タッチは sticky-hover で「タップ → 現れる → 別の場所をタップ → 消える」が繰り返される。 - Click 抑制: ドラッグ終了後の
onceclick リスナーは、タッチに trailingclickがなく次のタップまで飲み込みうる — 短い timeout で解除する。
実務適用
外側クリックで閉じる処理は mousedown だけでなく pointerdown も扱う。
トレードオフ
| 選択 | 利得 | コスト |
|---|---|---|
| タッチのロングプレス有効化 | スクロールとの衝突を減らす | ドラッグ開始がやや遅い |
touch-action: none | ジェスチャーの所有 | 該当領域でスクロール不可 — ヒットゾーン最小化 |
| coarse 常時表示 | ちらつき除去 | 画面密度・視覚的ノイズ |
| HTML5 DnD API | ブラウザネイティブ | タッチ・カスタム UX の制約が大きい |
使ってはいけない場合
- ページ全体に
touch-action: none— スクロール麻痺。 pointercancelなしでpointerupだけ処理 — 固着状態。- hover-only ハンドルだけで coarse フォールバックなし。
よくある間違い
- デスクトップと同じ 4px 移動閾値をタッチに適用。
defaultPreventedでイベント抑制を検証 — 他のハンドラーが常にpreventDefaultすると信号がない; bubble-phase probe を使う。- jsdom で
scrollBy/elementFromPointが未実装でテスト失敗 — 環境を stub。
関連概念
- html5-drag-and-drop — ネイティブ DnD vs Pointer Events
- selectionchange-floating-ui — 選択・フローティング UI とポインター