Skip to content
academia.sh

Lesson 08 / 27

The Role of Semantic Markup

How the accessibility tree is derived from the document tree, the implicit roles native elements produce, the order in which the accessible name is computed, and an accounting of the cost paid for a meaningless container.

Contents

The previous lesson put criterion 4.1.2 on the list: an interface component’s name, role, and value must be programmatically readable. For the criterion to be testable, this triple must actually exist somewhere. A button’s name does not live in the page’s visual appearance; it lives in a second structure derived from the document.

That structure is the accessibility tree introduced in the Semantic Markup and Media topic. This lesson takes up how the tree is derived at the rule level: which node enters the tree, where the role of one that enters comes from, and in what order its name is computed. All three answers live in the markup; none is read from the presentation layer.

The Tree Is Derived From the Tree

The browser walks the Document Object Model tree and builds a second tree. The second tree’s nodes are not the elements themselves but their accessibility counterparts: each node carries a role, a name, a description, and a set of states.

Some nodes never enter the tree during derivation. The pruning rules are these: elements not rendered in the presentation layer or whose visibility has been removed do not enter; elements carrying the hidden attribute do not enter; an element declaring aria-hidden="true" and its whole subtree do not enter; an image whose alternative text has been left empty is treated as presentational and does not enter.

This produces a distinction. There are two ways to remove an element from the screen, and they give different results in the tree. Taking an element out of rendering in the presentation layer also deletes it from the tree; moving it off-screen or making it transparent keeps it in the tree. A skip link being able to work while invisible rests on the second path: the link is in the tree, it takes focus, and it is only invisible to the eye.

Implicit Role

Every node that enters the tree has a role, and when no declaration is written, that role comes from the element itself. This is called the implicit role. Part of the mapping is conditional: the same element produces a different role depending on an attribute.

The a element produces the link role only when it carries an href attribute; without one it is a meaningless node in the tree. The input element’s role comes from its type attribute: a text field, a search field, a number field, and a checkbox are separate roles. The th element’s role is column header or row header depending on its scope attribute. The mapping from sectioning elements to landmark roles, and the conditions on that mapping, were established in the Web Fundamentals and HTML course; here the same derivation continues for form controls.

Computing the Accessible Name

The question after role is name. The accessible name is the string that introduces a node to the user, and it does not come from a single source: it is computed by a defined order of precedence. The script below implements the local part of that order. ARIA sources sit at the very top of the order and are the next lesson’s subject; here we see what happens without them.

// name.mjs — implicit role of native elements and accessible name computation (ARIA sources excluded)

function role(d) {
  const a = d.attrs ?? {};
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input":
      return { checkbox: "checkbox", radio: "radio", number: "spinbutton",
               search: "searchbox", submit: "button" }[a.type ?? "text"] ?? "textbox";
    case "select": return "combobox";
    case "table": return "table";
    case "th": return a.scope === "row" ? "rowheader" : "columnheader";
    case "td": return "cell";
    case "tr": return "row";
    case "fieldset": return "group";
    case "img": return a.alt === "" ? "— (presentational)" : "image";
    case "ul": return "list";
    case "li": return "listitem";
    case "h1": case "h2": case "h3": return "heading";
    case "output": return "status";
    default: return "generic";
  }
}

// roles that take a name from content in ARIA
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader",
  "rowheader", "listitem", "checkbox", "radio", "option"]);

function text(d) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (a.hidden) return "";
  if (d.element === "img") return typeof a.alt === "string" ? a.alt : "";
  return (d.children ?? []).map(text).join(" ").replace(/\s+/g, " ").trim();
}

// Order: local source -> content text (roles that allow it) -> last resort
function name(d, labels) {
  const a = d.attrs ?? {};
  if (a.hidden) return ["", "hidden"];
  if (d.element === "img" && typeof a.alt === "string" && a.alt !== "") return [a.alt, "alt"];
  for (const [el, sub] of [["table", "caption"], ["fieldset", "legend"], ["figure", "figcaption"]]) {
    if (d.element !== el) continue;
    const c = (d.children ?? []).find((x) => x.element === sub);
    if (c) return [text(c), sub];
  }
  if (["input", "select", "textarea"].includes(d.element)) {
    if (labels[a.id]) return [labels[a.id], "label element"];
    if (a.title) return [a.title, "title (last resort)"];
    if (a.placeholder) return [a.placeholder, "placeholder (last resort)"];
    return ["", "no name"];
  }
  if (FROM_CONTENT.has(role(d))) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  if (a.title) return [a.title, "title (last resort)"];
  return ["", "no name"];
}

// --- North Slope: examples from the filter panel and the measurement table --
const LABELS = {
  "filter-code": "Station code",
  "filter-alert": "Only measurements with an alert",
  value: "Measured value (°C)",
};

