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 (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.

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. Mixing causes the handler to fire twice (once from the directive, once from the browser's compatibility click).

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).