[Accessibility] Add required semantics flags (#164585)
This adds "required" semantic nodes, which indicate a node that requires
user input before a form can be submitted.
On Flutter Web, these get converted into [`aria-required`
attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-required).
Addresses https://github.com/flutter/flutter/issues/162139
### Example app
_⚠️ This example app includes a `DropdownMenu` which currently produces
an incorrect semantics tree. That will be fixed by
https://github.com/flutter/flutter/pull/163638._
Today, you wrap your control in a `Semantics(required: true, child
...)`. For example:
<details>
<summary>Example app with required semantic flags...</summary>
```dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
void main() {
runApp(const MyApp());
SemanticsBinding.instance.ensureSemantics();
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: const MyForm()));
}
}
class MyForm extends StatefulWidget {
const MyForm({super.key});
@override
State<MyForm> createState() => MyFormState();
}
class MyFormState extends State<MyForm> {
int _dropdownValue = 0;
bool _checkboxValue = false;
int _radioGroupValue = 0;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Semantics(required: true, child: TextField()),
Semantics(
required: true,
child: DropdownMenu<int>(
initialSelection: _dropdownValue,
onSelected: (value) => setState(() => _dropdownValue = value ?? 0),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: 'Dropdown entry 1'),
DropdownMenuEntry(value: 1, label: 'Dropdown entry 2'),
],
),
),
ListTile(
title: Text('Checkbox'),
leading: Semantics(
required: true,
child: Checkbox(
value: _checkboxValue,
onChanged:
(value) => setState(() => _checkboxValue = value ?? false),
),
),
),
Semantics(
label: 'Radio group',
role: SemanticsRole.radioGroup,
explicitChildNodes: true,
required: true,
child: Column(
children: <Widget>[
ListTile(
title: const Text('Radio 1'),
leading: Radio<int>(
value: 0,
groupValue: _radioGroupValue,
onChanged:
(int? value) =>
setState(() => _radioGroupValue = value ?? 0),
),
),
ListTile(
title: const Text('Radio 2'),
leading: Radio<int>(
value: 1,
groupValue: _radioGroupValue,
onChanged:
(int? value) =>
setState(() => _radioGroupValue = value ?? 0),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(onPressed: () {}, child: const Text('Submit')),
),
],
);
}
}
```
</details>
<details>
<summary>Semantics tree...</summary>
```
SemanticsNode#0
│ Rect.fromLTRB(0.0, 0.0, 645.0, 1284.0)
│
└─SemanticsNode#1
│ Rect.fromLTRB(0.0, 0.0, 645.0, 1284.0)
│ textDirection: ltr
│
└─SemanticsNode#2
│ Rect.fromLTRB(0.0, 0.0, 645.0, 1284.0)
│ sortKey: OrdinalSortKey#e3336(order: 0.0)
│
└─SemanticsNode#3
│ Rect.fromLTRB(0.0, 0.0, 645.0, 1284.0)
│ flags: scopesRoute
│
├─SemanticsNode#4
│ Rect.fromLTRB(0.0, 0.0, 645.0, 48.0)
│ actions: didGainAccessibilityFocus, didLoseAccessibilityFocus,
│ focus, tap
│ flags: isTextField, hasEnabledState, isEnabled, hasRequiredState,
│ isRequired
│ textDirection: ltr
│ text selection: [0, 0]
│ currentValueLength: 0
│
├─SemanticsNode#5
│ │ Rect.fromLTRB(0.0, 48.0, 199.3, 96.0)
│ │ flags: hasRequiredState, isRequired
│ │
│ └─SemanticsNode#7
│ │ Rect.fromLTRB(0.0, 0.0, 199.3, 48.0)
│ │ actions: didGainAccessibilityFocus, didLoseAccessibilityFocus,
│ │ focus, moveCursorBackwardByCharacter, moveCursorBackwardByWord,
│ │ moveCursorForwardByCharacter, moveCursorForwardByWord, tap
│ │ flags: isTextField, hasEnabledState, isEnabled
│ │ value: "Dropdown entry 1"
│ │ textDirection: ltr
│ │ text selection: [15, 15]
│ │ currentValueLength: 16
│ │
│ ├─SemanticsNode#9
│ │ Rect.fromLTRB(4.0, 4.0, 44.0, 44.0)
│ │ actions: focus, tap
│ │ flags: hasSelectedState, isButton, hasEnabledState, isEnabled,
│ │ isFocusable
│ │
│ └─SemanticsNode#8
│ Rect.fromLTRB(155.3, 4.0, 195.3, 44.0)
│ actions: focus, tap
│ flags: hasSelectedState, isButton, hasEnabledState, isEnabled,
│ isFocusable
│
├─SemanticsNode#10
│ │ Rect.fromLTRB(0.0, 96.0, 645.0, 144.0)
│ │ flags: hasSelectedState, hasEnabledState, isEnabled
│ │ label: "Checkbox"
│ │ textDirection: ltr
│ │
│ └─SemanticsNode#11
│ Rect.fromLTRB(16.0, 4.0, 56.0, 44.0)
│ actions: focus, tap
│ flags: hasCheckedState, hasEnabledState, isEnabled, isFocusable,
│ hasRequiredState, isRequired
│
├─SemanticsNode#12
│ │ Rect.fromLTRB(0.0, 144.0, 645.0, 240.0)
│ │ flags: hasRequiredState, isRequired
│ │ label: "Radio group"
│ │ textDirection: ltr
│ │ role: radioGroup
│ │
│ ├─SemanticsNode#13
│ │ │ Rect.fromLTRB(0.0, 0.0, 645.0, 48.0)
│ │ │ flags: hasSelectedState, hasEnabledState, isEnabled
│ │ │ label: "Radio 1"
│ │ │ textDirection: ltr
│ │ │
│ │ └─SemanticsNode#14
│ │ Rect.fromLTRB(16.0, 8.0, 48.0, 40.0)
│ │ actions: focus, tap
│ │ flags: hasCheckedState, isChecked, hasSelectedState, isSelected,
│ │ hasEnabledState, isEnabled, isInMutuallyExclusiveGroup,
│ │ isFocusable
│ │
│ └─SemanticsNode#15
│ │ Rect.fromLTRB(0.0, 48.0, 645.0, 96.0)
│ │ flags: hasSelectedState, hasEnabledState, isEnabled
│ │ label: "Radio 2"
│ │ textDirection: ltr
│ │
│ └─SemanticsNode#16
│ Rect.fromLTRB(16.0, 8.0, 48.0, 40.0)
│ actions: focus, tap
│ flags: hasCheckedState, hasSelectedState, hasEnabledState,
│ isEnabled, isInMutuallyExclusiveGroup, isFocusable
│
└─SemanticsNode#17
Rect.fromLTRB(0.0, 256.0, 92.7, 288.0)
actions: focus, tap
flags: isButton, hasEnabledState, isEnabled, isFocusable
label: "Submit"
textDirection: ltr
thickness: 1.0
```
</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-polite>
<flt-announcement-assertive aria-live="assertive" style="">
</flt-announcement-assertive>
</flt-announcement-host>
<flutter-view flt-view-id="0" tabindex="0" 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-4" style="">
<input type="text" spellcheck="false" autocorrect="on" autocomplete="on"
data-semantics-role="text-field" aria-required="true" style="">
</flt-semantics>
<flt-semantics id="flt-semantic-node-5" aria-required="true" style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-7" style="">
<input type="text" spellcheck="false" autocorrect="off" autocomplete="off"
data-semantics-role="text-field" style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-9" role="button" tabindex="0" aria-selected="false"
flt-tappable="" style="">
</flt-semantics>
<flt-semantics id="flt-semantic-node-8" role="button" tabindex="0" aria-selected="false"
flt-tappable="" style="">
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
<flt-semantics id="flt-semantic-node-10" role="group" aria-label="Checkbox" aria-selected="false"
style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-11" tabindex="0" aria-required="true" flt-tappable=""
role="checkbox" aria-checked="false" style="">
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
<flt-semantics id="flt-semantic-node-12" role="radiogroup" aria-label="Radio group"
aria-required="true" style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-13" role="group" aria-label="Radio 1"
aria-selected="false" style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-14" tabindex="0" flt-tappable="" role="radio"
aria-checked="true" style="">
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
<flt-semantics id="flt-semantic-node-15" role="group" aria-label="Radio 2"
aria-selected="false" style="">
<flt-semantics-container style="">
<flt-semantics id="flt-semantic-node-16" tabindex="0" flt-tappable="" role="radio"
aria-checked="false" style="">
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
<flt-semantics id="flt-semantic-node-17" role="button" tabindex="0" flt-tappable="" style="">
</flt-semantics-container>
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-container>
</flt-semantics>
</flt-semantics-host>
</flutter-view>
</body>
</html>
```
</details>
In the future, we can update Material and Cupertino widgets to
automatically make their semantics node required when desirable.
## 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