# HTML semantics

<!-- Generated by scripts/gen-semantics.mjs from semantics.json. Do not edit. -->

Normative HTML semantics for ExpressiveCSS. This data file is the single source of truth: SEMANTICS.md is generated from it by scripts/gen-semantics.mjs, and tests/semantics.test.js enforces it. One row per roster entry, each stating its kind - component, foundation or behavior, as CONTEXT.md defines them. CONTEXT.md defines the rest of the vocabulary.

## Rules

Element choice, landmark structure, and the ARIA that element choice implies. Keyboard interaction, focus management and colour contrast are deliberately out of scope.

1. Static semantics are the page author's; dynamic state is the framework's. See CONTEXT.md for both terms.
2. A component may declare a composite role only if its keyboard contract is implemented and tested. Tabs are navigation (`<nav>` + anchors + `aria-current`) by default; a strip the page marks `role="tablist"` gets the tabs keyboard model and `aria-selected` from the component. Carousel is a different case - its keyboard contract is implemented, and it rejects `tablist` because it follows the ARIA carousel pattern instead.
3. Icons are `<span class="material-symbols" aria-hidden="true">`. A bare `<i>` gets no icon styling.
4. `<nav>` is for genuine navigation only, and every one carries a required `aria-label`. Card action rows and toolbars are not navigation.
5. Every user-facing string the framework generates has an `i18n` option.

## Enforcement

`tests/semantics.test.js` runs every rule below against 4 surfaces:
`llm.md`, `docs/src`, `tests/fixtures.js`, `m3-guidelines.md (forbid rules only)`.

A rule runs only when its component is **enforced**. An **exempt** component
has not been swept yet; the exempt list only ever shrinks, and a component
added to the framework starts enforced. An individual example may opt out with
a reason - ```` ```html ignore-semantics: why ```` in Markdown, or
`<Code check={false} reason="why" ... />` in an Astro page.

**108 of 108 rows enforced; 0 remaining.**

103 of those rows are components - a part of the framework an author writes markup for.
The rest are not, and say which they are: `character-counter` (behavior), `docked-display` (behavior), `modal-display` (behavior), `scrim` (foundation), `transitions` (foundation).
CONTEXT.md defines the kinds. Their rules run the same either way: a kind says what a row is,
not whether it is checked.

`table_of_contents` has no row at all: the list it styles is Scrollspy's markup, taught under Scrollspy in `llm.md` and rendered per page by the docs site's page scaffold. The partial stays in the sheet; `scrollspy` owns its rule.

**Conformance debt** is a separate axis from enforced/exempt: a fully enforced
component may still withhold a composite role because the keyboard contract rule 2
demands does not exist yet. Each one below names the role it withholds, what that
waits on, and the rule that keeps the role out of markup meanwhile.

A component may also *reject* a role: one it will never take because it implements a
different pattern. That is not debt - the code is not behind, it went another way - and
the same rule-linking applies, so neither can be recorded without enforcement.

The composite roles that can be withheld or rejected: `combobox`, `grid`, `listbox`, `menu`, `menubar`, `radiogroup`, `tablist`, `toolbar`, `tree`, `treegrid`.

**2 of 103 components declare conformance debt.**

That is a count of *declarations*, not of debt. The suite pairs a declaration with a
rule and a role-blocking rule with a declaration, so neither can exist alone - but a
role withheld by convention, with no rule behind it, is invisible to it.

## Enforced

### accordion

Added with the accordion component. Each item is a native <details>, so the browser exposes <summary> as a button with its expanded state and handles the keyboard. The rules keep authors from re-stating that state or nesting controls in the toggle.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `accordion-items-are-details` | forbid | `.accordion > :not(details)` | must not match |
| `accordion-summary-holds-no-controls` | forbid | `.accordion summary :is(a[href], button, input, select, textarea, [tabindex])` | must not match |
| `accordion-summary-state-is-native` | forbid | `.accordion summary:is([role="button"], [aria-expanded])` | must not match |

- **accordion-items-are-details** - Write every accordion item as <details> with a <summary>. The element is the disclosure: a div with a click handler has no expanded state and no keyboard support.
- **accordion-summary-holds-no-controls** - A <summary> is already the toggle button. A link or control inside it is a control nested in a control: pressing it also opens or closes the item, and screen readers flatten it into the summary name. Put actions in the panel.
- **accordion-summary-state-is-native** - The browser exposes <summary> as a button and reports its expanded state from the open attribute. An authored role="button" repeats it, and an authored aria-expanded goes stale the first time the item toggles.

### alert

Added with the alert component, which replaces the removed banner. A static alert is plain content and needs no role. A live region must hold only the message, because a screen reader reads every button label inside it as part of the announcement.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `alert-is-not-the-banner-landmark` | forbid | `.alert[role="banner"]` | must not match |
| `alert-live-region-holds-no-controls` | forbid | `.alert:is([role="alert"], [role="status"], [aria-live]):has(button, a[href], input, select, textarea)` | must not match |
| `error-summary-form-is-novalidate` | forbid | `form:not([novalidate]):has(.error-summary)` | must not match |
| `error-summary-has-a-list` | forbid | `.error-summary:not(:has(ul, ol))` | must not match |

- **alert-is-not-the-banner-landmark** - role="banner" is the page header landmark, not a message. Remove it: a static alert needs no role, and a changing one puts role="status" or role="alert" on its text.
- **alert-live-region-holds-no-controls** - Put role="status" or role="alert" on the alert's text, not on an alert that holds buttons or links. A live region reads its controls' labels as part of the message.
- **error-summary-form-is-novalidate** - Add novalidate to a form with an .error-summary. Without it the browser stops an invalid submit before the submit event, so the summary never fills and only the first field's bubble shows.
- **error-summary-has-a-list** - Give the .error-summary an empty <ul>. The Forms behavior writes one link per invalid field into it.

### aura

Added with the aura component. An aura wraps one element and draws its light with a pseudo-element.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `aura-wraps-one-element` | forbid | `.aura > * + *` | must not match |

- **aura-wraps-one-element** - An .aura wraps one element. Give each highlighted element its own .aura, and keep to one per screen.

### autocomplete

Swept 0.8.0. The combobox it builds is checked at runtime in tests/autocomplete.test.js.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `autocomplete-options-are-options` | forbid | `.autocomplete-content li:not([role="option"])` | must not match |

- **autocomplete-options-are-options** - The suggestion list is a listbox; every entry in it is an option.

### avatar

Added with the avatar component. Initials and icon ligatures read as letters, so a non-image avatar is either a named role="img" or hidden beside a visible name.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `avatar-image-has-alt` | forbid | `img.avatar:not([alt])` | must not match |
| `avatar-is-an-image-or-hidden` | forbid | `.avatar:not(img):not([role="img"]):not([aria-hidden="true"])` | must not match |
| `avatar-image-role-is-named` | require-accessible-name | `.avatar[role="img"]` | must end up with an accessible name |

- **avatar-image-has-alt** - Give an avatar image alt text naming the person, or alt="" when their name is already beside it.
- **avatar-is-an-image-or-hidden** - Initials read as separate letters and an icon reads as its ligature name. Give a non-image avatar role="img" and a name, or aria-hidden="true" when the person is named next to it.
- **avatar-image-role-is-named** - An avatar with role="img" needs aria-label or aria-labelledby. Its initials are hidden from the name computation of an image.

### back-to-top

Added with the back-to-top component. An in-page link, so it jumps to its target without the script. BackToTop keeps it inert until the window scrolls past the threshold, then scrolls to the top and focuses the target. Inert is the framework's dynamic state.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `back-to-top-is-an-in-page-link` | forbid | `.back-to-top:not(a[href^="#"])` | must not match |
| `back-to-top-is-named` | require-accessible-name | `.back-to-top` | must end up with an accessible name |
| `back-to-top-inert-is-not-authored` | forbid | `.back-to-top[inert]` | must not match |

- **back-to-top-is-an-in-page-link** - Make .back-to-top an <a> whose href names an element on the page, such as #main. The link works without the script, and the script focuses that element.
- **back-to-top-is-named** - The link usually holds only an icon. Give it an aria-label such as "Back to top".
- **back-to-top-inert-is-not-authored** - Hidden is dynamic state, so the framework owns it. BackToTop sets inert while the page is above the threshold and removes it past it; authoring it hides the link from a page without the script.

### badges

Swept 0.8.0. A badge nested in an icon is positioned against the glyph, so hiding the icon hides the count with it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `badge-count-survives-a-hidden-icon` | forbid | `:is(a, button):not([aria-label]):not([aria-labelledby]) [aria-hidden="true"] .badge:not(:empty)` | must not match |

- **badge-count-survives-a-hidden-icon** - A badge inside a hidden icon is hidden with it - aria-hidden="false" on a descendant does not undo that. Put the count in the control name (aria-label="Inbox, 3 unread"), or move the badge out of the icon into the flow.

### bar-chart

Added with the bar chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn bars, values, labels and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `bar-chart-is-a-figure-with-a-table` | forbid | `.bar-chart:not(figure:has(> table))` | must not match |
| `bar-chart-has-a-caption` | forbid | `.bar-chart:not(:has(> figcaption))` | must not match |

- **bar-chart-is-a-figure-with-a-table** - Write .bar-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **bar-chart-has-a-caption** - Name a .bar-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### bottom-sheet

Swept 0.8.0. A <dialog>, so the dialog rules carry it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### breadcrumb

Swept 0.8.0. A trail is an ordered list, because the order is the meaning.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `breadcrumb-is-ordered-list` | forbid | `.breadcrumb-wrapper > a` | must not match |
| `breadcrumb-marks-current` | forbid | `.breadcrumb-wrapper li:last-child :is(a, span):not([aria-current])` | must not match |

- **breadcrumb-is-ordered-list** - Crumbs are an ordered list - nav > ol > li > a. Loose anchors give no count and no position, so "3 of 4" is never announced.
- **breadcrumb-marks-current** - The last crumb is the page you are on; it needs aria-current="page" to say so.

### button-groups

Material 3 Expressive button groups support action buttons and opt-in toggle-button selection. ButtonGroup redistributes standard-group width during press after AutoInit(). Every item remains a native Tab stop; the container is not a composite widget and must not claim toolbar or radiogroup behavior. Named clusters use role="group". Selection groups use data-selection="single|multiple", direct native buttons, and aria-pressed; ButtonGroup owns subsequent state changes.

**Rejected role:** `toolbar` is not withheld but declined - its buttons are independent commands reached with Tab - the group is a visual grouping, not a composite widget, so there is no arrow-key model to implement and none to promise. `button-group-is-not-an-authored-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `button-group-is-not-an-authored-composite-widget` | forbid-composite-roles | `.button-group` | must not match with any composite role |
| `button-group-items-are-controls` | forbid | `.button-group > :not(button):not(a.button[href])` | must not match |
| `button-group-items-have-containers` | forbid | `.button-group > :is(button, a.button).text` | must not match |
| `button-group-icon-items-have-containers` | forbid | `.button-group > .icon-button:not(.filled):not(.tonal):not(.outlined)` | must not match |
| `button-group-selection-mode-is-valid` | forbid | `.button-group[data-selection]:not([data-selection="single"]):not([data-selection="multiple"])` | must not match |
| `button-group-required-needs-selection` | forbid | `.button-group[data-selection-required]:not([data-selection])` | must not match |
| `button-group-connected-is-selectable` | forbid | `.button-group.connected:not([data-selection])` | must not match |
| `button-group-selection-items-are-buttons` | forbid | `.button-group[data-selection] > :not(button)` | must not match |
| `button-group-selection-button-type` | require-attr | `.button-group[data-selection] > button` | must have `type` = `button` |
| `button-group-selection-state-is-pressed` | require-attr | `.button-group[data-selection] > button` | must have `aria-pressed` |
| `button-group-selection-state-is-boolean` | forbid | `.button-group[data-selection] > button[aria-pressed]:not([aria-pressed="true"]):not([aria-pressed="false"])` | must not match |
| `button-group-selection-does-not-use-choice-state` | forbid | `.button-group[data-selection] > :is([aria-checked], [aria-selected])` | must not match |
| `button-group-action-does-not-claim-selection` | forbid | `.button-group:not([data-selection]) > [aria-pressed]` | must not match |

