Pour ouvrir un panneau ou un menu dans une LiveView, on a en général deux habitudes.

La première passe par le serveur : un phx-click, un booléen dans les assigns et une classe conditionnelle dans le template. Cela fonctionne, mais chaque clic fait un aller-retour, et sur une connexion lente le panneau met une ou deux secondes à s’ouvrir.

La seconde passe par un hook, c’est-à-dire un peu de JavaScript qui modifie la page directement dans le navigateur. C’est instantané, mais il faut écrire et maintenir du JavaScript pour une opération aussi simple que masquer un bloc.

LiveView propose une troisième voie, déjà présente dans le code que Phoenix génère : le module Phoenix.LiveView.JS. On décrit en Elixir ce que le navigateur doit faire, et le navigateur l’exécute seul, sans contacter le serveur.

Cet article construit avec lui un panneau de filtres, puis une fenêtre de confirmation, en répondant à quatre questions :

  • que contient exactement une « commande JS » ?
  • comment garder ouvert un panneau que le serveur met à jour chaque seconde ?
  • comment indiquer à un lecteur d’écran que le panneau est ouvert, et où placer le focus ?
  • que peut-on en vérifier dans un test ?

Tout ce qui suit a été exécuté avec Phoenix 1.8.16, LiveView 1.2.13 et Elixir 1.19.5, et chaque comportement décrit a été observé dans Chromium.

Une commande JS est une liste de consignes

Avant de construire quoi que ce soit, regardons ce que renvoie une fonction de ce module. Dans iex :

iex> alias Phoenix.LiveView.JS
iex> JS.hide(to: "#filtres")
%Phoenix.LiveView.JS{ops: [["hide", %{to: "#filtres"}]]}

L’appel n’a rien masqué. Il a seulement renvoyé une structure, dont le champ ops contient une liste avec une seule consigne : cacher l’élément #filtres.

Si l’on ajoute une deuxième fonction avec le pipe, sa consigne s’ajoute à la suite de la première :

iex> JS.hide(to: "#filtres") |> JS.add_class("actif", to: "#bouton-filtres")
%Phoenix.LiveView.JS{
  ops: [
    ["hide", %{to: "#filtres"}],
    ["add_class", %{names: ["actif"], to: "#bouton-filtres"}]
  ]
}

Si vous avez déjà utilisé Ecto.Multi, vous connaissez ce fonctionnement. Un Multi est une liste d’opérations qu’on construit en Elixir, étape par étape, et que Repo.transaction exécute plus tard. Une structure %JS{} est une liste de consignes qu’on construit de la même façon, et c’est le navigateur qui l’exécutera, au moment du clic.

Pour arriver jusqu’au navigateur, la liste est convertie en JSON et placée dans l’attribut. Le template suivant :

<button phx-click={JS.hide(to: "#filtres")}>Fermer</button>

produit ce HTML, où &quot; est simplement un guillemet échappé :

<button phx-click="[[&quot;hide&quot;,{&quot;to&quot;:&quot;#filtres&quot;}]]">Fermer</button>

Au clic, le JavaScript de LiveView, déjà chargé par app.js, lit cet attribut. Il y trouve une liste de consignes au lieu d’un nom d’événement, et les exécute une par une. On peut voir cela comme un hook déjà écrit par l’équipe de LiveView, qu’on pilote depuis le template.

Ranger ses consignes dans une fonction

Puisqu’une commande est une valeur, on peut la renvoyer depuis une fonction. C’est ce que fait cette fonction, que nous utiliserons plus loin :

defp fermer_filtres(js \\ %JS{}) do
  js
  |> JS.hide(to: "#filtres")
  |> JS.set_attribute({"aria-expanded", "false"}, to: "#bouton-filtres")
end

Elle reçoit une liste de consignes, js, y ajoute deux consignes, et renvoie la liste complétée. Le \\ %JS{} donne une valeur par défaut : appelée sans argument, la fonction part d’une liste vide.

Cela permet deux usages :

<button phx-click={fermer_filtres()}>Fermer</button>
<button phx-click={JS.push("effacer") |> fermer_filtres()}>Effacer et fermer</button>

Dans le premier bouton, la liste contient les deux consignes de fermer_filtres. Dans le second, elle commence par push, qui envoie l’événement "effacer" au serveur, et fermer_filtres ajoute ses deux consignes derrière.

C’est la forme des fonctions qui prennent et renvoient un Ecto.Multi, et c’est aussi celle des fonctions show/2 et hide/2 que Phoenix 1.8 génère dans core_components.ex. Le message flash généré se ferme d’ailleurs avec JS.push suivi de hide/2.

Le panneau, d’abord par le serveur

