Vue.js 13 min de lecture

Construire une directive v-longpress robuste et accessible en Vue 3

#laravel#vuejs

Directive v-longpress Vue 3 : implémentez un long press fiable avec pointer et clavier, modificateurs, durée, tolérance et nettoyage. Guide pas à pas.

Construire une directive v-longpress robuste et accessible en Vue 3

Une action “appui prolongé” est devenue un standard sur mobile, mais on veut souvent la même expérience sur desktop et au clavier, tout en restant fiable et accessible. Dans ce tutoriel, vous allez créer une directive Vue 3 v-longpress complète, configurable (durée, tolérance), avec modificateurs, support unifié pointer/touch/mouse, prise en charge clavier, et nettoyage irréprochable, prête à être utilisée en production.

Objectif

L’objectif est d’écrire une directive personnalisée v-longpress qui déclenche un handler après un appui prolongé, et qui se comporte correctement sur un bouton, une carte cliquable ou un canvas. Vous implémenterez la gestion des événements pointer pour couvrir souris, tactile et stylet, ainsi que les touches Space et Enter pour l’accessibilité clavier. Vous ajouterez des options de durée, de tolérance au mouvement, des modificateurs .once, .prevent, .stop, et vous annulerez proprement en cas de scroll, blur, ou menu contextuel.

Objectif et prérequis

Nous cherchons à déclencher une action seulement si l’utilisateur maintient réellement son interaction. Par exemple, appuyer 600 ms sur une carte doit ouvrir un menu contextuel custom, mais un simple tap ou un glissement doit annuler. Il faut gérer les faux positifs (petit tremblement de doigt) via une tolérance (en pixels), et interrompre si l’utilisateur scrolle, change d’onglet ou relâche trop tôt. La directive doit également fonctionner avec Space/Enter au clavier, et attribuer automatiquement un rôle ARIA “button” et un tabindex lorsque l’élément n’est pas naturellement interactif, pour assurer la navigation au clavier.

Les fonctionnalités incluent une durée configurable (500 ms par défaut), une tolérance de mouvement (6 px par défaut), les modificateurs .once (débrancher après déclenchement), .prevent (bloquer comportement par défaut pendant l’appui, utile contre le scroll au clavier et le menu contextuel), .stop (stopPropagation), une gestion pointer/touch/mouse unifiée, et des annulations sur scroll, blur, contextmenu, ou changement de visibilité de la page. Vous aurez besoin de Vue 3 et de notions d’événements DOM. TypeScript est facultatif mais recommandé pour clarifier les types.

API de la directive: usage attendu

L’usage de base ressemble à un bouton qui déclenche une fonction quand on maintient la pression:

<button v-longpress="onHold">Maintenir</button>

Dans ce cas, la durée par défaut (500 ms) et la tolérance (6 px) s’appliquent. Pour personnaliser, on peut passer un objet avec handler, duration et tolerance:

<div v-longpress="{ handler: onHold, duration: 600, tolerance: 10 }">Carte</div>

Les modificateurs .once, .prevent et .stop se composent facilement. Par exemple, .prevent évite l’ouverture du menu contextuel natif sur mobile pendant l’appui et empêche le scroll avec Space au clavier:

<div v-longpress.once.prevent.stop="onHold">Appui unique</div>

La valeur de la directive accepte soit une fonction, soit un objet de la forme { handler, duration?: number, tolerance?: number }. Le handler reçoit l’événement original et un détail additionnel accessible via evt.detail?.longpress, contenant la durée configurée et l’événement de départ. Concrètement, dans votre handler, vous pouvez lire:

function onHold(e: Event) {
  const info = (e as any).detail?.longpress
  console.log('Longpress!', info?.duration, info?.startEvent?.type)
}

Squelette de la directive (fichier directives/longpress.ts)