- **button-group-is-not-an-authored-composite-widget** - A button group takes no composite role; its buttons are reached with Tab, not arrow keys. `role="group"` with an aria-label is the role that fits, and it promises no keyboard model.
- **button-group-items-are-controls** - Every item in a group is a control the user can reach, and one the sheet styles as a button: a <button>, or an <a class="button" href> when it navigates. A bare <a href> passes for a control and renders as a link - none of the group's corners, press behaviour or inherited size reach it. A wrapper element is refused too: the gap and the connected corners are written against direct children, so a nested <div> loses both.
- **button-group-items-have-containers** - Material warns against text buttons in a group because they have no visible container for the shape interaction. Use filled, tonal, outlined, or elevated buttons.
- **button-group-icon-items-have-containers** - Material warns against standard icon buttons in a group because they have no visible container for the shape interaction. Use a filled, tonal, or outlined icon button.
- **button-group-selection-mode-is-valid** - ButtonGroup supports data-selection="single" or data-selection="multiple". Any other value exposes toggle state without a defined selection model.
- **button-group-required-needs-selection** - data-selection-required only has meaning on a single- or multiple-selection ButtonGroup.
- **button-group-connected-is-selectable** - Material connected button groups contain toggleable choices. Add data-selection="single|multiple", or use the standard group for one-shot actions.
- **button-group-selection-items-are-buttons** - Selection items are native buttons. Links navigate and cannot represent toggle-button state.
- **button-group-selection-button-type** - A selection button inside a form must not submit it; set type="button".
- **button-group-selection-state-is-pressed** - Author each toggle button's initial aria-pressed value. ButtonGroup maintains that state after initialization.
- **button-group-selection-state-is-boolean** - A toggle button's initial aria-pressed value is true or false; mixed is not a Button Group state.
- **button-group-selection-does-not-use-choice-state** - These are toggle buttons, not radios, checkboxes, options, or tabs. Expose their state with aria-pressed.
- **button-group-action-does-not-claim-selection** - aria-pressed requires ButtonGroup selection behavior. Add data-selection="single|multiple" or remove the toggle state.

### buttons

Icon-only controls need accessible names.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `icon-only-control-is-named` | require-accessible-name | `:is(a, button):has([aria-hidden="true"])` | must end up with an accessible name |
| `fab-no-small-variant` | forbid | `:is(button, a.button, label.button).circle:is(.extra, .large).small` | must not match |
| `extended-fab-requires-size` | forbid | `:is(button, a.button, label.button).extend:not(.small, .medium, .large)` | must not match |
| `fab-no-surface-color` | forbid | `:is(button, a.button, label.button):is(.circle.extra, .circle.large, .extend):is(.surface, .surface-dim, .surface-bright, .surface-container, .surface-container-lowest, .surface-container-low, .surface-container-high, .surface-container-highest)` | must not match |

- **icon-only-control-is-named** - Every icon is hidden from assistive technology, so a control whose only content is one has no name left. Give it an aria-label.
- **fab-no-small-variant** - The 40dp small FAB has been removed. Use circle extra for a 56dp FAB.
- **extended-fab-requires-size** - The baseline extended FAB has been removed. Specify extend small, extend medium or extend large.
- **fab-no-surface-color** - Use a primary, secondary or tertiary container color for a FAB instead of a surface color.

### calendar

Added with the calendar component. A native table the page renders; its caption and column headers are what tell a screen reader the month and weekday of each day.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `calendar-is-a-table` | forbid | `.calendar:not(table)` | must not match |
| `calendar-has-a-caption` | forbid | `table.calendar:not(:has(> caption))` | must not match |

- **calendar-is-a-table** - Write .calendar as a <table> with a <caption> and a header row of weekdays. A grid of divs loses the day-to-weekday relationship.
- **calendar-has-a-caption** - Give the calendar a <caption> naming the month, such as "October 2026". Without it nothing says which month the days belong to.

### cards

Swept 0.8.0. Cards use native disclosure controls, and a directly actionable card contains no second action.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `card-action-row-not-nav` | forbid | `article nav` | must not match |
| `card-primary-action-is-control` | forbid | `article > .primary-action:not(a[href]):not(a[aria-disabled="true"])` | must not match |
| `card-disabled-link-has-no-href` | forbid | `article > a.primary-action[aria-disabled="true"][href]` | must not match |
| `card-disabled-link-not-tabbable` | forbid | `article > a.primary-action[aria-disabled="true"]:not([tabindex="-1"])` | must not match |
| `card-primary-action-is-only-action` | forbid | `article:has(> .primary-action) :is(a[href], button, input, select, textarea):not(.primary-action)` | must not match |
| `card-primary-action-is-single` | forbid | `article > .primary-action ~ .primary-action` | must not match |
| `card-reveal-has-trigger` | require-owned-descendant | `article:has(> aside)` | must contain `button.card-reveal-trigger` owned by its closest `article` outside `aside` within that owner |
| `card-reveal-has-one-panel` | forbid | `article > aside ~ aside` | must not match |
| `card-reveal-trigger-is-button` | forbid | `.card-reveal-trigger:not(button)` | must not match |
| `card-reveal-trigger-type-button` | require-attr | `button.card-reveal-trigger` | must have `type` = `button` |
| `card-reveal-trigger-has-label` | require-accessible-name | `button.card-reveal-trigger` | must end up with an accessible name |
| `card-reveal-trigger-controls-panel` | require-idref | `button.card-reveal-trigger` | `aria-controls` must reference `:scope > aside` inside `article` |
| `card-reveal-panel-has-id` | require-attr | `article:has(.card-reveal-trigger) > aside` | must have `id` |
| `card-reveal-expanded-is-not-authored` | forbid | `.card-reveal-trigger[aria-expanded], article > aside[aria-expanded]` | must not match |

- **card-action-row-not-nav** - Cards do not contain navigation or tabs. Put navigation outside the card and use <div class="actions"> for its action buttons.
- **card-primary-action-is-control** - A directly actionable card uses one native link as its primary action. Use the action row for commands; common button styles do not turn a button into the card container.
- **card-disabled-link-has-no-href** - A disabled card destination must not retain href. aria-disabled does not stop a link from navigating when activated from the keyboard.
- **card-disabled-link-not-tabbable** - A disabled card destination is not operable, so remove it from the tab order with tabindex="-1".
- **card-primary-action-is-only-action** - A directly actionable card contains no second action. Use one primary-action link, or make the card non-actionable and put its controls in the action row.
- **card-primary-action-is-single** - A directly actionable card has exactly one direct primary-action link. Remove duplicate primary actions or use a non-actionable card with an action row.
- **card-reveal-has-trigger** - A card with a direct reveal panel requires a native card-reveal-trigger button whose closest article is that card and which sits outside the reveal panel.
- **card-reveal-has-one-panel** - A card reveal has exactly one direct aside panel. With multiple direct panels, Cards rejects disclosure setup and every panel remains visible and unmanaged.
- **card-reveal-trigger-is-button** - The reveal trigger is a native button so Enter, Space, disabled state, and focus work without synthetic keyboard handling.
- **card-reveal-trigger-type-button** - A card reveal button may appear inside a form, so set type="button" to prevent accidental submission.
- **card-reveal-trigger-has-label** - The reveal button needs a non-empty accessible name describing the details it toggles; icon-only triggers should use aria-label.
- **card-reveal-trigger-controls-panel** - Name the reveal panel from aria-controls so assistive technology can relate the disclosure button to its content.
- **card-reveal-panel-has-id** - The reveal panel needs the id referenced by its trigger's aria-controls.
- **card-reveal-expanded-is-not-authored** - Cards writes aria-expanded on the disclosure button. Do not author a stale initial value, and do not put aria-expanded on the panel.

### carousel

Swept 0.8.0. Material 3 items remain exposed because several can be visible at once. Checked at runtime in tests/generated-a11y.test.js. Its keyboard contract is implemented and tested, so tablist is rejected rather than withheld. The role rule keeps out every composite role but not every role: role="group" with aria-roledescription="carousel" is the pattern carousel.ts writes and stays legal.