La page de l’exemple liste des commandes en attente, avec un compteur que le serveur met à jour chaque seconde. Au-dessus, un bouton « Filtres » ouvre un panneau.

Voici la version par le serveur :

def mount(_params, _session, socket) do
  if connected?(socket), do: :timer.send_interval(1000, :tick)
  {:ok, assign(socket, en_attente: 12, filtres_ouverts: false)}
end

def handle_info(:tick, socket), do: {:noreply, update(socket, :en_attente, &(&1 + 1))}

def handle_event("basculer", _params, socket) do
  {:noreply, update(socket, :filtres_ouverts, &(!&1))}
end
<p>Commandes en attente : {@en_attente}</p>
<button phx-click="basculer">Filtres</button>
<div id="filtres" class={!@filtres_ouverts && "hidden"}>…</div>

En local, l’ouverture paraît immédiate. Pour voir ce que vit un utilisateur sur une connexion mobile, LiveView fournit un simulateur de latence, qu’on active dans la console du navigateur :

liveSocket.enableLatencySim(800)

Avec 800 ms de latence simulée, le panneau met environ 1,8 s à s’ouvrir, le temps que le clic parte au serveur et que la réponse revienne.

Avec JS, la même ouverture tient en une ligne, et le serveur n’a plus besoin de l’assign filtres_ouverts ni de l’événement "basculer" :

<button phx-click={JS.toggle(to: "#filtres")}>Filtres</button>
<div id="filtres" class="hidden">…</div>

Avec la même latence simulée, le panneau s’ouvre en quelques dizaines de millisecondes, puisque rien ne part au serveur.

Pourquoi le panneau reste ouvert malgré les mises à jour

On pourrait obtenir la même rapidité avec un simple attribut onclick qui retire la classe hidden. Le panneau s’ouvrirait aussi vite, mais il se refermerait une seconde plus tard, à la mise à jour suivante du compteur.

Pour comprendre pourquoi, il faut savoir ce que fait LiveView à chaque mise à jour. Il compare la page affichée avec ce que le serveur vient de rendre, et corrige les différences. Pour le serveur, le panneau porte la classe hidden. Une classe retirée par un onclick est donc une différence, et LiveView la corrige en remettant la classe.

Les consignes de JS ne subissent pas ce sort. Le JavaScript de LiveView garde la trace de ce qu’elles ont modifié sur chaque élément, et réapplique ces modifications après chaque mise à jour. Dans le navigateur, le panneau ouvert par JS.toggle reste ouvert, seconde après seconde, pendant que le compteur continue d’avancer.

C’est la principale raison de préférer JS à du JavaScript écrit à la main pour ce genre de geste.

Indiquer au lecteur d’écran que le panneau est ouvert

Visuellement, le panneau est terminé. Une personne qui utilise un lecteur d’écran entend le libellé du bouton, mais rien ne lui dit si le panneau est ouvert ou fermé.

Pour cela, le bouton porte deux attributs, décrits dans le motif « disclosure » des pratiques WAI-ARIA : aria-controls, qui indique quel élément le bouton contrôle, et aria-expanded, qui vaut "true" quand le panneau est ouvert et "false" quand il est fermé. Le lecteur d’écran lit ce second attribut et annonce l’état du panneau.

Il faut donc que aria-expanded change à chaque clic. C’est le rôle de JS.toggle_attribute, qui fait alterner un attribut entre deux valeurs :

<div id="zone-filtres" phx-click-away={fermer_filtres()}>
  <button
    id="bouton-filtres"
    aria-expanded="false"
    aria-controls="filtres"
    phx-click={
      JS.toggle(to: "#filtres")
      |> JS.toggle_attribute({"aria-expanded", "true", "false"})
    }
  >
    Filtres
  </button>

  <div id="filtres" class="hidden">
    <label>Statut <select>…</select></label>
    <label>Client <input type="text" /></label>
  </div>
</div>

Au clic, la liste contient deux consignes : afficher ou masquer #filtres, puis faire alterner aria-expanded entre "true" et "false". Sans option to:, toggle_attribute agit sur l’élément cliqué, ici le bouton.

phx-click-away exécute ses consignes quand on clique en dehors de l’élément qui le porte. Ici, il appelle fermer_filtres(), la fonction vue plus haut, qui masque le panneau et remet aria-expanded à "false".

Il est placé sur la div qui contient à la fois le bouton et le panneau. Placé sur le panneau seul, un clic sur le bouton comptait comme un clic à l’extérieur : le click-away et le toggle s’exécutaient tous les deux, et aria-expanded finissait par annoncer l’inverse de ce qu’on voyait.

Le clavier n’a besoin de rien de plus. Un <button> s’active déjà avec Entrée ou Espace, et la touche Tab mène ensuite aux champs du panneau. Dans le navigateur, aria-expanded suit l’état du panneau à la souris comme au clavier, et sa valeur résiste aux mises à jour du compteur.

