[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
This commit is contained in:
Loïc Sharma 2025-03-14 14:06:18 -07:00 committed by GitHub
parent 635ed5b778
commit f6f6030b20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 293 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -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<int, SemanticsFlag> _kFlagById = <int, SemanticsFlag>{
_kHasCheckedStateIndex: hasCheckedState,
@ -245,6 +252,8 @@ class SemanticsFlag {
_kIsCheckStateMixedIndex: isCheckStateMixed,
_kHasExpandedStateIndex: hasExpandedState,
_kIsExpandedIndex: isExpanded,
_kHasRequiredStateIndex: hasRequiredState,
_kIsRequiredIndex: isRequired,
};
static List<SemanticsFlag> get values => _kFlagById.values.toList(growable: false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <SemanticsNodeUpdate>[
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(), '''
<sem>
<sem-c>
<sem></sem>
<sem aria-required="false"></sem>
<sem aria-required="true"></sem>
</sem-c>
</sem>
''');
// 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(), '''
<sem>
<sem-c>
<sem></sem>
<sem aria-required="true"></sem>
<sem aria-required="false"></sem>
</sem-c>
</sem>
''');
// 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(), '''
<sem>
<sem-c>
<sem></sem>
<sem></sem>
<sem></sem>
</sem-c>
</sem>
''');
// 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(

View File

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

View File

@ -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(), '''<sem><input aria-required="true" /></sem>''');
});
test('renders a not required text field', () {
createTextFieldSemantics(isRequired: false, value: 'hello');
expectSemanticsTree(owner(), '''<sem><input aria-required="false" /></sem>''');
});
});
}
@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<bool>('mixed', mixed, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('expanded', expanded, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('selected', selected, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('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.
///

View File

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

View File

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

View File

@ -555,6 +555,7 @@ void main() {
image: true,
liveRegion: true,
expanded: true,
isRequired: true,
),
);
final List<SemanticsFlag> flags = SemanticsFlag.values.toList();
@ -620,6 +621,7 @@ void main() {
image: true,
liveRegion: true,
expanded: true,
isRequired: true,
),
);
flags

View File

@ -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 = <SemanticsAction, bool>{
if (hasTapAction != null) SemanticsAction.tap: hasTapAction,

View File

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