On commence par définir les types, un WeakMap pour stocker l’état par élément (timeouts, options, flags…), les hooks de directive (beforeMount, updated, unmounted), et une garde SSR pour ne rien faire côté serveur. Le WeakMap garantit l’absence de fuite mémoire lorsque des éléments sont retirés du DOM, et centralise le nettoyage.

// directives/longpress.ts
import type { DirectiveBinding, ObjectDirective } from 'vue'

type LongPressValue =
  | ((e: Event) => void)
  | { handler: (e: Event) => void; duration?: number; tolerance?: number }

type Options = {
  duration: number
  tolerance: number
  prevent: boolean
  stop: boolean
  once: boolean
}

type State = {
  handler: (e: Event) => void
  options: Options
  timeoutId: number | null
  pressed: boolean
  startX: number
  startY: number
  pointerId: number | null
  keyActive: boolean
  cleanup: (() => void) | null
}

const states = new WeakMap<HTMLElement, State>()
const DEFAULTS: Options = { duration: 500, tolerance: 6, prevent: false, stop: false, once: false }

Gestion pointer: down/move/up/cancel avec tolérance de mouvement

Le cœur de l’appui prolongé côté pointeur consiste à armer un timer au pointerdown, surveiller le mouvement au pointermove en annulant si la distance dépasse la tolérance, et annuler au pointerup/cancel/leave s’il n’a pas encore déclenché. Pendant l’appui, si .prevent est actif, on peut appeler preventDefault pour minimiser les effets indésirables (p. ex. sélection de texte). On capte aussi le pointeur via setPointerCapture pour s’assurer de recevoir les événements correspondants, et on n’écoute que le pointerId en cours.

function startPointer(el: HTMLElement, e: PointerEvent, st: State) {
  if (st.pressed) return
  st.pressed = true
  st.pointerId = e.pointerId
  st.startX = e.clientX
  st.startY = e.clientY

  if (st.options.prevent) e.preventDefault()
  if (st.options.stop) e.stopPropagation()

  const fire = () => {
    st.timeoutId = null
    const detail = { longpress: { duration: st.options.duration, startEvent: e } }
    const custom = new CustomEvent('longpress', { detail })
    el.dispatchEvent(custom)
    st.handler(Object.assign(e, { detail }))
    if (st.options.once) detach(el)
  }

  st.timeoutId = window.setTimeout(fire, st.options.duration)
  el.setPointerCapture?.(e.pointerId)
}

function movePointer(el: HTMLElement, e: PointerEvent, st: State) {
  if (!st.pressed) return
  if (st.pointerId != null && e.pointerId !== st.pointerId) return
  const dx = e.clientX - st.startX
  const dy = e.clientY - st.startY
  if (Math.hypot(dx, dy) > st.options.tolerance) cancel(el, st)
}

function endPointer(el: HTMLElement, e: PointerEvent, st: State) {
  if (st.pointerId != null && e.pointerId !== st.pointerId) return
  cancel(el, st)
}

function cancel(_el: HTMLElement, st: State) {
  if (st.timeoutId != null) {
    clearTimeout(st.timeoutId)
    st.timeoutId = null
  }
  st.pressed = false
  st.pointerId = null
}

Avec cette logique, un léger tremblement n’annule pas l’appui si le déplacement reste sous la tolérance, mais un glissement ou un scroll met fin proprement au timer. Vous pourrez annuler aussi sur scroll ou blur au niveau de l’assemblage des listeners.

Support clavier (Space/Enter) et accessibilité

Pour l’accessibilité, un utilisateur clavier doit pouvoir déclencher l’appui prolongé via Space ou Enter. On arme donc un timer au keydown, on annule au keyup/blur, et on empêche le scroll de la page sur Space si le modificateur .prevent est utilisé. De plus, si la directive est appliquée à un élément non interactif (div, span…), on lui ajoute role="button" et tabindex="0" afin qu’il soit focusable et annoncé correctement par les lecteurs d’écran.

