Powered by AppSignal & Oban Pro

RGAA et LiveView : ce que le navigateur expose, et ce qu'un test peut vérifier

Tips/rgaa_liveview_tests.livemd

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 invalid et 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" et aria-describedby vers 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.
  • LazyHTML permet 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