AngularJSでmousedown/touchstartが発火しない時に読む徹底ガイド

AngularJSでmousedown/touchstartが発火しない時に読む徹底ガイド

はじめに (対象読者・この記事でわかること)

本記事は、AngularJS(1.x)を用いたWebアプリケーションで、mousedownやtouchstartが想定通りに発火しない問題に悩むフロントエンド/フルスタックエンジニア、レガシープロダクトを保守する開発者を対象としています。読了後には、イベントが発火しない典型的な原因(イベント抑止、パッシブリスナー、要素の重なり、Angularタッチモジュールの仕様、CSS/ブラウザ挙動など)を切り分ける方法と、ストレスなく再現・修正する具体的な手順が理解できます。また、AngularJS特有のngTouchや$parse、$apply、directive内でのイベント取り扱いの落とし穴と、その代替やベストプラクティスも押さえられます。

開発環境・前提知識

この記事で紹介する内容は、以下の環境で動作確認をしています。

カテゴリ バージョン/情報
OS macOS Sonoma 14.x
言語/FW Node.js 20.x / AngularJS 1.8.x
ライブラリ angular-touch 1.8.x, Hammer.js 2.0.x (任意)

また、この記事を読み進める上で、以下の知識があるとスムーズです。 * DOMイベント伝播(キャプチャ/バブリング)、pointer/mouse/touchイベントの基本 * AngularJSディレクティブ(compile/link)、$scope/$apply/$timeoutの基礎

そもそもなぜ発火しないのか:概要・背景

AngularJS 1.xでは、ngTouch(angular-touch)を導入すると、click遅延回避・ゴーストクリック対策のために内部でtouch系イベントを扱い、場合によっては既定のイベント(mousedownやclick)を抑止・変換します。さらに、iOS Safariのタッチイベント優先処理や、CSSでのpointer-eventsやz-index、overflowのスクロール挙動、passive: trueが既定化されたブラウザの変更などが絡み合い、期待するタイミングでイベントが配信されないことが起こります。

また、AngularJSのディレクティブ内でjQuery liteや生のaddEventListenerを使う際、$scopeの変更を$applyで伝搬しない、あるいはイベントの設置フェーズ(compile/link/$(document).ready)が不適切でDOMが未構築など、フレームワークとのインテグレーションに起因する問題も多発します。本記事では、ブラウザ仕様・CSS・AngularJS/ライブラリの相互作用を切り分け、最短で原因に到達するためのチェックリストと修正パターンを解説します。

実践ガイド:原因の切り分けと実装パターン

ここが記事のメインパートです。端末・ブラウザ・AngularJS設定によって複数の要因が重なることが多いため、再現性ある最小構成での確認から進めます。

ステップ1:最小再現とイベント水位の確認

1) 最小HTMLを用意(AngularJSを読み込まずに確認)

<!doctype html>
<html>
  <body>
    <button id="btn" style="position:relative; z-index:1;">Tap/Click me</button>
    <script>
      const btn = document.getElementById('btn');
      ['touchstart','pointerdown','mousedown','click'].forEach(type => {
        btn.addEventListener(type, e => console.log('fired:', type), {passive:false});
      });
    </script>
  </body>
</html>

期待: 端末ごとにtouchstart/pointerdown/mousedown/clickの順または一部が発火。ここで発火するならブラウザ/DOMの問題は小さく、Angular統合層が疑わしい。

2) AngularJSを導入し、同じ要素にng-clickや自作ディレクティブを追加して再確認。発火が消える/変わるタイミングを特定します。

3) ブラウザDevToolsで以下を確認 - Elementsで要素の重なり: pointer-events:none、z-index、position、overflow/scrollが干渉していないか - Consoleでイベントログ順序 - Renderingパネルでヒットテスト(Layers/Hit-test)やPaintフラッシング - モバイルではTouch emulation、Issuesでパッシブリスナー警告

4) OS/ブラウザ差異 - iOS Safari: touchstart優先、hover非対応、click 300ms遅延(近年はviewport metaで回避可) - Chrome/Android: pointerイベントが標準、パッシブ既定のケースあり

ステップ2:AngularJS/周辺の代表的な落とし穴と対処

1) ngTouchによる抑止/変換 - 症状: touchstartが拾えない、mousedownが来ない、クリック相当の動作が二重/無発火。 - 対策: - ngTouchを一時的に外して再確認。 - ngClickの代わりにpointerdown/pointerupを自前ディレクティブで扱う。 - クリック遅延対策はで代替し、ngTouch依存を減らす。 - 必要箇所のみngTouchを適用する(画面単位モジュール分割)。

2) パッシブリスナー問題 - 症状: preventDefault()が効かず、スクロール優先でtouchstart相当が通らない。 - 対策: addEventListenerで{ passive:false }を明示。AngularJS/jqLiteはオプション未対応のため、rawなaddEventListenerを使うか、polyfillを導入。

3) 要素の重なり/ヒットテスト - 症状: 見えているのにクリック不可。別レイヤが覆っている。 - チェック: DevToolsで:hov強制、pointer-events、z-indexコンテキスト(transform/opacity/positionで新しいスタッキングコンテキストができる)を確認。 - 対策: pointer-events:auto、z-index/positionの見直し、透明オーバーレイの除去。スクロールコンテナにtouch-actionを適切に設定。