**Rejected role:** `tablist` is not withheld but declined - it implements the ARIA carousel pattern instead - a group with aria-roledescription and independent indicator controls. `carousel-not-a-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `hidden-subtree-holds-nothing-focusable` | forbid | `[aria-hidden="true"]:has(:is(a[href], button, input, select, textarea, [tabindex]):not([tabindex="-1"]):not([disabled]))` | must not match |
| `carousel-not-a-composite-widget` | forbid-composite-roles | `.carousel` | must not match with any composite role |
| `carousel-indicators-are-not-tabs` | forbid | `.carousel [role="tab"]` | must not match |

- **hidden-subtree-holds-nothing-focusable** - Hidden from assistive technology but still in the tab order: focus lands on a control the user has no way to perceive. Take away the tab stop (tabindex="-1"), or mark the subtree inert when it is genuinely not displayed - inert also removes it from hit-testing, which is wrong for anything still visible.
- **carousel-not-a-composite-widget** - A carousel takes no composite role; it is a group of slides with independent controls.
- **carousel-indicators-are-not-tabs** - Carousel indicators are independent controls, not tabs.

### cascader

Added with the cascader component. The author writes a text input, its label and a ul.cascader-options of nested lists with one data-value per option; AutoInit builds the columns, their listbox and option roles and their states in a popover, so the author's lists stay plain lists.

**Rejected role:** `listbox` is not withheld but declined - the author's lists are only the source the script reads and are never shown; the listbox columns it builds in the popover carry the roles, states and arrow-key contract. `cascader-lists-take-no-roles` enforces that.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `cascader-lists-take-no-roles` | forbid | `.cascader-options[role], .cascader-options [role]` | must not match |
| `cascader-option-has-value` | forbid | `.cascader-options li:not([data-value])` | must not match |
| `cascader-needs-a-text-field` | forbid | `.cascader > input:not(:is(:not([type]), [type="text" i]))` | must not match |
| `cascader-input-is-named` | forbid | `.cascader:not(:has(> label[for])) > input:not([aria-label]):not([aria-labelledby])` | must not match |

- **cascader-lists-take-no-roles** - Leave roles off .cascader-options and its items. The lists are the source the script reads; it builds the listbox columns, with their roles and states, itself.
- **cascader-option-has-value** - Give every option in .cascader-options a data-value. The form submits one value per level of the chosen path, and an option without one submits an empty string.
- **cascader-needs-a-text-field** - A .cascader shows the chosen path in an <input> of type text, which the script makes read-only. Other input types reject the path's labels.
- **cascader-input-is-named** - Name the cascader's input with a <label for> in the field, or aria-label. The popover and its first column take their name from the same label.

### character-counter

**Kind:** behavior.

Swept 0.8.0. A behavior, not a component: the author writes `data-length` on a field they already had, and the counter element is generated. Checked at runtime in tests/character-counter.test.js.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `counter-is-announced` | require-attr | `.character-counter` | must have `aria-live` = `polite` |

- **counter-is-announced** - The count changes as the user types, so it has to be a live region or the remaining characters are never announced.

### chips

Swept 0.8.0. The four Material 3 chip types plus a non-interactive display chip, across three root elements; the delete affordance is a real button.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `chip-root-element` | forbid | `.chip:not(span):not(button):not(label)` | must not match |
| `chip-not-focusable-span` | forbid | `span.chip[tabindex]` | must not match |
| `chip-close-is-button` | forbid | `.chip .close:not(button)` | must not match |
| `chip-close-type-button` | require-attr | `.chip button.close` | must have `type` = `button` |
| `chip-close-has-label` | require-attr | `.chip button.close` | must have `aria-label` |
| `chip-no-i-element` | forbid | `.chip i` | must not match |
| `chip-icon-hidden` | require-attr | `.chip :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)` | must have `aria-hidden` = `true` |
| `filter-chip-label-for` | require-attr | `label.chip` | must have `for` |

- **chip-root-element** - A chip is <span> (display), <button> (assist/suggestion/input), or <label> (filter). Never <div>.
- **chip-not-focusable-span** - A display chip is not a control and must not be in the tab order. Put the action on a nested <button>.
- **chip-close-is-button** - The chip delete affordance is a control: use <button type="button" class="close">, not <i> or <span>.
- **chip-close-type-button** - A <button> inside a form defaults to type=submit. Chip delete buttons must set type="button".
- **chip-close-has-label** - The delete button contains only an icon, so it needs an accessible name naming the chip it removes.
- **chip-no-i-element** - <i> means idiomatic text, not icon. Use <span class="material-symbols" aria-hidden="true">.
- **chip-icon-hidden** - A ligature icon is read aloud verbatim. Decorative icons inside a labelled control must be aria-hidden.
- **filter-chip-label-for** - A filter chip is <input type="checkbox"> + <label class="chip">; the label must point at its input.

### column-chart

Added with the column chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn columns, labels and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `column-chart-is-a-figure-with-a-table` | forbid | `.column-chart:not(figure:has(> table))` | must not match |
| `column-chart-has-a-caption` | forbid | `.column-chart:not(:has(> figcaption))` | must not match |

- **column-chart-is-a-figure-with-a-table** - Write .column-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **column-chart-has-a-caption** - Name a .column-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### command-palette

Added with the command palette component. A native modal dialog; the component makes the input a combobox and the list a listbox of options at runtime and owns the active option.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `command-palette-is-a-dialog` | forbid | `.command-palette:not(dialog)` | must not match |
| `command-palette-is-named` | require-accessible-name | `dialog.command-palette` | must end up with an accessible name |
| `command-palette-input-is-named` | require-accessible-name | `dialog.command-palette > input` | must end up with an accessible name |

- **command-palette-is-a-dialog** - Write the palette as <dialog class="command-palette">. The dialog makes it modal, closes it on Escape and returns focus.
- **command-palette-is-named** - Name the palette with aria-label so it is announced when it opens.
- **command-palette-input-is-named** - Name the search input with aria-label. A placeholder disappears as the user types and is not a reliable name.

### confetti

Added with the confetti component. The pieces are decoration drawn on a canvas, so an authored confetti canvas is hidden from assistive technology. The page-wide canvas the script creates hides itself.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `confetti-canvas-is-hidden` | require-attr | `canvas.confetti` | must have `aria-hidden` = `true` |

- **confetti-canvas-is-hidden** - Confetti is decoration. Mark the canvas aria-hidden="true" and say what happened in text, for example in a snackbar.

### countdown

Added with the countdown component. The digits are generated content hidden from assistive technology, so the element's text is the value a screen reader reads.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `countdown-has-text` | forbid | `.countdown:empty` | must not match |

- **countdown-has-text** - Write the number as the countdown's text, matching --md-comp-countdown-value. The rolling digits are hidden from screen readers, so an empty countdown reads as nothing.

### currency-input

Added with the currency input component. The author writes `data-currency` with an ISO 4217 code on a text field they already had; AutoInit shows the amount in the page's locale and that currency once the field loses focus, and the form submits the plain number. Only a text input can hold the formatted amount. A malformed code is caught at runtime, where the field stays a plain text input, because these rules are selectors.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `currency-input-needs-a-text-field` | forbid | `[data-currency]:not(input:is(:not([type]), [type="text" i])), [data-currency=""]` | must not match |

- **currency-input-needs-a-text-field** - Put data-currency, with a currency code such as EUR, on an <input> of type text. Other input types reject a formatted amount such as 1.234,50 €, and AutoInit skips them; a <textarea> is not formatted.

### data-table

Added with the data table component. The wrapper is the scroll container so the <table> keeps its table semantics. Sorting and selection live in the markup: aria-sort on the header cell and a checkbox in the first cell. With data-sort the framework reorders the rows and moves aria-sort, and it keeps a select-all box in the first header cell in step with the rows.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `data-table-wraps-the-table` | forbid | `table.data-table` | must not match |
| `data-table-sort-is-on-a-header` | forbid | `.data-table [aria-sort]:not(th)` | must not match |
| `data-table-row-is-not-selected` | forbid | `.data-table tr[aria-selected]` | must not match |
| `data-table-scroll-region-is-a-region` | require-attr | `.data-table[tabindex]` | must have `role` = `region` |
| `data-table-scroll-region-is-named` | require-accessible-name | `.data-table[tabindex]` | must end up with an accessible name |
| `data-table-filter-is-named` | require-accessible-name | `.data-table input[type="search"][data-table-filter]` | must end up with an accessible name |
| `data-table-filter-count-is-named` | require-accessible-name | `.data-table [data-table-filter-count]` | must end up with an accessible name |
| `data-table-filter-count-is-status` | require-attr | `.data-table [data-table-filter-count]` | must have `role` = `status` |
| `data-table-filter-count-is-polite` | require-attr | `.data-table [data-table-filter-count]` | must have `aria-live` = `polite` |
| `data-table-page-size-is-select` | forbid | `.data-table[data-page-size] [data-table-page-size]:not(select)` | must not match |
| `data-table-page-size-is-single` | forbid | `.data-table[data-page-size] select[data-table-page-size][multiple]` | must not match |
| `data-table-page-size-is-named` | require-accessible-name | `.data-table[data-page-size] select[data-table-page-size]` | must end up with an accessible name |
| `data-table-page-control-is-button` | forbid | `.data-table[data-page-size] [data-table-page]:not(button[type="button"])` | must not match |
| `data-table-page-control-is-named` | require-accessible-name | `.data-table[data-page-size] button[data-table-page]` | must end up with an accessible name |
| `data-table-page-control-is-in-pagination` | forbid | `.data-table[data-page-size] [data-table-page]:not(nav.pagination *)` | must not match |
| `data-table-page-navigation-is-named` | require-accessible-name | `.data-table[data-page-size] nav.pagination` | must end up with an accessible name |
| `data-table-page-direction-is-known` | forbid | `.data-table[data-page-size] [data-table-page]:not([data-table-page="previous"], [data-table-page="next"])` | must not match |
| `data-table-page-status-is-named` | require-accessible-name | `.data-table [data-table-page-status]` | must end up with an accessible name |
| `data-table-page-status-is-status` | require-attr | `.data-table [data-table-page-status]` | must have `role` = `status` |
| `data-table-page-status-is-polite` | require-attr | `.data-table [data-table-page-status]` | must have `aria-live` = `polite` |

- **data-table-wraps-the-table** - Put .data-table on a <div> around the <table>. The wrapper scrolls; a table made to scroll itself needs display:block, which drops its table role in some engines.
- **data-table-sort-is-on-a-header** - aria-sort belongs on the column header cell (<th>), not on the button inside it or on a data cell. Assistive technology reads the sort state from the header.
- **data-table-row-is-not-selected** - aria-selected is not valid on a row of a plain table. It belongs to role="grid" and its keyboard contract. Select a row with a labelled checkbox in its first cell.
- **data-table-scroll-region-is-a-region** - A focusable scroll wrapper needs role="region" so its name is announced. aria-label is not allowed on a plain div.
- **data-table-scroll-region-is-named** - Name a focusable scroll wrapper with aria-label, or aria-labelledby pointing at the table caption, so keyboard users know what they are scrolling.
- **data-table-filter-is-named** - Give the table search input a label identifying the records it filters.
- **data-table-filter-count-is-named** - Name the result count, for example aria-label="Matching records", so its number has context.
- **data-table-filter-count-is-status** - Give the table result count role="status" so changes are announced without moving focus.
- **data-table-filter-count-is-polite** - Use aria-live="polite" on the table result count so typing does not interrupt other speech.
- **data-table-page-size-is-select** - Use a native select for an authored table page-size control. The existing FormSelect enhancement reads and updates that same select.
- **data-table-page-size-is-single** - A table has one page size. Omit multiple from its page-size select.
- **data-table-page-size-is-named** - Label the table page-size select, for example Rows per page. Option values do not name the control.
- **data-table-page-control-is-button** - Use a native button with type="button" for a local table page control. Ordinary pagination links keep their navigation behavior.
- **data-table-page-control-is-named** - Name each table page button, for example Previous or Next, using visible text or an accessible label.
- **data-table-page-control-is-in-pagination** - Put local table page buttons in a named nav.pagination inside their table wrapper.
- **data-table-page-navigation-is-named** - Name the table's pagination navigation so it is distinct from other navigation on the page.
- **data-table-page-direction-is-known** - Set data-table-page to previous or next. Local table paging does not generate numbered page controls.
- **data-table-page-status-is-named** - Name the page status, for example aria-label="Pages", so its numbers have context.
- **data-table-page-status-is-status** - Give the table page status role="status" so page changes are announced without moving focus.
- **data-table-page-status-is-polite** - Use aria-live="polite" on the table page status so page changes do not interrupt other speech.

### datepicker

Swept 0.8.0. Generated markup inside a <dialog>; the dialog rules carry the container.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### description-list

Added with the description list component. The native <dl> is what pairs each value with its label for assistive technology, so the class only works on one.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `description-list-is-a-dl` | forbid | `.description-list:not(dl)` | must not match |

- **description-list-is-a-dl** - Write .description-list as a <dl> with <dt> labels and <dd> values. Divs drawn as two columns read as an unpaired run of text.

### dialog

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `dialog-is-named` | forbid | `dialog:not([aria-label]):not([aria-labelledby])` | must not match |

- **dialog-is-named** - A <dialog> takes no name from its heading. Point aria-labelledby at that heading, or give it an aria-label - otherwise it opens announced as just "dialog".

### docked-display

**Kind:** behavior.

Swept 0.8.0. A behavior, not a component: the picker plugins generate this positioning wrapper, so there is no element an author writes to get one.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### drag-handle

The affordance that says a thing can be dragged, added with the drag handle component (#34). Which element it is *is* the whole semantic: a <span aria-hidden="true"> when it is decoration - the usual case, and what a bottom sheet wants, since Escape already dismisses the sheet and the drag is a pointer nicety on top - and a <button> with a name when something is genuinely wired to it. The two rules below hold that split, and the sheet leans on it: `button.drag-handle` is where every interactive state in the partial is scoped, which is only correct because nothing else can be interactive. What no rule can check is the thing this component is most likely to be misused for. Dragging is a pointer gesture, so any outcome reachable only by dragging is unreachable without one; the Sortable behavior, the one reordering the framework ships, pairs a decorative handle with Move up and Move down buttons, and a page that builds its own owes it a keyboard path too. The docs say so plainly rather than implying the handle supplies it. The first rule is deliberately not fragmentSafe - what it detects is a *missing* aria-hidden, and a fragment omits by nature.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `drag-handle-is-hidden-or-a-control` | forbid | `.drag-handle:not(button):not([aria-hidden="true"])` | must not match |
| `drag-handle-button-is-not-hidden` | forbid | `button.drag-handle[aria-hidden="true"]` | must not match |
| `drag-handle-button-is-named` | require-accessible-name | `button.drag-handle` | must end up with an accessible name |

- **drag-handle-is-hidden-or-a-control** - A drag handle has no text and reports nothing, so on any element but a <button> it is decoration and needs aria-hidden="true". Left exposed it arrives as an unlabelled blank in the reading order. Make it a <button> with a name if it is meant to be operated.
- **drag-handle-button-is-not-hidden** - aria-hidden on a <button> hides it from assistive technology without taking it out of the tab order, so keyboard focus lands on something that is not there. Decoration is a <span>; a control stays exposed.
- **drag-handle-button-is-named** - The handle is a bar drawn in CSS - there is no text inside it to be named by. A handle worth making a control is worth an aria-label saying what it moves.

### empty-state

Added with the empty state component. Layout around authored content: the heading, text and buttons carry their own semantics, and the icon rules cover a leading icon.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `empty-state-image-has-alt` | forbid | `.empty-state > img:not([alt])` | must not match |

- **empty-state-image-has-alt** - Give an empty-state illustration alt="" when the heading already says what it shows, or alt text when the image adds information.

### expanding-card

Added 0.8.0. The compact article opens a named native dialog; its media and back actions are buttons.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `expanding-card-trigger-is-button` | forbid | `article.expanding-card .expanding-card-trigger:not(button)` | must not match |
| `expanding-card-close-is-button` | forbid | `dialog.expanding-card-dialog .expanding-card-close:not(button)` | must not match |

- **expanding-card-trigger-is-button** - The media-sized trigger opens a dialog, so use a button for native keyboard and disabled behavior.
- **expanding-card-close-is-button** - The back affordance closes the expanded surface, so use a button for native keyboard behavior.

### fab-menu

A FAB that expands into labelled actions reached with Tab. The runtime owns expanded state and makes closed actions inert; no composite menu role is promised.

**Conformance debt:** `menu` is withheld pending keyboard model, and `fab-menu-not-a-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `fab-menu-not-a-composite-widget` | forbid-composite-roles | `.fab-menu` | must not match with any composite role |
| `fab-menu-expanded-is-not-authored` | forbid | `.fab-menu [aria-expanded]` | must not match |

