Skip to content

Kiosk Tap Directive (v-kiosk-tap)

The Rule

In the kiosk app (upvendo-kiosk), never use @click for interactive elements. Always use v-kiosk-tap.

vue
<!-- ❌ Don't -->
<button @click="handleSubmit">Submit</button>

<!-- ✅ Do -->
<button v-kiosk-tap="handleSubmit">Submit</button>

This rule applies to:

  • Buttons
  • Item cards
  • Modal backdrops
  • Keyboard keys (both in-app keyboards and OTP inputs)
  • Category tabs and navigation
  • Modifier/variant selectors
  • Quantity +/- controls
  • Any element a customer taps

It does not apply to:

  • Online ordering (zestidoo-online-ordering — note the repo name is not upvendo-online-ordering) — regular @click is fine there, it runs in real browsers.
  • Backoffice (upvendo-backoffice) — same, desktop browsers behave normally.
  • Laravel backend / service-orchestrator — unrelated.

Why

Android WebView (and all mobile browsers) suppress the click event when a touch duration exceeds approximately 300ms. The browser reserves long presses for native gestures: text selection, context menus, image drag. The result: a firm press on a button fires touchstarttouchend, but the click event is silently swallowed.

On phones, this rarely matters — people tap quickly. On industrial QSR kiosks, merchants and customers naturally press firmly and longer. The result was the "I pressed the button and nothing happened" complaint from the field that kicked off the whole investigation.

We confirmed the behavior with live instrumentation on production hardware (RK3588, Android 12, WebView 146):

Touch durationclick event fires?
33ms (quick tap)Yes
293ms (medium)Yes
450ms (firm)Sometimes
1636ms (long press)No

touchend and pointerup still fire regardless of duration. Only the high-level click event is suppressed. v-kiosk-tap listens to raw touch events directly, bypassing the browser's gesture classifier, so it fires reliably for any press from 50ms to several seconds.

How v-kiosk-tap works

Source: src/directives/vKioskTap.ts in upvendo-kiosk.

The directive implements a custom tap recognizer:

  1. Listens to touchstart / touchend / touchmove / touchcancel — raw touch events, not browser-abstracted click.
  2. Single-touch only — rejects multi-touch (palm contact, accidental double-touches).
  3. 15px movement threshold — if the finger moves more than 15 pixels during the press, it's treated as a scroll and the tap is discarded.
  4. Bounds check on release — the finger must release within the element's rectangle.
  5. Duration agnostic — quick taps and multi-second holds are both valid as long as start + end are inside the element and movement stays under the threshold.
  6. setPointerCapture on pointerdown — locks the pointer to the element so Android can't hand the gesture off to a parent scroll container.
  7. Closest-ancestor resolution — when nested elements both have v-kiosk-tap, only the innermost one activates. No .stop modifiers needed.
  8. Automatic CSS hardening — applies touch-action: manipulation, user-select: none, -webkit-touch-callout: none, and contextmenu prevention to every element it's attached to.
  9. Visual feedback — opacity dims to 0.85 during the press for instant pressed-state feedback.
  10. A click listener as a desktop/test fallback — the directive is not touch-only. It also binds click, so v-kiosk-tap fires under a real mouse (desktop browsers, jsdom/happy-dom tests). Compatibility clicks synthesised after a touch are filtered out by a 600 ms suppression window (see "Don't mix @click" below).

(Verified: upvendo-kiosk/src/directives/vKioskTap.ts on origin/productionMOVE_THRESHOLD = 15 line 35; single-touch guard line 209; closest-ancestor resolution via the data-kiosk-tap attribute lines 211-216 and 330-333; bounds check lines 179-196; setPointerCapture lines 282-290; CSS hardening lines 391-395; el.style.opacity = '0.85' line 228; listener set lines 358-367.)

Pointer capture is explicitly released — and that's the fix for "taps die, scrolling still works"

