@expressivecss/mcp-server 0.4.1 → 0.5.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 +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +58 -0
- package/README.md +2 -2
- package/capability-roadmap.json +1812 -289
- package/component-decisions.json +3290 -891
- package/component-guides.json +176 -76
- package/contract.json +5 -4
- package/lint.mjs +8 -4
- package/package.json +4 -2
- package/scripts/bounded-file.mjs +73 -0
- package/scripts/resolve-version.mjs +17 -60
- package/scripts/semantics-rules.mjs +184 -0
- package/semantics-data.json +746 -33
- package/server.js +242 -196
- package/smoke.mjs +206 -22
package/semantics-data.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedBy": "
|
|
4
|
-
"frameworkVersion": "0.
|
|
3
|
+
"generatedBy": "scripts/gen-expressivecss-skill.mjs",
|
|
4
|
+
"frameworkVersion": "0.16.0",
|
|
5
5
|
"semantics": {
|
|
6
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
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
8
|
"rules": [
|
|
9
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
|
|
11
|
-
"Icons are `<span class=\"material-symbols\" aria-hidden=\"true\">`. `<i>`
|
|
10
|
+
"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.",
|
|
11
|
+
"Icons are `<span class=\"material-symbols\" aria-hidden=\"true\">`. A bare `<i>` gets no icon styling.",
|
|
12
12
|
"`<nav>` is for genuine navigation only, and every one carries a required `aria-label`. Card action rows and toolbars are not navigation.",
|
|
13
13
|
"Every user-facing string the framework generates has an `i18n` option."
|
|
14
14
|
],
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
"additional": [
|
|
47
47
|
"autocomplete",
|
|
48
48
|
"character-counter",
|
|
49
|
-
"
|
|
49
|
+
"currency-input",
|
|
50
|
+
"hover-card",
|
|
51
|
+
"input-mask",
|
|
52
|
+
"landmarks",
|
|
53
|
+
"tree-select"
|
|
50
54
|
],
|
|
51
55
|
"notComponents": {
|
|
52
56
|
"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."
|
|
@@ -128,7 +132,7 @@
|
|
|
128
132
|
{
|
|
129
133
|
"id": "fab-no-surface-color",
|
|
130
134
|
"kind": "forbid",
|
|
131
|
-
"selector": ":is(button, a.button, label.button):is(.circle.extra, .circle.large, .extend):is(.surface, .surface-dim, .surface-bright, .surface-
|
|
135
|
+
"selector": ":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)",
|
|
132
136
|
"fragmentSafe": true,
|
|
133
137
|
"message": "Use a primary, secondary or tertiary container color for a FAB instead of a surface color."
|
|
134
138
|
}
|
|
@@ -395,6 +399,45 @@
|
|
|
395
399
|
}
|
|
396
400
|
]
|
|
397
401
|
},
|
|
402
|
+
"cascader": {
|
|
403
|
+
"status": "enforced",
|
|
404
|
+
"note": "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.",
|
|
405
|
+
"rejects": {
|
|
406
|
+
"rejected_role": "listbox",
|
|
407
|
+
"because": "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",
|
|
408
|
+
"rule": "cascader-lists-take-no-roles"
|
|
409
|
+
},
|
|
410
|
+
"rules": [
|
|
411
|
+
{
|
|
412
|
+
"id": "cascader-lists-take-no-roles",
|
|
413
|
+
"kind": "forbid",
|
|
414
|
+
"selector": ".cascader-options[role], .cascader-options [role]",
|
|
415
|
+
"fragmentSafe": true,
|
|
416
|
+
"message": "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."
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"id": "cascader-option-has-value",
|
|
420
|
+
"kind": "forbid",
|
|
421
|
+
"selector": ".cascader-options li:not([data-value])",
|
|
422
|
+
"fragmentSafe": true,
|
|
423
|
+
"message": "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."
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"id": "cascader-needs-a-text-field",
|
|
427
|
+
"kind": "forbid",
|
|
428
|
+
"selector": ".cascader > input:not(:is(:not([type]), [type=\"text\" i]))",
|
|
429
|
+
"fragmentSafe": true,
|
|
430
|
+
"message": "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."
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
"id": "cascader-input-is-named",
|
|
434
|
+
"kind": "forbid",
|
|
435
|
+
"selector": ".cascader:not(:has(> label[for])) > input:not([aria-label]):not([aria-labelledby])",
|
|
436
|
+
"fragmentSafe": true,
|
|
437
|
+
"message": "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."
|
|
438
|
+
}
|
|
439
|
+
]
|
|
440
|
+
},
|
|
398
441
|
"character-counter": {
|
|
399
442
|
"status": "enforced",
|
|
400
443
|
"kind": "behavior",
|
|
@@ -460,7 +503,7 @@
|
|
|
460
503
|
{
|
|
461
504
|
"id": "chip-icon-hidden",
|
|
462
505
|
"kind": "require-attr",
|
|
463
|
-
"selector": ".chip :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp
|
|
506
|
+
"selector": ".chip :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)",
|
|
464
507
|
"attr": "aria-hidden",
|
|
465
508
|
"equals": "true",
|
|
466
509
|
"message": "A ligature icon is read aloud verbatim. Decorative icons inside a labelled control must be aria-hidden."
|
|
@@ -497,27 +540,33 @@
|
|
|
497
540
|
"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.",
|
|
498
541
|
"rules": []
|
|
499
542
|
},
|
|
543
|
+
"modal-display": {
|
|
544
|
+
"status": "enforced",
|
|
545
|
+
"kind": "behavior",
|
|
546
|
+
"note": "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.",
|
|
547
|
+
"rules": []
|
|
548
|
+
},
|
|
500
549
|
"drag-handle": {
|
|
501
550
|
"status": "enforced",
|
|
502
|
-
"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
|
|
551
|
+
"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 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.",
|
|
503
552
|
"rules": [
|
|
504
553
|
{
|
|
505
554
|
"id": "drag-handle-is-hidden-or-a-control",
|
|
506
555
|
"kind": "forbid",
|
|
507
|
-
"selector": "
|
|
556
|
+
"selector": ".drag-handle:not(button):not([aria-hidden=\"true\"])",
|
|
508
557
|
"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."
|
|
509
558
|
},
|
|
510
559
|
{
|
|
511
560
|
"id": "drag-handle-button-is-not-hidden",
|
|
512
561
|
"kind": "forbid",
|
|
513
|
-
"selector": "button
|
|
562
|
+
"selector": "button.drag-handle[aria-hidden=\"true\"]",
|
|
514
563
|
"fragmentSafe": true,
|
|
515
564
|
"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."
|
|
516
565
|
},
|
|
517
566
|
{
|
|
518
567
|
"id": "drag-handle-button-is-named",
|
|
519
568
|
"kind": "require-accessible-name",
|
|
520
|
-
"selector": "button
|
|
569
|
+
"selector": "button.drag-handle",
|
|
521
570
|
"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."
|
|
522
571
|
}
|
|
523
572
|
]
|
|
@@ -565,6 +614,32 @@
|
|
|
565
614
|
}
|
|
566
615
|
]
|
|
567
616
|
},
|
|
617
|
+
"back-to-top": {
|
|
618
|
+
"status": "enforced",
|
|
619
|
+
"note": "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.",
|
|
620
|
+
"rules": [
|
|
621
|
+
{
|
|
622
|
+
"id": "back-to-top-is-an-in-page-link",
|
|
623
|
+
"kind": "forbid",
|
|
624
|
+
"selector": ".back-to-top:not(a[href^=\"#\"])",
|
|
625
|
+
"fragmentSafe": true,
|
|
626
|
+
"message": "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."
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"id": "back-to-top-is-named",
|
|
630
|
+
"kind": "require-accessible-name",
|
|
631
|
+
"selector": ".back-to-top",
|
|
632
|
+
"message": "The link usually holds only an icon. Give it an aria-label such as \"Back to top\"."
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
"id": "back-to-top-inert-is-not-authored",
|
|
636
|
+
"kind": "forbid",
|
|
637
|
+
"selector": ".back-to-top[inert]",
|
|
638
|
+
"fragmentSafe": true,
|
|
639
|
+
"message": "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."
|
|
640
|
+
}
|
|
641
|
+
]
|
|
642
|
+
},
|
|
568
643
|
"forms/checkboxes": {
|
|
569
644
|
"status": "enforced",
|
|
570
645
|
"note": "Swept 0.8.0.",
|
|
@@ -594,6 +669,13 @@
|
|
|
594
669
|
"selector": ".drop-zone-files:not(ul, ol)",
|
|
595
670
|
"fragmentSafe": true,
|
|
596
671
|
"message": "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."
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
"id": "drop-zone-type-error-is-live-status",
|
|
675
|
+
"kind": "forbid",
|
|
676
|
+
"selector": ".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\"])",
|
|
677
|
+
"fragmentSafe": true,
|
|
678
|
+
"message": "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."
|
|
597
679
|
}
|
|
598
680
|
]
|
|
599
681
|
},
|
|
@@ -649,7 +731,7 @@
|
|
|
649
731
|
{
|
|
650
732
|
"id": "field-icon-hidden",
|
|
651
733
|
"kind": "require-attr",
|
|
652
|
-
"selector": ".field .material-symbols, .field .material-symbols-outlined, .field .material-symbols-rounded, .field .material-symbols-sharp
|
|
734
|
+
"selector": ".field .material-symbols, .field .material-symbols-outlined, .field .material-symbols-rounded, .field .material-symbols-sharp",
|
|
653
735
|
"attr": "aria-hidden",
|
|
654
736
|
"equals": "true",
|
|
655
737
|
"message": "The ligature is read aloud. A field icon is decoration; the label names the control."
|
|
@@ -657,8 +739,8 @@
|
|
|
657
739
|
{
|
|
658
740
|
"id": "field-icon-side",
|
|
659
741
|
"kind": "forbid",
|
|
660
|
-
"selector": ".field > :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp
|
|
661
|
-
"message": "A field icon must name its side with .prefix or .suffix.
|
|
742
|
+
"selector": ".field > :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp):not(.prefix):not(.suffix)",
|
|
743
|
+
"message": "A field icon must name its side with .prefix or .suffix. Fields have no positional fallback."
|
|
662
744
|
},
|
|
663
745
|
{
|
|
664
746
|
"id": "field-label-for",
|
|
@@ -751,7 +833,45 @@
|
|
|
751
833
|
"kind": "forbid",
|
|
752
834
|
"selector": "fieldset.rating [type=\"radio\"] + span",
|
|
753
835
|
"fragmentSafe": true,
|
|
754
|
-
"message": "Put a star's visually hidden text before its radio. A span after the radio is the
|
|
836
|
+
"message": "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."
|
|
837
|
+
}
|
|
838
|
+
]
|
|
839
|
+
},
|
|
840
|
+
"forms/color-swatches": {
|
|
841
|
+
"status": "enforced",
|
|
842
|
+
"note": "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.",
|
|
843
|
+
"rules": [
|
|
844
|
+
{
|
|
845
|
+
"id": "color-swatches-is-a-fieldset",
|
|
846
|
+
"kind": "forbid",
|
|
847
|
+
"selector": ".color-swatches:not(fieldset)",
|
|
848
|
+
"fragmentSafe": true,
|
|
849
|
+
"message": "A .color-swatches is a <fieldset> with a <legend>, so the radios read as one named group."
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
"id": "color-swatch-is-named",
|
|
853
|
+
"kind": "require-accessible-name",
|
|
854
|
+
"selector": ".color-swatches > [type=\"radio\"]",
|
|
855
|
+
"message": "A swatch shows no text, so name each radio with visually hidden text in its label, such as \"Purple\"."
|
|
856
|
+
}
|
|
857
|
+
]
|
|
858
|
+
},
|
|
859
|
+
"forms/color-picker": {
|
|
860
|
+
"status": "enforced",
|
|
861
|
+
"note": "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.",
|
|
862
|
+
"rules": [
|
|
863
|
+
{
|
|
864
|
+
"id": "color-picker-wraps-a-color-input",
|
|
865
|
+
"kind": "forbid",
|
|
866
|
+
"selector": ".color-picker:not(:has(> input[type=\"color\"]))",
|
|
867
|
+
"fragmentSafe": true,
|
|
868
|
+
"message": "A .color-picker holds an <input type=\"color\"> as a direct child. It is the form value, the preview and the picker without JavaScript."
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
"id": "color-picker-is-a-named-group",
|
|
872
|
+
"kind": "require-accessible-name",
|
|
873
|
+
"selector": ".color-picker",
|
|
874
|
+
"message": "Give the .color-picker role=\"group\" and a name, such as aria-label=\"Accent\", so its sliders and hex field read as one control."
|
|
755
875
|
}
|
|
756
876
|
]
|
|
757
877
|
},
|
|
@@ -767,9 +887,22 @@
|
|
|
767
887
|
}
|
|
768
888
|
]
|
|
769
889
|
},
|
|
890
|
+
"tree-select": {
|
|
891
|
+
"status": "enforced",
|
|
892
|
+
"note": "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.",
|
|
893
|
+
"rules": [
|
|
894
|
+
{
|
|
895
|
+
"id": "tree-select-needs-native-options",
|
|
896
|
+
"kind": "forbid",
|
|
897
|
+
"selector": "[data-tree]:not(select), select[data-tree] optgroup, select:not([data-tree]) option[data-parent]",
|
|
898
|
+
"fragmentSafe": true,
|
|
899
|
+
"message": "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."
|
|
900
|
+
}
|
|
901
|
+
]
|
|
902
|
+
},
|
|
770
903
|
"forms/slider": {
|
|
771
904
|
"status": "enforced",
|
|
772
|
-
"note": "Swept 0.8.0. Renamed from range to the name M3 uses; `.range`
|
|
905
|
+
"note": "Swept 0.8.0. Renamed from range to the name M3 uses; 0.16.0 removed the `.range` and `.range-field` hosts.",
|
|
773
906
|
"rules": [
|
|
774
907
|
{
|
|
775
908
|
"id": "range-labelled",
|
|
@@ -793,7 +926,7 @@
|
|
|
793
926
|
{
|
|
794
927
|
"id": "switch-decorative-text-hidden",
|
|
795
928
|
"kind": "require-attr",
|
|
796
|
-
"selector": ".switch > span
|
|
929
|
+
"selector": ".switch > span",
|
|
797
930
|
"attr": "aria-hidden",
|
|
798
931
|
"equals": "true",
|
|
799
932
|
"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."
|
|
@@ -821,7 +954,7 @@
|
|
|
821
954
|
{
|
|
822
955
|
"id": "icon-button-icon-hidden",
|
|
823
956
|
"kind": "require-attr",
|
|
824
|
-
"selector": ".icon-button :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp
|
|
957
|
+
"selector": ".icon-button :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)",
|
|
825
958
|
"attr": "aria-hidden",
|
|
826
959
|
"equals": "true",
|
|
827
960
|
"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\"."
|
|
@@ -841,18 +974,66 @@
|
|
|
841
974
|
{
|
|
842
975
|
"id": "icon-not-i-element",
|
|
843
976
|
"kind": "forbid",
|
|
844
|
-
"selector": "i.material-symbols, i.material-symbols-outlined, i.material-symbols-rounded, i.material-symbols-sharp
|
|
977
|
+
"selector": "i.material-symbols, i.material-symbols-outlined, i.material-symbols-rounded, i.material-symbols-sharp",
|
|
845
978
|
"message": "<i> means idiomatic text. The canonical icon element is <span class=\"material-symbols\">.",
|
|
846
979
|
"fragmentSafe": true
|
|
847
980
|
},
|
|
848
981
|
{
|
|
849
982
|
"id": "icon-hidden-or-labelled",
|
|
850
983
|
"kind": "forbid",
|
|
851
|
-
"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])
|
|
984
|
+
"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]):not([aria-hidden=\"true\"]):not([role=\"img\"][aria-label]):not([role=\"img\"][aria-labelledby])",
|
|
852
985
|
"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."
|
|
853
986
|
}
|
|
854
987
|
]
|
|
855
988
|
},
|
|
989
|
+
"currency-input": {
|
|
990
|
+
"status": "enforced",
|
|
991
|
+
"note": "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.",
|
|
992
|
+
"rules": [
|
|
993
|
+
{
|
|
994
|
+
"id": "currency-input-needs-a-text-field",
|
|
995
|
+
"kind": "forbid",
|
|
996
|
+
"selector": "[data-currency]:not(input:is(:not([type]), [type=\"text\" i])), [data-currency=\"\"]",
|
|
997
|
+
"fragmentSafe": true,
|
|
998
|
+
"message": "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."
|
|
999
|
+
}
|
|
1000
|
+
]
|
|
1001
|
+
},
|
|
1002
|
+
"hover-card": {
|
|
1003
|
+
"status": "enforced",
|
|
1004
|
+
"note": "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.",
|
|
1005
|
+
"rules": [
|
|
1006
|
+
{
|
|
1007
|
+
"id": "hover-card-trigger-is-a-link",
|
|
1008
|
+
"kind": "forbid",
|
|
1009
|
+
"selector": ".hover-card-trigger:not(a[href])",
|
|
1010
|
+
"fragmentSafe": true,
|
|
1011
|
+
"message": "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."
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
"id": "hover-card-names-its-card",
|
|
1015
|
+
"kind": "require-idref",
|
|
1016
|
+
"selector": ".hover-card-trigger",
|
|
1017
|
+
"attr": "aria-details",
|
|
1018
|
+
"container": "body",
|
|
1019
|
+
"targetSelector": "[popover]",
|
|
1020
|
+
"message": "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."
|
|
1021
|
+
}
|
|
1022
|
+
]
|
|
1023
|
+
},
|
|
1024
|
+
"input-mask": {
|
|
1025
|
+
"status": "enforced",
|
|
1026
|
+
"note": "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.",
|
|
1027
|
+
"rules": [
|
|
1028
|
+
{
|
|
1029
|
+
"id": "input-mask-needs-a-caret",
|
|
1030
|
+
"kind": "forbid",
|
|
1031
|
+
"selector": "[data-mask]:not(input:is(:not([type]), [type=\"text\" i], [type=\"tel\" i], [type=\"search\" i], [type=\"url\" i], [type=\"password\" i]))",
|
|
1032
|
+
"fragmentSafe": true,
|
|
1033
|
+
"message": "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."
|
|
1034
|
+
}
|
|
1035
|
+
]
|
|
1036
|
+
},
|
|
856
1037
|
"landmarks": {
|
|
857
1038
|
"status": "enforced",
|
|
858
1039
|
"note": "Swept 0.8.0. Cross-cutting: the landmark budget belongs to no single component.",
|
|
@@ -896,6 +1077,19 @@
|
|
|
896
1077
|
"selector": "li[aria-selected]:not([role])",
|
|
897
1078
|
"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.",
|
|
898
1079
|
"fragmentSafe": true
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
"id": "sortable-items-move-without-dragging",
|
|
1083
|
+
"kind": "forbid",
|
|
1084
|
+
"selector": ".sortable > li:not(:has([data-move=\"up\"]):has([data-move=\"down\"]))",
|
|
1085
|
+
"message": "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."
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
"id": "list-unstyled-has-list-role",
|
|
1089
|
+
"kind": "forbid",
|
|
1090
|
+
"selector": ":is(ul, ol).list-unstyled:not([role])",
|
|
1091
|
+
"fragmentSafe": true,
|
|
1092
|
+
"message": "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."
|
|
899
1093
|
}
|
|
900
1094
|
]
|
|
901
1095
|
},
|
|
@@ -919,6 +1113,18 @@
|
|
|
919
1113
|
}
|
|
920
1114
|
]
|
|
921
1115
|
},
|
|
1116
|
+
"loading-overlay": {
|
|
1117
|
+
"status": "enforced",
|
|
1118
|
+
"note": "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.",
|
|
1119
|
+
"rules": [
|
|
1120
|
+
{
|
|
1121
|
+
"id": "loading-overlay-has-an-indicator",
|
|
1122
|
+
"kind": "forbid",
|
|
1123
|
+
"selector": ".loading-overlay:not(:has(> .loading-indicator))",
|
|
1124
|
+
"message": "Give a .loading-overlay a .loading-indicator as a direct child. It is what shows over the scrim while the region is busy."
|
|
1125
|
+
}
|
|
1126
|
+
]
|
|
1127
|
+
},
|
|
922
1128
|
"menu": {
|
|
923
1129
|
"status": "enforced",
|
|
924
1130
|
"note": "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.",
|
|
@@ -930,6 +1136,13 @@
|
|
|
930
1136
|
"fragmentSafe": true,
|
|
931
1137
|
"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."
|
|
932
1138
|
},
|
|
1139
|
+
{
|
|
1140
|
+
"id": "menu-group-is-named",
|
|
1141
|
+
"kind": "forbid",
|
|
1142
|
+
"selector": "menu ul[role=\"group\"]:not([aria-label]):not([aria-labelledby])",
|
|
1143
|
+
"fragmentSafe": true,
|
|
1144
|
+
"message": "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."
|
|
1145
|
+
},
|
|
933
1146
|
{
|
|
934
1147
|
"id": "context-menu-region-is-not-a-control",
|
|
935
1148
|
"kind": "forbid",
|
|
@@ -964,6 +1177,24 @@
|
|
|
964
1177
|
}
|
|
965
1178
|
]
|
|
966
1179
|
},
|
|
1180
|
+
"pull-to-refresh": {
|
|
1181
|
+
"status": "enforced",
|
|
1182
|
+
"note": "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.",
|
|
1183
|
+
"rules": [
|
|
1184
|
+
{
|
|
1185
|
+
"id": "pull-to-refresh-has-a-refresh-button",
|
|
1186
|
+
"kind": "forbid",
|
|
1187
|
+
"selector": ".pull-to-refresh:not(:has(button[data-refresh]))",
|
|
1188
|
+
"message": "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."
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
"id": "pull-to-refresh-has-an-indicator",
|
|
1192
|
+
"kind": "forbid",
|
|
1193
|
+
"selector": ".pull-to-refresh:not(:has(> .loading-indicator))",
|
|
1194
|
+
"message": "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."
|
|
1195
|
+
}
|
|
1196
|
+
]
|
|
1197
|
+
},
|
|
967
1198
|
"rich-text-editor": {
|
|
968
1199
|
"status": "enforced",
|
|
969
1200
|
"note": "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.",
|
|
@@ -1026,7 +1257,7 @@
|
|
|
1026
1257
|
},
|
|
1027
1258
|
"data-table": {
|
|
1028
1259
|
"status": "enforced",
|
|
1029
|
-
"note": "Added with the data table component. The wrapper is the scroll container so the <table> keeps its table semantics. Sorting and selection
|
|
1260
|
+
"note": "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.",
|
|
1030
1261
|
"rules": [
|
|
1031
1262
|
{
|
|
1032
1263
|
"id": "data-table-wraps-the-table",
|
|
@@ -1062,6 +1293,109 @@
|
|
|
1062
1293
|
"kind": "require-accessible-name",
|
|
1063
1294
|
"selector": ".data-table[tabindex]",
|
|
1064
1295
|
"message": "Name a focusable scroll wrapper with aria-label, or aria-labelledby pointing at the table caption, so keyboard users know what they are scrolling."
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
"id": "data-table-filter-is-named",
|
|
1299
|
+
"kind": "require-accessible-name",
|
|
1300
|
+
"selector": ".data-table input[type=\"search\"][data-table-filter]",
|
|
1301
|
+
"message": "Give the table search input a label identifying the records it filters."
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
"id": "data-table-filter-count-is-named",
|
|
1305
|
+
"kind": "require-accessible-name",
|
|
1306
|
+
"selector": ".data-table [data-table-filter-count]",
|
|
1307
|
+
"message": "Name the result count, for example aria-label=\"Matching records\", so its number has context."
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
"id": "data-table-filter-count-is-status",
|
|
1311
|
+
"kind": "require-attr",
|
|
1312
|
+
"selector": ".data-table [data-table-filter-count]",
|
|
1313
|
+
"attr": "role",
|
|
1314
|
+
"equals": "status",
|
|
1315
|
+
"message": "Give the table result count role=\"status\" so changes are announced without moving focus."
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
"id": "data-table-filter-count-is-polite",
|
|
1319
|
+
"kind": "require-attr",
|
|
1320
|
+
"selector": ".data-table [data-table-filter-count]",
|
|
1321
|
+
"attr": "aria-live",
|
|
1322
|
+
"equals": "polite",
|
|
1323
|
+
"message": "Use aria-live=\"polite\" on the table result count so typing does not interrupt other speech."
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
"id": "data-table-page-size-is-select",
|
|
1327
|
+
"kind": "forbid",
|
|
1328
|
+
"selector": ".data-table[data-page-size] [data-table-page-size]:not(select)",
|
|
1329
|
+
"fragmentSafe": true,
|
|
1330
|
+
"message": "Use a native select for an authored table page-size control. The existing FormSelect enhancement reads and updates that same select."
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
"id": "data-table-page-size-is-single",
|
|
1334
|
+
"kind": "forbid",
|
|
1335
|
+
"selector": ".data-table[data-page-size] select[data-table-page-size][multiple]",
|
|
1336
|
+
"fragmentSafe": true,
|
|
1337
|
+
"message": "A table has one page size. Omit multiple from its page-size select."
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
"id": "data-table-page-size-is-named",
|
|
1341
|
+
"kind": "require-accessible-name",
|
|
1342
|
+
"selector": ".data-table[data-page-size] select[data-table-page-size]",
|
|
1343
|
+
"message": "Label the table page-size select, for example Rows per page. Option values do not name the control."
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
"id": "data-table-page-control-is-button",
|
|
1347
|
+
"kind": "forbid",
|
|
1348
|
+
"selector": ".data-table[data-page-size] [data-table-page]:not(button[type=\"button\"])",
|
|
1349
|
+
"fragmentSafe": true,
|
|
1350
|
+
"message": "Use a native button with type=\"button\" for a local table page control. Ordinary pagination links keep their navigation behavior."
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
"id": "data-table-page-control-is-named",
|
|
1354
|
+
"kind": "require-accessible-name",
|
|
1355
|
+
"selector": ".data-table[data-page-size] button[data-table-page]",
|
|
1356
|
+
"message": "Name each table page button, for example Previous or Next, using visible text or an accessible label."
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
"id": "data-table-page-control-is-in-pagination",
|
|
1360
|
+
"kind": "forbid",
|
|
1361
|
+
"selector": ".data-table[data-page-size] [data-table-page]:not(nav.pagination *)",
|
|
1362
|
+
"fragmentSafe": true,
|
|
1363
|
+
"message": "Put local table page buttons in a named nav.pagination inside their table wrapper."
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
"id": "data-table-page-navigation-is-named",
|
|
1367
|
+
"kind": "require-accessible-name",
|
|
1368
|
+
"selector": ".data-table[data-page-size] nav.pagination",
|
|
1369
|
+
"message": "Name the table's pagination navigation so it is distinct from other navigation on the page."
|
|
1370
|
+
},
|
|
1371
|
+
{
|
|
1372
|
+
"id": "data-table-page-direction-is-known",
|
|
1373
|
+
"kind": "forbid",
|
|
1374
|
+
"selector": ".data-table[data-page-size] [data-table-page]:not([data-table-page=\"previous\"], [data-table-page=\"next\"])",
|
|
1375
|
+
"fragmentSafe": true,
|
|
1376
|
+
"message": "Set data-table-page to previous or next. Local table paging does not generate numbered page controls."
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
"id": "data-table-page-status-is-named",
|
|
1380
|
+
"kind": "require-accessible-name",
|
|
1381
|
+
"selector": ".data-table [data-table-page-status]",
|
|
1382
|
+
"message": "Name the page status, for example aria-label=\"Pages\", so its numbers have context."
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
"id": "data-table-page-status-is-status",
|
|
1386
|
+
"kind": "require-attr",
|
|
1387
|
+
"selector": ".data-table [data-table-page-status]",
|
|
1388
|
+
"attr": "role",
|
|
1389
|
+
"equals": "status",
|
|
1390
|
+
"message": "Give the table page status role=\"status\" so page changes are announced without moving focus."
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
"id": "data-table-page-status-is-polite",
|
|
1394
|
+
"kind": "require-attr",
|
|
1395
|
+
"selector": ".data-table [data-table-page-status]",
|
|
1396
|
+
"attr": "aria-live",
|
|
1397
|
+
"equals": "polite",
|
|
1398
|
+
"message": "Use aria-live=\"polite\" on the table page status so page changes do not interrupt other speech."
|
|
1065
1399
|
}
|
|
1066
1400
|
]
|
|
1067
1401
|
},
|
|
@@ -1102,6 +1436,181 @@
|
|
|
1102
1436
|
}
|
|
1103
1437
|
]
|
|
1104
1438
|
},
|
|
1439
|
+
"alert": {
|
|
1440
|
+
"status": "enforced",
|
|
1441
|
+
"note": "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.",
|
|
1442
|
+
"rules": [
|
|
1443
|
+
{
|
|
1444
|
+
"id": "alert-is-not-the-banner-landmark",
|
|
1445
|
+
"kind": "forbid",
|
|
1446
|
+
"selector": ".alert[role=\"banner\"]",
|
|
1447
|
+
"fragmentSafe": true,
|
|
1448
|
+
"message": "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."
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
"id": "alert-live-region-holds-no-controls",
|
|
1452
|
+
"kind": "forbid",
|
|
1453
|
+
"selector": ".alert:is([role=\"alert\"], [role=\"status\"], [aria-live]):has(button, a[href], input, select, textarea)",
|
|
1454
|
+
"fragmentSafe": true,
|
|
1455
|
+
"message": "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."
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
"id": "error-summary-form-is-novalidate",
|
|
1459
|
+
"kind": "forbid",
|
|
1460
|
+
"selector": "form:not([novalidate]):has(.error-summary)",
|
|
1461
|
+
"message": "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."
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
"id": "error-summary-has-a-list",
|
|
1465
|
+
"kind": "forbid",
|
|
1466
|
+
"selector": ".error-summary:not(:has(ul, ol))",
|
|
1467
|
+
"message": "Give the .error-summary an empty <ul>. The Forms behavior writes one link per invalid field into it."
|
|
1468
|
+
}
|
|
1469
|
+
]
|
|
1470
|
+
},
|
|
1471
|
+
"description-list": {
|
|
1472
|
+
"status": "enforced",
|
|
1473
|
+
"note": "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.",
|
|
1474
|
+
"rules": [
|
|
1475
|
+
{
|
|
1476
|
+
"id": "description-list-is-a-dl",
|
|
1477
|
+
"kind": "forbid",
|
|
1478
|
+
"selector": ".description-list:not(dl)",
|
|
1479
|
+
"fragmentSafe": true,
|
|
1480
|
+
"message": "Write .description-list as a <dl> with <dt> labels and <dd> values. Divs drawn as two columns read as an unpaired run of text."
|
|
1481
|
+
}
|
|
1482
|
+
]
|
|
1483
|
+
},
|
|
1484
|
+
"mega-menu": {
|
|
1485
|
+
"status": "enforced",
|
|
1486
|
+
"note": "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.",
|
|
1487
|
+
"rejects": {
|
|
1488
|
+
"rejected_role": "menu",
|
|
1489
|
+
"because": "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",
|
|
1490
|
+
"rule": "mega-menu-is-not-a-composite-widget"
|
|
1491
|
+
},
|
|
1492
|
+
"rules": [
|
|
1493
|
+
{
|
|
1494
|
+
"id": "mega-menu-is-a-named-nav",
|
|
1495
|
+
"kind": "forbid",
|
|
1496
|
+
"selector": ".mega-menu:not(nav)",
|
|
1497
|
+
"fragmentSafe": true,
|
|
1498
|
+
"message": "Write .mega-menu as a <nav> with an aria-label. It is site navigation: a landmark people jump to, not a generic row."
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
"id": "mega-menu-panel-is-a-popover",
|
|
1502
|
+
"kind": "forbid",
|
|
1503
|
+
"selector": ".mega-menu-panel:not([popover])",
|
|
1504
|
+
"fragmentSafe": true,
|
|
1505
|
+
"message": "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."
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
"id": "mega-menu-is-not-a-composite-widget",
|
|
1509
|
+
"kind": "forbid-composite-roles",
|
|
1510
|
+
"selector": ".mega-menu",
|
|
1511
|
+
"fragmentSafe": true,
|
|
1512
|
+
"message": "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."
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
"id": "mega-menu-items-are-not-menuitems",
|
|
1516
|
+
"kind": "forbid",
|
|
1517
|
+
"selector": ".mega-menu :is([role=\"menu\"], [role=\"menuitem\"])",
|
|
1518
|
+
"fragmentSafe": true,
|
|
1519
|
+
"message": "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."
|
|
1520
|
+
}
|
|
1521
|
+
]
|
|
1522
|
+
},
|
|
1523
|
+
"menubar": {
|
|
1524
|
+
"status": "enforced",
|
|
1525
|
+
"note": "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.",
|
|
1526
|
+
"rules": [
|
|
1527
|
+
{
|
|
1528
|
+
"id": "menubar-is-named",
|
|
1529
|
+
"kind": "require-accessible-name",
|
|
1530
|
+
"selector": ".menubar",
|
|
1531
|
+
"message": "Name the menubar with aria-label, such as \"Editor\". A page can hold several, and the name says which commands they are."
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
"id": "menubar-holds-only-triggers",
|
|
1535
|
+
"kind": "forbid",
|
|
1536
|
+
"selector": ".menubar > :not(.menu-trigger)",
|
|
1537
|
+
"fragmentSafe": true,
|
|
1538
|
+
"message": "A .menubar holds only .menu-trigger buttons. Anything else breaks the menubar's arrow-key order; put the menus themselves after the bar."
|
|
1539
|
+
}
|
|
1540
|
+
]
|
|
1541
|
+
},
|
|
1542
|
+
"calendar": {
|
|
1543
|
+
"status": "enforced",
|
|
1544
|
+
"note": "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.",
|
|
1545
|
+
"rules": [
|
|
1546
|
+
{
|
|
1547
|
+
"id": "calendar-is-a-table",
|
|
1548
|
+
"kind": "forbid",
|
|
1549
|
+
"selector": ".calendar:not(table)",
|
|
1550
|
+
"fragmentSafe": true,
|
|
1551
|
+
"message": "Write .calendar as a <table> with a <caption> and a header row of weekdays. A grid of divs loses the day-to-weekday relationship."
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
"id": "calendar-has-a-caption",
|
|
1555
|
+
"kind": "forbid",
|
|
1556
|
+
"selector": "table.calendar:not(:has(> caption))",
|
|
1557
|
+
"message": "Give the calendar a <caption> naming the month, such as \"October 2026\". Without it nothing says which month the days belong to."
|
|
1558
|
+
}
|
|
1559
|
+
]
|
|
1560
|
+
},
|
|
1561
|
+
"tour": {
|
|
1562
|
+
"status": "enforced",
|
|
1563
|
+
"note": "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.",
|
|
1564
|
+
"rules": [
|
|
1565
|
+
{
|
|
1566
|
+
"id": "tour-is-a-manual-popover",
|
|
1567
|
+
"kind": "forbid",
|
|
1568
|
+
"selector": ".tour:not([popover=\"manual\"])",
|
|
1569
|
+
"fragmentSafe": true,
|
|
1570
|
+
"message": "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."
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
"id": "tour-is-a-dialog",
|
|
1574
|
+
"kind": "require-attr",
|
|
1575
|
+
"selector": ".tour",
|
|
1576
|
+
"attr": "role",
|
|
1577
|
+
"equals": "dialog",
|
|
1578
|
+
"message": "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."
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
"id": "tour-steps-name-a-target",
|
|
1582
|
+
"kind": "forbid",
|
|
1583
|
+
"selector": ".tour > section:not([data-tour-target])",
|
|
1584
|
+
"fragmentSafe": true,
|
|
1585
|
+
"message": "Each step <section> needs data-tour-target with the id of the element it explains. A step without one is never shown."
|
|
1586
|
+
}
|
|
1587
|
+
]
|
|
1588
|
+
},
|
|
1589
|
+
"image-compare": {
|
|
1590
|
+
"status": "enforced",
|
|
1591
|
+
"note": "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.",
|
|
1592
|
+
"rules": [
|
|
1593
|
+
{
|
|
1594
|
+
"id": "image-compare-has-a-range",
|
|
1595
|
+
"kind": "forbid",
|
|
1596
|
+
"selector": ".image-compare:not(:has(> input[type=\"range\"]))",
|
|
1597
|
+
"message": "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."
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
"id": "image-compare-range-is-named",
|
|
1601
|
+
"kind": "require-accessible-name",
|
|
1602
|
+
"selector": ".image-compare > input[type=\"range\"]",
|
|
1603
|
+
"message": "Name the range, such as aria-label=\"Compare before and after\". It is the only control and has no visible label."
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
"id": "image-compare-images-have-alt",
|
|
1607
|
+
"kind": "forbid",
|
|
1608
|
+
"selector": ".image-compare > img:not([alt])",
|
|
1609
|
+
"fragmentSafe": true,
|
|
1610
|
+
"message": "Give each image alt text that says which it is, such as \"Before: faded photo\". The comparison means nothing if neither image is described."
|
|
1611
|
+
}
|
|
1612
|
+
]
|
|
1613
|
+
},
|
|
1105
1614
|
"countdown": {
|
|
1106
1615
|
"status": "enforced",
|
|
1107
1616
|
"note": "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.",
|
|
@@ -1318,6 +1827,31 @@
|
|
|
1318
1827
|
}
|
|
1319
1828
|
]
|
|
1320
1829
|
},
|
|
1830
|
+
"org-chart": {
|
|
1831
|
+
"status": "enforced",
|
|
1832
|
+
"note": "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.",
|
|
1833
|
+
"rejects": {
|
|
1834
|
+
"rejected_role": "tree",
|
|
1835
|
+
"because": "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",
|
|
1836
|
+
"rule": "org-chart-is-not-a-tree-widget"
|
|
1837
|
+
},
|
|
1838
|
+
"rules": [
|
|
1839
|
+
{
|
|
1840
|
+
"id": "org-chart-is-not-a-tree-widget",
|
|
1841
|
+
"kind": "forbid",
|
|
1842
|
+
"selector": ".org-chart[role], .org-chart [role=\"treeitem\"], .org-chart [role=\"group\"]",
|
|
1843
|
+
"fragmentSafe": true,
|
|
1844
|
+
"message": "A .org-chart takes no ARIA tree roles. It is lists of disclosures, which keep their own roles and keyboard behavior."
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
"id": "org-chart-manager-holds-no-link",
|
|
1848
|
+
"kind": "forbid",
|
|
1849
|
+
"selector": ".org-chart summary :is(a[href], button)",
|
|
1850
|
+
"fragmentSafe": true,
|
|
1851
|
+
"message": "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\">."
|
|
1852
|
+
}
|
|
1853
|
+
]
|
|
1854
|
+
},
|
|
1321
1855
|
"command-palette": {
|
|
1322
1856
|
"status": "enforced",
|
|
1323
1857
|
"note": "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.",
|
|
@@ -1459,6 +1993,78 @@
|
|
|
1459
1993
|
}
|
|
1460
1994
|
]
|
|
1461
1995
|
},
|
|
1996
|
+
"sankey-chart": {
|
|
1997
|
+
"status": "enforced",
|
|
1998
|
+
"note": "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.",
|
|
1999
|
+
"rules": [
|
|
2000
|
+
{
|
|
2001
|
+
"id": "sankey-chart-is-a-figure-with-a-table",
|
|
2002
|
+
"kind": "forbid",
|
|
2003
|
+
"selector": ".sankey-chart:not(figure:has(> table))",
|
|
2004
|
+
"message": "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."
|
|
2005
|
+
},
|
|
2006
|
+
{
|
|
2007
|
+
"id": "sankey-chart-has-a-caption",
|
|
2008
|
+
"kind": "forbid",
|
|
2009
|
+
"selector": ".sankey-chart:not(:has(> figcaption))",
|
|
2010
|
+
"message": "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."
|
|
2011
|
+
}
|
|
2012
|
+
]
|
|
2013
|
+
},
|
|
2014
|
+
"treemap-chart": {
|
|
2015
|
+
"status": "enforced",
|
|
2016
|
+
"note": "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.",
|
|
2017
|
+
"rules": [
|
|
2018
|
+
{
|
|
2019
|
+
"id": "treemap-chart-is-a-figure-with-a-table",
|
|
2020
|
+
"kind": "forbid",
|
|
2021
|
+
"selector": ".treemap-chart:not(figure:has(> table))",
|
|
2022
|
+
"message": "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."
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
"id": "treemap-chart-has-a-caption",
|
|
2026
|
+
"kind": "forbid",
|
|
2027
|
+
"selector": ".treemap-chart:not(:has(> figcaption))",
|
|
2028
|
+
"message": "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."
|
|
2029
|
+
}
|
|
2030
|
+
]
|
|
2031
|
+
},
|
|
2032
|
+
"scatter-chart": {
|
|
2033
|
+
"status": "enforced",
|
|
2034
|
+
"note": "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.",
|
|
2035
|
+
"rules": [
|
|
2036
|
+
{
|
|
2037
|
+
"id": "scatter-chart-is-a-figure-with-a-table",
|
|
2038
|
+
"kind": "forbid",
|
|
2039
|
+
"selector": ".scatter-chart:not(figure:has(> table))",
|
|
2040
|
+
"message": "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."
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
"id": "scatter-chart-has-a-caption",
|
|
2044
|
+
"kind": "forbid",
|
|
2045
|
+
"selector": ".scatter-chart:not(:has(> figcaption))",
|
|
2046
|
+
"message": "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."
|
|
2047
|
+
}
|
|
2048
|
+
]
|
|
2049
|
+
},
|
|
2050
|
+
"funnel-chart": {
|
|
2051
|
+
"status": "enforced",
|
|
2052
|
+
"note": "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.",
|
|
2053
|
+
"rules": [
|
|
2054
|
+
{
|
|
2055
|
+
"id": "funnel-chart-is-a-figure-with-a-table",
|
|
2056
|
+
"kind": "forbid",
|
|
2057
|
+
"selector": ".funnel-chart:not(figure:has(> table))",
|
|
2058
|
+
"message": "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."
|
|
2059
|
+
},
|
|
2060
|
+
{
|
|
2061
|
+
"id": "funnel-chart-has-a-caption",
|
|
2062
|
+
"kind": "forbid",
|
|
2063
|
+
"selector": ".funnel-chart:not(:has(> figcaption))",
|
|
2064
|
+
"message": "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."
|
|
2065
|
+
}
|
|
2066
|
+
]
|
|
2067
|
+
},
|
|
1462
2068
|
"bar-chart": {
|
|
1463
2069
|
"status": "enforced",
|
|
1464
2070
|
"note": "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.",
|
|
@@ -1554,6 +2160,13 @@
|
|
|
1554
2160
|
"selector": ".navigation-rail a.active:not([aria-current])",
|
|
1555
2161
|
"fragmentSafe": false,
|
|
1556
2162
|
"message": "The active destination needs aria-current=\"page\"."
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
"id": "navigation-rail-panel-is-a-popover",
|
|
2166
|
+
"kind": "forbid",
|
|
2167
|
+
"selector": ".navigation-rail-panel:not([popover])",
|
|
2168
|
+
"fragmentSafe": true,
|
|
2169
|
+
"message": "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."
|
|
1557
2170
|
}
|
|
1558
2171
|
]
|
|
1559
2172
|
},
|
|
@@ -1655,7 +2268,7 @@
|
|
|
1655
2268
|
"kind": "forbid",
|
|
1656
2269
|
"selector": ".search-bar input:not([type=\"search\"])",
|
|
1657
2270
|
"fragmentSafe": true,
|
|
1658
|
-
"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.
|
|
2271
|
+
"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."
|
|
1659
2272
|
}
|
|
1660
2273
|
]
|
|
1661
2274
|
},
|
|
@@ -1677,6 +2290,26 @@
|
|
|
1677
2290
|
"note": "Swept 0.8.0. A <dialog>, so the dialog rules carry it.",
|
|
1678
2291
|
"rules": []
|
|
1679
2292
|
},
|
|
2293
|
+
"show-more": {
|
|
2294
|
+
"status": "enforced",
|
|
2295
|
+
"note": "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.",
|
|
2296
|
+
"rules": [
|
|
2297
|
+
{
|
|
2298
|
+
"id": "show-more-needs-a-button",
|
|
2299
|
+
"kind": "forbid",
|
|
2300
|
+
"selector": ".show-more:not(:has(> button[type=\"button\" i]))",
|
|
2301
|
+
"fragmentSafe": true,
|
|
2302
|
+
"message": "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."
|
|
2303
|
+
},
|
|
2304
|
+
{
|
|
2305
|
+
"id": "show-more-needs-clamped-text",
|
|
2306
|
+
"kind": "forbid",
|
|
2307
|
+
"selector": ".show-more:not(:has(> :is(.line-clamp-1, .line-clamp-2, .line-clamp-3)))",
|
|
2308
|
+
"fragmentSafe": true,
|
|
2309
|
+
"message": "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."
|
|
2310
|
+
}
|
|
2311
|
+
]
|
|
2312
|
+
},
|
|
1680
2313
|
"split-button": {
|
|
1681
2314
|
"status": "enforced",
|
|
1682
2315
|
"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.",
|
|
@@ -1743,7 +2376,7 @@
|
|
|
1743
2376
|
{
|
|
1744
2377
|
"id": "split-button-trailing-icon-hidden",
|
|
1745
2378
|
"kind": "require-attr",
|
|
1746
|
-
"selector": ".split-button > .menu-trigger :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp
|
|
2379
|
+
"selector": ".split-button > .menu-trigger :is(.material-symbols,.material-symbols-outlined,.material-symbols-rounded,.material-symbols-sharp)",
|
|
1747
2380
|
"attr": "aria-hidden",
|
|
1748
2381
|
"equals": "true",
|
|
1749
2382
|
"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."
|
|
@@ -1762,26 +2395,75 @@
|
|
|
1762
2395
|
"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.",
|
|
1763
2396
|
"rules": []
|
|
1764
2397
|
},
|
|
2398
|
+
"swipe-actions": {
|
|
2399
|
+
"status": "enforced",
|
|
2400
|
+
"note": "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.",
|
|
2401
|
+
"rules": [
|
|
2402
|
+
{
|
|
2403
|
+
"id": "swipeable-rows-open-without-swiping",
|
|
2404
|
+
"kind": "forbid",
|
|
2405
|
+
"selector": ".swipeable > li:has(> .swipe-actions):not(:has([data-swipe-toggle]))",
|
|
2406
|
+
"message": "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."
|
|
2407
|
+
}
|
|
2408
|
+
]
|
|
2409
|
+
},
|
|
1765
2410
|
"tabs": {
|
|
1766
2411
|
"status": "enforced",
|
|
1767
|
-
"note": "Swept 0.8.0. Anchor navigation,
|
|
1768
|
-
"conformance": {
|
|
1769
|
-
"withheld_role": "tablist",
|
|
1770
|
-
"blocked_on": "keyboard model",
|
|
1771
|
-
"rule": "tabs-not-a-tablist"
|
|
1772
|
-
},
|
|
2412
|
+
"note": "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.",
|
|
1773
2413
|
"rules": [
|
|
1774
2414
|
{
|
|
1775
|
-
"id": "tabs-
|
|
2415
|
+
"id": "tabs-role-is-tablist",
|
|
2416
|
+
"kind": "forbid",
|
|
2417
|
+
"selector": ".tabs[role]:not([role=\"tablist\"])",
|
|
2418
|
+
"fragmentSafe": true,
|
|
2419
|
+
"message": "A tab strip takes no role, or role=\"tablist\" for the tabs pattern. Any other role promises a keyboard model it does not have."
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
"id": "tabs-tab-needs-tablist",
|
|
2423
|
+
"kind": "forbid",
|
|
2424
|
+
"selector": ".tabs:not([role=\"tablist\"]) [role=\"tab\"]",
|
|
2425
|
+
"fragmentSafe": true,
|
|
2426
|
+
"message": "role=\"tab\" belongs in a strip marked role=\"tablist\". Link tabs take no role."
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
"id": "tablist-on-a-div",
|
|
2430
|
+
"kind": "forbid",
|
|
2431
|
+
"selector": ".tabs[role=\"tablist\"]:not(div)",
|
|
2432
|
+
"fragmentSafe": true,
|
|
2433
|
+
"message": "Write a tablist on a <div>. A tablist is not navigation, so <nav> stays for link tabs."
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
"id": "tablist-has-a-name",
|
|
2437
|
+
"kind": "forbid",
|
|
2438
|
+
"selector": ".tabs[role=\"tablist\"]:not([aria-label], [aria-labelledby])",
|
|
2439
|
+
"fragmentSafe": true,
|
|
2440
|
+
"message": "A tablist needs a name: aria-label or aria-labelledby."
|
|
2441
|
+
},
|
|
2442
|
+
{
|
|
2443
|
+
"id": "tablist-tabs-are-tabs",
|
|
1776
2444
|
"kind": "forbid",
|
|
1777
|
-
"selector": ".tabs[role
|
|
2445
|
+
"selector": ".tabs[role=\"tablist\"] > a:not([role=\"tab\"])",
|
|
1778
2446
|
"fragmentSafe": true,
|
|
1779
|
-
"message": "
|
|
2447
|
+
"message": "In a tablist every tab takes role=\"tab\"."
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
"id": "tablist-tab-names-panel",
|
|
2451
|
+
"kind": "forbid",
|
|
2452
|
+
"selector": ".tabs[role=\"tablist\"] > a[role=\"tab\"]:not([aria-controls])",
|
|
2453
|
+
"fragmentSafe": true,
|
|
2454
|
+
"message": "A tab names the panel it shows in aria-controls."
|
|
2455
|
+
},
|
|
2456
|
+
{
|
|
2457
|
+
"id": "tablist-no-current",
|
|
2458
|
+
"kind": "forbid",
|
|
2459
|
+
"selector": ".tabs[role=\"tablist\"]:has([aria-current])",
|
|
2460
|
+
"fragmentSafe": true,
|
|
2461
|
+
"message": "A tablist marks its tab with aria-selected, which the component keeps; aria-current belongs to link tabs."
|
|
1780
2462
|
},
|
|
1781
2463
|
{
|
|
1782
2464
|
"id": "tabs-marks-current",
|
|
1783
2465
|
"kind": "forbid",
|
|
1784
|
-
"selector": ".tabs a.active:not([aria-current])",
|
|
2466
|
+
"selector": ".tabs:not([role=\"tablist\"]) a.active:not([aria-current])",
|
|
1785
2467
|
"fragmentSafe": false,
|
|
1786
2468
|
"message": "The active tab is the section you are on; aria-current says so where a class cannot."
|
|
1787
2469
|
}
|
|
@@ -1829,6 +2511,37 @@
|
|
|
1829
2511
|
}
|
|
1830
2512
|
]
|
|
1831
2513
|
},
|
|
2514
|
+
"transfer-list": {
|
|
2515
|
+
"status": "enforced",
|
|
2516
|
+
"note": "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.",
|
|
2517
|
+
"rules": [
|
|
2518
|
+
{
|
|
2519
|
+
"id": "transfer-list-needs-a-name",
|
|
2520
|
+
"kind": "forbid",
|
|
2521
|
+
"selector": ".transfer-list:not([data-name]), .transfer-list[data-name=\"\"]",
|
|
2522
|
+
"message": "Give each .transfer-list a data-name. The chosen rows submit their values under it; without one the form gets nothing."
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
"id": "transfer-list-checkbox-unnamed",
|
|
2526
|
+
"kind": "forbid",
|
|
2527
|
+
"selector": ".transfer-list input[type=\"checkbox\"][name]",
|
|
2528
|
+
"fragmentSafe": true,
|
|
2529
|
+
"message": "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."
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
"id": "transfer-list-lists-labelled",
|
|
2533
|
+
"kind": "forbid",
|
|
2534
|
+
"selector": ":is(.transfer-available, .transfer-chosen):not([aria-label], [aria-labelledby])",
|
|
2535
|
+
"message": "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."
|
|
2536
|
+
},
|
|
2537
|
+
{
|
|
2538
|
+
"id": "transfer-list-buttons-typed",
|
|
2539
|
+
"kind": "forbid",
|
|
2540
|
+
"selector": ".transfer-list [data-transfer]:not([type=\"button\" i])",
|
|
2541
|
+
"message": "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."
|
|
2542
|
+
}
|
|
2543
|
+
]
|
|
2544
|
+
},
|
|
1832
2545
|
"transitions": {
|
|
1833
2546
|
"status": "enforced",
|
|
1834
2547
|
"kind": "foundation",
|