11/**
22 * 触覚フィードバック(バイブレーション)ユーティリティ。
3- * web-haptics ライブラリを使用して iOS / Android 両対応の haptic を提供する。
43 *
5- * iOS (Safari 17.4+):
6- * web-haptics が内部で input[type=checkbox][switch] + label を DOM に作成し、
7- * label.click() で Taptic Engine を起動する。
4+ * iOS Safari (17.4+):
5+ * input[type=checkbox][switch] + label.click() を使用して Taptic Engine を起動する。
86 *
97 * 重要な制約:
10- * - web-haptics はデフォルトで要素を display:none にするが、iOS では
11- * display:none の要素から発火した click() では Taptic Engine が動作しない。
12- * - そのため attachHapticToButton の初回呼び出し時に DOM 要素を事前生成し、
13- * display:none → 画面外配置 (position:fixed left:-9999px) に変更する。
14- * - trigger() の同期部分はユーザージェスチャーコンテキスト内で実行されるため、
15- * pointerdown ハンドラから void trigger() で呼び出すことで iOS でも動作する。
8+ * - iOS では async 関数内から label.click() を呼び出すと、await より前でも
9+ * ユーザージェスチャーコンテキストが失われる場合がある。
10+ * - そのため label.click() は pointerdown ハンドラから直接・同期的に呼び出す。
11+ * - display:none の要素では Taptic Engine が動作しないため、
12+ * position:fixed で画面外に配置して描画ツリーに残す。
1613 *
1714 * Android 等:
18- * Vibration API (navigator.vibrate) を使用する。DOM 要素は作成されない。
15+ * Vibration API (navigator.vibrate) を使用する。
1916 */
2017
21- import { WebHaptics } from "web-haptics" ;
22-
23- // シングルトンインスタンス(DOM 要素はインスタンス内で管理される)
24- let haptics : WebHaptics | null = null ;
25- // DOM 事前初期化済みフラグ(複数コンポーネントから呼ばれても一度だけ実行)
26- let preInitDone = false ;
27-
28- function getHaptics ( ) : WebHaptics {
29- if ( ! haptics ) {
30- haptics = new WebHaptics ( ) ;
31- }
32- return haptics ;
33- }
18+ // iOS向け label 要素(シングルトン)
19+ let iosLabel : HTMLLabelElement | null = null ;
20+ let domInitialized = false ;
3421
3522/**
36- * web-haptics が作成した DOM 要素の display:none を画面外配置に変更する。
37- * iOS では display:none の要素への click() では Taptic Engine が動作しないため。
23+ * iOS向け DOM 要素を初期化する。
24+ * display:none を使わず position:fixed で画面外に配置して描画ツリーに残す。
25+ * ユーザージェスチャーの前に呼び出してよい(DOM 生成のみ、haptic は発火しない)。
3826 */
39- function fixHapticsElementStyles ( ) : void {
40- if ( typeof document === "undefined" ) return ;
41- const label = document . querySelector ( 'label[for^="web-haptics"]' ) ;
42- if ( ! label ) return ; // Android は Vibration API を使用するため DOM 要素なし
43- // display:none → 画面外に配置(描画ツリーに残したまま非表示にする)
44- ( label as HTMLElement ) . style . cssText =
45- "position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;" ;
46- const input = label . querySelector ( "input" ) ;
47- if ( input ) {
48- // all:initial でリセット後に switch 要素として描画させる
49- ( input as HTMLInputElement ) . style . cssText =
50- "all:initial;appearance:auto;display:block;" ;
27+ function initDOM ( ) : void {
28+ if ( domInitialized || typeof document === "undefined" ) return ;
29+ domInitialized = true ;
30+
31+ // Android / Chrome 系は Vibration API を使用するため DOM 要素不要
32+ if (
33+ typeof navigator !== "undefined" &&
34+ typeof navigator . vibrate === "function"
35+ ) {
36+ return ;
5137 }
38+
39+ const label = document . createElement ( "label" ) ;
40+ const input = document . createElement ( "input" ) ;
41+ input . type = "checkbox" ;
42+ input . setAttribute ( "switch" , "" ) ;
43+ input . id = "haptic-switch" ;
44+ label . setAttribute ( "for" , "haptic-switch" ) ;
45+
46+ // 画面外に配置(display:none は iOS で haptic が動作しないため使用しない)
47+ label . style . cssText =
48+ "position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;pointer-events:none;" ;
49+
50+ label . appendChild ( input ) ;
51+ document . body . appendChild ( label ) ;
52+ iosLabel = label ;
5253}
5354
5455/**
55- * DOM 要素を事前初期化する。
56- * trigger() を非ジェスチャーコンテキストで呼び出すことで iOS 向け DOM 要素を生成し、
57- * その後 display:none を画面外配置に変更する。
58- * (非ジェスチャーなので haptic は発火しないが DOM だけ作られる)
56+ * ユーザージェスチャーコンテキスト内で同期的に haptic を発火する。
57+ * 必ず同期的なイベントハンドラ(pointerdown 等)から直接呼び出すこと。
58+ * async 関数内から呼び出すと iOS で gesture context が失われる。
5959 */
60- function preInitHaptics ( ) : void {
61- if ( preInitDone || typeof document === "undefined" ) return ;
62- preInitDone = true ;
63- // trigger() の同期部分が実行されることで iOS 向け DOM 要素が生成される
64- void getHaptics ( ) . trigger ( "light" ) ;
65- // trigger() の同期部分完了後(await 前)に DOM 要素が存在するのでスタイルを修正
66- fixHapticsElementStyles ( ) ;
60+ function triggerHaptic ( ) : void {
61+ if (
62+ typeof navigator !== "undefined" &&
63+ typeof navigator . vibrate === "function"
64+ ) {
65+ // Android / Chrome: Vibration API
66+ navigator . vibrate ( 25 ) ;
67+ return ;
68+ }
69+ // iOS Safari: label.click() でTaptic Engineを起動
70+ iosLabel ?. click ( ) ;
6771}
6872
6973/**
70- * ボタン要素に pointerdown ハンドラを付与し、ユーザージェスチャーの中で振動させる 。
74+ * ボタン要素に pointerdown ハンドラを付与し、haptic を発火する 。
7175 * iOS Safari ではユーザージェスチャーコンテキストが必要なため、
72- * 非同期の成功コールバックではなくボタン押下時点で発火する 。
76+ * pointerdown で同期的に発火する 。
7377 */
7478export function attachHapticToButton ( button : HTMLElement ) : ( ) => void {
75- // 初回呼び出し時に iOS 向け DOM 要素を事前初期化してスタイルを修正する
76- preInitHaptics ( ) ;
79+ // iOS向け DOM 要素を事前生成(ユーザージェスチャーの前に実行してOK)
80+ initDOM ( ) ;
7781
7882 function handler ( ) {
7983 // div ラッパーに attach した場合も含め、内包ボタンが disabled なら無視する
@@ -82,9 +86,10 @@ export function attachHapticToButton(button: HTMLElement): () => void {
8286 ? ( button as HTMLButtonElement )
8387 : button . querySelector ( "button" ) ;
8488 if ( btn ?. disabled ) return ;
85- // void で Promise を明示的に破棄(同期部分のみジェスチャーコンテキストで実行される )
86- void getHaptics ( ) . trigger ( "medium" ) ;
89+ // 同期的に haptic を発火(async 経由にしないことで iOS の gesture context を保持 )
90+ triggerHaptic ( ) ;
8791 }
92+
8893 button . addEventListener ( "pointerdown" , handler ) ;
8994 // クリーンアップ関数を返す
9095 return ( ) => button . removeEventListener ( "pointerdown" , handler ) ;
0 commit comments