From 7d8c78ce20724dbf2100cc7d055eb10143d0e955 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Lo=C3=AFc=20Sharma?=
<737941+loic-sharma@users.noreply.github.com>
Date: Mon, 3 Mar 2025 13:42:08 -0800
Subject: [PATCH] [A11y] Add radio group role (#164154)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This adds a new "radio group" accessibility role to `dart:ui` and the
Flutter web engine.
This does not update existing widgets to use this new role. Currently,
users must manually add a `Semantics` widget to use this accessibility
role. See the example app below.
Part of: https://github.com/flutter/flutter/issues/162093
## Example app
Example app that uses the radio group role...
```dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
void main() {
runApp(const RadioExampleApp());
SemanticsBinding.instance.ensureSemantics();
}
class RadioExampleApp extends StatelessWidget {
const RadioExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Radio Sample')),
body: const Center(child: RadioExample()),
),
);
}
}
class RadioExample extends StatefulWidget {
const RadioExample({super.key});
@override
State createState() => _RadioExampleState();
}
class _RadioExampleState extends State {
int _groupValue = 0;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Radio group',
role: SemanticsRole.radioGroup,
explicitChildNodes: true,
child: Column(
children: [
ListTile(
title: const Text('Foo'),
leading: Radio(
value: 0,
groupValue: _groupValue,
onChanged: (int? value) => setState(() => _groupValue = value ?? 0),
),
),
ListTile(
title: const Text('Bar'),
leading: Radio(
value: 1,
groupValue: _groupValue,
onChanged: (int? value) => setState(() => _groupValue = value ?? 0),
),
),
],
),
);
}
}
```
Accessibility tree...
```
SemanticsNode#0
│ Rect.fromLTRB(0.0, 0.0, 1200.0, 924.0)
│
└─SemanticsNode#1
│ Rect.fromLTRB(0.0, 0.0, 1200.0, 924.0)
│ textDirection: ltr
│
└─SemanticsNode#2
│ Rect.fromLTRB(0.0, 0.0, 1200.0, 924.0)
│ sortKey: OrdinalSortKey#83a1d(order: 0.0)
│
└─SemanticsNode#3
│ Rect.fromLTRB(0.0, 0.0, 1200.0, 924.0)
│ flags: scopesRoute
│
├─SemanticsNode#9
│ │ Rect.fromLTRB(0.0, 0.0, 1200.0, 56.0)
│ │
│ └─SemanticsNode#10
│ Rect.fromLTRB(532.6, 14.0, 667.4, 42.0)
│ flags: isHeader
│ label: "Radio Sample"
│ textDirection: ltr
│
└─SemanticsNode#4
│ Rect.fromLTRB(0.0, 56.0, 1200.0, 924.0)
│ label: "Radio group"
│ textDirection: ltr
│ role: radioGroup
│
├─SemanticsNode#5
│ │ Rect.fromLTRB(0.0, 0.0, 1200.0, 48.0)
│ │ flags: hasSelectedState, hasEnabledState, isEnabled
│ │ label: "Foo"
│ │ textDirection: ltr
│ │
│ └─SemanticsNode#6
│ Rect.fromLTRB(16.0, 8.0, 48.0, 40.0)
│ actions: focus, tap
│ flags: hasCheckedState, hasSelectedState, hasEnabledState,
│ isEnabled, isInMutuallyExclusiveGroup, isFocusable
│
└─SemanticsNode#7
│ Rect.fromLTRB(0.0, 48.0, 1200.0, 96.0)
│ flags: hasSelectedState, hasEnabledState, isEnabled
│ label: "Bar"
│ textDirection: ltr
│
└─SemanticsNode#8
Rect.fromLTRB(16.0, 8.0, 48.0, 40.0)
actions: focus, tap
flags: hasCheckedState, isChecked, hasSelectedState, isSelected,
hasEnabledState, isEnabled, isInMutuallyExclusiveGroup,
isFocusable
```
HTML generated by Flutter web...
```html
...
...
Radio Sample
```
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [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 documentation (doc comments with `///`).
- [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].
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[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
[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
---
engine/src/flutter/lib/ui/semantics.dart | 3 +
.../flutter/lib/ui/semantics/semantics_node.h | 1 +
.../src/flutter/lib/web_ui/lib/semantics.dart | 1 +
.../lib/src/engine/semantics/checkable.dart | 18 ++
.../lib/src/engine/semantics/semantics.dart | 6 +
.../test/engine/semantics/semantics_test.dart | 45 +++++
.../flutter/lib/src/semantics/semantics.dart | 34 ++++
.../widgets/semantics_role_checks_test.dart | 163 ++++++++++++++++++
8 files changed, 271 insertions(+)
diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart
index 9ad35343f7..0a916bf6aa 100644
--- a/engine/src/flutter/lib/ui/semantics.dart
+++ b/engine/src/flutter/lib/ui/semantics.dart
@@ -459,6 +459,9 @@ enum SemanticsRole {
///
/// For example, [Shortcuts].
hotKey,
+
+ /// A group of radio buttons.
+ radioGroup,
}
/// A Boolean value that can be associated with a semantics node.
diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h
index 5473240d87..55900ced43 100644
--- a/engine/src/flutter/lib/ui/semantics/semantics_node.h
+++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h
@@ -93,6 +93,7 @@ enum class SemanticsRole : int32_t {
kLoadingSpinner = 21,
kProgressBar = 22,
kHotKey = 23,
+ kRadioGroup = 24,
};
/// C/C++ representation of `SemanticsFlags` defined in
diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart
index 61a21ae294..6a8644f883 100644
--- a/engine/src/flutter/lib/web_ui/lib/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart
@@ -281,6 +281,7 @@ enum SemanticsRole {
loadingSpinner,
progressBar,
hotKey,
+ radioGroup,
}
// When adding a new StringAttributeType, the classes in these file must be
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
index 8ec09b4759..2df46ec131 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
@@ -40,6 +40,24 @@ _CheckableKind _checkableKindFromSemanticsFlag(SemanticsObject semanticsObject)
}
}
+/// Renders semantics objects that contain a group of radio buttons.
+///
+/// Radio buttons in the group have the [SemanticCheckable] role and must have
+/// the [ui.SemanticsFlag.isInMutuallyExclusiveGroup] flag.
+class SemanticRadioGroup extends SemanticRole {
+ SemanticRadioGroup(SemanticsObject semanticsObject)
+ : super.withBasics(
+ EngineSemanticsRole.radioGroup,
+ semanticsObject,
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
+ ) {
+ setAriaRole('radiogroup');
+ }
+
+ @override
+ bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
+}
+
/// Renders semantics objects that have checkable (on/off) states.
///
/// Three objects which are implemented by this class are checkboxes, radio
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
index f4dc7c64c9..cee6ce84ec 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
@@ -367,6 +367,9 @@ enum EngineSemanticsRole {
/// Contains editable text.
textField,
+ /// A group of radio buttons.
+ radioGroup,
+
/// A control that has a checked state, such as a check box or a radio button.
checkable,
@@ -1782,6 +1785,8 @@ class SemanticsObject {
return EngineSemanticsRole.row;
case ui.SemanticsRole.columnHeader:
return EngineSemanticsRole.columnHeader;
+ case ui.SemanticsRole.radioGroup:
+ return EngineSemanticsRole.radioGroup;
// TODO(chunhtai): implement these roles.
// https://github.com/flutter/flutter/issues/159741.
case ui.SemanticsRole.searchBox:
@@ -1837,6 +1842,7 @@ class SemanticsObject {
EngineSemanticsRole.scrollable => SemanticScrollable(this),
EngineSemanticsRole.incrementable => SemanticIncrementable(this),
EngineSemanticsRole.button => SemanticButton(this),
+ EngineSemanticsRole.radioGroup => SemanticRadioGroup(this),
EngineSemanticsRole.checkable => SemanticCheckable(this),
EngineSemanticsRole.route => SemanticRoute(this),
EngineSemanticsRole.image => SemanticImage(this),
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
index 13d60d1c0e..39b9dbdc1b 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
@@ -2172,6 +2172,51 @@ void _testCheckables() {
semantics().semanticsEnabled = false;
});
+ test('renders a radio button group', () async {
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+
+ final tester = SemanticsTester(owner());
+ tester.updateNode(
+ id: 0,
+ role: ui.SemanticsRole.radioGroup,
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 40),
+ children: [
+ tester.updateNode(
+ id: 1,
+ isEnabled: true,
+ hasEnabledState: true,
+ hasCheckedState: true,
+ isInMutuallyExclusiveGroup: true,
+ isChecked: false,
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 20),
+ ),
+ tester.updateNode(
+ id: 2,
+ isEnabled: true,
+ hasEnabledState: true,
+ hasCheckedState: true,
+ isInMutuallyExclusiveGroup: true,
+ isChecked: true,
+ rect: const ui.Rect.fromLTRB(0, 20, 100, 40),
+ ),
+ ],
+ );
+ tester.apply();
+
+ expectSemanticsTree(owner(), '''
+
+
+
+
+
+
+''');
+
+ semantics().semanticsEnabled = false;
+ });
+
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index 75e898c61e..0d54d5243d 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -113,6 +113,7 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.table => _noCheckRequired,
SemanticsRole.cell => _semanticsCell,
SemanticsRole.columnHeader => _semanticsColumnHeader,
+ SemanticsRole.radioGroup => _semanticsRadioGroup,
// TODO(chunhtai): add checks when the roles are used in framework.
// https://github.com/flutter/flutter/issues/159741.
SemanticsRole.row => _unimplemented,
@@ -177,6 +178,39 @@ sealed class _DebugSemanticsRoleChecks {
}
return null;
}
+
+ static FlutterError? _semanticsRadioGroup(SemanticsNode node) {
+ FlutterError? error;
+ bool hasCheckedChild = false;
+ bool validateRadioGroupChildren(SemanticsNode node) {
+ final SemanticsData data = node.getSemanticsData();
+ if (!data.hasFlag(SemanticsFlag.hasCheckedState)) {
+ node.visitChildren(validateRadioGroupChildren);
+ return error == null;
+ }
+
+ if (!data.hasFlag(SemanticsFlag.isInMutuallyExclusiveGroup)) {
+ error = FlutterError(
+ 'Radio buttons in a radio group must be in a mutually exclusive group',
+ );
+ return false;
+ }
+
+ if (data.hasFlag(SemanticsFlag.isChecked)) {
+ if (hasCheckedChild) {
+ error = FlutterError('Radio groups must not have multiple checked children');
+ return false;
+ }
+ hasCheckedChild = true;
+ }
+
+ assert(error == null);
+ return true;
+ }
+
+ node.visitChildren(validateRadioGroupChildren);
+ return error;
+ }
}
/// A tag for a [SemanticsNode].
diff --git a/packages/flutter/test/widgets/semantics_role_checks_test.dart b/packages/flutter/test/widgets/semantics_role_checks_test.dart
index 4775570d9e..07c3e37d34 100644
--- a/packages/flutter/test/widgets/semantics_role_checks_test.dart
+++ b/packages/flutter/test/widgets/semantics_role_checks_test.dart
@@ -104,4 +104,167 @@ void main() {
expect(tester.takeException(), isNull);
});
});
+
+ group('radioGroup', () {
+ testWidgets('failure case, child is not mutually exclusive', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Semantics(
+ checked: false,
+ inMutuallyExclusiveGroup: false,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ),
+ ),
+ );
+ final Object? exception = tester.takeException();
+ expect(exception, isFlutterError);
+ final FlutterError error = exception! as FlutterError;
+ expect(error.message, 'Radio buttons in a radio group must be in a mutually exclusive group');
+ });
+
+ testWidgets('failure case, multiple checked children', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Column(
+ children: [
+ Semantics(
+ checked: true,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ Semantics(
+ checked: true,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ final Object? exception = tester.takeException();
+ expect(exception, isFlutterError);
+ final FlutterError error = exception! as FlutterError;
+ expect(error.message, 'Radio groups must not have multiple checked children');
+ });
+
+ testWidgets('error case, reports first error', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Column(
+ children: [
+ Semantics(
+ label: 'Option A',
+ child: Semantics(checked: true, child: const SizedBox.square(dimension: 1)),
+ ),
+ Semantics(
+ label: 'Option B',
+ child: Semantics(
+ checked: true,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ // The widget tree has multiple errors. The validation walk should stop
+ // on the first error.
+ final Object? exception = tester.takeException();
+ expect(exception, isFlutterError);
+ final FlutterError error = exception! as FlutterError;
+ expect(error.message, 'Radio buttons in a radio group must be in a mutually exclusive group');
+ });
+
+ testWidgets('success case', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Column(
+ children: [
+ Semantics(
+ checked: false,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ Semantics(
+ checked: true,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('success case, radio buttons with labels', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Column(
+ children: [
+ Semantics(
+ label: 'Option A',
+ child: Semantics(
+ checked: false,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ),
+ Semantics(
+ label: 'Option B',
+ child: Semantics(
+ checked: true,
+ inMutuallyExclusiveGroup: true,
+ child: const SizedBox.square(dimension: 1),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('success case, radio group with no checkable children', (
+ WidgetTester tester,
+ ) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Semantics(
+ role: SemanticsRole.radioGroup,
+ explicitChildNodes: true,
+ child: Semantics(toggled: true, child: const SizedBox.square(dimension: 1)),
+ ),
+ ),
+ );
+ expect(tester.takeException(), isNull);
+ });
+ });
}