function startKey(el: HTMLElement, e: KeyboardEvent, st: State) {
  if (st.keyActive) return
  if (e.key !== ' ' && e.key !== 'Enter') return
  st.keyActive = true

  if (st.options.prevent) e.preventDefault()
  if (st.options.stop) e.stopPropagation()

  st.timeoutId = window.setTimeout(() => {
    const detail = { longpress: { duration: st.options.duration, startEvent: e } }
    const custom = new CustomEvent('longpress', { detail })
    el.dispatchEvent(custom)
    st.handler(Object.assign(e, { detail }))
    if (st.options.once) detach(el)
  }, st.options.duration)
}

function endKey(_el: HTMLElement, _e: KeyboardEvent, st: State) {
  if (!st.keyActive) return
  st.keyActive = false
  if (st.timeoutId != null) {
    clearTimeout(st.timeoutId)
    st.timeoutId = null
  }
}

Appliquez des attributs d’accessibilité dès l’initialisation si l’élément n’est pas un bouton, un lien ou un input. Cela rend la directive “drop-in” sans imposer de markup spécifique:

function ensureA11y(el: HTMLElement) {
  if (!/^(BUTTON|A|INPUT)$/.test(el.tagName)) {
    if (!el.hasAttribute('role')) el.setAttribute('role', 'button')
    if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0')
  }
}

Assembler: attacher/détacher les listeners et fusionner les options

L’assemblage consiste à fusionner la value et les modificateurs en un objet Options, à attacher tous les listeners pertinents (pointerdown/move/up/cancel/leave/lostpointercapture, keydown/keyup, contextmenu, blur, scroll, visibilitychange), et à stocker une fonction de nettoyage unique dans l’état. Le handler peut être mis à jour dynamiquement à chaque updated, ce qui permet de changer la durée ou la tolérance sans ré-attacher les événements.

function resolve(binding: DirectiveBinding<LongPressValue>): { handler: (e: Event) => void; options: Options } {
  const v = binding.value as any
  const handler = typeof v === 'function' ? v : v?.handler
  if (typeof handler !== 'function') throw new Error('v-longpress: handler requis')

  const duration = typeof v?.duration === 'number' ? v.duration : DEFAULTS.duration
  const tolerance = typeof v?.tolerance === 'number' ? v.tolerance : DEFAULTS.tolerance

  const opts: Options = {
    duration,
    tolerance,
    prevent: !!binding.modifiers.prevent,
    stop: !!binding.modifiers.stop,
    once: !!binding.modifiers.once
  }
  return { handler, options: opts }
}

function attach(el: HTMLElement, st: State) {
  const onPD = (e: PointerEvent) => startPointer(el, e, st)
  const onPM = (e: PointerEvent) => movePointer(el, e, st)
  const onPU = (e: PointerEvent) => endPointer(el, e, st)

  const onKD = (e: KeyboardEvent) => startKey(el, e, st)
  const onKU = (e: KeyboardEvent) => endKey(el, e, st)

  const onBlur = () => {
    endKey(el, {} as any, st)
    cancel(el, st)
  }

  const onCtx = (e: Event) => {
    if (!st.pressed) return
    if (st.options.prevent) {
      e.preventDefault()
    } else {
      // Si le menu contextuel natif s’ouvre, on annule le longpress
      cancel(el, st)
    }
  }

  const onScroll = () => cancel(el, st)

  const onWinBlur = () => cancel(el, st)

  const onVisibility = () => {
    if (document.visibilityState !== 'visible') cancel(el, st)
  }

  el.addEventListener('pointerdown', onPD)
  el.addEventListener('pointermove', onPM)
  el.addEventListener('pointerup', onPU)
  el.addEventListener('pointercancel', onPU)
  el.addEventListener('pointerleave', onPU)
  el.addEventListener('lostpointercapture', onPU)

  el.addEventListener('keydown', onKD)
  el.addEventListener('keyup', onKU)

  // blur en capture pour capter la perte de focus interne
  el.addEventListener('blur', onBlur, true)

  // iOS/Android: menu contextuel pendant l’appui
  el.addEventListener('contextmenu', onCtx)

  // Annuler pendant le scroll intentionnel
  window.addEventListener('scroll', onScroll, { passive: true, capture: true })

  // Annuler si on change d’onglet/fenêtre
  window.addEventListener('blur', onWinBlur, true)
  document.addEventListener('visibilitychange', onVisibility, true)

  st.cleanup = () => {
    cancel(el, st)

    el.removeEventListener('pointerdown', onPD)
    el.removeEventListener('pointermove', onPM)
    el.removeEventListener('pointerup', onPU)
    el.removeEventListener('pointercancel', onPU)
    el.removeEventListener('pointerleave', onPU)
    el.removeEventListener('lostpointercapture', onPU)

    el.removeEventListener('keydown', onKD)
    el.removeEventListener('keyup', onKU)

    el.removeEventListener('blur', onBlur, true)
    el.removeEventListener('contextmenu', onCtx)

    window.removeEventListener('scroll', onScroll, true)
    window.removeEventListener('blur', onWinBlur, true)
    document.removeEventListener('visibilitychange', onVisibility, true)
  }
}

