On est habitué à écouter le clic sur un élément, beaucoup moins à écouter le clic hors d'un élément. Parfois on a besoin de faire ça pourtant : fermer une modale au clic hors de celle-ci, un tooltip qui fonctionne au clic qu'on voudrait masquer en cliquant sur la page, etc. Je vous montre comment faire !

La théorie

Déjà il faut savoir qu'il n'existe aucun événement natif pour faire ça : il n'existe aucun moyen natif de dire "je veux écouter les clicks hors de cet élément", là où on peut écouter que le curseur entre et sort d'un élément par exemple.

Petit rappel sur la propagation des Event dans le DOM. Prenons l'exemple de code suivant :

<html>
    <body>
        <header>...</header>
        <main>
            <section id="one">
                <button>Button 1</button>
            </section>
            <section id="two">
                <button>Button 2</button>
            </section>
        </main>
        <footer>...</footer>
    </body>
</html>

Si on clique sur le bouton "Button 1", on va avoir un événement MouseEvent qui sera émis sur l'élément <button>, puis il sera émis sur son parent, l'élément <section id="one">, puis à nouveau à son parent <main>, puis à <body> puis finalement à <html>. Peu importe sur lequel de ces éléments on place notre écouteur (onclick="..." en natif html, document.addEventListener() en JavaScript vanilla, (click)="" en Angular, onClick={} en React, etc.) on pourra capter l'événement du clic. Imaginons qu'on change <body> en <body onclick="console.log('Clicked')"> : à chaque clic quelque part sur la page, on aura Clicked qui va apparaitre dans la console du navigateur.

À partir de ce constat on peut donc déduire la solution suivante pour écouter un clic hors d'un élément : écouter tous les clicks sur la page, inspecter la cible de chaque MouseEvent pour déterminer si le clic a été effectué sur un élément ou un enfant de l'élément ou non.

En vanilla

Pour écrire ça de manière très simple en vanilla JavaScript (même en TypeScript d'ailleurs !) :

function listenOutsideClick(target: HTMLElement, callback: VoidFunction) {
  document.addEventListener('click', (event: MouseEvent) => {
    if (!target.contains(event.target as HTMLElement)) {
      callback();
    }
  });
}

Une fonction toute simple : on lui passe une target qui correspond à l'élément qui englobe la zone sur laquelle on ne veut pas cliquer, et une fonction callback. Dans cette fonction on va ajouter un listener sur l'événement click au niveau du document. À chaque MouseEvent on regarde si le clic a été fait sur un élément contenu dans la target, si ce n'est pas le cas on appelle la fonction callback.

Preuve en image que cette version naïve suffit :

Démonstration du code

Pourquoi je parle de version naïve ? En fait il manque pour moi 2 éléments pour que ce soit parfaitement utilisable : on masque l'événement (je n'ai pas vraiment de cas d'usage où on en aurait besoin, mais pourquoi le masquer ?), et surtout on a aucun moyen d'arrêter l'écoute !

Donc je vous propose de changer le code comme ceci :

function listenOutsideClick(target: HTMLElement, callback: (event: MouseEvent) => void) {
  const listener = (event: MouseEvent) => {
    if (!target.contains(event.target as HTMLElement)) {
      callback(event);
    }
  };
  document.addEventListener('click', listener);
  return () => document.removeEventListener('click', listener);
}

Maintenant on peut éventuellement manipuler notre événement et on peut aussi arrêter d'écouter en appelant le callback qu'on renvoie. Ce second point est souvent oublié mais plus on a d'écouteur (listener), plus on risque d'attaquer les performances de notre application, ici on ajoute un écouteur sur le document entier, donc il sera beaucoup sollicité, autant éviter d'en avoir qui ne servent à rien !

Et avec Angular ? React ?

Je ne traite que ces deux-là, mais je pense que vous n'aurez pas grand mal à adapter à votre framework préféré !

React Hook

Pour React, pour moi la solution la plus simple est de créer un custom hook !

export function useOutsideClick<TElement extends HTMLElement>(
  callback: (event: MouseEvent) => void
) {
  const targetRef = useRef<TElement | null>(null);

  useEffect(() => {
    if (targetRef.current == undefined) {
      return;
    }
    return listenOutsideClick(targetRef.current, callback);
  }, [targetRef]);

  return targetRef;
}

Du coup un hook tout simple. On crée une ref qui devra être associé à l'élément conteneur, donc on la renvoie. Un useEffect pour gérer le listener et le désabonnement quand on démonte le composant (via la fonction de nettoyage, je vous avais dit que c'était important !).

À noter ici que j'ai volontairement utilisé un == undefined et pas un === undefined. On a pas tout à fait le même résultat, dans le cas du == une valeur null validera l'égalité aussi, mais pas dans le second cas !

À l'usage c'est pas bien compliqué non plus :

function App() {
  const [color, setColor] = useState('red');
  const containerRef = useOutsideClick<HTMLDivElement>(() => setColor('red'));

  return (
    <div
      ref={containerRef}
      onClick={() => setColor('green')}
      style={{ border: '2px solid ' + color }}
    >
      ...
    </div>
  );
}

Angular Directive !

Côté Angular j'opte directement pour une directive !

@Directive({
  selector: '[ngxOutsideClick]',
  standalone: true,
})
export class NgxOutsideClickDirective implements OnInit, OnDestroy {
  @Output() ngxOutsideClick = new EventEmitter<MouseEvent>();
  private elementRef = inject(ElementRef);
  private clearListener: VoidFunction | undefined;

  ngOnInit() {
    this.clearListener = listenOutsideClick(
      this.elementRef.nativeElement,
      (event) => this.ngxOutsideClick.emit(event)
    );
  }

  ngOnDestroy() {
    this.clearListener?.();
  }
}

Comme pour React rien de compliqué : on agit en 2 phases, à l'initialisation on commence à écouter les clics en mettant de côté dans le scope de la directive la fonction de nettoyage, qu'on appelle dans le ngOnDestroy.

À l'utilisation :

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, NgxOutsideClickDirective],
  template: `
    <div
      (ngxOutsideClick)="color = 'red'"
      (click)="color = 'green'"
      [style]="'border: 1px solid ' + color"
    >
      ...
    </div>
  `,
  styleUrl: './app.component.css',
})
export class AppComponent {
  color = 'red';
}

Conclusion

Comme je n'ai pas trop copier-coller du code d'un projet à l'autre (toujours des problèmes pour faire évoluer ce code !), j'ai packagé les morceaux de code que je vous ai montré !

  • Version vanilla : npm i @anthonypena/outside-click
  • Version React : npm i @anthonypena/react-outside-click
  • Version Angular : npm i ngx-outside-click (je suis en train de repackager une nouvelle version de cette lib en standalone, etc. mais cette ancienne version que j'ai faite il y a quelques années fonctionnent toujours aussi bien ! 😎)

Comme d'habitude : n'hésitez pas à me dire si vous avez appris quelque chose et si ce que je partage vous est utile ! 🤓

Crédit photo : https://unsplash.com/fr/photos/photographie-en-contre-plongee-dun-batiment-BY66BwIa9Bg