@expressivecss/mcp-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +14 -0
- package/.cursor-plugin/plugin.json +25 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +101 -0
- package/README.md +240 -0
- package/capability-roadmap.json +3109 -0
- package/component-decisions.json +6950 -0
- package/component-guides.json +171 -0
- package/contract.json +27 -0
- package/lint.mjs +68 -0
- package/mcp.json +14 -0
- package/package.json +49 -0
- package/sample-hermes-config.yaml +13 -0
- package/scripts/resolve-version.mjs +736 -0
- package/semantics-data.json +1166 -0
- package/server.js +2380 -0
- package/smoke.mjs +1237 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"generatedBy": "mcp/expressivecss/scripts/sync-guides.mjs",
|
|
4
|
+
"frameworkVersion": "0.10.1",
|
|
5
|
+
"semantics": {
|
|
6
|
+
"about": "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.",
|
|
7
|
+
"scope": "Element choice, landmark structure, and the ARIA that element choice implies. Keyboard interaction, focus management and colour contrast are deliberately out of scope.",
|
|
8
|
+
"rules": [
|
|
9
|
+
"Static semantics are the page author's; dynamic state is the framework's. See CONTEXT.md for both terms.",
|
|
10
|
+
"A component may declare a composite role only if its keyboard contract is implemented and tested. Tabs do not qualify: they are navigation (`<nav>` + anchors + `aria-current`), not a `tablist`. Carousel is a different case - its keyboard contract is implemented, and it rejects `tablist` because it follows the ARIA carousel pattern instead.",
|
|
11
|
+
"Icons are `<span class=\"material-symbols\" aria-hidden=\"true\">`. `<i>` keeps working but is undocumented.",
|
|
12
|
+
"`<nav>` is for genuine navigation only, and every one carries a required `aria-label`. Card action rows and toolbars are not navigation.",
|
|
13
|
+
"Every user-facing string the framework generates has an `i18n` option."
|
|
14
|
+
],
|
|
15
|
+
"ruleKinds": {
|
|
16
|
+
"forbid": "selector must match nothing in a checked example",
|
|
17
|
+
"forbid-composite-roles": "selector must match nothing in a checked example once expanded over `compositeRoles` - the component may not *be* a composite widget of any kind",
|
|
18
|
+
"require-attr": "every element matching selector must carry attr (and equal `equals`, when given)",
|
|
19
|
+
"require-idref": "every element matching selector must carry attr whose ID references resolve to targetSelector inside its closest container",
|
|
20
|
+
"require-owned-descendant": "every element matching selector must contain a descendant matching descendantSelector whose closest ownerSelector is that element and which is outside excludeAncestorSelector within that owner when given",
|
|
21
|
+
"require-accessible-name": "every element matching selector must end up with a non-empty accessible name (aria-label, aria-labelledby, or text that is not inside an aria-hidden subtree)"
|
|
22
|
+
},
|
|
23
|
+
"rowKinds": {
|
|
24
|
+
"component": "A part of the framework an author writes markup for. The default, and the only kind whose rules describe markup a page states directly. CONTEXT.md defines all three.",
|
|
25
|
+
"foundation": "A token or style system with no markup of its own. It has a partial, and therefore a row, but there is nothing an author writes to get it.",
|
|
26
|
+
"behavior": "Script that attaches to markup the author has already written. Any element it names is generated, not authored."
|
|
27
|
+
},
|
|
28
|
+
"compositeRoles": [
|
|
29
|
+
"combobox",
|
|
30
|
+
"grid",
|
|
31
|
+
"listbox",
|
|
32
|
+
"menu",
|
|
33
|
+
"menubar",
|
|
34
|
+
"radiogroup",
|
|
35
|
+
"tablist",
|
|
36
|
+
"toolbar",
|
|
37
|
+
"tree",
|
|
38
|
+
"treegrid"
|
|
39
|
+
],
|
|
40
|
+
"surfaces": [
|
|
41
|
+
"llm.md",
|
|
42
|
+
"docs/src",
|
|
43
|
+
"tests/fixtures.js",
|
|
44
|
+
"m3-guidelines.md (forbid rules only)"
|
|
45
|
+
],
|
|
46
|
+
"additional": [
|
|
47
|
+
"autocomplete",
|
|
48
|
+
"character-counter",
|
|
49
|
+
"landmarks"
|
|
50
|
+
],
|
|
51
|
+
"notComponents": {
|
|
52
|
+
"table_of_contents": "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."
|
|
53
|
+
},
|
|
54
|
+
"rows": {
|
|
55
|
+
"autocomplete": {
|
|
56
|
+
"status": "enforced",
|
|
57
|
+
"note": "Swept 0.8.0. The combobox it builds is checked at runtime in tests/autocomplete.test.js.",
|
|
58
|
+
"rules": [
|
|
59
|
+
{
|
|
60
|
+
"id": "autocomplete-options-are-options",
|
|
61
|
+
"kind": "forbid",
|
|
62
|
+
"selector": ".autocomplete-content li:not([role=\"option\"])",
|
|
63
|
+
"message": "The suggestion list is a listbox; every entry in it is an option."
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"badges": {
|
|
68
|
+
"status": "enforced",
|
|
69
|
+
"note": "Swept 0.8.0. A badge nested in an icon is positioned against the glyph, so hiding the icon hides the count with it.",
|
|
70
|
+
"rules": [
|
|
71
|
+
{
|
|
72
|
+
"id": "badge-count-survives-a-hidden-icon",
|
|
73
|
+
"kind": "forbid",
|
|
74
|
+
"selector": ":is(a, button):not([aria-label]):not([aria-labelledby]) [aria-hidden=\"true\"] .badge:not(:empty)",
|
|
75
|
+
"message": "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."
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"bottom-sheet": {
|
|
80
|
+
"status": "enforced",
|
|
81
|
+
"note": "Swept 0.8.0. A <dialog>, so the dialog rules carry it.",
|
|
82
|
+
"rules": []
|
|
83
|
+
},
|
|
84
|
+
"breadcrumb": {
|
|
85
|
+
"status": "enforced",
|
|
86
|
+
"note": "Swept 0.8.0. A trail is an ordered list, because the order is the meaning.",
|
|
87
|
+
"rules": [
|
|
88
|
+
{
|
|
89
|
+
"id": "breadcrumb-is-ordered-list",
|
|
90
|
+
"kind": "forbid",
|
|
91
|
+
"selector": ".breadcrumb-wrapper > a",
|
|
92
|
+
"fragmentSafe": true,
|
|
93
|
+
"message": "Crumbs are an ordered list - nav > ol > li > a. Loose anchors give no count and no position, so \"3 of 4\" is never announced."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "breadcrumb-marks-current",
|
|
97
|
+
"kind": "forbid",
|
|
98
|
+
"selector": ".breadcrumb-wrapper li:last-child :is(a, span):not([aria-current])",
|
|
99
|
+
"fragmentSafe": false,
|
|
100
|
+
"message": "The last crumb is the page you are on; it needs aria-current=\"page\" to say so."
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"buttons": {
|
|
105
|
+
"status": "enforced",
|
|
106
|
+
"note": "Icon-only controls need accessible names.",
|
|
107
|
+
"rules": [
|
|
108
|
+
{
|
|
109
|
+
"id": "icon-only-control-is-named",
|
|
110
|
+
"kind": "require-accessible-name",
|
|
111
|
+
"selector": ":is(a, button):has([aria-hidden=\"true\"])",
|
|
112
|
+
"message": "Every icon is hidden from assistive technology, so a control whose only content is one has no name left. Give it an aria-label."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"id": "fab-no-small-variant",
|
|
116
|
+
"kind": "forbid",
|
|
117
|
+
"selector": ":is(button, a.button, label.button).circle:is(.extra, .large).small",
|
|
118
|
+
"fragmentSafe": true,
|
|
119
|
+
"message": "The 40dp small FAB has been removed. Use circle extra for a 56dp FAB."
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"id": "extended-fab-requires-size",
|
|
123
|
+
"kind": "forbid",
|
|
124
|
+
"selector": ":is(button, a.button, label.button).extend:not(.small, .medium, .large)",
|
|
125
|
+
"fragmentSafe": true,
|
|
126
|
+
"message": "The baseline extended FAB has been removed. Specify extend small, extend medium or extend large."
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "fab-no-surface-color",
|
|
130
|
+
"kind": "forbid",
|
|
131
|
+
"selector": ":is(button, a.button, label.button):is(.circle.extra, .circle.large, .extend):is(.surface, .surface-dim, .surface-bright, .surface-variant, .surface-container, .surface-container-lowest, .surface-container-low, .surface-container-high, .surface-container-highest)",
|
|
132
|
+
"fragmentSafe": true,
|
|
133
|
+
"message": "Use a primary, secondary or tertiary container color for a FAB instead of a surface color."
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
"button-groups": {
|
|
138
|
+
"status": "enforced",
|
|
139
|
+
"note": "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.",
|
|
140
|
+
"rejects": {
|
|
141
|
+
"rejected_role": "toolbar",
|
|
142
|
+
"because": "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",
|
|
143
|
+
"rule": "button-group-is-not-an-authored-composite-widget"
|
|
144
|
+
},
|
|
145
|
+
"rules": [
|
|
146
|
+
{
|
|
147
|
+
"id": "button-group-is-not-an-authored-composite-widget",
|
|
148
|
+
"kind": "forbid-composite-roles",
|
|
149
|
+
"selector": ".button-group",
|
|
150
|
+
"fragmentSafe": true,
|
|
151
|
+
"message": "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."
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"id": "button-group-items-are-controls",
|
|
155
|
+
"kind": "forbid",
|
|
156
|
+
"selector": ".button-group > :not(button):not(a.button[href])",
|
|
157
|
+
"fragmentSafe": true,
|
|
158
|
+
"message": "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."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"id": "button-group-items-have-containers",
|
|
162
|
+
"kind": "forbid",
|
|
163
|
+
"selector": ".button-group > :is(button, a.button).text",
|
|
164
|
+
"fragmentSafe": true,
|
|
165
|
+
"message": "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."
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"id": "button-group-icon-items-have-containers",
|
|
169
|
+
"kind": "forbid",
|
|
170
|
+
"selector": ".button-group > .icon-button:not(.filled):not(.tonal):not(.outlined)",
|
|
171
|
+
"fragmentSafe": true,
|
|
172
|
+
"message": "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."
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"id": "button-group-selection-mode-is-valid",
|
|
176
|
+
"kind": "forbid",
|
|
177
|
+
"selector": ".button-group[data-selection]:not([data-selection=\"single\"]):not([data-selection=\"multiple\"])",
|
|
178
|
+
"fragmentSafe": true,
|
|
179
|
+
"message": "ButtonGroup supports data-selection=\"single\" or data-selection=\"multiple\". Any other value exposes toggle state without a defined selection model."
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "button-group-required-needs-selection",
|
|
183
|
+
"kind": "forbid",
|
|
184
|
+
"selector": ".button-group[data-selection-required]:not([data-selection])",
|
|
185
|
+
"fragmentSafe": true,
|
|
186
|
+
"message": "data-selection-required only has meaning on a single- or multiple-selection ButtonGroup."
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"id": "button-group-connected-is-selectable",
|
|
190
|
+
"kind": "forbid",
|
|
191
|
+
"selector": ".button-group.connected:not([data-selection])",
|
|
192
|
+
"fragmentSafe": true,
|
|
193
|
+
"message": "Material connected button groups contain toggleable choices. Add data-selection=\"single|multiple\", or use the standard group for one-shot actions."
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"id": "button-group-selection-items-are-buttons",
|
|
197
|
+
"kind": "forbid",
|
|
198
|
+
"selector": ".button-group[data-selection] > :not(button)",
|
|
199
|
+
"fragmentSafe": true,
|
|
200
|
+
"message": "Selection items are native buttons. Links navigate and cannot represent toggle-button state."
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "button-group-selection-button-type",
|
|
204
|
+
"kind": "require-attr",
|
|
205
|
+
"selector": ".button-group[data-selection] > button",
|
|
206
|
+
"attr": "type",
|
|
207
|
+
"equals": "button",
|
|
208
|
+
"message": "A selection button inside a form must not submit it; set type=\"button\"."
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"id": "button-group-selection-state-is-pressed",
|
|
212
|
+
"kind": "require-attr",
|
|
213
|
+
"selector": ".button-group[data-selection] > button",
|
|
214
|
+
"attr": "aria-pressed",
|
|
215
|
+
"message": "Author each toggle button's initial aria-pressed value. ButtonGroup maintains that state after initialization."
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"id": "button-group-selection-state-is-boolean",
|
|
219
|
+
"kind": "forbid",
|
|
220
|
+
"selector": ".button-group[data-selection] > button[aria-pressed]:not([aria-pressed=\"true\"]):not([aria-pressed=\"false\"])",
|
|
221
|
+
"fragmentSafe": true,
|
|
222
|
+
"message": "A toggle button's initial aria-pressed value is true or false; mixed is not a Button Group state."
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"id": "button-group-selection-does-not-use-choice-state",
|
|
226
|
+
"kind": "forbid",
|
|
227
|
+
"selector": ".button-group[data-selection] > :is([aria-checked], [aria-selected])",
|
|
228
|
+
"fragmentSafe": true,
|
|
229
|
+
"message": "These are toggle buttons, not radios, checkboxes, options, or tabs. Expose their state with aria-pressed."
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"id": "button-group-action-does-not-claim-selection",
|
|
233
|
+
"kind": "forbid",
|
|
234
|
+
"selector": ".button-group:not([data-selection]) > [aria-pressed]",
|
|
235
|
+
"fragmentSafe": true,
|
|
236
|
+
"message": "aria-pressed requires ButtonGroup selection behavior. Add data-selection=\"single|multiple\" or remove the toggle state."
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
"cards": {
|
|
241
|
+
"status": "enforced",
|
|
242
|
+
"note": "Swept 0.8.0. Cards use native disclosure controls, and a directly actionable card contains no second action.",
|
|
243
|
+
"rules": [
|
|
244
|
+
{
|
|
245
|
+
"id": "card-action-row-not-nav",
|
|
246
|
+
"kind": "forbid",
|
|
247
|
+
"selector": "article nav",
|
|
248
|
+
"fragmentSafe": true,
|
|
249
|
+
"message": "Cards do not contain navigation or tabs. Put navigation outside the card and use <div class=\"actions\"> for its action buttons."
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"id": "card-primary-action-is-control",
|
|
253
|
+
"kind": "forbid",
|
|
254
|
+
"selector": "article > .primary-action:not(a[href]):not(a[aria-disabled=\"true\"])",
|
|
255
|
+
"fragmentSafe": true,
|
|
256
|
+
"message": "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."
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"id": "card-disabled-link-has-no-href",
|
|
260
|
+
"kind": "forbid",
|
|
261
|
+
"selector": "article > a.primary-action[aria-disabled=\"true\"][href]",
|
|
262
|
+
"fragmentSafe": true,
|
|
263
|
+
"message": "A disabled card destination must not retain href. aria-disabled does not stop a link from navigating when activated from the keyboard."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"id": "card-disabled-link-not-tabbable",
|
|
267
|
+
"kind": "forbid",
|
|
268
|
+
"selector": "article > a.primary-action[aria-disabled=\"true\"]:not([tabindex=\"-1\"])",
|
|
269
|
+
"fragmentSafe": true,
|
|
270
|
+
"message": "A disabled card destination is not operable, so remove it from the tab order with tabindex=\"-1\"."
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"id": "card-primary-action-is-only-action",
|
|
274
|
+
"kind": "forbid",
|
|
275
|
+
"selector": "article:has(> .primary-action) :is(a[href], button, input, select, textarea):not(.primary-action)",
|
|
276
|
+
"fragmentSafe": true,
|
|
277
|
+
"message": "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."
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"id": "card-primary-action-is-single",
|
|
281
|
+
"kind": "forbid",
|
|
282
|
+
"selector": "article > .primary-action ~ .primary-action",
|
|
283
|
+
"fragmentSafe": true,
|
|
284
|
+
"message": "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."
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"id": "card-reveal-has-trigger",
|
|
288
|
+
"kind": "require-owned-descendant",
|
|
289
|
+
"selector": "article:has(> aside)",
|
|
290
|
+
"descendantSelector": "button.card-reveal-trigger",
|
|
291
|
+
"ownerSelector": "article",
|
|
292
|
+
"excludeAncestorSelector": "aside",
|
|
293
|
+
"fragmentSafe": true,
|
|
294
|
+
"scope": "row",
|
|
295
|
+
"severity": "error",
|
|
296
|
+
"message": "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."
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"id": "card-reveal-has-one-panel",
|
|
300
|
+
"kind": "forbid",
|
|
301
|
+
"selector": "article > aside ~ aside",
|
|
302
|
+
"fragmentSafe": true,
|
|
303
|
+
"scope": "row",
|
|
304
|
+
"severity": "error",
|
|
305
|
+
"message": "A card reveal has exactly one direct aside panel. With multiple direct panels, Cards rejects disclosure setup and every panel remains visible and unmanaged."
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"id": "card-reveal-trigger-is-button",
|
|
309
|
+
"kind": "forbid",
|
|
310
|
+
"selector": ".card-reveal-trigger:not(button)",
|
|
311
|
+
"fragmentSafe": true,
|
|
312
|
+
"message": "The reveal trigger is a native button so Enter, Space, disabled state, and focus work without synthetic keyboard handling."
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"id": "card-reveal-trigger-type-button",
|
|
316
|
+
"kind": "require-attr",
|
|
317
|
+
"selector": "button.card-reveal-trigger",
|
|
318
|
+
"attr": "type",
|
|
319
|
+
"equals": "button",
|
|
320
|
+
"message": "A card reveal button may appear inside a form, so set type=\"button\" to prevent accidental submission."
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"id": "card-reveal-trigger-has-label",
|
|
324
|
+
"kind": "require-accessible-name",
|
|
325
|
+
"selector": "button.card-reveal-trigger",
|
|
326
|
+
"message": "The reveal button needs a non-empty accessible name describing the details it toggles; icon-only triggers should use aria-label."
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"id": "card-reveal-trigger-controls-panel",
|
|
330
|
+
"kind": "require-idref",
|
|
331
|
+
"selector": "button.card-reveal-trigger",
|
|
332
|
+
"attr": "aria-controls",
|
|
333
|
+
"container": "article",
|
|
334
|
+
"targetSelector": ":scope > aside",
|
|
335
|
+
"message": "Name the reveal panel from aria-controls so assistive technology can relate the disclosure button to its content."
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"id": "card-reveal-panel-has-id",
|
|
339
|
+
"kind": "require-attr",
|
|
340
|
+
"selector": "article:has(.card-reveal-trigger) > aside",
|
|
341
|
+
"attr": "id",
|
|
342
|
+
"message": "The reveal panel needs the id referenced by its trigger's aria-controls."
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"id": "card-reveal-expanded-is-not-authored",
|
|
346
|
+
"kind": "forbid",
|
|
347
|
+
"selector": ".card-reveal-trigger[aria-expanded], article > aside[aria-expanded]",
|
|
348
|
+
"fragmentSafe": true,
|
|
349
|
+
"message": "Cards writes aria-expanded on the disclosure button. Do not author a stale initial value, and do not put aria-expanded on the panel."
|
|
350
|
+
}
|
|
351
|
+
]
|
|
352
|
+
},
|
|
353
|
+
"carousel": {
|
|
354
|
+
"status": "enforced",
|
|
355
|
+
"note": "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.",
|
|
356
|
+
"rejects": {
|
|
357
|
+
"rejected_role": "tablist",
|
|
358
|
+
"because": "it implements the ARIA carousel pattern instead - a group with aria-roledescription and independent indicator controls",
|
|
359
|
+
"rule": "carousel-not-a-composite-widget"
|
|
360
|
+
},
|
|
361
|
+
"rules": [
|
|
362
|
+
{
|
|
363
|
+
"id": "hidden-subtree-holds-nothing-focusable",
|
|
364
|
+
"kind": "forbid",
|
|
365
|
+
"selector": "[aria-hidden=\"true\"]:has(:is(a[href], button, input, select, textarea, [tabindex]):not([tabindex=\"-1\"]):not([disabled]))",
|
|
366
|
+
"fragmentSafe": true,
|
|
367
|
+
"message": "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."
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"id": "carousel-not-a-composite-widget",
|
|
371
|
+
"kind": "forbid-composite-roles",
|
|
372
|
+
"selector": ".carousel",
|
|
373
|
+
"fragmentSafe": true,
|
|
374
|
+
"message": "A carousel takes no composite role; it is a group of slides with independent controls."
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"id": "carousel-indicators-are-not-tabs",
|
|
378
|
+
"kind": "forbid",
|
|
379
|
+
"selector": ".carousel [role=\"tab\"]",
|
|
380
|
+
"fragmentSafe": true,
|
|
381
|
+
"message": "Carousel indicators are independent controls, not tabs."
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
},
|
|
385
|
+
"character-counter": {
|
|
386
|
+
"status": "enforced",
|
|
387
|
+
"kind": "behavior",
|
|
388
|
+
"note": "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.",
|
|
389
|
+
"rules": [
|
|
390
|
+
{
|
|
391
|
+
"id": "counter-is-announced",
|
|
392
|
+
"kind": "require-attr",
|
|
393
|
+
"selector": ".character-counter",
|
|
394
|
+
"attr": "aria-live",
|
|
395
|
+
"equals": "polite",
|
|
396
|
+
"message": "The count changes as the user types, so it has to be a live region or the remaining characters are never announced."
|
|
397
|
+
}
|
|
398
|
+
]
|
|
399
|
+
},
|
|
400
|
+
"chips": {
|
|
401
|
+
"status": "enforced",
|
|
402
|
+
"note": "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.",
|
|
403
|
+
"rules": [
|
|
404
|
+
{
|
|
405
|
+
"id": "chip-root-element",
|
|
406
|
+
"kind": "forbid",
|
|
407
|
+
"selector": ".chip:not(span):not(button):not(label)",
|
|
408
|
+
"message": "A chip is <span> (display), <button> (assist/suggestion/input), or <label> (filter). Never <div>.",
|
|
409
|
+
"fragmentSafe": true
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"id": "chip-not-focusable-span",
|
|
413
|
+
"kind": "forbid",
|
|
414
|
+
"selector": "span.chip[tabindex]",
|
|
415
|
+
"message": "A display chip is not a control and must not be in the tab order. Put the action on a nested <button>.",
|
|
416
|
+
"fragmentSafe": true
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"id": "chip-close-is-button",
|
|
420
|
+
"kind": "forbid",
|
|
421
|
+
"selector": ".chip .close:not(button)",
|
|
422
|
+
"message": "The chip delete affordance is a control: use <button type=\"button\" class=\"close\">, not <i> or <span>.",
|
|
423
|
+
"fragmentSafe": true
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"id": "chip-close-type-button",
|
|
427
|
+
"kind": "require-attr",
|
|
428
|
+
"selector": ".chip button.close",
|
|
429
|
+
"attr": "type",
|
|
430
|
+
"equals": "button",
|
|
431
|
+
"message": "A <button> inside a form defaults to type=submit. Chip delete buttons must set type=\"button\"."
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
"id": "chip-close-has-label",
|
|
435
|
+
"kind": "require-attr",
|
|
436
|
+
"selector": ".chip button.close",
|
|
437
|
+
"attr": "aria-label",
|
|
438
|
+
"message": "The delete button contains only an icon, so it needs an accessible name naming the chip it removes."
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
"id": "chip-no-i-element",
|
|
442
|
+
"kind": "forbid",
|
|
443
|
+
"selector": ".chip i",
|
|
444
|
+
"message": "<i> means idiomatic text, not icon. Use <span class=\"material-symbols\" aria-hidden=\"true\">.",
|
|
445
|
+
"fragmentSafe": true
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"id": "chip-icon-hidden",
|
|
449
|
+
"kind": "require-attr",
|
|
450
|
+
"selector": ".chip :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp,.material-icons)",
|
|
451
|
+
"attr": "aria-hidden",
|
|
452
|
+
"equals": "true",
|
|
453
|
+
"message": "A ligature icon is read aloud verbatim. Decorative icons inside a labelled control must be aria-hidden."
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"id": "filter-chip-label-for",
|
|
457
|
+
"kind": "require-attr",
|
|
458
|
+
"selector": "label.chip",
|
|
459
|
+
"attr": "for",
|
|
460
|
+
"message": "A filter chip is <input type=\"checkbox\"> + <label class=\"chip\">; the label must point at its input."
|
|
461
|
+
}
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
"datepicker": {
|
|
465
|
+
"status": "enforced",
|
|
466
|
+
"note": "Swept 0.8.0. Generated markup inside a <dialog>; the dialog rules carry the container.",
|
|
467
|
+
"rules": []
|
|
468
|
+
},
|
|
469
|
+
"dialog": {
|
|
470
|
+
"status": "enforced",
|
|
471
|
+
"note": "Swept 0.8.0.",
|
|
472
|
+
"rules": [
|
|
473
|
+
{
|
|
474
|
+
"id": "dialog-is-named",
|
|
475
|
+
"kind": "forbid",
|
|
476
|
+
"selector": "dialog:not([aria-label]):not([aria-labelledby])",
|
|
477
|
+
"message": "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\"."
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
},
|
|
481
|
+
"docked-display": {
|
|
482
|
+
"status": "enforced",
|
|
483
|
+
"kind": "behavior",
|
|
484
|
+
"note": "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.",
|
|
485
|
+
"rules": []
|
|
486
|
+
},
|
|
487
|
+
"drag-handle": {
|
|
488
|
+
"status": "enforced",
|
|
489
|
+
"note": "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 framework ships no reordering behaviour to hang off this handle, and a page that builds one owes it a keyboard path of its own. The docs say so plainly rather than implying the handle supplies it. Every selector below names `.handle` beside `.drag-handle`: the bottom sheet styles the pre-1.0 spelling too, and an alias the framework blesses is an alias these rules have to reach. The first rule is deliberately not fragmentSafe - what it detects is a *missing* aria-hidden, and a fragment omits by nature.",
|
|
490
|
+
"rules": [
|
|
491
|
+
{
|
|
492
|
+
"id": "drag-handle-is-hidden-or-a-control",
|
|
493
|
+
"kind": "forbid",
|
|
494
|
+
"selector": ":is(.handle, .drag-handle):not(button):not([aria-hidden=\"true\"])",
|
|
495
|
+
"message": "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."
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
"id": "drag-handle-button-is-not-hidden",
|
|
499
|
+
"kind": "forbid",
|
|
500
|
+
"selector": "button:is(.handle, .drag-handle)[aria-hidden=\"true\"]",
|
|
501
|
+
"fragmentSafe": true,
|
|
502
|
+
"message": "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."
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
"id": "drag-handle-button-is-named",
|
|
506
|
+
"kind": "require-accessible-name",
|
|
507
|
+
"selector": "button:is(.handle, .drag-handle)",
|
|
508
|
+
"message": "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."
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
},
|
|
512
|
+
"expanding-card": {
|
|
513
|
+
"status": "enforced",
|
|
514
|
+
"note": "Added 0.8.0. The compact article opens a named native dialog; its media and back actions are buttons.",
|
|
515
|
+
"rules": [
|
|
516
|
+
{
|
|
517
|
+
"id": "expanding-card-trigger-is-button",
|
|
518
|
+
"kind": "forbid",
|
|
519
|
+
"selector": "article.expanding-card .expanding-card-trigger:not(button)",
|
|
520
|
+
"message": "The media-sized trigger opens a dialog, so use a button for native keyboard and disabled behavior."
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
"id": "expanding-card-close-is-button",
|
|
524
|
+
"kind": "forbid",
|
|
525
|
+
"selector": "dialog.expanding-card-dialog .expanding-card-close:not(button)",
|
|
526
|
+
"message": "The back affordance closes the expanded surface, so use a button for native keyboard behavior."
|
|
527
|
+
}
|
|
528
|
+
]
|
|
529
|
+
},
|
|
530
|
+
"fab-menu": {
|
|
531
|
+
"status": "enforced",
|
|
532
|
+
"note": "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.",
|
|
533
|
+
"conformance": {
|
|
534
|
+
"withheld_role": "menu",
|
|
535
|
+
"blocked_on": "keyboard model",
|
|
536
|
+
"rule": "fab-menu-not-a-composite-widget"
|
|
537
|
+
},
|
|
538
|
+
"rules": [
|
|
539
|
+
{
|
|
540
|
+
"id": "fab-menu-not-a-composite-widget",
|
|
541
|
+
"kind": "forbid-composite-roles",
|
|
542
|
+
"selector": ".fab-menu",
|
|
543
|
+
"fragmentSafe": true,
|
|
544
|
+
"message": "The FAB menu takes no composite role; its actions are reached with Tab, not arrow keys."
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
"id": "fab-menu-expanded-is-not-authored",
|
|
548
|
+
"kind": "forbid",
|
|
549
|
+
"selector": ".fab-menu [aria-expanded]",
|
|
550
|
+
"fragmentSafe": true,
|
|
551
|
+
"message": "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."
|
|
552
|
+
}
|
|
553
|
+
]
|
|
554
|
+
},
|
|
555
|
+
"forms/checkboxes": {
|
|
556
|
+
"status": "enforced",
|
|
557
|
+
"note": "Swept 0.8.0.",
|
|
558
|
+
"rules": [
|
|
559
|
+
{
|
|
560
|
+
"id": "checkbox-labelled",
|
|
561
|
+
"kind": "forbid",
|
|
562
|
+
"selector": "input[type=\"checkbox\"]:not([id]):not(label *):not(.chip-input)",
|
|
563
|
+
"message": "A checkbox must be inside its <label> or carry an id a label points at."
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
"forms/fieldset": {
|
|
568
|
+
"status": "enforced",
|
|
569
|
+
"note": "Swept 0.8.0.",
|
|
570
|
+
"rules": [
|
|
571
|
+
{
|
|
572
|
+
"id": "fieldset-has-legend",
|
|
573
|
+
"kind": "forbid",
|
|
574
|
+
"selector": "fieldset:not(:has(> legend))",
|
|
575
|
+
"message": "A fieldset without a legend groups controls visually but names nothing."
|
|
576
|
+
}
|
|
577
|
+
]
|
|
578
|
+
},
|
|
579
|
+
"forms/file-input": {
|
|
580
|
+
"status": "enforced",
|
|
581
|
+
"note": "Swept 0.8.0.",
|
|
582
|
+
"rules": [
|
|
583
|
+
{
|
|
584
|
+
"id": "file-input-not-nested",
|
|
585
|
+
"kind": "forbid",
|
|
586
|
+
"selector": ":is(button, a) input[type=\"file\"]",
|
|
587
|
+
"message": "A control inside a control is not valid HTML and gives two overlapping hit targets. Use a <label> styled as a button.",
|
|
588
|
+
"fragmentSafe": true
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
"id": "file-path-readonly",
|
|
592
|
+
"kind": "forbid",
|
|
593
|
+
"selector": "input.file-path:not([readonly])",
|
|
594
|
+
"message": "The path field only ever displays what the file input holds; leaving it writable offers an edit that is discarded."
|
|
595
|
+
}
|
|
596
|
+
]
|
|
597
|
+
},
|
|
598
|
+
"forms/input-fields": {
|
|
599
|
+
"status": "enforced",
|
|
600
|
+
"note": "Swept 0.8.0. .field is the container; icons name their side and are hidden; supporting text is linked to the control it describes.",
|
|
601
|
+
"rules": [
|
|
602
|
+
{
|
|
603
|
+
"id": "field-container-class",
|
|
604
|
+
"kind": "forbid",
|
|
605
|
+
"selector": ".input-field:not(.chips)",
|
|
606
|
+
"message": "The field container is `.field`. `.input-field` matches nothing in the sheet - the only `.input-field` rule is `.chips.input-field`.",
|
|
607
|
+
"fragmentSafe": true
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
"id": "field-icon-not-i",
|
|
611
|
+
"kind": "forbid",
|
|
612
|
+
"selector": ".field i",
|
|
613
|
+
"message": "<i> means idiomatic text, not icon. Use <span class=\"material-symbols\">.",
|
|
614
|
+
"fragmentSafe": true
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
"id": "field-icon-hidden",
|
|
618
|
+
"kind": "require-attr",
|
|
619
|
+
"selector": ".field .material-symbols, .field .material-symbols-outlined, .field .material-symbols-rounded, .field .material-symbols-sharp, .field .material-icons",
|
|
620
|
+
"attr": "aria-hidden",
|
|
621
|
+
"equals": "true",
|
|
622
|
+
"message": "The ligature is read aloud. A field icon is decoration; the label names the control."
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
"id": "field-icon-side",
|
|
626
|
+
"kind": "forbid",
|
|
627
|
+
"selector": ".field > :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp,.material-icons):not(.prefix):not(.suffix)",
|
|
628
|
+
"message": "A field icon must name its side with .prefix or .suffix. The positional fallback counts elements of the same *type*, which only ever worked for <i>."
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
"id": "field-label-for",
|
|
632
|
+
"kind": "require-attr",
|
|
633
|
+
"selector": ".field > label:not(:has(input, select, textarea))",
|
|
634
|
+
"attr": "for",
|
|
635
|
+
"message": "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."
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
"id": "field-control-id",
|
|
639
|
+
"kind": "require-attr",
|
|
640
|
+
"selector": ".field > :is(input, textarea, select)",
|
|
641
|
+
"attr": "id",
|
|
642
|
+
"message": "Without an id the label has nothing to point at."
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
"id": "field-supporting-text-id",
|
|
646
|
+
"kind": "require-attr",
|
|
647
|
+
"selector": ".field > small",
|
|
648
|
+
"attr": "id",
|
|
649
|
+
"message": "Supporting and error text has to be referenceable to be announced with the control."
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
"id": "field-supporting-text-linked",
|
|
653
|
+
"kind": "forbid",
|
|
654
|
+
"selector": ".field:has(> small) > :is(input, textarea, select):not([aria-describedby])",
|
|
655
|
+
"message": "A field with supporting text must point at it with aria-describedby, or the text is never read out with the control."
|
|
656
|
+
}
|
|
657
|
+
]
|
|
658
|
+
},
|
|
659
|
+
"forms/radio-buttons": {
|
|
660
|
+
"status": "enforced",
|
|
661
|
+
"note": "Swept 0.8.0. A radio is one of a set, and the set needs a name.",
|
|
662
|
+
"rules": [
|
|
663
|
+
{
|
|
664
|
+
"id": "radio-in-fieldset",
|
|
665
|
+
"kind": "forbid",
|
|
666
|
+
"selector": "input[type=\"radio\"]:not(fieldset *)",
|
|
667
|
+
"message": "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."
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
"id": "radio-labelled",
|
|
671
|
+
"kind": "forbid",
|
|
672
|
+
"selector": "input[type=\"radio\"]:not([id]):not(label *)",
|
|
673
|
+
"message": "A radio must be inside its <label> or carry an id a label points at."
|
|
674
|
+
}
|
|
675
|
+
]
|
|
676
|
+
},
|
|
677
|
+
"forms/select": {
|
|
678
|
+
"status": "enforced",
|
|
679
|
+
"note": "Swept 0.8.0. The listbox FormSelect builds is checked at runtime in tests/select.test.js.",
|
|
680
|
+
"rules": [
|
|
681
|
+
{
|
|
682
|
+
"id": "option-has-selected-state",
|
|
683
|
+
"kind": "forbid",
|
|
684
|
+
"selector": "li[role=\"option\"]:not([aria-selected])",
|
|
685
|
+
"message": "role=option promises a selection state. Without aria-selected the listbox never says which entry is chosen."
|
|
686
|
+
}
|
|
687
|
+
]
|
|
688
|
+
},
|
|
689
|
+
"forms/slider": {
|
|
690
|
+
"status": "enforced",
|
|
691
|
+
"note": "Swept 0.8.0. Renamed from range to the name M3 uses; `.range` stays as an alias.",
|
|
692
|
+
"rules": [
|
|
693
|
+
{
|
|
694
|
+
"id": "range-labelled",
|
|
695
|
+
"kind": "forbid",
|
|
696
|
+
"selector": "input[type=\"range\"]:not([id]):not([aria-label]):not([aria-labelledby]):not(label *)",
|
|
697
|
+
"message": "A range announces only its value unless something names it."
|
|
698
|
+
}
|
|
699
|
+
]
|
|
700
|
+
},
|
|
701
|
+
"forms/switches": {
|
|
702
|
+
"status": "enforced",
|
|
703
|
+
"note": "Swept 0.8.0.",
|
|
704
|
+
"rules": [
|
|
705
|
+
{
|
|
706
|
+
"id": "switch-is-label",
|
|
707
|
+
"kind": "forbid",
|
|
708
|
+
"selector": ".switch:not(label)",
|
|
709
|
+
"message": "A switch is a <label> wrapping its checkbox - that is what makes the text its accessible name.",
|
|
710
|
+
"fragmentSafe": true
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
"id": "switch-decorative-text-hidden",
|
|
714
|
+
"kind": "require-attr",
|
|
715
|
+
"selector": ".switch > span:not(.lever)",
|
|
716
|
+
"attr": "aria-hidden",
|
|
717
|
+
"equals": "true",
|
|
718
|
+
"message": "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."
|
|
719
|
+
}
|
|
720
|
+
]
|
|
721
|
+
},
|
|
722
|
+
"icon-buttons": {
|
|
723
|
+
"status": "enforced",
|
|
724
|
+
"note": "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.",
|
|
725
|
+
"rules": [
|
|
726
|
+
{
|
|
727
|
+
"id": "icon-button-is-a-control",
|
|
728
|
+
"kind": "forbid",
|
|
729
|
+
"selector": ".icon-button:not(button):not(a[href])",
|
|
730
|
+
"fragmentSafe": true,
|
|
731
|
+
"message": "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."
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
"id": "icon-button-link-is-not-fake-disabled",
|
|
735
|
+
"kind": "forbid",
|
|
736
|
+
"selector": "a[href].icon-button:is(.disabled, [aria-disabled=\"true\"])",
|
|
737
|
+
"fragmentSafe": true,
|
|
738
|
+
"message": "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."
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
"id": "icon-button-icon-hidden",
|
|
742
|
+
"kind": "require-attr",
|
|
743
|
+
"selector": ".icon-button :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp,.material-icons)",
|
|
744
|
+
"attr": "aria-hidden",
|
|
745
|
+
"equals": "true",
|
|
746
|
+
"message": "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\"."
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
"id": "icon-button-is-named",
|
|
750
|
+
"kind": "require-accessible-name",
|
|
751
|
+
"selector": ".icon-button",
|
|
752
|
+
"message": "The icon is hidden from assistive technology, so an icon button has nothing left to be named by. Give it an aria-label."
|
|
753
|
+
}
|
|
754
|
+
]
|
|
755
|
+
},
|
|
756
|
+
"icons-material-design": {
|
|
757
|
+
"status": "enforced",
|
|
758
|
+
"note": "Swept 0.8.0. The canonical icon is <span class=\"material-symbols\">, and an icon is either decoration or an image - never unannounced text.",
|
|
759
|
+
"rules": [
|
|
760
|
+
{
|
|
761
|
+
"id": "icon-not-i-element",
|
|
762
|
+
"kind": "forbid",
|
|
763
|
+
"selector": "i.material-symbols, i.material-symbols-outlined, i.material-symbols-rounded, i.material-symbols-sharp, i.material-icons",
|
|
764
|
+
"message": "<i> means idiomatic text. The canonical icon element is <span class=\"material-symbols\">.",
|
|
765
|
+
"fragmentSafe": true
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
"id": "icon-hidden-or-labelled",
|
|
769
|
+
"kind": "forbid",
|
|
770
|
+
"selector": ".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]), .material-icons:not([aria-hidden=\"true\"]):not([role=\"img\"][aria-label]):not([role=\"img\"][aria-labelledby])",
|
|
771
|
+
"message": "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."
|
|
772
|
+
}
|
|
773
|
+
]
|
|
774
|
+
},
|
|
775
|
+
"landmarks": {
|
|
776
|
+
"status": "enforced",
|
|
777
|
+
"note": "Swept 0.8.0. Cross-cutting: the landmark budget belongs to no single component.",
|
|
778
|
+
"rules": [
|
|
779
|
+
{
|
|
780
|
+
"id": "nav-needs-label",
|
|
781
|
+
"kind": "forbid",
|
|
782
|
+
"selector": "nav:not([aria-label]):not([aria-labelledby]):not(article nav):not(.tooltip nav):not(.toolbar)",
|
|
783
|
+
"fragmentSafe": false,
|
|
784
|
+
"message": "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."
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
"id": "main-not-nested",
|
|
788
|
+
"kind": "forbid",
|
|
789
|
+
"selector": "main main",
|
|
790
|
+
"fragmentSafe": true,
|
|
791
|
+
"message": "A document has one <main>. A pane that holds the primary content of a region is a <section> or a <div>."
|
|
792
|
+
}
|
|
793
|
+
]
|
|
794
|
+
},
|
|
795
|
+
"lightbox": {
|
|
796
|
+
"status": "enforced",
|
|
797
|
+
"note": "Swept 0.8.0.",
|
|
798
|
+
"rules": [
|
|
799
|
+
{
|
|
800
|
+
"id": "lightbox-trigger-is-operable",
|
|
801
|
+
"kind": "forbid",
|
|
802
|
+
"selector": "img.lightboxed:not([tabindex]):not(:is(a, button) > img)",
|
|
803
|
+
"fragmentSafe": true,
|
|
804
|
+
"message": "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."
|
|
805
|
+
}
|
|
806
|
+
]
|
|
807
|
+
},
|
|
808
|
+
"list": {
|
|
809
|
+
"status": "enforced",
|
|
810
|
+
"note": "Swept 0.8.0.",
|
|
811
|
+
"rules": [
|
|
812
|
+
{
|
|
813
|
+
"id": "list-item-not-selected",
|
|
814
|
+
"kind": "forbid",
|
|
815
|
+
"selector": "li[aria-selected]:not([role])",
|
|
816
|
+
"message": "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.",
|
|
817
|
+
"fragmentSafe": true
|
|
818
|
+
}
|
|
819
|
+
]
|
|
820
|
+
},
|
|
821
|
+
"loading-indicator": {
|
|
822
|
+
"status": "enforced",
|
|
823
|
+
"note": "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.",
|
|
824
|
+
"rules": [
|
|
825
|
+
{
|
|
826
|
+
"id": "loading-indicator-reports-itself",
|
|
827
|
+
"kind": "require-attr",
|
|
828
|
+
"attr": "role",
|
|
829
|
+
"equals": "status",
|
|
830
|
+
"selector": ".loading-indicator",
|
|
831
|
+
"message": "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."
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
"id": "loading-indicator-is-named",
|
|
835
|
+
"kind": "require-accessible-name",
|
|
836
|
+
"selector": ".loading-indicator",
|
|
837
|
+
"message": "Name the waiting state with aria-label or aria-labelledby. Verify meaningful status updates separately from its accessible name."
|
|
838
|
+
}
|
|
839
|
+
]
|
|
840
|
+
},
|
|
841
|
+
"menu": {
|
|
842
|
+
"status": "enforced",
|
|
843
|
+
"note": "Swept 0.8.0.",
|
|
844
|
+
"rules": [
|
|
845
|
+
{
|
|
846
|
+
"id": "menu-children-are-list-items",
|
|
847
|
+
"kind": "forbid",
|
|
848
|
+
"selector": "menu > :not(li):not(script):not(template)",
|
|
849
|
+
"fragmentSafe": true,
|
|
850
|
+
"message": "<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."
|
|
851
|
+
}
|
|
852
|
+
]
|
|
853
|
+
},
|
|
854
|
+
"navbar": {
|
|
855
|
+
"status": "enforced",
|
|
856
|
+
"note": "Swept 0.8.0. The bar is <header> whose child is <nav>; the landmark rules carry it.",
|
|
857
|
+
"rules": []
|
|
858
|
+
},
|
|
859
|
+
"navigation-bar": {
|
|
860
|
+
"status": "enforced",
|
|
861
|
+
"note": "Swept 0.8.0.",
|
|
862
|
+
"rules": [
|
|
863
|
+
{
|
|
864
|
+
"id": "navigation-bar-marks-current",
|
|
865
|
+
"kind": "forbid",
|
|
866
|
+
"selector": ".navigation-bar a.active:not([aria-current])",
|
|
867
|
+
"fragmentSafe": false,
|
|
868
|
+
"message": "The active destination needs aria-current=\"page\"."
|
|
869
|
+
}
|
|
870
|
+
]
|
|
871
|
+
},
|
|
872
|
+
"navigation-rail": {
|
|
873
|
+
"status": "enforced",
|
|
874
|
+
"note": "Swept 0.8.0.",
|
|
875
|
+
"rules": [
|
|
876
|
+
{
|
|
877
|
+
"id": "navigation-rail-marks-current",
|
|
878
|
+
"kind": "forbid",
|
|
879
|
+
"selector": ".navigation-rail a.active:not([aria-current])",
|
|
880
|
+
"fragmentSafe": false,
|
|
881
|
+
"message": "The active destination needs aria-current=\"page\"."
|
|
882
|
+
}
|
|
883
|
+
]
|
|
884
|
+
},
|
|
885
|
+
"page-footer": {
|
|
886
|
+
"status": "enforced",
|
|
887
|
+
"note": "Swept 0.8.0.",
|
|
888
|
+
"rules": [
|
|
889
|
+
{
|
|
890
|
+
"id": "footer-nav-has-links",
|
|
891
|
+
"kind": "forbid",
|
|
892
|
+
"selector": "footer nav:not(:has(a))",
|
|
893
|
+
"fragmentSafe": false,
|
|
894
|
+
"message": "A footer column with no links is not navigation - it is a <section> with a heading. Marking it <nav> spends a landmark on prose."
|
|
895
|
+
}
|
|
896
|
+
]
|
|
897
|
+
},
|
|
898
|
+
"pagination": {
|
|
899
|
+
"status": "enforced",
|
|
900
|
+
"note": "Swept 0.8.0.",
|
|
901
|
+
"rules": [
|
|
902
|
+
{
|
|
903
|
+
"id": "pagination-in-nav",
|
|
904
|
+
"kind": "forbid",
|
|
905
|
+
"selector": ".pagination:not(nav):not(nav *)",
|
|
906
|
+
"fragmentSafe": true,
|
|
907
|
+
"message": "Pagination is navigation and belongs in a labelled <nav>, or it is just a list of numbers."
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
"id": "pagination-marks-current",
|
|
911
|
+
"kind": "forbid",
|
|
912
|
+
"selector": ".pagination .active :is(a, span):not([aria-current])",
|
|
913
|
+
"fragmentSafe": false,
|
|
914
|
+
"message": "The current page needs aria-current=\"page\". A class only colours it."
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
"id": "pagination-disabled-not-a-link",
|
|
918
|
+
"kind": "forbid",
|
|
919
|
+
"selector": ".pagination .disabled a[href]",
|
|
920
|
+
"fragmentSafe": true,
|
|
921
|
+
"message": "A disabled control that is still an <a href> stays in the tab order and still navigates. Use a <span>, or drop the href."
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
},
|
|
925
|
+
"panes": {
|
|
926
|
+
"status": "enforced",
|
|
927
|
+
"note": "Swept 0.8.0.",
|
|
928
|
+
"rules": [
|
|
929
|
+
{
|
|
930
|
+
"id": "pane-is-not-main",
|
|
931
|
+
"kind": "forbid",
|
|
932
|
+
"selector": ":is(.panes, .pane-layout, .list-detail, .supporting-pane-layout) main",
|
|
933
|
+
"fragmentSafe": true,
|
|
934
|
+
"message": "A document has one <main>, and a pane is a region inside it, not another one. Use <section>."
|
|
935
|
+
}
|
|
936
|
+
]
|
|
937
|
+
},
|
|
938
|
+
"progress": {
|
|
939
|
+
"status": "enforced",
|
|
940
|
+
"note": "Swept 0.8.0. Renamed from preloader; M3 calls the component Progress indicators.",
|
|
941
|
+
"rules": [
|
|
942
|
+
{
|
|
943
|
+
"id": "div-progress-reports-progress",
|
|
944
|
+
"kind": "forbid",
|
|
945
|
+
"selector": "div.progress:not([role=\"progressbar\"])",
|
|
946
|
+
"message": "<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."
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
"id": "determinate-progress-reports-its-value",
|
|
950
|
+
"kind": "forbid",
|
|
951
|
+
"selector": "[role=\"progressbar\"]:has(> .determinate):not([aria-valuenow])",
|
|
952
|
+
"message": "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."
|
|
953
|
+
}
|
|
954
|
+
]
|
|
955
|
+
},
|
|
956
|
+
"scrim": {
|
|
957
|
+
"status": "enforced",
|
|
958
|
+
"kind": "foundation",
|
|
959
|
+
"note": "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.",
|
|
960
|
+
"rules": []
|
|
961
|
+
},
|
|
962
|
+
"search": {
|
|
963
|
+
"status": "enforced",
|
|
964
|
+
"note": "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.",
|
|
965
|
+
"rejects": {
|
|
966
|
+
"rejected_role": "listbox",
|
|
967
|
+
"because": "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.",
|
|
968
|
+
"rule": "search-view-not-a-composite-widget"
|
|
969
|
+
},
|
|
970
|
+
"rules": [
|
|
971
|
+
{
|
|
972
|
+
"id": "search-view-not-a-composite-widget",
|
|
973
|
+
"kind": "forbid-composite-roles",
|
|
974
|
+
"selector": ".search-view",
|
|
975
|
+
"fragmentSafe": true,
|
|
976
|
+
"message": "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."
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
"id": "search-bar-holds-a-search-input",
|
|
980
|
+
"kind": "forbid",
|
|
981
|
+
"selector": ".search-bar input:not([type=\"search\"])",
|
|
982
|
+
"fragmentSafe": true,
|
|
983
|
+
"message": "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. The selector names `.search-bar` and deliberately not the `.searchbar` alias: pre-1.0 markup of that shape predates the requirement, and holding old markup to a rule it could not have known is how a promise of an additive rename gets broken."
|
|
984
|
+
}
|
|
985
|
+
]
|
|
986
|
+
},
|
|
987
|
+
"scrollspy": {
|
|
988
|
+
"status": "enforced",
|
|
989
|
+
"note": "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.",
|
|
990
|
+
"rules": [
|
|
991
|
+
{
|
|
992
|
+
"id": "toc-in-nav",
|
|
993
|
+
"kind": "forbid",
|
|
994
|
+
"selector": ".table-of-contents:not(nav):not(nav *)",
|
|
995
|
+
"fragmentSafe": true,
|
|
996
|
+
"message": "A table of contents is a set of destinations within the page. It belongs in a labelled <nav>."
|
|
997
|
+
}
|
|
998
|
+
]
|
|
999
|
+
},
|
|
1000
|
+
"side-sheet": {
|
|
1001
|
+
"status": "enforced",
|
|
1002
|
+
"note": "Swept 0.8.0. A <dialog>, so the dialog rules carry it.",
|
|
1003
|
+
"rules": []
|
|
1004
|
+
},
|
|
1005
|
+
"split-button": {
|
|
1006
|
+
"status": "enforced",
|
|
1007
|
+
"note": "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.",
|
|
1008
|
+
"rejects": {
|
|
1009
|
+
"rejected_role": "toolbar",
|
|
1010
|
+
"because": "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",
|
|
1011
|
+
"rule": "split-button-is-not-an-authored-composite-widget"
|
|
1012
|
+
},
|
|
1013
|
+
"rules": [
|
|
1014
|
+
{
|
|
1015
|
+
"id": "split-button-is-not-an-authored-composite-widget",
|
|
1016
|
+
"kind": "forbid-composite-roles",
|
|
1017
|
+
"selector": ".split-button",
|
|
1018
|
+
"fragmentSafe": true,
|
|
1019
|
+
"message": "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."
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
"id": "split-button-halves-are-controls",
|
|
1023
|
+
"kind": "forbid",
|
|
1024
|
+
"selector": ".split-button > :not(button):not(a.button[href]):not(menu)",
|
|
1025
|
+
"fragmentSafe": true,
|
|
1026
|
+
"message": "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."
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
"id": "split-button-half-is-not-an-icon-button",
|
|
1030
|
+
"kind": "forbid",
|
|
1031
|
+
"selector": ".split-button > .icon-button",
|
|
1032
|
+
"fragmentSafe": true,
|
|
1033
|
+
"message": "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."
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
"id": "split-button-trigger-is-the-trailing-half",
|
|
1037
|
+
"kind": "forbid",
|
|
1038
|
+
"selector": ".split-button > .menu-trigger ~ :not(menu)",
|
|
1039
|
+
"fragmentSafe": true,
|
|
1040
|
+
"message": "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."
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
"id": "split-button-is-two-controls",
|
|
1044
|
+
"kind": "forbid",
|
|
1045
|
+
"selector": ".split-button > :is(button, a.button) ~ :is(button, a.button) ~ :is(button, a.button)",
|
|
1046
|
+
"fragmentSafe": true,
|
|
1047
|
+
"message": "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."
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
"id": "split-button-has-a-leading-action",
|
|
1051
|
+
"kind": "forbid",
|
|
1052
|
+
"selector": ".split-button:not(:has(> :is(button, a.button):not(.menu-trigger)))",
|
|
1053
|
+
"message": "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."
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
"id": "split-button-has-a-trailing-menu-trigger",
|
|
1057
|
+
"kind": "forbid",
|
|
1058
|
+
"selector": ".split-button:not(:has(> .menu-trigger))",
|
|
1059
|
+
"message": "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."
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
"id": "split-button-trigger-names-its-menu",
|
|
1063
|
+
"kind": "forbid",
|
|
1064
|
+
"selector": ".split-button > .menu-trigger:not([data-target]):not([href])",
|
|
1065
|
+
"fragmentSafe": true,
|
|
1066
|
+
"message": "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."
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
"id": "split-button-trailing-icon-hidden",
|
|
1070
|
+
"kind": "require-attr",
|
|
1071
|
+
"selector": ".split-button > .menu-trigger :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp,.material-icons)",
|
|
1072
|
+
"attr": "aria-hidden",
|
|
1073
|
+
"equals": "true",
|
|
1074
|
+
"message": "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."
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
"id": "split-button-expanded-is-not-authored",
|
|
1078
|
+
"kind": "forbid",
|
|
1079
|
+
"selector": ".split-button [aria-expanded]",
|
|
1080
|
+
"fragmentSafe": true,
|
|
1081
|
+
"message": "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."
|
|
1082
|
+
}
|
|
1083
|
+
]
|
|
1084
|
+
},
|
|
1085
|
+
"snackbar": {
|
|
1086
|
+
"status": "enforced",
|
|
1087
|
+
"note": "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.",
|
|
1088
|
+
"rules": []
|
|
1089
|
+
},
|
|
1090
|
+
"tabs": {
|
|
1091
|
+
"status": "enforced",
|
|
1092
|
+
"note": "Swept 0.8.0. Anchor navigation, not a tablist - see rule 2.",
|
|
1093
|
+
"conformance": {
|
|
1094
|
+
"withheld_role": "tablist",
|
|
1095
|
+
"blocked_on": "keyboard model",
|
|
1096
|
+
"rule": "tabs-not-a-tablist"
|
|
1097
|
+
},
|
|
1098
|
+
"rules": [
|
|
1099
|
+
{
|
|
1100
|
+
"id": "tabs-not-a-tablist",
|
|
1101
|
+
"kind": "forbid",
|
|
1102
|
+
"selector": ".tabs[role], .tabs [role=\"tab\"], .tabs [role=\"tabpanel\"]",
|
|
1103
|
+
"fragmentSafe": true,
|
|
1104
|
+
"message": "A tab strip takes no ARIA role here; these are links to in-page sections."
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
"id": "tabs-marks-current",
|
|
1108
|
+
"kind": "forbid",
|
|
1109
|
+
"selector": ".tabs a.active:not([aria-current])",
|
|
1110
|
+
"fragmentSafe": false,
|
|
1111
|
+
"message": "The active tab is the section you are on; aria-current says so where a class cannot."
|
|
1112
|
+
}
|
|
1113
|
+
]
|
|
1114
|
+
},
|
|
1115
|
+
"timepicker": {
|
|
1116
|
+
"status": "enforced",
|
|
1117
|
+
"note": "Swept 0.8.0. Generated markup inside a <dialog>; the dialog rules carry the container.",
|
|
1118
|
+
"rules": []
|
|
1119
|
+
},
|
|
1120
|
+
"toolbar": {
|
|
1121
|
+
"status": "enforced",
|
|
1122
|
+
"note": "A toolbar holds commands, not destinations. It does not implement composite arrow-key navigation.",
|
|
1123
|
+
"conformance": {
|
|
1124
|
+
"withheld_role": "toolbar",
|
|
1125
|
+
"blocked_on": "keyboard model",
|
|
1126
|
+
"rule": "toolbar-not-a-composite-widget"
|
|
1127
|
+
},
|
|
1128
|
+
"rules": [
|
|
1129
|
+
{
|
|
1130
|
+
"id": "toolbar-not-nav",
|
|
1131
|
+
"kind": "forbid",
|
|
1132
|
+
"selector": "nav.toolbar",
|
|
1133
|
+
"message": "A toolbar holds commands (Bold, Italic), not navigation, so it is not a <nav> landmark.",
|
|
1134
|
+
"fragmentSafe": true
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
"id": "toolbar-not-a-composite-widget",
|
|
1138
|
+
"kind": "forbid-composite-roles",
|
|
1139
|
+
"selector": ".toolbar",
|
|
1140
|
+
"fragmentSafe": true,
|
|
1141
|
+
"message": "A toolbar takes no composite role; its buttons are reached with Tab, not arrow keys."
|
|
1142
|
+
}
|
|
1143
|
+
]
|
|
1144
|
+
},
|
|
1145
|
+
"tooltip": {
|
|
1146
|
+
"status": "enforced",
|
|
1147
|
+
"note": "Swept 0.8.0.",
|
|
1148
|
+
"rules": [
|
|
1149
|
+
{
|
|
1150
|
+
"id": "css-tooltip-is-described-by",
|
|
1151
|
+
"kind": "forbid",
|
|
1152
|
+
"selector": ":is(a, button):has(> .tooltip):not([aria-describedby])",
|
|
1153
|
+
"message": "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."
|
|
1154
|
+
}
|
|
1155
|
+
]
|
|
1156
|
+
},
|
|
1157
|
+
"transitions": {
|
|
1158
|
+
"status": "enforced",
|
|
1159
|
+
"kind": "foundation",
|
|
1160
|
+
"note": "Swept 0.8.0. A foundation, not a component: motion applied through classes to whatever element already exists, with no markup of its own.",
|
|
1161
|
+
"rules": []
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
"fragmentSafe": "Set on a rule that fires on a wrong thing being PRESENT. Only these run against m3-guidelines.md, whose markup is prose fragments: a fragment can be wrong about what it contains, but omitting something is what a fragment is for. Note this is not the same as kind=forbid - `fieldset:not(:has(> legend))` forbids, but what it detects is an omission."
|
|
1165
|
+
}
|
|
1166
|
+
}
|