const EXAMPLE = [
  ["<button>Filter</button>", { element: "button", children: ["Filter"] }],
  ['<button><img alt="Clear filter"></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "Clear filter" } }] }],
  ['<button><img alt=""></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "" } }] }],
  ['<input id="filter-code" type="search"> + label',
   { element: "input", attrs: { id: "filter-code", type: "search" } }],
  ['<input type="search" placeholder="station">',
   { element: "input", attrs: { id: "search", type: "search", placeholder: "station" } }],
  ['<input id="value" type="number"> + label',
   { element: "input", attrs: { id: "value", type: "number" } }],
  ['<input id="filter-alert" type="checkbox"> + label',
   { element: "input", attrs: { id: "filter-alert", type: "checkbox" } }],
  ["<table><caption>Daily measurements</caption>",
   { element: "table", children: [{ element: "caption", children: ["Daily measurements"] }] }],
  ["<fieldset><legend>Measurement source</legend>",
   { element: "fieldset", children: [{ element: "legend", children: ["Measurement source"] }] }],
  ['<a href="/stations/">Stations</a>',
   { element: "a", attrs: { href: "/stations/" }, children: ["Stations"] }],
  ["<a>Stations</a>", { element: "a", children: ["Stations"] }],
  ['<div class="button">Save</div>', { element: "div", children: ["Save"] }],
  ['<th scope="col">Temperature</th>',
   { element: "th", attrs: { scope: "col" }, children: ["Temperature"] }],
  ['<img src="alert.svg" alt="">', { element: "img", attrs: { alt: "" } }],
];

console.log("markup".padEnd(51) + "role".padEnd(20) + "accessible name".padEnd(35) + "source");
for (const [markup, node] of EXAMPLE) {
  const [n, from] = name(node, LABELS);
  console.log(
    markup.padEnd(51) + role(node).padEnd(20) +
    (n === "" ? "(empty)" : '"' + n + '"').padEnd(35) + from,
  );
}
markup                                             role                accessible name                    source
<button>Filter</button>                            button              "Filter"                           content text
<button><img alt="Clear filter"></button>          button              "Clear filter"                     content text
<button><img alt=""></button>                      button              (empty)                            no name
<input id="filter-code" type="search"> + label     searchbox           "Station code"                     label element
<input type="search" placeholder="station">        searchbox           "station"                          placeholder (last resort)
<input id="value" type="number"> + label           spinbutton          "Measured value (°C)"              label element
<input id="filter-alert" type="checkbox"> + label  checkbox            "Only measurements with an alert"  label element
<table><caption>Daily measurements</caption>       table               "Daily measurements"               caption
<fieldset><legend>Measurement source</legend>      group               "Measurement source"               legend
<a href="/stations/">Stations</a>                  link                "Stations"                         content text
<a>Stations</a>                                    generic             (empty)                            no name
<div class="button">Save</div>                     generic             (empty)                            no name
<th scope="col">Temperature</th>                   columnheader        "Temperature"                      content text
<img src="alert.svg" alt="">                       — (presentational)  (empty)                            no name

There are three places in the table worth reading closely.

Content only produces a name for certain roles. The set in the script enforces this: button, link, heading, and cell roles take their name from their inner text; text field, table, and group roles do not. That is why the text Stations produces a name inside a link, while the same text inside an a element without an href produces nothing. The distinction is not arbitrary: the text inside a text field is its value, not its name; if the two were conflated, what the user typed would be mistaken for the field’s name.

Text alternative traversal collects the names of child nodes. The button that clears the filter has no text inside it, only an image; the image’s alternative text becomes the button’s name. The third row is the reverse case: when the same button’s alternative text is left empty, the image is treated as presentational and pruned, leaving a nameless button. An icon appears on screen; the tree has no name. This is the most common defect in icon buttons, and it directly fails criterion 4.1.2.

Last-resort sources turn into a name but do not do the job. The search field with placeholder text gains a name. That name is text that disappears from the screen once the field is filled, and it also carries the low contrast measured in the Contrast and Accessibility lesson. The computation accepting it as a name does not mean the design may put it in place of a label. The same is true of the title attribute: it exists in the computation, it depends on a pointing device, and it is no substitute for a persistent label.

Value and State

The last member of the triple is value, and it varies by role: a text field’s value is the text typed into it, a checkbox’s value is whether it is checked, and a number field’s value is the number itself along with its lower and upper bounds. Alongside these are states: disabled, required, invalid, read-only, expanded.

In native elements, all of these declarations come from attributes and are never written separately. When required is written, the requirement passes into the tree; when disabled is written, both the enabled state is declared and the element drops out of focus; when min and max are written on a number field, the value range appears in the tree. This is why constraint validation in the Web Fundamentals and HTML course does two jobs at once: it declares the rule to the browser and the state to the accessibility tree.

The Cost of a Meaningless Container

When the button that deletes a row of the measurement table is built with a div element, it looks the same on screen. In the tree, six declarations are missing at once, and each one has to be put back by hand: the role (that it is a button), the accessible name, focusability, operability with the space and enter keys, the declaration of the disabled state, and its relationship to form submission.

Five of these six items are recovered by changing a single element name in the markup. Preferring a native element is not a matter of style; it drives the number of declarations that must be reproduced down to zero. The same accounting applies to a table: in a grid built from a stack of div elements, row and column relationships, which data a header cell belongs to, and the total row count never reach the tree at all; a user moving between cells cannot hear the name of the column they are in.

The decision criterion is this: if a defined element exists for the job, it is used. When no defined element exists — a tabbed panel, a tree view, a combobox — the missing declarations must be written by hand, and ARIA is the tool for that.

Summary

  • The accessibility tree is derived from the document tree; nodes that are not rendered, carry hidden, declare aria-hidden, or are images with empty alternative text are pruned.
  • The implicit role comes from the element and is conditional: a is a link only if it carries href, input’s role is determined by its type, and th’s role is determined by its scope.
  • The accessible name is computed by a defined order; content only produces a name for roles that allow it, and text alternative traversal collects the alternative text of child nodes.
  • A button containing an icon with empty alternative text stays nameless; a placeholder and title produce a name in the computation but are no substitute for a persistent label.
  • Value and state declarations come from attributes on native elements; a control built from a meaningless container must have its role, name, focusability, key behavior, and state produced by hand.

Next Step

The last section left a gap: how is a component declared when it has no defined element? The filter panel above the measurement list is a section that opens and closes, alert notifications appear on their own, and the measurement table has sortable columns. None of these has a native counterpart, and all three must appear correctly in the tree. The next lesson takes up the set of declarations that fills this gap — roles, states, and properties — and shows, using the same name computation, how that set breaks the tree when it is used incorrectly.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close