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); + }); + }); }