function detach(el: HTMLElement) {
  const st = states.get(el)
  if (!st) return
  st.cleanup?.()
  states.delete(el)
}

Il ne reste plus qu’à exposer la directive complète avec les hooks Vue. Notez la garde SSR qui sort immédiatement si window est indisponible.

export const vLongpress: ObjectDirective<HTMLElement, LongPressValue> = {
  beforeMount(el, binding) {
    if (typeof window === 'undefined') return
    const { handler, options } = resolve(binding)
    ensureA11y(el)
    const st: State = {
      handler,
      options,
      timeoutId: null,
      pressed: false,
      startX: 0,
      startY: 0,
      pointerId: null,
      keyActive: false,
      cleanup: null
    }
    states.set(el, st)
    attach(el, st)
  },
  updated(el, binding) {
    const st = states.get(el)
    if (!st) return
    const { handler, options } = resolve(binding)
    st.handler = handler
    st.options = options
  },
  unmounted(el) {
    detach(el)
  }
}

Vous disposez maintenant d’une directive cohérente, avec une gestion propre des états et un nettoyage garanti.

Edge cases et fiabilité

Lors d’un changement de fenêtre ou d’onglet, un appui en cours ne doit pas déclencher inopinément; c’est pourquoi la directive annule sur window blur et visibilitychange. Sur iOS, un appui long déclenche souvent le menu contextuel natif. Avec le modificateur .prevent, on empêche son apparition pendant l’appui; sinon, on annule votre longpress dès que le menu contextuel s’ouvre, ce qui évite un double comportement. En toutes circonstances, le composant doit être débranché sans fuite mémoire: la fonction cleanup supprime l’ensemble des listeners et efface le timeout restant. Enfin, pour respecter la scrollabilité, tout scroll active une annulation immédiate, ce qui évite de déclencher après un geste de défilement.

Utilisation dans un composant Vue

Vous pouvez enregistrer la directive globalement au démarrage de l’app:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { vLongpress } from './directives/longpress'

const app = createApp(App)
app.directive('longpress', vLongpress)
app.mount('#app')

Ou l’enregistrer localement dans un composant:

<script setup lang="ts">
import { vLongpress } from '@/directives/longpress'

function onHold(e: Event) {
  const info = (e as any).detail?.longpress
  console.log('Longpress!', info?.duration, info?.startEvent?.type)
}
</script>

<template>
  <button v-longpress.prevent="onHold">Maintenez 500ms</button>
  <div v-longpress.once="{ handler: onHold, duration: 800, tolerance: 12 }">
    Carte avec appui long personnalisé
  </div>
</template>

