0

Reland "Make inferred roles for CSS toggles influence accessibility role."

This is a reland of commit 23486a5ee1
and commit 2957ce3557.

Original change's description:
> Make inferred roles for CSS toggles influence accessibility role.
>
> The tests added in this CL are mostly testing the inference engine added
> in the previous CL.
>
> Support for toggles is controlled by the CSSToggles flag (currently off)
> in RuntimeEnabledFeatures.
>
> Bug: 1250716
> Change-Id: Ia0b38b5c7ffacc4d2a6eec88ecfe0ada852c4b09
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4200858
> Reviewed-by: Aaron Leventhal <aleventhal@chromium.org>
> Commit-Queue: David Baron <dbaron@chromium.org>
> Cr-Commit-Position: refs/heads/main@{#1106967}

Original change's description:
> Refactor CSS toggle influence on ARIA role into its own function.
>
> Support for toggles is controlled by the CSSToggles flag (currently off)
> in RuntimeEnabledFeatures.
>
> Bug: 1250716
> Change-Id: I7751d1d48a3444cc249979185ff5517ecd7c5e01
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4265191
> Reviewed-by: Aaron Leventhal <aleventhal@chromium.org>
> Commit-Queue: David Baron <dbaron@chromium.org>
> Cr-Commit-Position: refs/heads/main@{#1106983}

Bug: 1250716
Change-Id: I8dab23d70c84a2d3b845c79c245756ccab13e88d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4269430
Commit-Queue: Aaron Leventhal <aleventhal@chromium.org>
Reviewed-by: Aaron Leventhal <aleventhal@chromium.org>
Auto-Submit: David Baron <dbaron@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1107814}
This commit is contained in:
L. David Baron
2023-02-21 17:57:26 +00:00
committed by Chromium LUCI CQ
parent 2d3b3e6f8a
commit bfdf009143
3 changed files with 449 additions and 5 deletions
third_party/blink
renderer
core
modules
accessibility
web_tests
external

@ -534,7 +534,8 @@ void CSSToggleInference::Rebuild() {
element_roles_.insert(toggle_root, CSSToggleRole::kCheckbox);
// TODO(dbaron): We should only set parent_role here when
// there are multiple checkbox siblings with few non-checkbox
// siblings!
// siblings! (Once we do this we can probably remove the
// tabs_ish_elements.Contains(parent) test below.)
parent_role = CSSToggleRole::kCheckboxGroup;
}
@ -543,7 +544,8 @@ void CSSToggleInference::Rebuild() {
// order to ensure that hash map processing order doesn't affect
// the result).
if (parent_role != CSSToggleRole::kNone && parent &&
!accordion_ish_elements.Contains(parent)) {
!accordion_ish_elements.Contains(parent) &&
!tabs_ish_elements.Contains(parent)) {
auto parent_add_result = element_roles_.insert(parent, parent_role);
// prefer checkbox group to radio group if some children
// lead to either

@ -46,6 +46,7 @@
#include "third_party/blink/renderer/core/css/css_resolution_units.h"
#include "third_party/blink/renderer/core/css/properties/longhands.h"
#include "third_party/blink/renderer/core/display_lock/display_lock_utilities.h"
#include "third_party/blink/renderer/core/dom/css_toggle_inference.h"
#include "third_party/blink/renderer/core/dom/flat_tree_traversal.h"
#include "third_party/blink/renderer/core/dom/layout_tree_builder_traversal.h"
#include "third_party/blink/renderer/core/dom/node_computed_style.h"
@ -1343,6 +1344,75 @@ ax::mojom::blink::Role AXNodeObject::NativeRoleIgnoringAria() const {
return RoleFromLayoutObjectOrNode();
}
namespace {
ax::mojom::blink::Role InferredCSSToggleRole(Node* node) {
Element* element = DynamicTo<Element>(node);
if (!element) {
return ax::mojom::blink::Role::kUnknown;
}
// toggle_inference is null when CSS toggles are not used in the document.
CSSToggleInference* toggle_inference =
element->GetDocument().GetCSSToggleInference();
if (!toggle_inference) {
return ax::mojom::blink::Role::kUnknown;
}
DCHECK(RuntimeEnabledFeatures::CSSTogglesEnabled());
switch (toggle_inference->RoleForElement(element)) {
case CSSToggleRole::kNone:
break;
case CSSToggleRole::kButtonWithPopup:
return ax::mojom::blink::Role::kPopUpButton;
case CSSToggleRole::kDisclosure:
break;
case CSSToggleRole::kDisclosureButton:
return ax::mojom::blink::Role::kButton;
case CSSToggleRole::kTree:
return ax::mojom::blink::Role::kTree;
case CSSToggleRole::kTreeGroup:
return ax::mojom::blink::Role::kGroup;
case CSSToggleRole::kTreeItem:
return ax::mojom::blink::Role::kTreeItem;
case CSSToggleRole::kAccordion:
break;
case CSSToggleRole::kAccordionItem:
return ax::mojom::blink::Role::kRegion;
case CSSToggleRole::kAccordionItemButton:
return ax::mojom::blink::Role::kButton;
case CSSToggleRole::kTabContainer:
// TODO(dbaron): We should verify that using kTabList really
// works here, since this is a container that has both the tab
// list *and* the tab panels. We should also make sure that
// posinset/setsize work correctly for the tabs.
return ax::mojom::blink::Role::kTabList;
case CSSToggleRole::kTab:
return ax::mojom::blink::Role::kTab;
case CSSToggleRole::kTabPanel:
return ax::mojom::blink::Role::kTabPanel;
case CSSToggleRole::kRadioGroup:
return ax::mojom::blink::Role::kRadioGroup;
case CSSToggleRole::kRadioItem:
return ax::mojom::blink::Role::kRadioButton;
case CSSToggleRole::kCheckboxGroup:
break;
case CSSToggleRole::kCheckbox:
return ax::mojom::blink::Role::kCheckBox;
case CSSToggleRole::kListbox:
return ax::mojom::blink::Role::kListBox;
case CSSToggleRole::kListboxItem:
return ax::mojom::blink::Role::kListBoxOption;
case CSSToggleRole::kButton:
return ax::mojom::blink::Role::kButton;
}
return ax::mojom::blink::Role::kUnknown;
}
} // namespace
ax::mojom::blink::Role AXNodeObject::DetermineAccessibilityRole() {
#if DCHECK_IS_ON()
base::AutoReset<bool> reentrancy_protector(&is_computing_role_, true);
@ -1357,8 +1427,25 @@ ax::mojom::blink::Role AXNodeObject::DetermineAccessibilityRole() {
aria_role_ = DetermineAriaRoleAttribute();
return aria_role_ == ax::mojom::blink::Role::kUnknown ? native_role_
: aria_role_;
// Order of precedence is currently:
// 1. ARIA role
// 2. Inferred role from CSS Toggle inference engine
// 3. Native markup role
// but we may decide to change how the CSS Toggle inference fits in.
//
// TODO(dbaron): Perhaps revisit whether there are types of elements
// where toggles should not work.
if (aria_role_ != ax::mojom::blink::Role::kUnknown) {
return aria_role_;
}
ax::mojom::blink::Role css_toggle_role = InferredCSSToggleRole(GetNode());
if (css_toggle_role != ax::mojom::blink::Role::kUnknown) {
return css_toggle_role;
}
return native_role_;
}
void AXNodeObject::AccessibilityChildrenFromAOMProperty(
@ -1417,7 +1504,8 @@ void AXNodeObject::Init(AXObject* parent) {
#endif
AXObject::Init(parent);
DCHECK(role_ == native_role_ || role_ == aria_role_)
DCHECK(role_ == native_role_ || role_ == aria_role_ ||
GetNode()->GetDocument().GetCSSToggleInference())
<< "Role must be either the cached native role or cached aria role: "
<< "\n* Final role: " << role_ << "\n* Native role: " << native_role_
<< "\n* Aria role: " << aria_role_ << "\n* Node: " << GetNode();

@ -0,0 +1,354 @@
<!DOCTYPE HTML>
<meta charset="UTF-8">
<title>CSS Toggles: ARIA roles</title>
<link rel="author" title="L. David Baron" href="https://dbaron.org/">
<link rel="author" title="Google" href="http://www.google.com/">
<link rel="help" href="https://tabatkins.github.io/css-toggle/">
<link rel="help" href="https://github.com/tabatkins/css-toggle/issues/41">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="support/toggle-helpers.js"></script>
<style id="style"></style>
<body>
<div id="container"></div>
<script>
let aria_role_tests = [
// Markup to create the test assertions:
// data-expected-role: The expected aria role for this element.
//
// Helper markup to create more markup:
// class=group: group the group with the toggle-group property
// class=group-self: same, but with the self keyword (narrow scope)
// class=root: create a test-role toggle with the toggle-root property
// class=root-group: same, but with the 'group' keyword
// class=root-self: same, but with the 'self' keyword
// class=trigger: toggle-trigger to activate test-role toggle
// class=visibility: toggle-visibility connected to test-role toggle
`
<div></div>
`,
`
<div class="root">
<div></div>
</div>
`,
`
<div class="root trigger" data-expected-role="checkbox"></div>
`,
// Test that ARIA attributes override the toggle inference:
`
<div class="root trigger" role="link" data-expected-role="link"></div>
`,
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
</div>
`,
// Radios and radio groups:
`
<div class="group" data-expected-role="radiogroup">
<div class="root-group trigger" data-expected-role="radio"></div>
</div>
`,
`
<div class="group" data-expected-role="radiogroup">
<div class="root-group trigger" data-expected-role="radio"></div>
<div class="root-group trigger" data-expected-role="radio"></div>
</div>
`,
`
<div>
<div class="root-group trigger" data-expected-role="radio"></div>
</div>
`,
`
<div style="toggle-group: another-group">
<div class="root-group trigger" data-expected-role="radio"></div>
</div>
`,
`
<div style="toggle-group: another-group, test-role, third-group" data-expected-role="radiogroup">
<div class="root-group trigger" data-expected-role="radio"></div>
</div>
`,
// Checkboxes and checkbox groups:
`
<div>
<div class="root trigger" data-expected-role="checkbox"></div>
</div>
`,
// TODO(dbaron): This is a checkbox group... but we can't distinguish
// that with current ARIA roles.
`
<div>
<div class="root trigger" data-expected-role="checkbox"></div>
<div class="root trigger" data-expected-role="checkbox"></div>
</div>
`,
// Disclosure:
// TODO(dbaron): This is a disclosure... but how is it possible to
// distinguish with ARIA roles (compare to next test!)?
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
`,
// This is not a disclosure because it has a toggle-group.
`
<div class="root-group">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
`,
// This is button with popup (absolute positioning)
// TODO(dbaron): This test doesn't actually distinguish this from
// disclosure because the internal kPopUpButton role maps to "button"
// in kReverseRoles in ax_object.cc.
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility" style="position: absolute"></div>
</div>
`,
// This is button with popup (fixed positioning)
// TODO(dbaron): This test doesn't actually distinguish this from
// disclosure because the internal kPopUpButton role maps to "button"
// in kReverseRoles in ax_object.cc.
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility" style="position: fixed"></div>
</div>
`,
// This is button with popup (popover)
// TODO(dbaron): This test doesn't actually distinguish this from
// disclosure because the internal kPopUpButton role maps to "button"
// in kReverseRoles in ax_object.cc.
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility" popover="auto"></div>
</div>
`,
// This is disclosure (NOT button with popup) (sticky positioning)
`
<div class="root">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility" style="position: sticky"></div>
</div>
`,
// Accordion:
`
<div class="group">
<div class="root-group" data-expected-role="region">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
<div class="root-group" data-expected-role="region">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
</div>
`,
// Not accordion because of other siblings:
`
<div class="group">
<div class="root-group">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
<div class="root-group">
<div class="trigger" data-expected-role="button"></div>
<div class="visibility"></div>
</div>
<div></div>
<div></div>
<div></div>
</div>
`,
// Tree:
// TODO(dbaron): This should probably also work with the toggles on
// the <button>!
// TODO(dbaron): This should probably mark the non-interactive items
// as treeitem as well!
// TODO(dbaron): Do the elements getting the roles here make sense?
// TODO(dbaron): The requirement for having multiple disclosure-ish
// children to qualify as accordion-ish probably doesn't make sense
// here. The test below is basically the minimal example that gets
// detected as a tree, but simpler things definitely should be!
// TODO(dbaron): The inner parts of the tree should also be getting
// tree roles!
`
<ul data-expected-role="tree">
<li class="root-self" data-expected-role="group">
<button class="trigger" data-expected-role="treeitem"></button>
<ul class="visibility" data-expected-role="list">
<li>item</li>
<li class="root-self">
<button class="trigger" data-expected-role="button"></button>
<ul class="visibility" data-expected-role="list">
<li>item</li>
<li>item</li>
</ul>
</li>
<li class="root-self">
<button class="trigger" data-expected-role="button"></button>
<ul class="visibility" data-expected-role="list">
<li>item</li>
<li>item</li>
</ul>
</li>
</ul>
</li>
<li class="root-self" data-expected-role="group">
<button class="trigger" data-expected-role="treeitem"></button>
<ul class="visibility" data-expected-role="list">
<li class="root-self">
<button class="trigger" data-expected-role="button"></button>
<ul class="visibility" data-expected-role="list">
<li>item</li>
<li>item</li>
</ul>
</li>
<li class="root-self">
<button class="trigger" data-expected-role="button"></button>
<ul class="visibility" data-expected-role="list">
<li>item</li>
<li>item</li>
</ul>
</li>
</ul>
</li>
</ul>
`,
// Tabs:
`
<section class="group" data-expected-role="tablist">
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
</section>
`,
`
<section class="group" data-expected-role="tablist">
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<div></div>
</section>
`,
`
<section class="group" data-expected-role="tablist">
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 style="toggle-root: other-toggle; toggle-trigger: other-toggle" data-expected-role="checkbox"></h1>
</section>
`,
`
<section class="group" data-expected-role="tablist">
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 class="root-group trigger" data-expected-role="tab"></h1>
<div class="visibility" data-expected-role="tabpanel"></div>
<h1 style="toggle-root: other-toggle; toggle-trigger: other-toggle" data-expected-role="button"></h1>
<div style="toggle-visibility: toggle other-toggle"></div>
</section>
`,
// TODO(https://crbug.com/758089): The expected role for the <section>
// should be generic rather than null!
`
<section class="group" data-expected-role="null">
<h1 class="root-group trigger" data-expected-role="button"></h1>
<div class="visibility"></div>
<h1 class="root-group trigger" data-expected-role="button"></h1>
<div class="visibility"></div>
<div></div>
<div></div>
<div></div>
<div></div>
</section>
`,
`
<section class="group" data-expected-role="radiogroup">
<h1 class="root-group trigger" data-expected-role="radio"></h1>
<h1 class="root-group trigger" data-expected-role="radio"></h1>
<div></div>
<div></div>
<div></div>
<div></div>
</section>
`,
];
for (let t of aria_role_tests) {
promise_test(async function() {
container.innerHTML = t;
for (let e of container.querySelectorAll('.group')) {
e.style.toggleGroup = "test-role";
}
for (let e of container.querySelectorAll('.group-self')) {
e.style.toggleGroup = "test-role self";
}
for (let e of container.querySelectorAll('.root')) {
e.style.toggleRoot = "test-role";
}
for (let e of container.querySelectorAll('.root-group')) {
e.style.toggleRoot = "test-role group";
}
for (let e of container.querySelectorAll('.root-self')) {
e.style.toggleRoot = "test-role self";
}
for (let e of container.querySelectorAll('.trigger')) {
e.style.toggleTrigger = "test-role";
}
for (let e of container.querySelectorAll('.visibility')) {
e.style.toggleVisibility = "toggle test-role";
}
for (let e of container.querySelectorAll('.root, .root-nogroup')) {
await wait_for_toggle_creation(e);
}
let count = 0;
for (let e of container.querySelectorAll("*")) {
if (e == container)
continue;
let expected_role = "generic";
if (e.hasAttribute("data-expected-role")) {
expected_role = e.getAttribute("data-expected-role");
// TODO(https://crbug.com/758089): See above regarding <section>;
// this null handling should eventually be removed.
if (expected_role === "null") {
expected_role = null;
}
}
++count;
// NOTE: This relies on Element.computedRole, which is an
// experimental feature behind the ComputedAccessibilityInfo flag
// in blink.
assert_equals(e.computedRole, expected_role, `role on ${e.tagName} element (#${count})`);
}
}, `aria role test: ${t}`);
}
</script>