RGAA et LiveView : ce que le navigateur expose, et ce qu'un test peut vérifier
Section
Retour vers le sommaire des tips Accueil
Mix.install([
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.2"},
{:phoenix_ecto, "~> 4.6"},
{:jason, "~> 1.4"},
{:lazy_html, ">= 0.1.0"}
])
De quoi s'agit-il ?
Un lecteur d'écran ne voit pas la page. Il lit ce que le navigateur en expose pour chaque élément : un rôle (bouton, lien, champ de texte), un nom (le libellé qu'il annonce) et parfois un état (obligatoire, invalide, développé). Ces trois informations viennent du HTML : de la balise choisie, de l'étiquette <label>, et d'attributs comme aria-invalid ou aria-describedby.
Puisqu'elles viennent du HTML, une partie peut se vérifier dans un test LiveView, sans navigateur. Ce notebook monte un petit formulaire, le soumet vide, et vérifie dans le HTML rendu ce qu'exigent quelques critères du RGAA 4.1.2. Il dit aussi ce qu'un tel test ne peut pas voir.
L'article qui l'accompagne présente les grandes lignes du RGAA et six réflexes pour une application Phoenix : Le RGAA sans s'y perdre : six réflexes pour une application Phoenix accessible.
1. Le décor : un endpoint en mémoire
Comme dans les notebooks précédents, un endpoint démarre sans écouter aucun port, et les lignes ExUnit déclarent le processus du notebook comme processus de test, ce qu'exigent les fonctions de Phoenix.LiveViewTest.
Application.put_env(:demo, Demo.Endpoint,
secret_key_base: String.duplicate("s", 64),
live_view: [signing_salt: "accessible"],
pubsub_server: Demo.PubSub,
server: false
)
ExUnit.start(autorun: false)
ExUnit.OnExitHandler.register(self())
repertoire_demo = Path.join(System.tmp_dir!(), "demo/ebin")
File.mkdir_p!(repertoire_demo)
Code.append_path(repertoire_demo)
Logger.configure(level: :warning)
2. Un champ qui annonce son erreur
Voici une version réduite du composant input/1 de core_components.ex, avec la retouche décrite dans l'article. Quand le champ a des erreurs, il reçoit deux attributs : aria-invalid="true", qui le marque comme invalide, et aria-describedby, qui désigne l'élément contenant le message. Le navigateur expose alors ce message comme la description du champ.
defmodule Demo.Composants do
use Phoenix.Component
attr :field, Phoenix.HTML.FormField, required: true
attr :label, :string, required: true
attr :rest, :global
def input(assigns) do
erreurs = if used_input?(assigns.field), do: assigns.field.errors, else: []
assigns =
assigns
|> assign(:id, assigns.field.id)
|> assign(:erreurs, Enum.map(erreurs, fn {message, _options} -> message end))
~H"""
<div>
<label for={@id}>{@label}</label>
<input
type="text"
id={@id}
name={@field.name}
value={@field.value}
aria-invalid={@erreurs != [] && "true"}
aria-describedby={@erreurs != [] && "#{@id}-erreur"}
{@rest}
/>
<p :if={@erreurs != []} id={"#{@id}-erreur"}>{Enum.join(@erreurs, ", ")}</p>
</div>
"""
end
end
Quand il n'y a pas d'erreur, l'expression @erreurs != [] && "true" vaut false, et HEEx n'écrit pas l'attribut du tout. C'est important pour aria-describedby, qui ne doit jamais désigner un élément absent de la page.
La LiveView valide un seul champ, obligatoire, et affiche un message de statut après l'enregistrement.
defmodule Demo.CommandeLive do
use Phoenix.LiveView
import Demo.Composants
@types %{client: :string}
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: :commande), statut: nil)}
end
def handle_event("enregistrer", %{"commande" => params}, socket) do
changeset =
{%{}, @types}
|> Ecto.Changeset.cast(params, Map.keys(@types))
|> Ecto.Changeset.validate_required(:client, message: "doit être renseigné")
statut = if changeset.valid?, do: "Commande créée"
form = to_form(changeset, as: :commande, action: :insert)
{:noreply, assign(socket, form: form, statut: statut)}
end
def render(assigns) do
~H"""
<div id="statut" role="status">{@statut}</div>
<.form for={@form} id="commande" phx-submit="enregistrer">
<.input field={@form[:client]} label="Client (obligatoire)" aria-required="true" />
<button>Enregistrer</button>
</.form>
"""
end
end
La zone role="status" est présente dès le premier rendu, vide. Le message qui y apparaît ensuite est donc un changement dans une zone qui existait déjà, ce que les technologies d'assistance surveillent. Une zone créée en même temps que son message risque de passer inaperçue.
Ecto.Changeset sert ici sans base de données : un changeset sans schéma prend une map de données et une map de types. La dépendance phoenix_ecto permet à to_form de l'accepter.
defmodule Demo.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :navigateur do
plug :fetch_session
end
scope "/" do
pipe_through :navigateur
live "/", Demo.CommandeLive
end
end
defmodule Demo.Endpoint do
use Phoenix.Endpoint, otp_app: :demo
plug Plug.Session, store: :cookie, key: "_demo", signing_salt: "accessible"
plug Demo.Router
end
Supervisor.start_link([{Phoenix.PubSub, name: Demo.PubSub}, Demo.Endpoint],
strategy: :one_for_one
)
3. Soumettre vide, puis lire le HTML
Le module Navigateur ouvre la page et soumet le formulaire. LazyHTML permet ensuite d'interroger le HTML rendu avec des sélecteurs CSS, comme on le ferait dans un test.
defmodule Navigateur do
import Phoenix.ConnTest
import Phoenix.LiveViewTest
@endpoint Demo.Endpoint
def ouvrir(chemin) do
{:ok, vue, _html} = live(build_conn(), chemin)
vue
end
def soumettre(vue, params) do
vue |> form("#commande", commande: params) |> render_submit() |> LazyHTML.from_fragment()
end
end
vue = Navigateur.ouvrir("/")
doc = Navigateur.soumettre(vue, %{client: ""})
champ = LazyHTML.query(doc, "#commande_client")
%{
"aria-invalid" => LazyHTML.attribute(champ, "aria-invalid"),
"aria-describedby" => LazyHTML.attribute(champ, "aria-describedby"),
"aria-required" => LazyHTML.attribute(champ, "aria-required")
}
Le champ se déclare invalide et désigne un identifiant. Reste à vérifier que cet identifiant existe et contient bien le message, ce qui est exactement ce que le critère 11.10 demande : un message d'erreur relié au champ concerné.
[id_message] = LazyHTML.attribute(champ, "aria-describedby")
doc |> LazyHTML.query("##{id_message}") |> LazyHTML.text()
Une fois le champ rempli, l'erreur disparaît, et les deux attributs avec elle. Le message de statut apparaît dans la zone prévue.
doc = Navigateur.soumettre(vue, %{client: "Durand"})
%{
"aria-invalid" => doc |> LazyHTML.query("#commande_client") |> LazyHTML.attribute("aria-invalid"),
"statut" => doc |> LazyHTML.query("[role=status]") |> LazyHTML.text()
}
4. Une vérification qui balaie toute la page
Certains contrôles s'appliquent à tous les éléments d'un type. Le critère 6.2 demande que chaque lien ait un intitulé, et le critère 11.9 que chaque bouton ait un intitulé pertinent. Un test ne juge pas de la pertinence, mais il peut au moins trouver ceux qui n'en ont aucun.
La fonction suivante cherche les liens et les boutons dont ni le texte ni aria-label ne donnent un nom. Elle est essayée sur un fragment qui contient un bouton réduit à une icône, comme ceux du sélecteur de thème généré par Phoenix.
defmodule Verification do
def sans_intitule(doc) do
for element <- LazyHTML.query(doc, "a, button"),
nom = LazyHTML.text(element) <> Enum.join(LazyHTML.attribute(element, "aria-label")),
String.trim(nom) == "" do
LazyHTML.to_html(element)
end
end
end
"""
<a href="/commandes/1">Voir</a>
<button><span class="hero-sun-micro"></span></button>
<button aria-label="Thème sombre"><span class="hero-moon-micro"></span></button>
"""
|> LazyHTML.from_fragment()
|> Verification.sans_intitule()
Seul le bouton sans aria-label est relevé. Cette fonction ne voit pas les images d'un lien, ni le texte masqué par aria-hidden : c'est un filet de sécurité, pas une mesure complète du nom que le navigateur calculera.
5. Ce qu'un test LiveView ne voit pas
Tout ce qui précède se lit dans le HTML. Plusieurs critères du RGAA dépendent en revanche de l'affichage ou de l'usage, et aucun test sur le HTML ne peut les vérifier :
- le contraste entre le texte et son fond (critère 3.2), qui dépend de la feuille de style ;
- la visibilité du focus (critère 10.7) : dans la page générée, le lien « Show » recevait le focus en restant invisible ;
- l'ordre de tabulation (critère 12.8) et l'absence de piège au clavier (critère 12.9) ;
- la pertinence d'un intitulé ou d'un titre de page, qu'un humain doit juger.
Pour l'article, ces points ont été vérifiés dans Chromium, avec axe-core 4.10 et l'arbre d'accessibilité que le navigateur expose, sur une application Phoenix 1.8.16 avec LiveView 1.2.13 :
- avant retouche, axe relève trois boutons sans nom et une image sans alternative sur chaque page, plus un lien sans intitulé sur la page de détail ; après retouche, il n'en relève plus aucun ;
- après un envoi vide, chaque champ est exposé avec
invalidet la description « doit être renseigné » ; - un récapitulatif des erreurs reçoit le focus après un envoi refusé, à condition de différer ce focus, car LiveView rend le focus à l'élément précédent après la mise à jour ;
- le texte d'erreur généré atteint 4,49 pour 1 en thème clair et 2,82 pour 1 en thème sombre, sous le seuil de 4,5 pour 1, et passe au-dessus après un ajustement de
--color-error; - à 320 pixels de large, les pages générées font 528 pixels, et tiennent dans la largeur une fois les en-têtes autorisés à passer à la ligne ;
- le lien d'évitement reçoit le premier focus, et le lien « Voir », masqué hors focus, devient visible quand il le reçoit.
Plusieurs de ces défauts, comme le focus invisible, l'erreur silencieuse et le défilement horizontal, ont échappé à axe.
À retenir
- Un lecteur d'écran lit ce que le navigateur expose : un rôle, un nom et un état, tous tirés du HTML.
- Un champ en erreur reçoit
aria-invalid="true"etaria-describedbyvers son message ; les deux attributs disparaissent avec l'erreur. - Une zone
role="status"présente dès le premier rendu reçoit les messages de succès ;role="alert"est réservé aux erreurs. LazyHTMLpermet d'écrire ces vérifications comme des tests ordinaires, avec des sélecteurs CSS.- Le contraste, la visibilité du focus et l'ordre de tabulation demandent un navigateur et un humain.
- Référentiel : RGAA 4.1.2, critères et tests