Skip to content

Commit c92ef32

Browse files
[web] Expose mixed tristate checkboxes as aria-checked="mixed" (flutter#194586)
On the web, a tristate `Checkbox` whose value is `null` was announced by screen readers as not checked. `SemanticCheckable` set `aria-checked` to `"true"` or `"false"` only, so the `CheckedState.mixed` the framework sends for that state became `"false"`. This sets `aria-checked="mixed"` for checkboxes in the mixed state. Radio buttons and switches keep `"true"`/`"false"`, because ARIA defines `"mixed"` for checkboxes only. The existing `Checkable` behavior, which already handles `"mixed"`, isn't reused here because it ignores the toggled state that switches rely on. Adds a test next to the existing checked and unchecked checkbox semantics tests. Thanks to @jorgelrj for tracking down the cause in the issue; this implements the first fix suggested there. Fixes flutter#194547 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant in-code documentation (doc comments with `///`). - [x] If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/CONTRIBUTING.ai.md [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [flutter/website]: https://github.com/flutter/website [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: zhongliugo <[email protected]>
1 parent 5f2addd commit c92ef32

2 files changed

Lines changed: 40 additions & 8 deletions

File tree

‎engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ class SemanticRadioGroup extends SemanticRole {
5858
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
5959
}
6060

61-
/// Renders semantics objects that have checkable (on/off) states.
61+
/// Renders semantics objects that have checkable (on/off) states, plus the mixed
62+
/// state of a tristate checkbox.
6263
///
6364
/// Three objects which are implemented by this class are checkboxes, radio
6465
/// buttons and switches.
@@ -100,13 +101,18 @@ class SemanticCheckable extends SemanticRole {
100101
/// technologies of disabled elements.
101102
_updateDisabledAttribute();
102103

103-
setAttribute(
104-
'aria-checked',
105-
(semanticsObject.flags.isChecked == ui.CheckedState.isTrue ||
106-
semanticsObject.flags.isToggled == ui.Tristate.isTrue)
107-
? 'true'
108-
: 'false',
109-
);
104+
final String ariaChecked;
105+
if (semanticsObject.flags.isChecked == ui.CheckedState.isTrue ||
106+
semanticsObject.flags.isToggled == ui.Tristate.isTrue) {
107+
ariaChecked = 'true';
108+
} else if (_kind == _CheckableKind.checkbox &&
109+
semanticsObject.flags.isChecked == ui.CheckedState.mixed) {
110+
// ARIA defines "mixed" for checkboxes only, not for radio buttons or switches.
111+
ariaChecked = 'mixed';
112+
} else {
113+
ariaChecked = 'false';
114+
}
115+
setAttribute('aria-checked', ariaChecked);
110116
}
111117
}
112118

‎engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3179,6 +3179,32 @@ void _testCheckables() {
31793179
semantics().semanticsEnabled = false;
31803180
});
31813181

3182+
test('renders a mixed checkbox', () async {
3183+
semantics()
3184+
..debugOverrideTimestampFunction(() => _testTime)
3185+
..semanticsEnabled = true;
3186+
3187+
final builder = ui.SemanticsUpdateBuilder();
3188+
updateNode(
3189+
builder,
3190+
actions: 0 | ui.SemanticsAction.tap.index,
3191+
3192+
flags: const ui.SemanticsFlags(
3193+
isChecked: ui.CheckedState.mixed,
3194+
isEnabled: ui.Tristate.isTrue,
3195+
),
3196+
transform: Matrix4.identity().toFloat64(),
3197+
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
3198+
);
3199+
3200+
owner().updateSemantics(builder.build());
3201+
expectSemanticsTree(owner(), '''
3202+
<sem role="checkbox" flt-tappable aria-checked="mixed"></sem>
3203+
''');
3204+
3205+
semantics().semanticsEnabled = false;
3206+
});
3207+
31823208
test('renders a checked radio button', () async {
31833209
semantics()
31843210
..debugOverrideTimestampFunction(() => _testTime)

0 commit comments

Comments
 (0)