- **fab-menu-not-a-composite-widget** - The FAB menu takes no composite role; its actions are reached with Tab, not arrow keys.
- **fab-menu-expanded-is-not-authored** - Expanded is dynamic state, so the framework owns it. The constructor stamps aria-expanded on the trigger and every open() and close() rewrites it; authoring it states a value that is about to be overwritten.

### forms/checkboxes

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `checkbox-labelled` | forbid | `input[type="checkbox"]:not([id]):not(label *):not(.chip-input)` | must not match |

- **checkbox-labelled** - A checkbox must be inside its <label> or carry an id a label points at.

### forms/color-picker

Added with the color picker component. A named group around a native color input, which stays the form value; the component adds a saturation and brightness slider, a hue range and a hex field at runtime.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `color-picker-wraps-a-color-input` | forbid | `.color-picker:not(:has(> input[type="color"]))` | must not match |
| `color-picker-is-a-named-group` | require-accessible-name | `.color-picker` | must end up with an accessible name |

- **color-picker-wraps-a-color-input** - A .color-picker holds an <input type="color"> as a direct child. It is the form value, the preview and the picker without JavaScript.
- **color-picker-is-a-named-group** - Give the .color-picker role="group" and a name, such as aria-label="Accent", so its sliders and hex field read as one control.

### forms/color-swatches

Added with the color swatches component. A fieldset of native radios in labels with visually hidden names, drawn as round chips of their colors, with an optional native color input for a custom value.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `color-swatches-is-a-fieldset` | forbid | `.color-swatches:not(fieldset)` | must not match |
| `color-swatch-is-named` | require-accessible-name | `.color-swatches > [type="radio"]` | must end up with an accessible name |

- **color-swatches-is-a-fieldset** - A .color-swatches is a <fieldset> with a <legend>, so the radios read as one named group.
- **color-swatch-is-named** - A swatch shows no text, so name each radio with visually hidden text in its label, such as "Purple".

### forms/drop-zone

Added with the drop zone component. A <label> wraps the native file input, which covers it, so the label text names the input and the browser handles clicks, keys and dropped files.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `drop-zone-input-is-in-the-label` | forbid | `.drop-zone > input[type="file"]` | must not match |
| `drop-zone-files-are-a-list` | forbid | `.drop-zone-files:not(ul, ol)` | must not match |
| `drop-zone-type-error-is-live-status` | forbid | `.drop-zone:has(> label input[type="file"]:is([data-validate-types], [data-max-size], [data-max-files])):not(:has(> [data-error])), .drop-zone:has(> label input[type="file"]:is([data-validate-types], [data-max-size], [data-max-files])) > [data-error]:not([role="status"][aria-live="polite"])` | must not match |

- **drop-zone-input-is-in-the-label** - Put the file input inside the .drop-zone <label>. The label names the input and the input covers the label; a loose input is neither named nor a drop target.
- **drop-zone-files-are-a-list** - Write .drop-zone-files as a <ul>. The behavior fills it with one <li> per file, and a list tells assistive technology how many files were chosen.
- **drop-zone-type-error-is-live-status** - An opted-in file-validation zone needs a direct [data-error] area with role="status" and aria-live="polite". The runtime writes real error text there and describes the file input with it.

### forms/fieldset

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `fieldset-has-legend` | forbid | `fieldset:not(:has(> legend))` | must not match |

- **fieldset-has-legend** - A fieldset without a legend groups controls visually but names nothing.

### forms/file-input

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `file-input-not-nested` | forbid | `:is(button, a) input[type="file"]` | must not match |
| `file-path-readonly` | forbid | `input.file-path:not([readonly])` | must not match |

- **file-input-not-nested** - A control inside a control is not valid HTML and gives two overlapping hit targets. Use a <label> styled as a button.
- **file-path-readonly** - The path field only ever displays what the file input holds; leaving it writable offers an edit that is discarded.

### forms/input-fields

Swept 0.8.0. .field is the container; icons name their side and are hidden; supporting text is linked to the control it describes. A .password-toggle reports its state with aria-pressed; input.otp is one native input drawn as cells.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `field-container-class` | forbid | `.input-field:not(.chips)` | must not match |
| `field-icon-not-i` | forbid | `.field i` | must not match |
| `field-icon-hidden` | require-attr | `.field .material-symbols, .field .material-symbols-outlined, .field .material-symbols-rounded, .field .material-symbols-sharp` | must have `aria-hidden` = `true` |
| `field-icon-side` | forbid | `.field > :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp):not(.prefix):not(.suffix)` | must not match |
| `field-label-for` | require-attr | `.field > label:not(:has(input, select, textarea))` | must have `for` |
| `field-control-id` | require-attr | `.field > :is(input, textarea, select)` | must have `id` |
| `field-supporting-text-id` | require-attr | `.field > small` | must have `id` |
| `field-supporting-text-linked` | forbid | `.field:has(> small) > :is(input, textarea, select):not([aria-describedby])` | must not match |
| `password-toggle-reports-its-state` | require-attr | `.password-toggle` | must have `aria-pressed` |
| `otp-autocompletes` | require-attr | `input.otp` | must have `autocomplete` = `one-time-code` |
| `otp-is-not-a-number` | forbid | `input.otp[type="number"]` | must not match |

- **field-container-class** - The field container is `.field`. `.input-field` matches nothing in the sheet - the only `.input-field` rule is `.chips.input-field`.
- **field-icon-not-i** - <i> means idiomatic text, not icon. Use <span class="material-symbols">.
- **field-icon-hidden** - The ligature is read aloud. A field icon is decoration; the label names the control.
- **field-icon-side** - A field icon must name its side with .prefix or .suffix. Fields have no positional fallback.
- **field-label-for** - A label that does not wrap its control has to name it. The floating label sits *after* the control and cannot wrap it, so it always needs `for`; a label that does wrap its control (the file input trigger) does not.
- **field-control-id** - Without an id the label has nothing to point at.
- **field-supporting-text-id** - Supporting and error text has to be referenceable to be announced with the control.
- **field-supporting-text-linked** - A field with supporting text must point at it with aria-describedby, or the text is never read out with the control.
- **password-toggle-reports-its-state** - Give a .password-toggle aria-pressed="false". The behavior flips it, and its name stays "Show password", so the pressed state is how a screen reader user hears that the password is visible.
- **otp-autocompletes** - Set autocomplete="one-time-code" on a one-time code input so the browser can offer the code from a message.
- **otp-is-not-a-number** - A code is not a quantity: type="number" drops leading zeros and adds a spinner. Use type="text" with inputmode="numeric".

### forms/radio-buttons

Swept 0.8.0. A radio is one of a set, and the set needs a name.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `radio-in-fieldset` | forbid | `input[type="radio"]:not(fieldset *)` | must not match |
| `radio-labelled` | forbid | `input[type="radio"]:not([id]):not(label *)` | must not match |

- **radio-in-fieldset** - A radio only means something as one of a group, and the group needs a <fieldset> with a <legend> to name the question being answered.
- **radio-labelled** - A radio must be inside its <label> or carry an id a label points at.

### forms/rating

Added with the rating component. The input is a fieldset of native radios drawn as stars; the display is a named image.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `rating-is-a-fieldset-or-an-image` | forbid | `.rating:not(fieldset):not([role="img"])` | must not match |
| `rating-display-is-named` | require-accessible-name | `.rating[role="img"]` | must end up with an accessible name |
| `rating-label-text-precedes-the-radio` | forbid | `fieldset.rating [type="radio"] + span` | must not match |

- **rating-is-a-fieldset-or-an-image** - A .rating is either a <fieldset> of radios, for input, or an element with role="img" and a name, for display. Anything else draws stars that say nothing.
- **rating-display-is-named** - Name a rating display with the value, such as aria-label="Rated 4.5 out of 5".
- **rating-label-text-precedes-the-radio** - Put a star's visually hidden text before its radio. A span after the radio is the pre-1.0 input + span pattern, which 0.16.0 no longer draws.

### forms/select

Swept 0.8.0. The listbox FormSelect builds is checked at runtime in tests/select.test.js.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `option-has-selected-state` | forbid | `li[role="option"]:not([aria-selected])` | must not match |

- **option-has-selected-state** - role=option promises a selection state. Without aria-selected the listbox never says which entry is chosen.

### forms/slider

Swept 0.8.0. Renamed from range to the name M3 uses; 0.16.0 removed the `.range` and `.range-field` hosts.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `range-labelled` | forbid | `input[type="range"]:not([id]):not([aria-label]):not([aria-labelledby]):not(label *)` | must not match |

- **range-labelled** - A range announces only its value unless something names it.

### forms/switches

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `switch-is-label` | forbid | `.switch:not(label)` | must not match |
| `switch-decorative-text-hidden` | require-attr | `.switch > span` | must have `aria-hidden` = `true` |

- **switch-is-label** - A switch is a <label> wrapping its checkbox - that is what makes the text its accessible name.
- **switch-decorative-text-hidden** - On/off captions inside the label are folded into the accessible name, which then reads "Off On". Hide them and let the label text name the switch.

### funnel-chart

Added with the funnel chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn stages and labels are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `funnel-chart-is-a-figure-with-a-table` | forbid | `.funnel-chart:not(figure:has(> table))` | must not match |
| `funnel-chart-has-a-caption` | forbid | `.funnel-chart:not(:has(> figcaption))` | must not match |

- **funnel-chart-is-a-figure-with-a-table** - Write .funnel-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **funnel-chart-has-a-caption** - Name a .funnel-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### heatmap-chart