4) CSS touch-actionの未設定 - 症状: デフォルトジェスチャ(パン/ズーム)が優先され、pointerdown/mousedownが来ない。 - 対策: 操作したい要素にtouch-action: manipulation or none; を付与。例: ボタン系はmanipulation、カスタムジェスチャはnone。

5) ディレクティブ内スコープ更新漏れ - 症状: イベント自体は発火しているがUIが変わらない/状態が反映されない。 - 対策: 生イベントからスコープ更新時は$scope.$applyまたは$timeoutでダイジェストを起動。$apply中のエラーに注意。

6) ポインタイベントへの統一 - 推奨: mouse/touchの両対応をpointerイベントで一本化。古いiOS Safariでのフォールバックを用意。 - メリット: コードの分岐が減り、レースや二重発火を回避。

ステップ3:ディレクティブ実装の実例(安全なイベント取り扱い)

1) pointerイベントを優先、フォールバック付き

angular.module('app', [])
.directive('pressable', function($timeout) {
  return {
    restrict: 'A',
    scope: { onPress: '&' },
    link: function(scope, el) {
      var node = el[0];
      var down = function(e){
        // 重要: スクロールを制御したい場合に限り
        if (e.cancelable) e.preventDefault();
        // Angularダイジェストを確実に
        scope.$applyAsync(function(){ scope.onPress({ $event: e }); });
      };

      // pointer系があればそれを使う
      if (window.PointerEvent) {
        node.addEventListener('pointerdown', down, { passive:false });
      } else {
        // フォールバック: touch -> mouse の順に登録(重複抑制は必要に応じて追加)
        node.addEventListener('touchstart', down, { passive:false });
        node.addEventListener('mousedown', down, false);
      }

      scope.$on('$destroy', function(){
        if (window.PointerEvent) {
          node.removeEventListener('pointerdown', down, { passive:false });
        } else {
          node.removeEventListener('touchstart', down, { passive:false });
          node.removeEventListener('mousedown', down, false);
        }
      });
    }
  };
});

2) ngTouchとの干渉を避ける - ng-clickを使う要素と、上記pressableを併存させない。 - クリック遅延対策はviewport meta/TurboTap等で代替。

3) スクロールコンテナと競合する場合 - スクロールが必要な親要素にはtouch-action:auto(既定)を保持し、インタラクティブ要素にのみmanipulation/noneを付与。 - ドラッグ操作を実装するときは、pointercancel/pointerupも監視して取りこぼしを防ぐ。

ステップ4:ブラウザ固有対策とチェックリスト

チェックリスト(上から順に確認) - DevToolsで当該要素にヒットしているか(hover状態、イベントログ) - CSS: pointer-eventsがnoneではない、z-indexで覆われていない、transformで新スタックを作っていないか - viewport metaが適切(幅固定、ズーム要件に応じて調整) - touch-actionの指定が要件と一致 - passiveリスナー警告が出ていないか - ngTouchを外すと改善するか - ディレクティブ内で$apply/$digestを呼んでいるか - 生のaddEventListenerを使うなら{ passive:false }で登録しているか - モバイルでの長押しメニュー/選択防止(-webkit-touch-callout: none; user-select: none;)が必要か - 代替としてpointerイベントへ統一できるか

ハマった点やエラー解決

エラー内容:

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/...

解決策: - addEventListener('touchstart', handler, { passive:false }) として再登録。 - AngularJSのjqLiteではオプションが渡せないため、el[0].addEventListenerを使う。 - preventDefaultが不要なら呼ばない(スクロールを阻害しない)。

エラー内容:

$apply already in progress

解決策: - scope.$applyではなく$scope.$applyAsync/$timeoutを使用。 - 既存のイベントハンドラから多重に$applyを呼んでいないか見直す。

症状: - 見えているのに反応しない(特にiOS) 解決策: - 要素または親にpointer-events:autoを付与。 - position/z-index/transformの見直し。 - iOSでclick発火を期待するなら、タップ対象をbutton/inputにしてCSSで見た目を整えるか、pointerdownベースで自前処理へ移行。

まとめ

本記事では、AngularJSでmousedown/touchstartが発火しない問題を、ブラウザ仕様、CSS、ngTouch、イベント登録方法の4レイヤに分けて整理し、再現→切り分け→修正の手順を提示しました。 - 発火しない主因は、ngTouchの抑止、passiveリスナー、要素のヒットテスト失敗、touch-action未設定に集約されやすい。 - 生のaddEventListenerで{ passive:false }、pointerイベントへの統一、$applyAsyncの活用が有効。 - DevToolsでの重なり確認と最小再現の比較により、原因特定が大幅に早まる。

これらにより、端末差やレガシー構成でも安定した入力処理を構築できます。今後は、アクセシビリティやキーボード操作の統合、ドラッグ/ジェスチャの抽象化(Pointer Events対応のユーティリティ化)についても掘り下げる予定です。

参考資料