The directive calls setPointerCapture() on pointerdown (touch pointers only) so Android can't hand the gesture to a parent scroller. It then always releases the capture on pointerup and pointercancel, because Android WebView's automatic release is unreliable and a lingering capture wedges all subsequent tap and click delivery on the page — the field symptom being "buttons stopped responding but I can still scroll". A stuck capture is also the source of the touchend-fallthrough misses, where touchend's changedTouches omits the captured touch.

If you are debugging a kiosk that has gone tap-dead mid-session, this is the first thing to suspect. (Verified: vKioskTap.ts lines 292-320, including the comment at lines 292-297.)

Usage patterns

Simple handler

vue
<button v-kiosk-tap="submit">Submit</button>

Inline arrow with arguments

vue
<button v-kiosk-tap="() => addItem(product)">
  {{ product.name }}
</button>

Emitting events

vue
<div v-kiosk-tap="() => $emit('selected', item)">
  {{ item.name }}
</div>

Nested interactive elements

No special handling needed — innermost wins automatically.

vue
<div v-kiosk-tap="selectRow">
  <span>{{ label }}</span>
  <!-- Tapping the button fires decrement only, not selectRow -->
  <button v-kiosk-tap="decrement">−</button>
  <button v-kiosk-tap="increment">+</button>
</div>

Respects disabled

vue
<button v-kiosk-tap="submit" :disabled="isLoading">
  Submit
</button>

Backdrop dismiss pattern

Since the directive resolves to the innermost kiosk-tap ancestor, backdrops work correctly as long as interactive children also use v-kiosk-tap:

vue
<div class="fixed inset-0 bg-black/50" v-kiosk-tap="close">
  <div class="modal-content">
    <button v-kiosk-tap="confirm">Confirm</button>  <!-- fires confirm only -->
  </div>
</div>

Rules and pitfalls

Don't mix @click and v-kiosk-tap on the same element

Pick one. v-kiosk-tap handles all press durations and real mouse clicks, so the @click is never needed.

The mechanism is worth understanding, because it is not what it used to be. The directive now attaches its own click listener and guards it with a global 600 ms suppression window (CLICK_SUPPRESSION_WINDOW = 600, line 36): any click arriving within 600 ms of any v-kiosk-tap activation anywhere in the app is treated as a browser compatibility click and dropped. That module-level timestamp (lastGlobalTouchActivation, line 147) is deliberately global, so that tapping one element can't have its compat click swallowed by a different element that mounted mid-gesture — e.g. tapping an item card opens a detail overlay that would otherwise immediately see a fresh click and close itself.

That window protects the directive's click listener. It does not protect a Vue @click binding, which is a separate listener the directive knows nothing about. So on a short tap you still get two invocations: one from the directive's touchend path, one from Vue's @click on the compat click. On a long press you get one, because the compat click is never dispatched — which is exactly the kind of duration-dependent double-fire that is miserable to reproduce.

(Verified: vKioskTap.ts lines 36, 142-147, 198-204, 322-353, 366.)

Related: the directive's updated() hook deliberately only refreshes the binding reference and never recreates handlers or state — recreating them would reset the activation timestamp and let the compat click double-fire. This was the cause of the kiosk on-screen keyboard duplicating characters when an input re-rendered between touchend and click. (Verified: vKioskTap.ts lines 398-404.)

The directive doesn't pass the event object

If your old @click handler used $event:

vue
<!-- Old -->
<button @click="(e) => handle(e)">X</button>

<!-- New: don't rely on the event -->
<button v-kiosk-tap="() => handle()">X</button>

If a handler calls $event.preventDefault() or $event.stopPropagation(), remove those calls — the directive handles bubbling via the closest-ancestor logic, and it never dispatches a cancellable browser event in the first place. This caused a real bug in the quantity selector where toggleBtn was calling $event.preventDefault() on an undefined event, throwing silently and breaking the +/- buttons.

Native <input type="checkbox"> or <input type="radio">