On pourrait être tenté d’ajouter JS.focus_first(to: "#filtres") pour placer directement le curseur dans le premier champ. Dans un toggle, ce n’est pas une bonne idée, car toute la liste s’exécute aussi à la fermeture : focus_first vise alors un panneau caché, et le focus se retrouve sur <body>, tout en haut de la page.

Une fenêtre de confirmation qui rend le focus

Le focus désigne l’élément qui reçoit les frappes du clavier. Pour une fenêtre de confirmation, on attend deux choses : qu’il entre dans la fenêtre à l’ouverture, et qu’il revienne sur le bouton d’origine à la fermeture.

JS.push_focus mémorise l’élément qui a le focus, et JS.pop_focus lui redonne. On s’en sert dans deux fonctions, une pour ouvrir et une pour fermer :

defp ouvrir_confirmation(js \\ %JS{}) do
  js
  |> JS.push_focus()
  |> JS.show(to: "#confirmation")
  |> JS.focus_first(to: "#confirmation")
end

defp fermer_confirmation(js \\ %JS{}) do
  js
  |> JS.hide(to: "#confirmation")
  |> JS.pop_focus()
end

À l’ouverture, trois consignes s’exécutent dans l’ordre : mémoriser l’élément qui a le focus, c’est-à-dire le bouton qu’on vient d’activer, afficher la fenêtre, puis donner le focus à son premier élément focalisable. À la fermeture, on masque la fenêtre et on rend le focus à l’élément mémorisé.

Le template branche ces deux fonctions :

<button phx-click={ouvrir_confirmation()}>Vider les filtres</button>

<div
  id="confirmation"
  class="hidden"
  role="dialog"
  aria-modal="true"
  aria-labelledby="titre-confirmation"
  phx-window-keydown={fermer_confirmation()}
  phx-key="Escape"
>
  <h2 id="titre-confirmation">Vider les filtres enregistrés ?</h2>
  <button phx-click={fermer_confirmation()}>Annuler</button>
  <button phx-click={JS.push("vider_filtres") |> fermer_confirmation()}>Vider</button>
</div>

Dans le navigateur, la fenêtre s’ouvre avec le focus sur « Annuler ». Échap ou « Annuler » la referment, et le focus revient sur « Vider les filtres ».

phx-window-keydown écoute le clavier sur toute la page, même quand la fenêtre est fermée. Une pression sur Échap ailleurs dans la page exécute donc fermer_confirmation(). C’est sans conséquence, car pop_focus ne fait rien quand aucun focus n’a été mémorisé. Un JS.focus(to: ...) à la même place, en revanche, déplacerait le focus à chaque pression d’Échap.

Pour une fenêtre complète, il faut encore empêcher la touche Tab d’en sortir tant qu’elle est ouverte. LiveView fournit pour cela le composant <.focus_wrap>, qui s’utilise avec ces mêmes commandes.

Prévenir le serveur sans attendre sa réponse

Certains clics concernent bien le serveur. Archiver une commande en fait partie, mais l’utilisateur n’a pas besoin d’attendre la réponse pour voir la ligne disparaître.

JS.push envoie un événement au serveur, comme un phx-click ordinaire, et peut être suivi d’autres consignes :

<ul id="liste">
  <li :for={commande <- @commandes} id={"commande-#{commande.id}"}>
    Commande {commande.id}
    <button phx-click={
      JS.push("archiver", value: %{id: commande.id}, loading: "#liste")
      |> JS.hide(to: "#commande-#{commande.id}")
    }>
      Archiver
    </button>
  </li>
</ul>

Au clic, l’événement part au serveur et la ligne est masquée aussitôt. Pendant l’aller-retour, l’élément désigné par loading: porte la classe phx-click-loading, qu’on peut utiliser en CSS pour signaler un traitement en cours. Quand le serveur répond, il retire la ligne de son propre rendu, et la page redevient cohérente des deux côtés.

Dans le navigateur, le serveur reçoit %{"id" => 2, "value" => ""}. La valeur passée à JS.push voyage en JSON, donc un entier reste un entier, et le navigateur ajoute la propriété value du bouton, vide ici. Un motif %{"id" => id} dans handle_event ignore cette clé.

Ce qu’un test LiveView peut vérifier

Phoenix.LiveViewTest lit le même JSON que le navigateur. Sur le bouton « Archiver », render_click trouve la consigne push et envoie l’événement avec sa valeur. Il ignore le hide, qui ne concerne que le navigateur. Une différence est à connaître : le test envoie %{"id" => 2}, sans la clé "value" qu’ajoute le navigateur.

Sur le bouton « Filtres », il n’y a rien à envoyer au serveur, et le test échoue avec ce message :

