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,