[A11y] Add radio group role (#164154)
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 <details> <summary>Example app that uses the radio group role...</summary> ```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<RadioExample> createState() => _RadioExampleState(); } class _RadioExampleState extends State<RadioExample> { int _groupValue = 0; @override Widget build(BuildContext context) { return Semantics( label: 'Radio group', role: SemanticsRole.radioGroup, explicitChildNodes: true, child: Column( children: <Widget>[ ListTile( title: const Text('Foo'), leading: Radio<int>( value: 0, groupValue: _groupValue, onChanged: (int? value) => setState(() => _groupValue = value ?? 0), ), ), ListTile( title: const Text('Bar'), leading: Radio<int>( value: 1, groupValue: _groupValue, onChanged: (int? value) => setState(() => _groupValue = value ?? 0), ), ), ], ), ); } } ``` </details> <details> <summary>Accessibility tree...</summary> ``` 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 ``` </details> <details> <summary>HTML generated by Flutter web...</summary> ```html <html> ... <body flt-embedding="full-page" flt-renderer="canvaskit" flt-build-mode="debug" spellcheck="false" style="..."> ... <flt-announcement-host> <flt-announcement-polite aria-live="polite" style="..."> <flt-announcement-assertive aria-live="assertive" style="..."> <flutter-view style="..."> <flt-glass-pane></flt-glass-pane> <flt-text-editing-host></flt-text-editing-host> <flt-semantics-host style="..."> <flt-semantics id="flt-semantic-node-0" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-1" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-2" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-3" role="dialog" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-9" style="..."> <flt-semantics-container style="..."> <h2 id="flt-semantic-node-10" tabindex="-1" style="..."> Radio Sample</h2> </flt-semantics-container> </flt-semantics> <flt-semantics id="flt-semantic-node-4" role="radiogroup" aria-label="Radio group" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-5" role="group" aria-label="Foo" aria-selected="false" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-6" tabindex="0" flt-tappable="" role="radio" aria-checked="false" style="..."> </flt-semantics> </flt-semantics-container> </flt-semantics> <flt-semantics id="flt-semantic-node-7" role="group" aria-label="Bar" aria-selected="false" style="..."> <flt-semantics-container style="..."> <flt-semantics id="flt-semantic-node-8" tabindex="0" flt-tappable="" role="radio" aria-checked="true" style="..."> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-container> </flt-semantics> </flt-semantics-host> </body> </html> ``` </details> ## 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]. <!-- Links --> [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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -93,6 +93,7 @@ enum class SemanticsRole : int32_t {
|
||||
kLoadingSpinner = 21,
|
||||
kProgressBar = 22,
|
||||
kHotKey = 23,
|
||||
kRadioGroup = 24,
|
||||
};
|
||||
|
||||
/// C/C++ representation of `SemanticsFlags` defined in
|
||||
|
||||
@@ -281,6 +281,7 @@ enum SemanticsRole {
|
||||
loadingSpinner,
|
||||
progressBar,
|
||||
hotKey,
|
||||
radioGroup,
|
||||
}
|
||||
|
||||
// When adding a new StringAttributeType, the classes in these file must be
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: <SemanticsNodeUpdate>[
|
||||
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(), '''
|
||||
<sem role="radiogroup">
|
||||
<sem-c>
|
||||
<sem aria-checked="false"></sem>
|
||||
<sem aria-checked="true"></sem>
|
||||
</sem-c>
|
||||
</sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
test('sends focus events', () async {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user