** (ArgumentError) no push or navigation command found within JS commands: [["toggle",{"to":"#filtres"}]]

Ce message signale qu’un bouton purement client n’a rien à tester côté serveur. La même erreur apparaît avec JS.exec, qui exécute des consignes rangées dans l’attribut d’un autre élément : le navigateur suit ce renvoi, mais render_click ne le suit pas. Dans ce cas, render_hook(vue, "evenement", %{}) envoie l’événement directement.

Après un JS.toggle, le HTML rendu par le serveur contient toujours class="hidden". Un test LiveView ne peut donc pas savoir si le panneau est visible, ni où se trouve le focus. Pour vérifier ces comportements, il faut un navigateur piloté, par exemple avec Wallaby ou Playwright.

Les autres consignes disponibles

Le module contient d’autres fonctions, regroupées ici par usage :

Besoin Fonctions
Montrer et masquer, avec transition show, hide, toggle, transition
Classes CSS add_class, remove_class, toggle_class
Attributs, dont ARIA set_attribute, remove_attribute, toggle_attribute, ignore_attributes
Focus focus, focus_first, push_focus, pop_focus
Parler au serveur ou naviguer push, navigate, patch
Réutiliser et composer exec, concat
Prévenir du JavaScript à soi dispatch

ignore_attributes demande à LiveView de ne plus corriger certains attributs lors des mises à jour, ce qui sert pour un <dialog> natif dont le navigateur gère lui-même l’attribut open. dispatch déclenche un événement DOM qu’un écouteur JavaScript peut recevoir. C’est la solution quand aucune consigne ne convient, pour faire défiler la page par exemple.

Au-delà, on revient aux hooks, présentés dans Les hooks JavaScript de LiveView.

Lundi matin, dans un vrai projet

Je garderais une règle simple : un état que le serveur n’a pas besoin de connaître se gère avec JS. Un menu ouvert, un onglet affiché ou un panneau déplié n’ont pas leur place dans les assigns.

Je rangerais chaque geste dans une fonction de la forme geste(js \\ %JS{}), à côté de show/2 et hide/2 dans core_components.ex. Le template reste lisible, et ces fonctions se combinent entre elles.

Pour chaque panneau et chaque fenêtre, je vérifierais au clavier trois points : aria-expanded suit-il l’état du panneau, le focus entre-t-il dans la fenêtre à l’ouverture, et revient-il sur le bouton d’origine à la fermeture ? Cela prend une minute, et ce sont des points qu’un audit d’accessibilité contrôle.

Dans les tests LiveView, je ne cliquerais que sur les boutons qui contiennent un push, et je réserverais quelques tests dans un vrai navigateur aux gestes purement clients qui comptent.

Vais-je l’essayer ?

Oui, et pour une raison pratique : la plupart de mes hooks d’affichage peuvent disparaître. Ouvrir un panneau, basculer une classe, déplacer le focus ou masquer une ligne en attendant le serveur se font avec quelques consignes, écrites en Elixir et regroupées dans des fonctions.

Le template montre aussi clairement ce qui part au serveur : un phx-click qui contient un push lui envoie un événement, les autres restent dans le navigateur.

Le notebook qui accompagne cet article affiche ces listes de consignes et rejoue avec Phoenix.LiveViewTest ce que le serveur en reçoit : Phoenix.LiveView.JS à nu : des commandes qui sont des données.

À retenir

  • Une fonction de Phoenix.LiveView.JS n’exécute rien : elle renvoie une structure %JS{} qui contient une liste de consignes, comme un Ecto.Multi contient une liste d’opérations.
  • Le pipe ajoute des consignes à la liste, et une fonction de la forme geste(js \\ %JS{}) permet de réutiliser et de combiner ces gestes.
  • La liste est envoyée en JSON dans l’attribut, et le JavaScript de LiveView l’exécute au clic, sans aller-retour.
  • LiveView corrige à chaque mise à jour les modifications faites à la main dans la page, mais réapplique celles faites par JS.
  • toggle_attribute tient aria-expanded à jour. Dans un toggle, toute la liste s’exécute aussi à la fermeture, donc focus_first n’y a pas sa place.
  • Dans une fenêtre, push_focus, focus_first et pop_focus font entrer le focus puis le rendent au bouton d’origine. pop_focus ne fait rien quand aucun focus n’a été mémorisé.
  • phx-click-away se place sur une zone qui contient le bouton et le panneau, et phx-window-keydown s’exécute même quand l’élément est caché.
  • render_click envoie la consigne push et lève une erreur s’il n’y en a pas. Il ne suit pas JS.exec, ne voit ni la visibilité ni le focus, et n’envoie pas la clé "value" qu’ajoute le navigateur.