Déclencher des effets une fois le défilement automatisé terminé

Lorsque vous faites défiler l'écran de manière programmatique vers une section ou un élément (en particulier avec behavior: 'smooth'), le déclenchement immédiat d'une action de suivi (par exemple, faire clignoter un état de mise en surbrillance, démarrer une animation d'entrée ou déplacer la sélection) entraîne l'exécution de l'effet alors que la cible est toujours hors de l'écran ou en cours de défilement.

Auparavant, les développeurs devaient deviner la durée de défilement avec setTimeout() (qui se déclenche trop tôt sur les défilements longs et trop tard sur les défilements courts) ou encapsuler manuellement un écouteur d'événements scrollend dans un Promise avec un minuteur de remplacement (car scrollend ne se déclenche pas lorsque la cible est déjà visible et qu'aucun défilement ne se produit).

Les méthodes de défilement programmatique sur Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) et Window (scroll(), scrollTo(), scrollBy()) renvoient un Promise qui se résout lorsque l'opération de défilement est terminée. Si la cible se trouve déjà à la position de défilement demandée et qu'aucun défilement n'est nécessaire, le Promise renvoyé est résolu immédiatement sans blocage.

Implémentation

  1. Marquez le gestionnaire comme async : utilisez une fonction async pour l'écouteur d'événements ou le gestionnaire de navigation qui déclenche le défilement.
  2. Attendre directement la méthode de défilement : appelez await element.scrollIntoView({ behavior: 'smooth', ... }) (ou await element.scrollTo(...), await window.scrollTo(...), etc.). L'exécution est suspendue jusqu'à ce que le navigateur termine l'animation de défilement et que la position de défilement se stabilise.
  3. Appliquer l'effet post-défilement : activez/désactivez la classe de mise en surbrillance, démarrez l'animation ou déplacez le curseur une fois l'expression await terminée.

Défilements interrompus et gestion du focus

  • Gestion des défilements rapides/interrompus : si un nouveau défilement commence sur le même conteneur de défilement avant la fin d'un défilement fluide en cours, le navigateur annule le défilement fluide en cours et résout (ne rejette pas) toutes les promesses de défilement en attente sur ce conteneur. Si vous devez supprimer l'effet post-défilement pour les défilements remplacés lorsqu'un utilisateur clique rapidement, suivez un ID d'opération incrémentiel et revenez rapidement après await si un nouveau défilement a été lancé :
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • Placer le focus sur la cible après le défilement : lorsque vous déplacez le focus clavier vers l'élément cible (ou un titre qu'il contient avec tabindex="-1") après await targetElement.scrollIntoView(...), transmettez { preventScroll: true } à focus() pour que l'appel de focus ne lance pas un deuxième défilement concurrent :
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Contraintes et bonnes pratiques

  • OBLIGATOIRE : attendez le Promise renvoyé directement par scrollIntoView(), scroll(), scrollTo() ou scrollBy() au lieu de deviner les durées de défilement avec setTimeout().
  • NE PAS encapsuler scrollend dans un new Promise() manuel comme implémentation principale : scrollend ne se déclenche pas lorsque la cible est déjà visible et qu'aucune distance de défilement n'est parcourue, tandis que les promesses de la méthode de défilement se résolvent automatiquement dans ce cas.
  • À FAIRE : Transmettez { preventScroll: true } lorsque vous appelez .focus() après avoir attendu une méthode de défilement, afin que le navigateur ne saute pas ou ne défile pas une deuxième fois.
  • À FAIRE : Protégez les effets post-défilement avec un jeton de requête si des interactions utilisateur rapides peuvent déclencher des défilements fluides qui se chevauchent sur le même conteneur, car l'interruption d'un défilement fluide résout (plutôt que de rejeter) la promesse précédente.
  • Bug Chrome connu avec le défilement racine : Chrome présente un bug où la Promise renvoyée par scrollIntoView() se résout trop tôt lorsque c'est le défilement racine du document qui défile. L'attente d'un scrollIntoView() sur un conteneur de défilement imbriqué fonctionne comme prévu. Pour contourner ce bug, n'encapsulez pas votre contenu dans un conteneur de défilement, tel que div avec overflow-y: auto.

Stratégies de remplacement

Dans les navigateurs qui ne sont pas compatibles avec les promesses de méthode de défilement, scrollIntoView(), scroll(), scrollTo() et scrollBy() renvoient undefined au lieu d'un Promise.

  • Amélioration progressive (comportement par défaut de await) : étant donné que await undefined est un code JavaScript valide qui se résout immédiatement lors de la microtâche suivante, l'écriture de await element.scrollIntoView({ behavior: 'smooth' }) ne génère pas d'erreur dans les navigateurs non compatibles. Au lieu de cela, il se dégrade progressivement en exécutant le code post-défilement dès que le défilement commence. Pour les éléments visuels non critiques, comme une mise en surbrillance temporaire, cela est souvent suffisant.
  • Solution de secours fidèle (scrollend + délai avant expiration) : si votre cible de référence n'est pas compatible avec scroll-promises et que l'action après le défilement doit attendre la fin du défilement dans les anciens navigateurs, vérifiez si la valeur renvoyée est un Promise (result instanceof Promise). Lorsque la valeur est undefined, attendez l'événement scrollend sur le conteneur de défilement avec un court délai avant expiration de secours au cas où l'élément est déjà visible et qu'aucun défilement ne se produit :
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}