11/**
22 * Register a tap handler that works on both touch and non-touch devices.
33 *
4- * iOS Safari can fail to synthesise `click` after touch events on dynamically
5- * created elements. This listens for `touchend` (fires on finger lift — correct
6- * button press-then-release UX) and `click` (fallback for mouse/keyboard).
7- * A guard prevents double-fire when both events occur.
4+ * ## Why module-level touchFired (not per-element)
5+ *
6+ * On touch devices, after touchend fires, the browser synthesises a click
7+ * ~4ms later at the same coordinates. When a touchend handler opens an
8+ * overlay (higher z-index) at those coordinates, the synthesised click
9+ * hit-tests against the overlay instead of the original button — closing
10+ * it immediately. A per-element guard only prevents double-fire on the
11+ * SAME element; module-level state prevents cross-element synthesised clicks.
12+ *
13+ * ## Why not preventDefault() on touchend
14+ *
15+ * The W3C Touch Events spec says preventDefault() on touchend suppresses
16+ * synthesised mouse events — the "correct" solution. But it was removed
17+ * (d40fa46) because without synthesised mousedown, focus stays on the
18+ * terminal textarea and Android re-shows the keyboard when toolbar buttons
19+ * are pressed. Restoring it would require blur() + reworking keyboard state
20+ * preservation (isKeyboardOpen/conditionalFocus) across 13 call sites.
21+ *
22+ * ## Why not Pointer Events (pointerup)
23+ *
24+ * preventDefault() on pointerup does NOT suppress compatibility mouse
25+ * events per the Pointer Events spec. The browser still synthesises
26+ * mousedown/mouseup/click from the underlying touch events.
827 */
9- export function onTap ( element : HTMLElement , handler : ( e : Event ) => void ) : void {
10- let touchFired = false
1128
29+ let touchFired = false
30+ let touchGuardTimer : ReturnType < typeof setTimeout > | null = null
31+
32+ export function onTap ( element : HTMLElement , handler : ( e : Event ) => void ) : void {
1233 element . addEventListener ( 'touchend' , ( e : TouchEvent ) => {
1334 // No preventDefault — allow the browser to synthesise mousedown/click,
1435 // which transfers focus to the button and away from the terminal textarea.
1536 // Without this, Android re-shows the keyboard when buttons are pressed.
16- // The touchFired guard below prevents the handler from double-firing.
37+ // The module-level touchFired guard below prevents the handler from
38+ // double-firing on this element AND prevents cross-element synthesised
39+ // clicks from closing overlays that just opened at the same coordinates.
1740 touchFired = true
1841 handler ( e )
19- setTimeout ( ( ) => {
42+ if ( touchGuardTimer !== null ) clearTimeout ( touchGuardTimer )
43+ touchGuardTimer = setTimeout ( ( ) => {
2044 touchFired = false
45+ touchGuardTimer = null
2146 } , 400 )
2247 } )
2348
@@ -26,3 +51,12 @@ export function onTap(element: HTMLElement, handler: (e: Event) => void): void {
2651 handler ( e )
2752 } )
2853}
54+
55+ /** Reset module-level touch guard state — test-only. */
56+ export function _resetTouchGuard ( ) : void {
57+ touchFired = false
58+ if ( touchGuardTimer !== null ) {
59+ clearTimeout ( touchGuardTimer )
60+ touchGuardTimer = null
61+ }
62+ }
0 commit comments