Added with the heatmap chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn grid, labels and scale are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `heatmap-chart-is-a-figure-with-a-table` | forbid | `.heatmap-chart:not(figure:has(> table))` | must not match |
| `heatmap-chart-has-a-caption` | forbid | `.heatmap-chart:not(:has(> figcaption))` | must not match |

- **heatmap-chart-is-a-figure-with-a-table** - Write .heatmap-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **heatmap-chart-has-a-caption** - Name a .heatmap-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### hover-card

Added with the hover card component. The author writes a link with the hover-card-trigger class and points its aria-details at a .popover with popover="hint"; AutoInit shows that card after a delay when the link is hovered or gets keyboard focus. The card is extra detail, so it is not a tooltip and not the link's description.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `hover-card-trigger-is-a-link` | forbid | `.hover-card-trigger:not(a[href])` | must not match |
| `hover-card-names-its-card` | require-idref | `.hover-card-trigger` | `aria-details` must reference `[popover]` inside `body` |

- **hover-card-trigger-is-a-link** - Put hover-card-trigger on an <a href>. The card previews where the link goes, and a tap or click still has to follow it; a button already has a popover that opens on press.
- **hover-card-names-its-card** - Point the trigger's aria-details at the id of its card, a [popover] element in the page. The component finds the card through it, and screen readers use it to reach the card.

### icon-buttons

