Skip to content
academia.sh

Lesson 09 / 27

ARIA Usage

The role, state, and property declarations written into the accessibility tree; the accessible name computation in full, the rationale for the first rule, and the concrete breakages misuse produces.

Contents

The previous lesson established what native elements declare to the tree and left a gap: components with no defined counterpart. The filter panel opens and closes, the measurement table’s columns are sortable, alert notifications appear after the fact. None of these behaviors has a single element that corresponds to it; for them to appear correctly in the tree, the missing declarations have to be written.

The set that writes these declarations is ARIA. The same set is the most frequently misused tool, and when misused it makes an interface less usable than if no declaration had been written at all. This lesson builds two things together: what the set does, and what it breaks.

What ARIA Changes

ARIA declarations change only the accessibility tree. Writing an attribute does not add behavior to the element: it does not make it focusable, does not make it listen for keys, does not handle clicks, does not change its appearance.

This one sentence explains the source of most misuse. Code that writes the button role onto a div element produces something that looks like a button on screen and is declared as a button in the tree, but does not respond to the space key. When declaration and behavior diverge, the resulting state is worse than if the declaration had never been written: the user has been told it is a button and not given a way to use it.

Role, State, Property

The set carries three kinds of declaration.

Role states what the element is, and it replaces the implicit role. It is written once and is not expected to change at runtime.

State is the element’s condition at a given moment, and it changes with interaction: whether it is expanded (aria-expanded), checked (aria-checked), selected (aria-selected), busy (aria-busy), invalid (aria-invalid).

Property is a more lasting characteristic of the element: its name (aria-label, aria-labelledby), its description (aria-describedby), what it controls (aria-controls), whether it is required (aria-required), which order it is sorted in (aria-sort).

The distinction is not cosmetic: state declarations must be updated as they change. If the aria-expanded value is not updated when the panel opens, the tree carries information that contradicts what is on screen. Wrong information costs more than missing information.

The First Rule

The first of ARIA’s writing rules is this: if a native element carrying the required meaning exists, ARIA is not used. Instead of writing the button role onto a div element, button is written; instead of the list role, ul is used; instead of the heading role, a heading element is used.

The rationale is the accounting from the previous lesson. A native element brings role, name, focusability, key behavior, state declarations, and form relationship together; a role declaration brings only the first of these. Writing the remaining five by hand is both longer and a task where a new mistake can be made in every implementation.

The remaining rules are extensions of this first one: a native element’s meaning is not changed unless necessary; every interactive element with a written role must be operable by keyboard; a focusable element is not hidden from the tree; every interactive element must have an accessible name.

The Full Name Computation

The previous lesson’s name computation was limited to local sources. ARIA sources sit at the very top of the order and suppress everything local. The script below implements the full order and runs a rule audit over the same set of markup.

// aria.mjs — full accessible name computation (ARIA sources on top) and misuse audit

const INTERACTIVE = new Set(["button", "link", "checkbox", "textbox", "searchbox", "spinbutton"]);
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader", "checkbox", "listitem"]);
const NO_AUTHOR_NAME = new Set(["generic", "presentation", "none", "paragraph"]);   // does not accept an author-provided name

function role(d) {
  const a = d.attrs ?? {};
  if (a.role) return a.role;
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input": return a.type === "checkbox" ? "checkbox" : a.type === "search" ? "searchbox" : "textbox";
    case "table": return "table";
    case "section": return a["aria-label"] || a["aria-labelledby"] ? "region" : "generic";
    case "h2": return "heading";
    default: return "generic";
  }
}

const clean = (s) => s.replace(/\s+/g, " ").trim();

// Content text; aria-hidden subtree is skipped
function text(d, visible = true) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (visible && (a["aria-hidden"] === true || a.hidden)) return "";
  if (d.element === "img") return a.alt ?? "";
  return clean((d.children ?? []).map((c) => text(c, visible)).join(" "));
}

// 1 aria-labelledby -> 2 aria-label -> 3 local source -> 4 content -> 5 last resort
function name(d, doc) {
  const a = d.attrs ?? {};
  const r = role(d);
  const takesAuthorName = !NO_AUTHOR_NAME.has(r);

  if (takesAuthorName && a["aria-labelledby"]) {
    const parts = a["aria-labelledby"].split(" ").map((k) => doc[k]).filter(Boolean);
    // the text of a referenced node is used even if the node itself is hidden
    if (parts.length > 0) return [clean(parts.map((p) => text(p, false)).join(" ")), "aria-labelledby"];
  }
  if (takesAuthorName && a["aria-label"]) return [clean(a["aria-label"]), "aria-label"];
  if (["input", "select", "textarea"].includes(d.element) && doc["label:" + a.id]) {
    return [text(doc["label:" + a.id]), "label element"];
  }
  if (FROM_CONTENT.has(r)) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  return ["", "no name"];
}

