From f6f6030b20020be0910d0eda98138988faad0a76 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Lo=C3=AFc=20Sharma?=
<737941+loic-sharma@users.noreply.github.com>
Date: Fri, 14 Mar 2025 14:06:18 -0700
Subject: [PATCH] [Accessibility] Add required semantics flags (#164585)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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:
Example app with required semantic flags...
```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 createState() => MyFormState();
}
class MyFormState extends State {
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(
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: [
ListTile(
title: const Text('Radio 1'),
leading: Radio(
value: 0,
groupValue: _radioGroupValue,
onChanged:
(int? value) =>
setState(() => _radioGroupValue = value ?? 0),
),
),
ListTile(
title: const Text('Radio 2'),
leading: Radio(
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')),
),
],
);
}
}
```
Semantics tree...
```
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
```
HTML generated by Flutter web...
```html
```
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].
[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
---
.../ci/licenses_golden/licenses_flutter | 2 +
engine/src/flutter/lib/ui/semantics.dart | 26 +++++
.../flutter/lib/ui/semantics/semantics_node.h | 2 +
.../src/flutter/lib/web_ui/lib/semantics.dart | 9 ++
.../flutter/lib/web_ui/lib/src/engine.dart | 1 +
.../lib/web_ui/lib/src/engine/semantics.dart | 1 +
.../lib/src/engine/semantics/requirable.dart | 27 ++++++
.../lib/src/engine/semantics/semantics.dart | 22 +++++
.../lib/src/engine/semantics/text_field.dart | 6 ++
.../engine/semantics/semantics_api_test.dart | 2 +-
.../test/engine/semantics/semantics_test.dart | 96 +++++++++++++++++++
.../engine/semantics/semantics_tester.dart | 8 ++
.../engine/semantics/text_field_test.dart | 14 +++
.../io/flutter/view/AccessibilityBridge.java | 4 +-
.../shell/platform/embedder/embedder.h | 7 ++
.../flutter/testing/dart/semantics_test.dart | 2 +-
.../lib/src/rendering/custom_paint.dart | 3 +
.../flutter/lib/src/rendering/proxy_box.dart | 3 +
.../flutter/lib/src/semantics/semantics.dart | 38 +++++++-
packages/flutter/lib/src/widgets/basic.dart | 2 +
.../test/widgets/custom_painter_test.dart | 2 +
.../flutter/test/widgets/semantics_test.dart | 2 +
packages/flutter_test/lib/src/matchers.dart | 12 +++
packages/flutter_test/test/matchers_test.dart | 6 ++
24 files changed, 293 insertions(+), 4 deletions(-)
create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart
diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter
index 073e005ea7..b3bc225507 100644
--- a/engine/src/flutter/ci/licenses_golden/licenses_flutter
+++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter
@@ -42628,6 +42628,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart + ../../.
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/list.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart + ../../../flutter/LICENSE
+ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart + ../../../flutter/LICENSE
@@ -45598,6 +45599,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/list.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart
index 8d56db159d..472ec31126 100644
--- a/engine/src/flutter/lib/ui/semantics.dart
+++ b/engine/src/flutter/lib/ui/semantics.dart
@@ -528,6 +528,8 @@ class SemanticsFlag {
static const int _kHasExpandedStateIndex = 1 << 26;
static const int _kIsExpandedIndex = 1 << 27;
static const int _kHasSelectedStateIndex = 1 << 28;
+ static const int _kHasRequiredStateIndex = 1 << 29;
+ static const int _kIsRequiredIndex = 1 << 30;
// READ THIS: if you add a flag here, you MUST update the following:
//
// - Add an appropriately named and documented `static const SemanticsFlag`
@@ -840,6 +842,28 @@ class SemanticsFlag {
/// * [SemanticsFlag.hasExpandedState], which enables an expanded/collapsed state.
static const SemanticsFlag isExpanded = SemanticsFlag._(_kIsExpandedIndex, 'isExpanded');
+ /// The semantics node has the quality of either being required or not.
+ ///
+ /// See also:
+ ///
+ /// * [SemanticsFlag.isRequired], which controls whether the node is required.
+ static const SemanticsFlag hasRequiredState = SemanticsFlag._(
+ _kHasRequiredStateIndex,
+ 'hasRequiredState',
+ );
+
+ /// Whether a semantics node is required.
+ ///
+ /// If true, user input is required on the semantics node before a form can
+ /// be submitted.
+ ///
+ /// For example, a login form requires its email text field to be non-empty.
+ ///
+ /// See also:
+ ///
+ /// * [SemanticsFlag.hasRequiredState], which enables a required state state.
+ static const SemanticsFlag isRequired = SemanticsFlag._(_kIsRequiredIndex, 'isRequired');
+
/// The possible semantics flags.
///
/// The map's key is the [index] of the flag and the value is the flag itself.
@@ -873,6 +897,8 @@ class SemanticsFlag {
_kIsCheckStateMixedIndex: isCheckStateMixed,
_kHasExpandedStateIndex: hasExpandedState,
_kIsExpandedIndex: isExpanded,
+ _kHasRequiredStateIndex: hasRequiredState,
+ _kIsRequiredIndex: isRequired,
};
// TODO(matanlurey): have original authors document; see https://github.com/flutter/flutter/issues/151917.
diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h
index bc173f4736..f4e02a65cd 100644
--- a/engine/src/flutter/lib/ui/semantics/semantics_node.h
+++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h
@@ -134,6 +134,8 @@ enum class SemanticsFlags : int32_t {
kHasExpandedState = 1 << 26,
kIsExpanded = 1 << 27,
kHasSelectedState = 1 << 28,
+ kHasRequiredState = 1 << 29,
+ kIsRequired = 1 << 30,
};
const int kScrollableSemanticsFlags =
diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart
index f34ca6934f..d64fcc0061 100644
--- a/engine/src/flutter/lib/web_ui/lib/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart
@@ -160,6 +160,8 @@ class SemanticsFlag {
static const int _kHasExpandedStateIndex = 1 << 26;
static const int _kIsExpandedIndex = 1 << 27;
static const int _kHasSelectedStateIndex = 1 << 28;
+ static const int _kHasRequiredStateIndex = 1 << 29;
+ static const int _kIsRequiredIndex = 1 << 30;
static const SemanticsFlag hasCheckedState = SemanticsFlag._(
_kHasCheckedStateIndex,
@@ -214,6 +216,11 @@ class SemanticsFlag {
'hasExpandedState',
);
static const SemanticsFlag isExpanded = SemanticsFlag._(_kIsExpandedIndex, 'isExpanded');
+ static const SemanticsFlag hasRequiredState = SemanticsFlag._(
+ _kHasRequiredStateIndex,
+ 'hasRequiredState',
+ );
+ static const SemanticsFlag isRequired = SemanticsFlag._(_kIsRequiredIndex, 'isRequired');
static const Map _kFlagById = {
_kHasCheckedStateIndex: hasCheckedState,
@@ -245,6 +252,8 @@ class SemanticsFlag {
_kIsCheckStateMixedIndex: isCheckStateMixed,
_kHasExpandedStateIndex: hasExpandedState,
_kIsExpandedIndex: isExpanded,
+ _kHasRequiredStateIndex: hasRequiredState,
+ _kIsRequiredIndex: isRequired,
};
static List get values => _kFlagById.values.toList(growable: false);
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
index 69347c9a74..3355a475e1 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
@@ -117,6 +117,7 @@ export 'engine/semantics/link.dart';
export 'engine/semantics/list.dart';
export 'engine/semantics/live_region.dart';
export 'engine/semantics/platform_view.dart';
+export 'engine/semantics/requirable.dart';
export 'engine/semantics/route.dart';
export 'engine/semantics/scrollable.dart';
export 'engine/semantics/semantics.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
index cd30dbda03..7f7f593b41 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
@@ -16,6 +16,7 @@ export 'semantics/link.dart';
export 'semantics/list.dart';
export 'semantics/live_region.dart';
export 'semantics/platform_view.dart';
+export 'semantics/requirable.dart';
export 'semantics/scrollable.dart';
export 'semantics/semantics.dart';
export 'semantics/semantics_helper.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart
new file mode 100644
index 0000000000..9df6dd04d9
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/requirable.dart
@@ -0,0 +1,27 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'semantics.dart';
+
+/// Adds requirability behavior to a semantic node.
+///
+/// A requirable node has the `aria-required` attribute set to "true" if the node
+/// is currently required (i.e. [SemanticsObject.isRequired] is true), and set
+/// to "false" if it's not required (i.e. [SemanticsObject.isRequired] is false).
+/// If the node is not requirable (i.e. [SemanticsObject.isRequirable] is false),
+/// then `aria-required` is unset.
+class Requirable extends SemanticBehavior {
+ Requirable(super.semanticsObject, super.owner);
+
+ @override
+ void update() {
+ if (semanticsObject.isFlagsDirty) {
+ if (semanticsObject.isRequirable) {
+ owner.setAttribute('aria-required', semanticsObject.isRequired);
+ } else {
+ owner.removeAttribute('aria-required');
+ }
+ }
+ }
+}
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 f12aa7fdb4..4e23e4baf6 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
@@ -32,6 +32,7 @@ import 'link.dart';
import 'list.dart';
import 'live_region.dart';
import 'platform_view.dart';
+import 'requirable.dart';
import 'route.dart';
import 'scrollable.dart';
import 'semantics_helper.dart';
@@ -485,6 +486,7 @@ abstract class SemanticRole {
addLabelAndValue(preferredRepresentation: preferredLabelRepresentation);
addSelectableBehavior();
addExpandableBehavior();
+ addRequirableBehavior();
}
/// Initializes a blank role for a [semanticsObject].
@@ -655,6 +657,10 @@ abstract class SemanticRole {
addSemanticBehavior(Expandable(semanticsObject, this));
}
+ void addRequirableBehavior() {
+ addSemanticBehavior(Requirable(semanticsObject, this));
+ }
+
/// Adds a semantic behavior to this role.
///
/// This method should be called by concrete implementations of
@@ -2044,6 +2050,22 @@ class SemanticsObject {
/// selected.
bool get isSelected => hasFlag(ui.SemanticsFlag.isSelected);
+ /// If true, this node represents something that currently requires user input
+ /// before a form can be submitted.
+ ///
+ /// Requirability is managed by `aria-required` and is compatible with
+ /// multiple ARIA roles (checkbox, combobox, gridcell, listbox, radiogroup,
+ /// spinbutton, textbox, tree, etc). It is therefore mapped onto the
+ /// [Requirable] behavior.
+ ///
+ /// See also:
+ ///
+ /// * [isRequired], which indicates whether the is currently required.
+ bool get isRequirable => hasFlag(ui.SemanticsFlag.hasRequiredState);
+
+ /// If [isRequirable] is true, indicates whether the node is required.
+ bool get isRequired => hasFlag(ui.SemanticsFlag.isRequired);
+
/// If true, this node represents something that can be annotated as
/// "expanded", such as a expansion tile or drop down menu
///
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
index 4d9ae64c1c..815bcf7447 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
@@ -321,6 +321,12 @@ class SemanticTextField extends SemanticRole {
} else {
editableElement.removeAttribute('aria-label');
}
+
+ if (semanticsObject.isRequirable) {
+ editableElement.setAttribute('aria-required', semanticsObject.isRequired);
+ } else {
+ editableElement.removeAttribute('aria-required');
+ }
}
void _updateEnabledState() {
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart
index 8576246b9c..56ff12fc18 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart
@@ -18,7 +18,7 @@ void main() {
void testMain() {
// This must match the number of flags in lib/ui/semantics.dart
- const int numSemanticsFlags = 29;
+ const int numSemanticsFlags = 31;
test('SemanticsFlag.values refers to all flags.', () async {
expect(SemanticsFlag.values.length, equals(numSemanticsFlags));
for (int index = 0; index < numSemanticsFlags; ++index) {
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 e7b256b355..ce9d9db01a 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
@@ -138,6 +138,9 @@ void runSemanticsTests() {
group('controlsNodes', () {
_testControlsNodes();
});
+ group('requirable', () {
+ _testRequirable();
+ });
}
void _testSemanticRole() {
@@ -4228,6 +4231,99 @@ void _testControlsNodes() {
semantics().semanticsEnabled = false;
}
+void _testRequirable() {
+ test('renders and updates non-requirable, required, and unrequired nodes', () async {
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+
+ final tester = SemanticsTester(owner());
+ tester.updateNode(
+ id: 0,
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 60),
+ children: [
+ tester.updateNode(id: 1, isSelectable: false, rect: const ui.Rect.fromLTRB(0, 0, 100, 20)),
+ tester.updateNode(
+ id: 2,
+ hasRequiredState: true,
+ isRequired: false,
+ rect: const ui.Rect.fromLTRB(0, 20, 100, 40),
+ ),
+ tester.updateNode(
+ id: 3,
+ hasRequiredState: true,
+ isRequired: true,
+ rect: const ui.Rect.fromLTRB(0, 40, 100, 60),
+ ),
+ ],
+ );
+ tester.apply();
+
+ expectSemanticsTree(owner(), '''
+
+
+
+
+
+
+
+''');
+
+ // Missing attributes cannot be expressed using HTML patterns, so check directly.
+ final notRequirable1 = owner().debugSemanticsTree![1]!.element;
+ expect(notRequirable1.getAttribute('aria-required'), isNull);
+
+ // Flip the values and check that that ARIA attribute is updated.
+ tester.updateNode(
+ id: 2,
+ hasRequiredState: true,
+ isRequired: true,
+ rect: const ui.Rect.fromLTRB(0, 20, 100, 40),
+ );
+ tester.updateNode(
+ id: 3,
+ hasRequiredState: true,
+ isRequired: false,
+ rect: const ui.Rect.fromLTRB(0, 40, 100, 60),
+ );
+ tester.apply();
+
+ expectSemanticsTree(owner(), '''
+
+
+
+
+
+
+
+''');
+
+ // Remove the ARIA attribute
+ tester.updateNode(id: 2, hasRequiredState: false, rect: const ui.Rect.fromLTRB(0, 20, 100, 40));
+ tester.updateNode(id: 3, hasRequiredState: false, rect: const ui.Rect.fromLTRB(0, 40, 100, 60));
+ tester.apply();
+
+ expectSemanticsTree(owner(), '''
+
+
+
+
+
+
+
+''');
+
+ // Missing attributes cannot be expressed using HTML patterns, so check directly.
+ final notRequirable2 = owner().debugSemanticsTree![2]!.element;
+ expect(notRequirable2.getAttribute('aria-required'), isNull);
+
+ final notRequirable3 = owner().debugSemanticsTree![3]!.element;
+ expect(notRequirable3.getAttribute('aria-required'), isNull);
+
+ semantics().semanticsEnabled = false;
+ });
+}
+
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
index 7147ac5331..fceba273d7 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
@@ -58,6 +58,8 @@ class SemanticsTester {
bool? isMultiline,
bool? isSlider,
bool? isKeyboardKey,
+ bool? hasRequiredState,
+ bool? isRequired,
// Actions
int actions = 0,
@@ -205,6 +207,12 @@ class SemanticsTester {
if (isKeyboardKey ?? false) {
flags |= ui.SemanticsFlag.isKeyboardKey.index;
}
+ if (hasRequiredState ?? false) {
+ flags |= ui.SemanticsFlag.hasRequiredState.index;
+ }
+ if (isRequired ?? false) {
+ flags |= ui.SemanticsFlag.isRequired.index;
+ }
// Actions
if (hasTap ?? false) {
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart
index 617681680f..9819c1234e 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart
@@ -103,6 +103,7 @@ void testMain() {
expect(inputElement.tagName.toLowerCase(), 'input');
expect(inputElement.value, '');
expect(inputElement.disabled, isFalse);
+ expect(inputElement.getAttribute('aria-required'), isNull);
});
test('renders a password field', () {
@@ -473,6 +474,16 @@ void testMain() {
expect(strategy.domElement, tester.getTextField(2).editableElement);
}
});
+
+ test('renders a required text field', () {
+ createTextFieldSemantics(isRequired: true, value: 'hello');
+ expectSemanticsTree(owner(), '''''');
+ });
+
+ test('renders a not required text field', () {
+ createTextFieldSemantics(isRequired: false, value: 'hello');
+ expectSemanticsTree(owner(), '''''');
+ });
});
}
@@ -483,6 +494,7 @@ SemanticsObject createTextFieldSemantics({
bool isFocused = false,
bool isMultiline = false,
bool isObscured = false,
+ bool? isRequired,
ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
int textSelectionBase = 0,
int textSelectionExtent = 0,
@@ -497,6 +509,8 @@ SemanticsObject createTextFieldSemantics({
isFocused: isFocused,
isMultiline: isMultiline,
isObscured: isObscured,
+ hasRequiredState: isRequired != null,
+ isRequired: isRequired,
hasTap: true,
rect: rect,
textDirection: ui.TextDirection.ltr,
diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java
index 94d91a51b5..268e9a4e1d 100644
--- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java
+++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java
@@ -2170,7 +2170,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
IS_CHECK_STATE_MIXED(1 << 25),
HAS_EXPANDED_STATE(1 << 26),
IS_EXPANDED(1 << 27),
- HAS_SELECTED_STATE(1 << 28);
+ HAS_SELECTED_STATE(1 << 28),
+ HAS_REQUIRED_STATE(1 << 29),
+ IS_REQUIRED(1 << 30);
final int value;
diff --git a/engine/src/flutter/shell/platform/embedder/embedder.h b/engine/src/flutter/shell/platform/embedder/embedder.h
index 5a76854dd6..5a85bee325 100644
--- a/engine/src/flutter/shell/platform/embedder/embedder.h
+++ b/engine/src/flutter/shell/platform/embedder/embedder.h
@@ -249,6 +249,13 @@ typedef enum {
/// The semantics node has the quality of either being "selected" or
/// "not selected".
kFlutterSemanticsFlagHasSelectedState = 1 << 28,
+ /// Whether a semantics node has the quality of being required.
+ kFlutterSemanticsFlagHasRequiredState = 1 << 29,
+ /// Whether user input is required on the semantics node before a form can be
+ /// submitted.
+ ///
+ /// Only applicable when kFlutterSemanticsFlagHasRequiredState flag is on.
+ kFlutterSemanticsFlagIsRequired = 1 << 30,
} FlutterSemanticsFlag;
typedef enum {
diff --git a/engine/src/flutter/testing/dart/semantics_test.dart b/engine/src/flutter/testing/dart/semantics_test.dart
index 55c90353d3..857e67ec81 100644
--- a/engine/src/flutter/testing/dart/semantics_test.dart
+++ b/engine/src/flutter/testing/dart/semantics_test.dart
@@ -11,7 +11,7 @@ import 'package:test/test.dart';
void main() {
// This must match the number of flags in lib/ui/semantics.dart
- const int numSemanticsFlags = 29;
+ const int numSemanticsFlags = 31;
test('SemanticsFlag.values refers to all flags.', () async {
expect(SemanticsFlag.values.length, equals(numSemanticsFlags));
for (int index = 0; index < numSemanticsFlags; ++index) {
diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart
index 71b9868c55..09197214fd 100644
--- a/packages/flutter/lib/src/rendering/custom_paint.dart
+++ b/packages/flutter/lib/src/rendering/custom_paint.dart
@@ -984,6 +984,9 @@ class RenderCustomPaint extends RenderProxyBox {
if (properties.liveRegion != null) {
config.liveRegion = properties.liveRegion!;
}
+ if (properties.isRequired != null) {
+ config.isRequired = properties.isRequired;
+ }
if (properties.maxValueLength != null) {
config.maxValueLength = properties.maxValueLength;
}
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index c5a4d48d1e..2341e8563a 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -4489,6 +4489,9 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
if (_properties.image != null) {
config.isImage = _properties.image!;
}
+ if (_properties.isRequired != null) {
+ config.isRequired = _properties.isRequired;
+ }
if (_properties.identifier != null) {
config.identifier = _properties.identifier!;
}
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index 4fb75cadbd..675b14f45f 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -1175,6 +1175,7 @@ class SemanticsProperties extends DiagnosticableTree {
this.namesRoute,
this.image,
this.liveRegion,
+ this.isRequired,
this.maxValueLength,
this.currentValueLength,
this.identifier,
@@ -1445,6 +1446,22 @@ class SemanticsProperties extends DiagnosticableTree {
/// * [SemanticsConfiguration.liveRegion], for a full description of a live region.
final bool? liveRegion;
+ /// If non-null, whether the node should be considered required.
+ ///
+ /// If true, user input is required on the semantics node before a form can
+ /// be submitted. If false, the node is optional before a form can be
+ /// submitted. If null, the node does not have a required semantics.
+ ///
+ /// For example, a login form requires its email text field to be non-empty.
+ ///
+ /// On web, this will set a `aria-required` attribute on the DOM element
+ /// that corresponds to the semantics node.
+ ///
+ /// See also:
+ ///
+ /// * [SemanticsFlag.isRequired], for the flag this setting controls.
+ final bool? isRequired;
+
/// The maximum number of characters that can be entered into an editable
/// text field.
///
@@ -2036,6 +2053,7 @@ class SemanticsProperties extends DiagnosticableTree {
properties.add(DiagnosticsProperty('mixed', mixed, defaultValue: null));
properties.add(DiagnosticsProperty('expanded', expanded, defaultValue: null));
properties.add(DiagnosticsProperty('selected', selected, defaultValue: null));
+ properties.add(DiagnosticsProperty('isRequired', isRequired, defaultValue: null));
properties.add(StringProperty('identifier', identifier, defaultValue: null));
properties.add(StringProperty('label', label, defaultValue: null));
properties.add(
@@ -5347,7 +5365,7 @@ class SemanticsConfiguration {
}
/// Whether the owning [RenderObject] is a keyboard key (true) or not
- //(false).
+ /// (false).
bool get isKeyboardKey => _hasFlag(SemanticsFlag.isKeyboardKey);
set isKeyboardKey(bool value) {
_setFlag(SemanticsFlag.isKeyboardKey, value);
@@ -5407,6 +5425,24 @@ class SemanticsConfiguration {
_setFlag(SemanticsFlag.isMultiline, value);
}
+ /// Whether the semantics node has a required state.
+ ///
+ /// Do not call the setter for this field if the owning [RenderObject] doesn't
+ /// have a required state that can be controlled by the user.
+ ///
+ /// The getter returns null if the owning [RenderObject] does not have a
+ /// required state.
+ ///
+ /// See also:
+ ///
+ /// * [SemanticsFlag.isRequired], for a full description of required nodes.
+ bool? get isRequired =>
+ _hasFlag(SemanticsFlag.hasRequiredState) ? _hasFlag(SemanticsFlag.isRequired) : null;
+ set isRequired(bool? value) {
+ _setFlag(SemanticsFlag.hasRequiredState, true);
+ _setFlag(SemanticsFlag.isRequired, value!);
+ }
+
/// Whether the platform can scroll the semantics node when the user attempts
/// to move focus to an offscreen child.
///
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index 7c26ef8ec6..040e7c7290 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -7342,6 +7342,7 @@ class Semantics extends SingleChildRenderObjectWidget {
bool? image,
bool? liveRegion,
bool? expanded,
+ bool? isRequired,
int? maxValueLength,
int? currentValueLength,
String? identifier,
@@ -7416,6 +7417,7 @@ class Semantics extends SingleChildRenderObjectWidget {
hidden: hidden,
image: image,
liveRegion: liveRegion,
+ isRequired: isRequired,
maxValueLength: maxValueLength,
currentValueLength: currentValueLength,
identifier: identifier,
diff --git a/packages/flutter/test/widgets/custom_painter_test.dart b/packages/flutter/test/widgets/custom_painter_test.dart
index 3e1a582e41..146c174cdb 100644
--- a/packages/flutter/test/widgets/custom_painter_test.dart
+++ b/packages/flutter/test/widgets/custom_painter_test.dart
@@ -476,6 +476,7 @@ void _defineTests() {
liveRegion: true,
toggled: true,
expanded: true,
+ isRequired: true,
),
),
),
@@ -529,6 +530,7 @@ void _defineTests() {
image: true,
liveRegion: true,
expanded: true,
+ isRequired: true,
),
),
),
diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart
index cdd7591274..fe78f45866 100644
--- a/packages/flutter/test/widgets/semantics_test.dart
+++ b/packages/flutter/test/widgets/semantics_test.dart
@@ -555,6 +555,7 @@ void main() {
image: true,
liveRegion: true,
expanded: true,
+ isRequired: true,
),
);
final List flags = SemanticsFlag.values.toList();
@@ -620,6 +621,7 @@ void main() {
image: true,
liveRegion: true,
expanded: true,
+ isRequired: true,
),
);
flags
diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart
index 7616f43c41..3701332299 100644
--- a/packages/flutter_test/lib/src/matchers.dart
+++ b/packages/flutter_test/lib/src/matchers.dart
@@ -715,6 +715,8 @@ Matcher matchesSemantics({
bool hasImplicitScrolling = false,
bool hasExpandedState = false,
bool isExpanded = false,
+ bool hasRequiredState = false,
+ bool isRequired = false,
// Actions //
bool hasTapAction = false,
bool hasFocusAction = false,
@@ -795,6 +797,8 @@ Matcher matchesSemantics({
hasImplicitScrolling: hasImplicitScrolling,
hasExpandedState: hasExpandedState,
isExpanded: isExpanded,
+ hasRequiredState: hasRequiredState,
+ isRequired: isRequired,
// Actions
hasTapAction: hasTapAction,
hasFocusAction: hasFocusAction,
@@ -903,6 +907,8 @@ Matcher containsSemantics({
bool? hasImplicitScrolling,
bool? hasExpandedState,
bool? isExpanded,
+ bool? hasRequiredState,
+ bool? isRequired,
// Actions
bool? hasTapAction,
bool? hasFocusAction,
@@ -983,6 +989,8 @@ Matcher containsSemantics({
hasImplicitScrolling: hasImplicitScrolling,
hasExpandedState: hasExpandedState,
isExpanded: isExpanded,
+ hasRequiredState: hasRequiredState,
+ isRequired: isRequired,
// Actions
hasTapAction: hasTapAction,
hasFocusAction: hasFocusAction,
@@ -2416,6 +2424,8 @@ class _MatchesSemanticsData extends Matcher {
required bool? hasImplicitScrolling,
required bool? hasExpandedState,
required bool? isExpanded,
+ required bool? hasRequiredState,
+ required bool? isRequired,
// Actions
required bool? hasTapAction,
required bool? hasFocusAction,
@@ -2475,6 +2485,8 @@ class _MatchesSemanticsData extends Matcher {
if (isSlider != null) SemanticsFlag.isSlider: isSlider,
if (hasExpandedState != null) SemanticsFlag.hasExpandedState: hasExpandedState,
if (isExpanded != null) SemanticsFlag.isExpanded: isExpanded,
+ if (hasRequiredState != null) SemanticsFlag.hasRequiredState: hasRequiredState,
+ if (isRequired != null) SemanticsFlag.isRequired: isRequired,
},
actions = {
if (hasTapAction != null) SemanticsAction.tap: hasTapAction,
diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart
index 5bb2f681df..ee4f28d6a6 100644
--- a/packages/flutter_test/test/matchers_test.dart
+++ b/packages/flutter_test/test/matchers_test.dart
@@ -773,6 +773,8 @@ void main() {
hasImplicitScrolling: true,
hasExpandedState: true,
isExpanded: true,
+ hasRequiredState: true,
+ isRequired: true,
/* Actions */
hasTapAction: true,
hasLongPressAction: true,
@@ -1072,6 +1074,8 @@ void main() {
hasImplicitScrolling: true,
hasExpandedState: true,
isExpanded: true,
+ hasRequiredState: true,
+ isRequired: true,
/* Actions */
hasTapAction: true,
hasLongPressAction: true,
@@ -1169,6 +1173,8 @@ void main() {
hasImplicitScrolling: false,
hasExpandedState: false,
isExpanded: false,
+ hasRequiredState: false,
+ isRequired: false,
/* Actions */
hasTapAction: false,
hasLongPressAction: false,