Added with the icon button component (#22). An icon button is the case rule 3 exists for: its only content is a decorative icon, so the name has to live on the control. `.button.circle` is the older icon button and is covered by the buttons row.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `icon-button-is-a-control` | forbid | `.icon-button:not(button):not(a[href])` | must not match |
| `icon-button-link-is-not-fake-disabled` | forbid | `a[href].icon-button:is(.disabled, [aria-disabled="true"])` | must not match |
| `icon-button-icon-hidden` | require-attr | `.icon-button :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)` | must have `aria-hidden` = `true` |
| `icon-button-is-named` | require-accessible-name | `.icon-button` | must end up with an accessible name |

- **icon-button-is-a-control** - An icon button is a control: <button>, or <a href> when it navigates. A <div>, or an anchor with no href, is neither focusable nor announced.
- **icon-button-link-is-not-fake-disabled** - A link cannot be disabled by a class. `pointer-events: none` stops the pointer and nothing else, so this stays in the tab order and still navigates on Enter - it looks inert and is not. Drop the href, or the link.
- **icon-button-icon-hidden** - The ligature is real text and is read out verbatim. The icon in an icon button is decoration - the control carries the name - so it is aria-hidden="true".
- **icon-button-is-named** - The icon is hidden from assistive technology, so an icon button has nothing left to be named by. Give it an aria-label.

### icons-material-design

Swept 0.8.0. The canonical icon is <span class="material-symbols">, and an icon is either decoration or an image - never unannounced text.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `icon-not-i-element` | forbid | `i.material-symbols, i.material-symbols-outlined, i.material-symbols-rounded, i.material-symbols-sharp` | must not match |
| `icon-hidden-or-labelled` | forbid | `.material-symbols:not([aria-hidden="true"]):not([role="img"][aria-label]):not([role="img"][aria-labelledby]), .material-symbols-outlined:not([aria-hidden="true"]):not([role="img"][aria-label]):not([role="img"][aria-labelledby]), .material-symbols-rounded:not([aria-hidden="true"]):not([role="img"][aria-label]):not([role="img"][aria-labelledby]), .material-symbols-sharp:not([aria-hidden="true"]):not([role="img"][aria-label]):not([role="img"][aria-labelledby]):not([aria-hidden="true"]):not([role="img"][aria-label]):not([role="img"][aria-labelledby])` | must not match |

- **icon-not-i-element** - <i> means idiomatic text. The canonical icon element is <span class="material-symbols">.
- **icon-hidden-or-labelled** - The ligature is real text and is read out verbatim. An icon is either decoration - aria-hidden="true", with the enclosing control carrying the name - or an image in its own right, role="img" with a label. It is never left as bare text.

### image-compare

Added with the image compare component. A native range input is the control, so its slider semantics are the browser's; both images need alt text that says which is which.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `image-compare-has-a-range` | forbid | `.image-compare:not(:has(> input[type="range"]))` | must not match |
| `image-compare-range-is-named` | require-accessible-name | `.image-compare > input[type="range"]` | must end up with an accessible name |
| `image-compare-images-have-alt` | forbid | `.image-compare > img:not([alt])` | must not match |

- **image-compare-has-a-range** - Put an <input type="range"> in the .image-compare. It is the control people use, by pointer or keyboard; without it the divider cannot move.
- **image-compare-range-is-named** - Name the range, such as aria-label="Compare before and after". It is the only control and has no visible label.
- **image-compare-images-have-alt** - Give each image alt text that says which it is, such as "Before: faded photo". The comparison means nothing if neither image is described.

### input-mask

Added with the input mask component. The author writes `data-mask` on a text field they already had; AutoInit formats its value to the pattern as people type, paste or compose, and the form submits the formatted text. The component needs a caret to place, which only the text, tel, search, url and password input types have.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `input-mask-needs-a-caret` | forbid | `[data-mask]:not(input:is(:not([type]), [type="text" i], [type="tel" i], [type="search" i], [type="url" i], [type="password" i]))` | must not match |

- **input-mask-needs-a-caret** - Put data-mask on an <input> of type text, tel, search, url or password. Other input types have no caret to place and reject a formatted value, and AutoInit skips them; a <textarea> is not masked.

### keypad

Added with the keypad and keyboard component. The container is a named group, and every key is a native button that must not submit the form around it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `keypad-is-group` | require-attr | `:is(.keypad, .keyboard)` | must have `role` = `group` |
| `keypad-is-labelled` | require-attr | `:is(.keypad, .keyboard)` | must have `aria-label` |
| `keypad-key-type` | require-attr | `:is(.keypad, .keyboard) button` | must have `type` = `button` |

- **keypad-is-group** - A keypad or keyboard is a set of related keys. Give it role="group" and an aria-label so its keys are announced together.
- **keypad-is-labelled** - Name the keypad or keyboard with aria-label, for example "PIN keypad".
- **keypad-key-type** - A <button> inside a form defaults to type=submit, so every key would submit it. Give each key type="button".

### kpi

Added with the KPI component. A description list pairs the value with its label; the trend arrow is decoration and the text says the direction.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `kpi-is-a-card-with-a-labelled-value` | forbid | `.kpi:not(article:has(> dl > dt + dd.kpi-value))` | must not match |

- **kpi-is-a-card-with-a-labelled-value** - Write .kpi as an <article> holding a <dl> whose <dt> label comes right before the <dd class="kpi-value">. The article makes it a card, and without the pairing a screen reader reads a bare number with nothing to say what it is. Not fragmentSafe: what it detects is an omission.

### landmarks

Swept 0.8.0. Cross-cutting: the landmark budget belongs to no single component.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `nav-needs-label` | forbid | `nav:not([aria-label]):not([aria-labelledby]):not(article nav):not(.tooltip nav):not(.toolbar)` | must not match |
| `main-not-nested` | forbid | `main main` | must not match |

- **nav-needs-label** - A page carries several <nav> landmarks - app bar, tabs, breadcrumbs, footer columns, drawer. Unlabelled they arrive in the landmark menu as a row of identical entries, which is worse than not being landmarks at all. Three carve-outs, each an action row that should not be <nav> at all so that labelling it would entrench the error: `article nav` (card actions, awaits the cards sweep), `.tooltip nav` (rich-tooltip actions, awaits the tooltip sweep), `.toolbar` (awaits the toolbar sweep). Delete each carve-out with its sweep.
- **main-not-nested** - A document has one <main>. A pane that holds the primary content of a region is a <section> or a <div>.

### lightbox

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `lightbox-trigger-is-operable` | forbid | `img.lightboxed:not([tabindex]):not(:is(a, button) > img)` | must not match |

- **lightbox-trigger-is-operable** - A .lightboxed image opens an overlay on click, which makes it a control - and a bare <img> is not focusable and not operable from the keyboard. Wrap it in a <button>, or give it tabindex="0" and a role.

### line-chart

Added with the line chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn chart, labels and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `line-chart-is-a-figure-with-a-table` | forbid | `.line-chart:not(figure:has(> table))` | must not match |
| `line-chart-has-a-caption` | forbid | `.line-chart:not(:has(> figcaption))` | must not match |

- **line-chart-is-a-figure-with-a-table** - Write .line-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **line-chart-has-a-caption** - Name a .line-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### list

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `list-item-not-selected` | forbid | `li[aria-selected]:not([role])` | must not match |
| `sortable-items-move-without-dragging` | forbid | `.sortable > li:not(:has([data-move="up"]):has([data-move="down"]))` | must not match |
| `list-unstyled-has-list-role` | forbid | `:is(ul, ol).list-unstyled:not([role])` | must not match |

- **list-item-not-selected** - aria-selected is not valid on a listitem. A plain <ul> has nowhere to put it - either give the item a role that takes it (option, tab, row, treeitem) and implement that role’s keyboard contract, or drop the attribute.
- **sortable-items-move-without-dragging** - Give each .sortable item a [data-move="up"] and a [data-move="down"] button, in the row or in a menu. Dragging is a pointer gesture; the buttons are the keyboard path and the single-pointer alternative WCAG 2.5.7 requires.
- **list-unstyled-has-list-role** - WebKit drops the list role from a list with no markers, so screen readers stop announcing it as a list. Give a .list-unstyled list role="list", or the role it should have instead.

### loading-indicator

Short waits with unknown duration. The current framework contract uses a named status. ARIA also permits an indeterminate progressbar without aria-valuenow; that is not the current loading-indicator contract.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `loading-indicator-reports-itself` | require-attr | `.loading-indicator` | must have `role` = `status` |
| `loading-indicator-is-named` | require-accessible-name | `.loading-indicator` | must end up with an accessible name |

- **loading-indicator-reports-itself** - The current loading-indicator contract requires role="status". Provide meaningful waiting text updates and verify announcement delivery; a role alone does not prove spoken output.
- **loading-indicator-is-named** - Name the waiting state with aria-label or aria-labelledby. Verify meaningful status updates separately from its accessible name.

### loading-overlay

Added with the loading overlay component. The page owns aria-busy on the region; while it is "true" the script makes the region inert, so nothing inside takes pointer or keyboard input, and announces the start and the finish in a visually hidden role="status" region it adds after the region, because inert content leaves the accessibility tree. The author's loading indicator keeps the role and name it has everywhere else.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `loading-overlay-has-an-indicator` | forbid | `.loading-overlay:not(:has(> .loading-indicator))` | must not match |

- **loading-overlay-has-an-indicator** - Give a .loading-overlay a .loading-indicator as a direct child. It is what shows over the scrim while the region is busy.

### map

Added with the map component. MapLibre draws the map on a canvas, so the frame needs a name for the region it is, and a marker that opens a popup has to be a button to be reached and activated from the keyboard. Icon-only controls are covered by icon-only-control-is-named.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `map-is-named` | forbid | `.map:not([aria-label], [aria-labelledby])` | must not match |
| `map-popup-marker-is-button` | forbid | `.map-marker[aria-controls]:not(button)` | must not match |

- **map-is-named** - Name the map with aria-label or aria-labelledby, and give it role="region". MapLibre's canvas has no useful name of its own.
- **map-popup-marker-is-button** - A marker that opens a popup is a <button type="button"> with aria-label, so the keyboard can reach and open it.

### marquee

Added with the marquee component. The second group is a visual copy for the seamless loop, so assistive technology must skip it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `marquee-copy-is-hidden` | forbid | `.marquee > .marquee-content ~ .marquee-content:not([aria-hidden="true"])` | must not match |

- **marquee-copy-is-hidden** - The second .marquee-content repeats the first so the loop has no gap. Mark it aria-hidden="true" and give each link or button in it tabindex="-1", or a screen reader reads every item twice.

### mega-menu

Added with the mega menu component. A named nav of links and disclosure buttons whose panels are native popovers; the browser owns the expanded state, so no menu role is promised.

**Rejected role:** `menu` is not withheld but declined - its items are links and disclosure buttons reached with Tab in reading order - a menu role promises arrow-key navigation and menuitems that site navigation does not have, and the menubar component is the place for that contract. `mega-menu-is-not-a-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `mega-menu-is-a-named-nav` | forbid | `.mega-menu:not(nav)` | must not match |
| `mega-menu-panel-is-a-popover` | forbid | `.mega-menu-panel:not([popover])` | must not match |
| `mega-menu-is-not-a-composite-widget` | forbid-composite-roles | `.mega-menu` | must not match with any composite role |
| `mega-menu-items-are-not-menuitems` | forbid | `.mega-menu :is([role="menu"], [role="menuitem"])` | must not match |

- **mega-menu-is-a-named-nav** - Write .mega-menu as a <nav> with an aria-label. It is site navigation: a landmark people jump to, not a generic row.
- **mega-menu-panel-is-a-popover** - Give .mega-menu-panel the popover attribute and open it with popovertarget on its button. The browser then owns opening, Escape, light dismiss and the button's expanded state.
- **mega-menu-is-not-a-composite-widget** - A mega menu is links and disclosure buttons reached with Tab. A menu, menubar or any other composite role promises arrow-key navigation it does not have; use a menubar for application commands.
- **mega-menu-items-are-not-menuitems** - Leave the bar's links, buttons and panels without menu or menuitem roles. They are reached with Tab, and those roles promise arrow keys and typeahead the mega menu does not have.

### menu

Swept 0.8.0. A [data-context-menu] trigger is a region: the menu opens at the pointer and the region carries no expanded state.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `menu-children-are-list-items` | forbid | `menu > :not(li):not(script):not(template)` | must not match |
| `menu-group-is-named` | forbid | `menu ul[role="group"]:not([aria-label]):not([aria-labelledby])` | must not match |
| `context-menu-region-is-not-a-control` | forbid | `[data-context-menu]:is(button, a[href], input, select, textarea)` | must not match |

- **menu-children-are-list-items** - <menu> is a list: its content model permits only <li>. A bare <hr> between entries is invalid - put the separator inside an <li>, or use role=separator there.
- **menu-group-is-named** - A <ul role=group> in a menu is a set of choices, and its name is what a screen reader announces on entering it. Give it aria-label or aria-labelledby.
- **context-menu-region-is-not-a-control** - data-context-menu goes on a region of content. A button or link already has an action for Enter and click; give it its own menu trigger instead.

### menubar

Added with the menubar component. The Menubar component sets role=menubar on the bar and role=menuitem on each trigger at runtime and implements the menubar keyboard contract over Menu; tests/menubar-browser.test.js checks it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `menubar-is-named` | require-accessible-name | `.menubar` | must end up with an accessible name |
| `menubar-holds-only-triggers` | forbid | `.menubar > :not(.menu-trigger)` | must not match |

- **menubar-is-named** - Name the menubar with aria-label, such as "Editor". A page can hold several, and the name says which commands they are.
- **menubar-holds-only-triggers** - A .menubar holds only .menu-trigger buttons. Anything else breaks the menubar's arrow-key order; put the menus themselves after the bar.

### message

Added with the message component. The parts are divs on a CSS grid. A <header> or <footer> outside sectioning content is a banner or contentinfo landmark, so writing the parts as those elements would add two landmarks per message. Icon-only footer actions are already covered by icon-only-control-is-named.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `message-parts-are-not-landmarks` | forbid | `:is(header.message-header, footer.message-footer)` | must not match |

- **message-parts-are-not-landmarks** - Write .message-header and .message-footer as <div>. Outside sectioning content a <header> is a banner landmark and a <footer> is contentinfo, so a conversation would fill the landmark menu with one of each per message.

### message-scroller

Added with the message scroller component. The viewport is a focusable scroll container, so it needs a name; on a live conversation it is also the log. The script owns scroll position and the button's inert state, and adds no roles.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `message-scroller-viewport-is-named` | require-accessible-name | `.message-scroller-viewport` | must end up with an accessible name |

- **message-scroller-viewport-is-named** - Name the viewport with aria-label. It is a focusable scroll region, and a screen reader announces the name when focus lands on it.

### mixed-chart

Added with the mixed chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn columns, lines, labels and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `mixed-chart-is-a-figure-with-a-table` | forbid | `.mixed-chart:not(figure:has(> table))` | must not match |
| `mixed-chart-has-a-caption` | forbid | `.mixed-chart:not(:has(> figcaption))` | must not match |

- **mixed-chart-is-a-figure-with-a-table** - Write .mixed-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **mixed-chart-has-a-caption** - Name a .mixed-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### modal-display

**Kind:** behavior.

Added with the modal date and time pickers. A behavior, not a component: the picker plugins generate this <dialog>, named by the input's label, so there is no element an author writes to get one.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### navbar

Swept 0.8.0. The bar is <header> whose child is <nav>; the landmark rules carry it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### navigation-bar

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `navigation-bar-marks-current` | forbid | `.navigation-bar a.active:not([aria-current])` | must not match |

- **navigation-bar-marks-current** - The active destination needs aria-current="page".

### navigation-rail

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `navigation-rail-marks-current` | forbid | `.navigation-rail a.active:not([aria-current])` | must not match |
| `navigation-rail-panel-is-a-popover` | forbid | `.navigation-rail-panel:not([popover])` | must not match |

- **navigation-rail-marks-current** - The active destination needs aria-current="page".
- **navigation-rail-panel-is-a-popover** - Give .navigation-rail-panel the popover attribute and put it right after the destination button that names it with popovertarget. The browser then owns opening, Escape, light dismiss and the button's expanded state.

### org-chart

Added with the org chart component. Nested lists of native disclosures: each manager's card is a summary that folds the team away, so no tree role is promised.

**Rejected role:** `tree` is not withheld but declined - its managers are native disclosures reached with Tab - a tree role promises arrow-key navigation, type-ahead and expanded states on treeitems that the markup does not have and that native details would fight. `org-chart-is-not-a-tree-widget` enforces that.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `org-chart-is-not-a-tree-widget` | forbid | `.org-chart[role], .org-chart [role="treeitem"], .org-chart [role="group"]` | must not match |
| `org-chart-manager-holds-no-link` | forbid | `.org-chart summary :is(a[href], button)` | must not match |

- **org-chart-is-not-a-tree-widget** - A .org-chart takes no ARIA tree roles. It is lists of disclosures, which keep their own roles and keyboard behavior.
- **org-chart-manager-holds-no-link** - A manager's card is the summary that opens the team, so a link or button inside it competes with that. Link the profile elsewhere; only a card without reports can be an <a class="org-chart-node">.

### page-footer

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `footer-nav-has-links` | forbid | `footer nav:not(:has(a))` | must not match |

- **footer-nav-has-links** - A footer column with no links is not navigation - it is a <section> with a heading. Marking it <nav> spends a landmark on prose.

### pagination

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `pagination-in-nav` | forbid | `.pagination:not(nav):not(nav *)` | must not match |
| `pagination-marks-current` | forbid | `.pagination .active :is(a, span):not([aria-current])` | must not match |
| `pagination-disabled-not-a-link` | forbid | `.pagination .disabled a[href]` | must not match |

- **pagination-in-nav** - Pagination is navigation and belongs in a labelled <nav>, or it is just a list of numbers.
- **pagination-marks-current** - The current page needs aria-current="page". A class only colours it.
- **pagination-disabled-not-a-link** - A disabled control that is still an <a href> stays in the tab order and still navigates. Use a <span>, or drop the href.

### panes

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `pane-is-not-main` | forbid | `:is(.panes, .pane-layout, .list-detail, .supporting-pane-layout) main` | must not match |

- **pane-is-not-main** - A document has one <main>, and a pane is a region inside it, not another one. Use <section>.

### particles

Added with the particles component. The dots are decoration drawn on a canvas, so the canvas is hidden from assistive technology.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `particles-canvas-is-hidden` | require-attr | `canvas.particles` | must have `aria-hidden` = `true` |

- **particles-canvas-is-hidden** - Particles are decoration. Mark the canvas aria-hidden="true".

### pie-chart

Added with the pie chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn pie, values, total and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `pie-chart-is-a-figure-with-a-table` | forbid | `.pie-chart:not(figure:has(> table))` | must not match |
| `pie-chart-has-a-caption` | forbid | `.pie-chart:not(:has(> figcaption))` | must not match |

- **pie-chart-is-a-figure-with-a-table** - Write .pie-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **pie-chart-has-a-caption** - Name a .pie-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### popover

Added with the popover component. The panel is a native [popover] and its button names it with popovertarget or commandfor, so the browser owns opening, light dismiss, Escape and the button's expanded state.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `popover-is-native` | forbid | `.popover:not([popover])` | must not match |
| `popover-target-exists` | require-idref | `[popovertarget]` | `popovertarget` must reference `[popover]` inside `body` |
| `popover-is-not-a-tooltip` | forbid | `.popover[role="tooltip"]` | must not match |

- **popover-is-native** - Give a .popover the popover attribute. Without it the panel is never in the top layer, never light-dismisses and its button reports no expanded state.
- **popover-target-exists** - popovertarget must name the id of a [popover] element in the page. A dangling id leaves a button that does nothing.
- **popover-is-not-a-tooltip** - A popover opens on press and can hold links and buttons, which a tooltip never can. Leave the role off; the browser relates the panel to its button.

### progress

Swept 0.8.0. Renamed from preloader; M3 calls the component Progress indicators.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `div-progress-reports-progress` | forbid | `div.progress:not([role="progressbar"])` | must not match |
| `determinate-progress-reports-its-value` | forbid | `[role="progressbar"]:has(> .determinate):not([aria-valuenow])` | must not match |

- **div-progress-reports-progress** - <progress> reports itself. A <div class="progress"> is a bar drawn with CSS and reports nothing - it needs role="progressbar", plus aria-valuenow/min/max when it is determinate.
- **determinate-progress-reports-its-value** - role="progressbar" with no aria-valuenow is an *indeterminate* bar. A determinate one draws a width the user can see and must report the same number: aria-valuenow, plus valuemin/valuemax when they are not 0 and 100.

### pull-to-refresh

Added with the pull-to-refresh component. A touch pull and a [data-refresh] button start the same refresh, so people who cannot pull use the button. The author's loading indicator carries the role and name; the script shows it, sets aria-busy on the container while the refresh runs, and adds a visually hidden role="status" region that announces the start and the finish, because the indicator is hidden once the refresh ends.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `pull-to-refresh-has-a-refresh-button` | forbid | `.pull-to-refresh:not(:has(button[data-refresh]))` | must not match |
| `pull-to-refresh-has-an-indicator` | forbid | `.pull-to-refresh:not(:has(> .loading-indicator))` | must not match |

- **pull-to-refresh-has-a-refresh-button** - Put a <button data-refresh> inside each .pull-to-refresh. Pulling needs a touch screen, and the button is the way to refresh for a keyboard, mouse or switch user.
- **pull-to-refresh-has-an-indicator** - Give a .pull-to-refresh a .loading-indicator as a direct child. It is what comes down with a pull and shows while the refresh runs.

### questionnaire

Added with the questionnaire component. A native form of fieldsets; the component shows one question at a time and owns the progress row, hidden questions, invalid state and focus.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `questionnaire-is-a-form` | forbid | `.questionnaire:not(form)` | must not match |
| `questionnaire-is-named` | require-accessible-name | `form.questionnaire` | must end up with an accessible name |
| `questionnaire-navigation-does-not-submit` | forbid | `.questionnaire > footer > button:is([value=previous], [value=skip], [value=next]):not([type=button])` | must not match |

- **questionnaire-is-a-form** - Write the questionnaire as <form class="questionnaire">. The form sends the answers, runs the browser's validation and still works without JavaScript.
- **questionnaire-is-named** - Name the questionnaire with aria-label or aria-labelledby so it is announced as a form.
- **questionnaire-navigation-does-not-submit** - Give the Back, Skip and Next buttons type="button". A button without a type submits the form, so pressing one before the component starts would send it early.

### radar-chart

Added with the radar chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn shapes, labels and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `radar-chart-is-a-figure-with-a-table` | forbid | `.radar-chart:not(figure:has(> table))` | must not match |
| `radar-chart-has-a-caption` | forbid | `.radar-chart:not(:has(> figcaption))` | must not match |

- **radar-chart-is-a-figure-with-a-table** - Write .radar-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **radar-chart-has-a-caption** - Name a .radar-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### radial-chart

Added with the radial chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn rings, total and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `radial-chart-is-a-figure-with-a-table` | forbid | `.radial-chart:not(figure:has(> table))` | must not match |
| `radial-chart-has-a-caption` | forbid | `.radial-chart:not(:has(> figcaption))` | must not match |

- **radial-chart-is-a-figure-with-a-table** - Write .radial-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **radial-chart-has-a-caption** - Name a .radial-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### rich-text-editor

Added with the rich text editor component. The script hides the textarea and gives the editable element role="textbox" with aria-multiline, copying the textarea's name to it, so the name has to be on the textarea. The toolbar follows the toolbar rules; icon-only buttons are covered by icon-only-control-is-named.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `rich-text-editor-textarea-is-named` | forbid | `.rich-text-editor > textarea:not([aria-label], [aria-labelledby], [id])` | must not match |

- **rich-text-editor-textarea-is-named** - Name the textarea with a <label for>, aria-label or aria-labelledby. The editor copies that name to its editable area, which has none of its own. Its content is the starting HTML, not a name.

### sankey-chart

Added with the sankey chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn nodes, links and names are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `sankey-chart-is-a-figure-with-a-table` | forbid | `.sankey-chart:not(figure:has(> table))` | must not match |
| `sankey-chart-has-a-caption` | forbid | `.sankey-chart:not(:has(> figcaption))` | must not match |

- **sankey-chart-is-a-figure-with-a-table** - Write .sankey-chart as a <figure> holding a <table> of the flows. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **sankey-chart-has-a-caption** - Name a .sankey-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### scatter-chart

Added with the scatter chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn points, axes and legend are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `scatter-chart-is-a-figure-with-a-table` | forbid | `.scatter-chart:not(figure:has(> table))` | must not match |
| `scatter-chart-has-a-caption` | forbid | `.scatter-chart:not(:has(> figcaption))` | must not match |

- **scatter-chart-is-a-figure-with-a-table** - Write .scatter-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **scatter-chart-has-a-caption** - Name a .scatter-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

### scrim

**Kind:** foundation.

The wash a modal surface paints behind itself, added in 1.x (#37). A foundation, not a component: nothing is marked up to get one, so there is no element for a rule to name. `--md-comp-scrim-color` is the whole of it; dialogs, sheets, and the navigation rail own the markup that wears it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### scrollspy

Swept 0.8.0. The sections it watches are the author's own; the table of contents beside them is markup the author writes, which is what keeps this a component and not a behavior. `_table_of_contents.scss` styles that list and states no component of its own, so the rule for it lives here.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `toc-in-nav` | forbid | `.table-of-contents:not(nav):not(nav *)` | must not match |

- **toc-in-nav** - A table of contents is a set of destinations within the page. It belongs in a labelled <nav>.

### search

The bar is a `<search>` landmark holding an `<input type="search">`; the view is the surface it expands into. Suggestions are Autocomplete's - `class="autocomplete"` on the bar's input gives the combobox, its listbox and the keyboard contract that are already implemented and tested there, so this component neither duplicates them nor ships a second, weaker copy. Full-screen the view is a `<dialog>`, so the dialog rules carry it.

**Rejected role:** `listbox` is not withheld but declined - the view is a surface holding content - recent searches, results, filters - reached with Tab, not a set of options over which one keyboard focus moves. Where a page does want a suggestion listbox, that pattern is Autocomplete's and Autocomplete implements it; a second listbox here would be the same promise with no keyboard model behind it.. `search-view-not-a-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `search-view-not-a-composite-widget` | forbid-composite-roles | `.search-view` | must not match with any composite role |
| `search-bar-holds-a-search-input` | forbid | `.search-bar input:not([type="search"])` | must not match |

- **search-view-not-a-composite-widget** - The search view takes no composite role. Its contents are links and buttons reached with Tab; the combobox and listbox belong to the Autocomplete on the bar's input.
- **search-bar-holds-a-search-input** - The bar's control is a search input. `type="search"` is what tells assistive technology, the UA and the on-screen keyboard what the field is for, and it is static semantics, so the author states it.

### show-more

Added with the show-more component. The author writes clamped text and the button that expands it; AutoInit sets the button's aria-expanded and aria-controls, swaps its label while the text is open, and hides it when the text already fits.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `show-more-needs-a-button` | forbid | `.show-more:not(:has(> button[type="button" i]))` | must not match |
| `show-more-needs-clamped-text` | forbid | `.show-more:not(:has(> :is(.line-clamp-1, .line-clamp-2, .line-clamp-3)))` | must not match |

- **show-more-needs-a-button** - Give a .show-more a <button type="button"> child. A <span> or <div> with role="button" is not operable from the keyboard without script, and a <button> without type="button" submits the form it sits in.
- **show-more-needs-clamped-text** - Give a .show-more a child with line-clamp-1, line-clamp-2 or line-clamp-3. That is the text the button expands; without it the component does nothing.

### side-sheet

Swept 0.8.0. A <dialog>, so the dialog rules carry it.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### skeleton

Added with the skeleton component. A skeleton is an empty placeholder shape. The region it fills carries aria-busy, and a loading indicator or status reports the wait.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `skeleton-is-empty` | forbid | `.skeleton:not(:empty)` | must not match |

- **skeleton-is-empty** - Keep a .skeleton empty. Text inside it is read out as if it were content; report the wait through a status such as a loading indicator, and mark the loading region aria-busy="true".

### snackbar

Swept 0.8.0. Generated markup: role="status" and the labelled dismiss button are checked at runtime in tests/forms-generated.test.js and teardown.test.js.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### split-button

Added with the split button component (#42). A lead action and a trailing half that opens a menu, drawn as one shape with a 2dp seam. Two halves, two Tab stops, two different jobs - so the container declares no role at all. `toolbar` is rejected rather than withheld: there is no arrow-key model to write, because the only composite widget here is the <menu> the trailing half opens, and Menu implements and owns that contract. The trailing half *is* the menu trigger - `.menu-trigger` with a target is the JS contract Menu claims, and a split button whose trailing half opened nothing would be a lone button with a decoration glued to it. That is also what lets the expanded shape be drawn from a selector with no script of this component's own: Menu stamps `aria-expanded` on the trigger and rewrites it on every open and close, which is rule 1 working exactly as written. Two things the container cannot enforce for itself and the rules do. An `.icon-button` is not a half - it is its own component, setting its height, insets, colours and icon sizing on the element, so it reaches none of the split button's geometry; button groups refuse it for the same reason. The two-control contract is held from both ends - a third control gets the leading half's rules as well, and a container holding only the trigger leaves it squared off along a seam with nothing beside it. And the halves are told apart by *role* rather than by position, which keeps an authored <menu> from breaking the selectors - so nothing in the sheet notices a trigger written first, and a rule has to hold the order instead.

**Rejected role:** `toolbar` is not withheld but declined - the two halves are independent commands reached with Tab - the pair is one shape, not one widget, so there is no arrow-key model to implement and none to promise. The menu the trailing half opens has its own, and Menu owns it. `split-button-is-not-an-authored-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `split-button-is-not-an-authored-composite-widget` | forbid-composite-roles | `.split-button` | must not match with any composite role |
| `split-button-halves-are-controls` | forbid | `.split-button > :not(button):not(a.button[href]):not(menu)` | must not match |
| `split-button-half-is-not-an-icon-button` | forbid | `.split-button > .icon-button` | must not match |
| `split-button-trigger-is-the-trailing-half` | forbid | `.split-button > .menu-trigger ~ :not(menu)` | must not match |
| `split-button-is-two-controls` | forbid | `.split-button > :is(button, a.button) ~ :is(button, a.button) ~ :is(button, a.button)` | must not match |
| `split-button-has-a-leading-action` | forbid | `.split-button:not(:has(> :is(button, a.button):not(.menu-trigger)))` | must not match |
| `split-button-has-a-trailing-menu-trigger` | forbid | `.split-button:not(:has(> .menu-trigger))` | must not match |
| `split-button-trigger-names-its-menu` | forbid | `.split-button > .menu-trigger:not([data-target]):not([href])` | must not match |
| `split-button-trailing-icon-hidden` | require-attr | `.split-button > .menu-trigger :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)` | must have `aria-hidden` = `true` |
| `split-button-expanded-is-not-authored` | forbid | `.split-button [aria-expanded]` | must not match |

- **split-button-is-not-an-authored-composite-widget** - A split button takes no composite role; its two halves are reached with Tab, not arrow keys. The <menu> the trailing half opens is the composite widget here, and it carries its own role.
- **split-button-halves-are-controls** - A split button is exactly two controls and the menu one of them opens: a <button>, or an <a class="button" href> when it navigates. A wrapper element is refused too - the seam, the insets and the larger trailing icon are all written against direct children, so a nested <div> loses every one of them.
- **split-button-half-is-not-an-icon-button** - An icon button is its own component: its height, insets, colours, icon sizing and transitions are all set on the element itself, so it reaches none of the split button's geometry and fights the seam it is handed. The trailing half is <button class="button menu-trigger">, which the container sizes like the leading one.
- **split-button-trigger-is-the-trailing-half** - The menu trigger is the *trailing* half, and only the <menu> may follow it. Order is the whole of the geometry here: the halves are told apart by role, not by position, so a trigger written first still takes the trailing side's round outer corners while flex puts it on the leading side - a split button with its seam and its round ends swapped.
- **split-button-is-two-controls** - A split button is exactly two controls: one lead action and one menu trigger. A third gets the leading half's rules as well, so two buttons draw round start corners and a seam against each other. Several peer actions are a button group; several related ones belong in the menu.
- **split-button-has-a-leading-action** - A split button is a lead action *and* a menu of related ones; without the lead it is a menu trigger wearing half a pill. The trailing half's shape is written for a seam, so the only control ends up squared off on the edge it should be joined along, with nothing to join to. One control that opens a menu is a menu trigger - drop the container. Not fragmentSafe: what it detects is an omission, and a fragment omits by nature.
- **split-button-has-a-trailing-menu-trigger** - The trailing half is the whole point: it opens a menu of actions related to the leading one. Without `.menu-trigger` on it there is no menu, and the pair is a lone button with a chevron glued to it - write two buttons, or one. Not fragmentSafe: what it detects is an omission, and a fragment omits by nature.
- **split-button-trigger-names-its-menu** - Menu finds its surface through `data-target` on the trigger, falling back to an `href` fragment. A `.menu-trigger` with neither initializes against no element: it gets aria-haspopup and aria-expanded it will never change, which is a promise of a menu that does not exist.
- **split-button-trailing-icon-hidden** - The ligature is real text and is read out verbatim, so an unhidden chevron names the trailing half "arrow_drop_down". It is decoration - the control carries the name - so it is aria-hidden="true", and `icon-only-control-is-named` then requires the aria-label.
- **split-button-expanded-is-not-authored** - Expanded is dynamic state, so the framework owns it. Menu's constructor stamps aria-expanded on the trigger and every open() and close() rewrites it; authoring it states a value that is about to be overwritten - and the expanded shape is drawn from that attribute, so an authored "true" draws an open split button over a closed menu.

### stat

Added with the stat component. A description list pairs each value with its label; the change arrow is decoration and the text says the direction.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `stats-are-a-description-list` | forbid | `.stats:not(dl)` | must not match |

- **stats-are-a-description-list** - Write .stats as a <dl> with a <dt> label and <dd> value per tile. Without the pairing a screen reader reads a run of numbers with nothing to say what each one is.

### stepper

Added with the stepper component. The ordered list carries the step count and position; aria-current="step" marks the current step, and CSS alt text speaks the complete and invalid states.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `stepper-is-an-ordered-list` | forbid | `.stepper:not(ol)` | must not match |
| `stepper-current-is-a-step` | forbid | `.stepper > li[aria-current]:not([aria-current="step"])` | must not match |
| `stepper-has-one-current-step` | forbid | `.stepper > li[aria-current="step"] ~ li[aria-current="step"]` | must not match |
| `stepper-step-is-not-error-colored` | forbid | `.stepper > li.error` | must not match |

- **stepper-is-an-ordered-list** - Write a stepper as <ol class="stepper">. The order is the content: an ordered list announces how many steps there are and which one each is.
- **stepper-current-is-a-step** - Mark the current step with aria-current="step". "page" or "true" tells assistive technology something else.
- **stepper-has-one-current-step** - Only one step is current. Mark finished steps with .complete instead.
- **stepper-step-is-not-error-colored** - Mark a step that needs attention with .invalid. .error is the error color utility: it paints the whole step red and is not spoken.

### swipe-actions

Added with the swipe actions component. A touch swipe, the row's [data-swipe-toggle] button and focus on an action all open the same row, so people who cannot swipe use the toggle or the keyboard. The action buttons are named in markup and stay in the accessibility tree while the row is closed; the script keeps aria-expanded on the toggle in step with the row.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `swipeable-rows-open-without-swiping` | forbid | `.swipeable > li:has(> .swipe-actions):not(:has([data-swipe-toggle]))` | must not match |

- **swipeable-rows-open-without-swiping** - Give each .swipeable row with .swipe-actions a [data-swipe-toggle] button. Swiping is a path-based gesture; the toggle is the single-pointer alternative WCAG 2.5.1 requires, and focus on an action opens the row for keyboard users.

### tabs

Swept 0.8.0. Anchor navigation by default: Left, Right, Home and End move focus between the links as a shortcut, each link stays a Tab stop, and aria-current marks the one shown. A strip the page writes as role="tablist", with role="tab" links that name their panel in aria-controls, takes the ARIA tabs pattern instead - the component keeps aria-selected and a roving tabindex, and the arrow keys select as they move. The roles are the author's; the keyboard model and the state are the component's, implemented and tested, so rule 2 allows the role.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `tabs-role-is-tablist` | forbid | `.tabs[role]:not([role="tablist"])` | must not match |
| `tabs-tab-needs-tablist` | forbid | `.tabs:not([role="tablist"]) [role="tab"]` | must not match |
| `tablist-on-a-div` | forbid | `.tabs[role="tablist"]:not(div)` | must not match |
| `tablist-has-a-name` | forbid | `.tabs[role="tablist"]:not([aria-label], [aria-labelledby])` | must not match |
| `tablist-tabs-are-tabs` | forbid | `.tabs[role="tablist"] > a:not([role="tab"])` | must not match |
| `tablist-tab-names-panel` | forbid | `.tabs[role="tablist"] > a[role="tab"]:not([aria-controls])` | must not match |
| `tablist-no-current` | forbid | `.tabs[role="tablist"]:has([aria-current])` | must not match |
| `tabs-marks-current` | forbid | `.tabs:not([role="tablist"]) a.active:not([aria-current])` | must not match |

- **tabs-role-is-tablist** - A tab strip takes no role, or role="tablist" for the tabs pattern. Any other role promises a keyboard model it does not have.
- **tabs-tab-needs-tablist** - role="tab" belongs in a strip marked role="tablist". Link tabs take no role.
- **tablist-on-a-div** - Write a tablist on a <div>. A tablist is not navigation, so <nav> stays for link tabs.
- **tablist-has-a-name** - A tablist needs a name: aria-label or aria-labelledby.
- **tablist-tabs-are-tabs** - In a tablist every tab takes role="tab".
- **tablist-tab-names-panel** - A tab names the panel it shows in aria-controls.
- **tablist-no-current** - A tablist marks its tab with aria-selected, which the component keeps; aria-current belongs to link tabs.
- **tabs-marks-current** - The active tab is the section you are on; aria-current says so where a class cannot.

### timeline

Added with the timeline component. An ordered list of events; the order is the content.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `timeline-is-an-ordered-list` | forbid | `.timeline:not(ol)` | must not match |
| `timeline-time-is-machine-readable` | require-attr | `.timeline time` | must have `datetime` |

- **timeline-is-an-ordered-list** - Write a timeline as <ol class="timeline">. The events have an order, and an ordered list says so to assistive technology.
- **timeline-time-is-machine-readable** - Give each <time> a datetime value. "9:12 AM" or "Yesterday" has no date a script, a translation tool or a reader in another time zone can use.

### timepicker

Swept 0.8.0. Generated markup inside a <dialog>; the dialog rules carry the container.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### toolbar

A toolbar holds commands, not destinations. It does not implement composite arrow-key navigation.

**Conformance debt:** `toolbar` is withheld pending keyboard model, and `toolbar-not-a-composite-widget` enforces that, keeping every composite role out.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `toolbar-not-nav` | forbid | `nav.toolbar` | must not match |
| `toolbar-not-a-composite-widget` | forbid-composite-roles | `.toolbar` | must not match with any composite role |

- **toolbar-not-nav** - A toolbar holds commands (Bold, Italic), not navigation, so it is not a <nav> landmark.
- **toolbar-not-a-composite-widget** - A toolbar takes no composite role; its buttons are reached with Tab, not arrow keys.

### tooltip

Swept 0.8.0.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `css-tooltip-is-described-by` | forbid | `:is(a, button):has(> .tooltip):not([aria-describedby])` | must not match |

- **css-tooltip-is-described-by** - A .tooltip inside its control is swallowed: with an aria-label present the label wins and the tooltip is never announced, and without one the two run together. Give the tooltip an id and point aria-describedby at it.

### tour

Added with the tour component. A manual popover the Tour behavior shows one section at a time; the author writes it as a dialog so the step heading names it when focus arrives.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `tour-is-a-manual-popover` | forbid | `.tour:not([popover="manual"])` | must not match |
| `tour-is-a-dialog` | require-attr | `.tour` | must have `role` = `dialog` |
| `tour-steps-name-a-target` | forbid | `.tour > section:not([data-tour-target])` | must not match |

- **tour-is-a-manual-popover** - Write the .tour with popover="manual". An auto popover closes on any outside click, which ends the tour while the user looks at the target.
- **tour-is-a-dialog** - Give the .tour role="dialog". The behavior labels it with the current step's heading and moves focus to it, and the dialog role is what makes that name announced.
- **tour-steps-name-a-target** - Each step <section> needs data-tour-target with the id of the element it explains. A step without one is never shown.

### transfer-list

Added with the transfer list behavior. The rows are native checkboxes in .list label rows, so Tab reaches each one and Space checks it; the [data-transfer] buttons move the checked rows, and no listbox role or arrow-key contract is involved. The checkboxes carry no name: when the form builds its data, the script adds each chosen row's value under the container's data-name, in the chosen list's order.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `transfer-list-needs-a-name` | forbid | `.transfer-list:not([data-name]), .transfer-list[data-name=""]` | must not match |
| `transfer-list-checkbox-unnamed` | forbid | `.transfer-list input[type="checkbox"][name]` | must not match |
| `transfer-list-lists-labelled` | forbid | `:is(.transfer-available, .transfer-chosen):not([aria-label], [aria-labelledby])` | must not match |
| `transfer-list-buttons-typed` | forbid | `.transfer-list [data-transfer]:not([type="button" i])` | must not match |

- **transfer-list-needs-a-name** - Give each .transfer-list a data-name. The chosen rows submit their values under it; without one the form gets nothing.
- **transfer-list-checkbox-unnamed** - Leave the name off a transfer list's checkboxes. A checkbox's name submits whether it is checked, which only says what is selected to move; the script submits the chosen rows under the container's data-name.
- **transfer-list-lists-labelled** - Name each transfer list with aria-label or aria-labelledby, such as "Available toppings" and "Chosen toppings". The name tells people which list a row is in, and the move announcement reads it.
- **transfer-list-buttons-typed** - Give each [data-transfer] button type="button". A button in a form submits it by default, so an untyped move button would also send the form.

### transitions

**Kind:** foundation.

Swept 0.8.0. A foundation, not a component: motion applied through classes to whatever element already exists, with no markup of its own.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |


### tree

Added with the tree component. Nested lists of native disclosures and links: every row is a Tab stop and the browser opens a branch, so no tree role is promised.

**Rejected role:** `tree` is not withheld but declined - its rows are native disclosures and links reached with Tab - a tree role promises arrow-key navigation, type-ahead and expanded states on treeitems that the markup does not have and that native details would fight. `tree-is-not-a-tree-widget` enforces that.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `tree-is-not-a-tree-widget` | forbid | `.tree[role], .tree [role="treeitem"], .tree [role="group"]` | must not match |
| `tree-marks-current-not-selected` | forbid | `.tree [aria-selected]` | must not match |

- **tree-is-not-a-tree-widget** - A .tree takes no ARIA tree roles. It is lists of disclosures and links, which keep their own roles and keyboard behavior.
- **tree-marks-current-not-selected** - Mark the open file or page with aria-current. aria-selected is not valid on a link or a list item.

### tree-select

A native <select data-tree> owns single or multiple leaf values. Flat options name their parent value with data-parent. AutoInit builds the named ARIA tree; branches only navigate and never submit. Nesting comes only from data-parent because HTML cannot nest optgroups.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `tree-select-needs-native-options` | forbid | `[data-tree]:not(select), select[data-tree] optgroup, select:not([data-tree]) option[data-parent]` | must not match |

- **tree-select-needs-native-options** - Put data-tree on a native <select>, optionally with multiple. Nest flat options only through data-parent naming the parent option value. An <optgroup> cannot nest, and data-parent needs a select with data-tree.

### treemap-chart

Added with the treemap chart component. The <table> is the chart's text alternative and its no-JavaScript fallback; the drawn rectangles and their text are aria-hidden.

| Rule | Kind | Selector | Requirement |
| --- | --- | --- | --- |
| `treemap-chart-is-a-figure-with-a-table` | forbid | `.treemap-chart:not(figure:has(> table))` | must not match |
| `treemap-chart-has-a-caption` | forbid | `.treemap-chart:not(:has(> figcaption))` | must not match |

- **treemap-chart-is-a-figure-with-a-table** - Write .treemap-chart as a <figure> holding a <table> of the data. The drawn chart is hidden from assistive technology, so the table is all a screen reader reads, and it is what shows without JavaScript.
- **treemap-chart-has-a-caption** - Name a .treemap-chart with a <figcaption>. It names the focusable plot as well as the figure; hide it with .visually-hidden when nearby text already names the chart.

## Exempt

Not yet swept. Rules listed here are recorded but do not run.

| Component | Rules written | Note |
| --- | --- | --- |