Dans cet exemple, le bouton bloque le scroll au clavier grâce à .prevent et déclenche après 500 ms. La carte n’autorisera qu’un seul déclenchement avec .once et attendra 800 ms tout en tolérant un petit mouvement de 12 px.

Bonus: composable useLongPress pour les cas non-DOM

Lorsque vous gérez vous-même les événements et coordonnées (par exemple sur un canvas, une carte WebGL ou un composant custom), un composable simplifie la logique de longpress sans dépendre directement de la directive. Vous pouvez l’appeler depuis vos handlers onDown/onMove/onUp en lui passant les coordonnées.

// composables/useLongPress.ts
export function useLongPress(opts: { duration?: number; tolerance?: number; onTrigger: (info: any) => void }) {
  const options = { duration: 500, tolerance: 6, ...opts }
  let tid: number | null = null
  let sx = 0
  let sy = 0
  let active = false

  return {
    onDown(x: number, y: number) {
      active = true
      sx = x
      sy = y
      tid = window.setTimeout(() => {
        opts.onTrigger({ duration: options.duration })
        tid = null
        active = false
      }, options.duration)
    },
    onMove(x: number, y: number) {
      if (!active) return
      if (Math.hypot(x - sx, y - sy) > options.tolerance) {
        if (tid != null) clearTimeout(tid)
        tid = null
        active = false
      }
    },
    onUp() {
      if (tid != null) clearTimeout(tid)
      tid = null
      active = false
    }
  }
}

Ce composable est pratique pour synchroniser des interactions pointer ou même des gestes personnalisés. Vous contrôlez l’entrée (coordonnées, timing) et ne dépendez pas du DOM pour déclencher votre action.

Tests rapides et check-list

Testez d’abord la durée par défaut de 500 ms en maintenant un bouton; vous devez observer le déclenchement au bon moment, ni trop tôt ni trop tard. Augmentez ensuite la durée à 800 ms via une option et vérifiez que le délai correspond bien à votre attente. Évaluez la tolérance en faisant un léger tremblement du curseur ou du doigt: l’action doit se produire tant que le déplacement reste faible, tandis qu’un glissement net doit annuler la séquence. Vérifiez au clavier que Space et Enter déclenchent après la durée prévue, que le relâchement de la touche annule si vous ne tenez pas assez longtemps, et que le modificateur .prevent bloque le scroll de la page quand vous appuyez sur Space. Sur mobile, observez que .prevent empêche le menu contextuel natif pendant l’appui et que, sans .prevent, l’ouverture de ce menu annule votre longpress. Enfin, regardez côté mémoire que les listeners sont bien retirés quand le composant est démonté: naviguez entre pages, déclenchez l’appui, puis inspectez avec DevTools (Performance ou heap snapshot) pour confirmer l’absence de fuites.

Checklist

Relisez le code pour vérifier la cohérence des options, l’existence de la garde SSR et la présence des annulations sur scroll, blur, visibilitychange et contextmenu. Testez toutes les combinaisons de modificateurs (.once, .prevent, .stop) et les variations de durée et de tolérance. Exécutez les snippets d’exemples dans un composant réel et confirmez le comportement attendu sur desktop, mobile et au clavier. Publiez la directive en l’intégrant à votre base de composants ou à une librairie interne, et documentez son API et ses bonnes pratiques d’accessibilité.

Conclusion

Vous avez construit une directive v-longpress Vue 3 prête pour la production, qui unifie pointer, tactile, souris et clavier, expose une API simple, respecte l’accessibilité, et nettoie proprement ses effets. Cette approche vous évite les pièges habituels (menus contextuels, faux positifs liés au tremblement, scroll inattendu) tout en restant flexible grâce aux options et modificateurs. Vous pouvez l’utiliser telle quelle, l’étendre (par exemple en ajoutant un retour visuel pendant l’appui), ou la décliner en composables pour des contextes non-DOM.

Ressources