These inputs are also affected by click suppression, but v-kiosk-tap can't replace them directly (the directive doesn't know about input semantics). The pattern we use:

  1. Make the input purely visual: pointer-events: none on the input and its wrapper.
  2. Put v-kiosk-tap on the parent row/container.
  3. The parent handler toggles the input's bound value.

See src/components/CustomCheckbox.vue in upvendo-kiosk for the reference implementation.

@click.stop and @click.self with no handler

These are just propagation stoppers, not interactive elements. Leave them alone — v-kiosk-tap's closest-ancestor logic makes them redundant but also harmless.

Helper listeners like @click="resetTimer"

If an element has both @click and @touchstart firing the same helper (e.g. idle timer reset), you can leave it alone. The @touchstart companion catches the event path that @click misses on long presses.

Developer checklist

When writing or modifying an interactive component in upvendo-kiosk:

  • [ ] Using v-kiosk-tap instead of @click?
  • [ ] Touch target at least 45x45 CSS pixels for comfort on industrial touchscreens?
  • [ ] Press gives visible feedback within 100ms? (Directive dims opacity automatically; consider adding more if needed.)
  • [ ] If inside a scrollable container, does scroll still work? (Movement > 15px cancels the tap automatically.)
  • [ ] Any new native <input> made pointer-events: none with tap handled by parent?
  • [ ] Handler doesn't reference $event or call preventDefault/stopPropagation?
  • [ ] If nesting interactive elements, closest-ancestor logic will route correctly?

Testing on-device

To verify a new interactive element on real kiosk hardware:

  1. Build the dev APK:

    bash
    cd upvendo-kiosk/android
    JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDevDebug
  2. Install on a kiosk over WiFi:

    bash
    adb connect <kiosk-ip>:5555
    adb -s <kiosk-ip>:5555 install -r android/app/build/outputs/apk/dev/debug/app-dev-debug.apk
  3. Stream live console logs (filters only WebView output from noise):

    bash
    adb -s <kiosk-ip>:5555 logcat Capacitor/Console:I "*:S"
  4. Manually test with quick / medium / long presses on the element.

  5. Watch for any event that doesn't fire. If you need to add temporary console.log debug statements, first disable drop_console in vite.config.mts (revert before shipping).

Built-in tap-miss instrumentation

You usually don't need console.log — the directive already records every non-activating tap.

On-device ring buffer. The last 20 misses are kept in a module-level array exposed as window.__kioskTapMisses. Attach DevTools to a tethered kiosk and read it directly. Each record carries outcome, pressDuration, finalDx / finalDy, maxDx / maxDy, pointerCancelled, elementTag, a 40-character elementText snippet, the element's rectWidth / rectHeight, and a timestamp — enough to tell "the customer's finger drifted 22 px" apart from "the recognizer lost the gesture". (Verified: vKioskTap.ts lines 46-59 for the record shape, 81-100 for population, MISS_BUFFER_SIZE = 20 line 63, window.__kioskTapMisses line 100.)

Outcomes. Six values: activated, moved, out-of-bounds, disabled, touchend-fallthrough, touchcancel (lines 38-45). activated is never recorded — only failures are.

What reaches Sentry. Only touchend-fallthrough and touchcancel are escalated, as a kiosk-tap-miss message tagged with tap_outcome, tap_pointer_cancelled, and tap_element_tag. moved, out-of-bounds, and disabled are expected user behaviour (dragging to scroll, sliding a finger off, pressing a disabled button) and stay in the local buffer only — sending them drowned the real signal in scroll noise. In dev builds nothing is sent; the record is console.warned as [kiosk-tap-miss] instead. (Verified: REPORTED_OUTCOMES at vKioskTap.ts lines 65-73, the dev branch at lines 102-106, the escalation guard at line 110, and Sentry.captureMessage at lines 113-123. Rationale ref: UPVENDO-FRONTEND-K4.)

So: if the field reports dead buttons, check Sentry for kiosk-tap-miss with tap_outcome: touchend-fallthrough before changing any component code — that outcome means the recognizer itself lost the gesture, most often to a stuck pointer capture.