// --- North Slope: filter panel and measurement table components -------------
const DOC = {
  "heading": { element: "h2", children: ["Filter"] },
  "count": { element: "span", children: ["3 criteria"] },
  "hidden-name": { element: "span", attrs: { hidden: true }, children: ["Delete measurement row"] },
  "label:code": { element: "label", children: ["Station code"] },
};

const EXAMPLE = [
  ['<button>Filter</button>',
   { element: "button", children: ["Filter"], focusable: true }],
  ['<button aria-label="Save">Delete measurement</button>',
   { element: "button", attrs: { "aria-label": "Save" }, children: ["Delete measurement"], focusable: true }],
  ['<button aria-labelledby="heading count">Open</button>',
   { element: "button", attrs: { "aria-labelledby": "heading count" }, children: ["Open"], focusable: true }],
  ['<button aria-labelledby="no-such-id">Delete</button>',
   { element: "button", attrs: { "aria-labelledby": "no-such-id" }, children: ["Delete"], focusable: true }],
  ['<div role="button" tabindex="0">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: true }],
  ['<div role="button">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: false }],
  ['<input id="code" aria-label="Code"> + label',
   { element: "input", attrs: { id: "code", "aria-label": "Code" }, focusable: true }],
  ['<div aria-label="Filter panel">…</div>',
   { element: "div", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<section aria-label="Filter panel">…',
   { element: "section", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<button aria-hidden="true">Export</button>',
   { element: "button", attrs: { "aria-hidden": true }, children: ["Export"], focusable: true }],
  ['<button aria-labelledby="hidden-name">×</button>',
   { element: "button", attrs: { "aria-labelledby": "hidden-name" }, children: ["×"], focusable: true }],
  ['<table role="presentation">…</table>',
   { element: "table", attrs: { role: "presentation" }, children: ["Temperature"], focusable: false }],
];

console.log("markup".padEnd(55) + "role".padEnd(14) + "accessible name".padEnd(27) + "source");
for (const [markup, d] of EXAMPLE) {
  const [n, from] = name(d, DOC);
  console.log(markup.padEnd(55) + role(d).padEnd(14) + (n === "" ? "(empty)" : '"' + n + '"').padEnd(27) + from);
}

console.log("\nrule audit:");
let count = 0;
for (const [markup, d] of EXAMPLE) {
  const a = d.attrs ?? {};
  const r = role(d);
  const [n] = name(d, DOC);
  const visible = text(d);
  const findings = [];
  if (a["aria-hidden"] === true && d.focusable) findings.push("focusable element hidden from tree");
  if (a["aria-labelledby"] && !a["aria-labelledby"].split(" ").some((k) => DOC[k]))
    findings.push("aria-labelledby references an invalid id");
  if ((a["aria-label"] || a["aria-labelledby"]) && NO_AUTHOR_NAME.has(r))
    findings.push("name declaration ignored on a role that does not accept an author name");
  if (INTERACTIVE.has(r) && !d.focusable) findings.push("interactive role is not focusable");
  if (INTERACTIVE.has(r) && n === "") findings.push("interactive element has no name");
  if (["presentation", "none"].includes(a.role) && ["table", "ul", "ol", "li"].includes(d.element))
    findings.push("native structural meaning removed: row and column relationships absent from the tree");
  if (INTERACTIVE.has(r) && /\p{L}/u.test(visible) && n &&
      !n.toLocaleLowerCase("en-US").includes(visible.toLocaleLowerCase("en-US")))
    findings.push('visible text ("' + visible + '") is not contained in the accessible name');
  for (const f of findings) {
    count++;
    console.log("  " + markup.padEnd(55) + f);
  }
}
console.log(`\n${EXAMPLE.length} examples, ${count} findings`);
markup                                                 role          accessible name            source
<button>Filter</button>                                button        "Filter"                   content text
<button aria-label="Save">Delete measurement</button>  button        "Save"                     aria-label
<button aria-labelledby="heading count">Open</button>  button        "Filter 3 criteria"        aria-labelledby
<button aria-labelledby="no-such-id">Delete</button>   button        "Delete"                   content text
<div role="button" tabindex="0">Delete</div>           button        "Delete"                   content text
<div role="button">Delete</div>                        button        "Delete"                   content text
<input id="code" aria-label="Code"> + label            textbox       "Code"                     aria-label
<div aria-label="Filter panel">…</div>                 generic       (empty)                    no name
<section aria-label="Filter panel">…                   region        "Filter panel"             aria-label
<button aria-hidden="true">Export</button>             button        (empty)                    no name
<button aria-labelledby="hidden-name">×</button>       button        "Delete measurement row"   aria-labelledby
<table role="presentation">…</table>                   presentation  (empty)                    no name

rule audit:
  <button aria-label="Save">Delete measurement</button>  visible text ("Delete measurement") is not contained in the accessible name
  <button aria-labelledby="heading count">Open</button>  visible text ("Open") is not contained in the accessible name
  <button aria-labelledby="no-such-id">Delete</button>   aria-labelledby references an invalid id
  <div role="button">Delete</div>                        interactive role is not focusable
  <div aria-label="Filter panel">…</div>                 name declaration ignored on a role that does not accept an author name
  <button aria-hidden="true">Export</button>             focusable element hidden from tree
  <button aria-hidden="true">Export</button>             interactive element has no name
  <table role="presentation">…</table>                   native structural meaning removed: row and column relationships absent from the tree

12 examples, 8 findings

The focusable field in the script represents whether the element actually takes focus; for the div rows it corresponds to whether tabindex was written.

Reading the Findings

A name declaration suppresses the visible text. In the second row the button reads “Delete measurement” on screen, but its name in the tree is “Save.” A person using the interface by voice says the text they see, and the name they say matches no element. Criterion 2.5.3 forbids exactly this: the visible label text must be contained within the accessible name. The same breakage happens more subtly in the seventh row — the field has a label element, but because the aria-label declaration suppresses it, a field that reads “Station code” on screen has the name “Code.”

An invalid reference drops silently. In the fourth row aria-labelledby points to an id that does not exist; the computation skips that step and the name comes from content text instead. The result looks harmless in this example, but the intended declaration has been ignored. On an icon button with no content, the same mistake leaves a nameless button.

A reference to a hidden element is valid. The eleventh row shows this: the text inside a hidden span becomes the name of the button that references it. This is the defined way to give a name to a delete button that shows only an × on screen.

Some roles do not accept an author-provided name. In the eighth row an aria-label written on a div element is ignored; in the ninth row, the same declaration written on a section element both turns the element’s role into a region and produces a name. A name declaration is written against the element’s role, not the element.

A focusable element cannot be hidden from the tree. The tenth row produces two findings at once: the button takes focus but is absent from the tree. A user navigating by keyboard hits a stop where focus disappears; the tab key goes somewhere, and nothing is declared there.

Removing structural meaning removes data. In the last row the table’s role has been converted to presentational. For a table used for layout, that is the right decision; in a data-bearing measurement table, row and column relationships drop out of the tree entirely, and a user moving between cells cannot learn which column they are in.

Correct Usage in the Filter Panel

The markup below declares two structures that no native element covers, according to the rule: a panel that opens and closes, and a sortable column.

<h2 id="filter-heading">Filter</h2>
<button aria-expanded="false" aria-controls="filter-body">
  Filter criteria
</button>
<div id="filter-body" hidden>
  <label for="filter-code">Station code</label>
  <input id="filter-code" type="search">
  <p><label><input type="checkbox" name="alert"> Only measurements with an alert</label></p>
</div>

<table aria-labelledby="table-heading">
  <caption id="table-heading">Daily measurements</caption>
  <thead>
    <tr>
      <th scope="col" aria-sort="descending">
        <button>Date</button>
      </th>
      <th scope="col" aria-sort="none"><button>Temperature</button></th>
    </tr>
  </thead>
</table>

Three decisions deserve attention. The disclosure control is a button element; no role is written, because the required role already exists. The aria-expanded state must be updated every time the panel opens and closes; if it stays fixed, the tree carries wrong information. The sort state is written on the header cell, not on the button inside it: what is being sorted is the column, and the button is only the control that starts the sort.

Summary

  • ARIA declarations change only the accessibility tree; they add no behavior, focusability, or appearance.
  • Role declares what the element is, state its current condition, property its more lasting characteristics; state declarations must be updated together with interaction.
  • The first rule is not using ARIA when a native element carrying the required meaning exists; a native element brings name, focus, key behavior, and states along with role.
  • In the name computation, ARIA sources suppress local sources; a divergence between the visible text and the accessible name fails criterion 2.5.3.
  • An invalid id reference drops silently, a reference to a hidden element is valid, and aria-label is ignored on roles that do not accept an author name.
  • Hiding a focusable element from the tree and removing a data table’s structural meaning produce breakages more severe than writing no declaration at all.

Next Step

One row of the rule audit was left unopened in this lesson: an element with the button role written but not taking focus. It is not enough for the declaration to be correct; the element must also be operable by keyboard, and these two requirements are defined in separate criteria. The next lesson looks at the whole interface from the keyboard: which elements are in the tab order, how the order is computed, how an opened panel contains focus, and at what point that containment turns into a trap.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close