[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:
Loïc Sharma
2025-03-03 13:42:08 -08:00
committed by GitHub
parent 50d4d53c05
commit 7d8c78ce20
8 changed files with 271 additions and 0 deletions

View File

@@ -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.

View File

@@ -93,6 +93,7 @@ enum class SemanticsRole : int32_t {
kLoadingSpinner = 21,
kProgressBar = 22,
kHotKey = 23,
kRadioGroup = 24,
};
/// C/C++ representation of `SemanticsFlags` defined in

View File

@@ -281,6 +281,7 @@ enum SemanticsRole {
loadingSpinner,
progressBar,
hotKey,
radioGroup,
}
// When adding a new StringAttributeType, the classes in these file must be

View File

@@ -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

View File

@@ -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),

View File

@@ -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)

View File

@@ -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].

View File

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