Appearance
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
@clickis 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 touchstart → touchend, 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 duration | click 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:
- Listens to
touchstart/touchend/touchmove/touchcancel— raw touch events, not browser-abstracted click. - Single-touch only — rejects multi-touch (palm contact, accidental double-touches).
- 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.
- Bounds check on release — the finger must release within the element's rectangle.
- 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.
setPointerCaptureon pointerdown — locks the pointer to the element so Android can't hand the gesture off to a parent scroll container.- Closest-ancestor resolution — when nested elements both have
v-kiosk-tap, only the innermost one activates. No.stopmodifiers needed. - Automatic CSS hardening — applies
touch-action: manipulation,user-select: none,-webkit-touch-callout: none, andcontextmenuprevention to every element it's attached to. - 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:
- Make the input purely visual:
pointer-events: noneon the input and its wrapper. - Put
v-kiosk-tapon the parent row/container. - 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-tapinstead 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>madepointer-events: nonewith tap handled by parent? - [ ] Handler doesn't reference
$eventor callpreventDefault/stopPropagation? - [ ] If nesting interactive elements, closest-ancestor logic will route correctly?
Testing on-device
To verify a new interactive element on real kiosk hardware:
Build the dev APK:
bashcd upvendo-kiosk/android JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDevDebugInstall on a kiosk over WiFi:
bashadb connect <kiosk-ip>:5555 adb -s <kiosk-ip>:5555 install -r android/app/build/outputs/apk/dev/debug/app-dev-debug.apkStream live console logs (filters only WebView output from noise):
bashadb -s <kiosk-ip>:5555 logcat Capacitor/Console:I "*:S"Manually test with quick / medium / long presses on the element.
Watch for any event that doesn't fire. If you need to add temporary
console.logdebug statements, first disabledrop_consoleinvite.config.mts(revert before shipping).
Related
- Full developer guide:
docs/KIOSK_TAP_DIRECTIVE.mdin the upvendo-kiosk repo. - Frontend architecture: Frontends Overview.
- Directive source:
src/directives/vKioskTap.tsin upvendo-kiosk.