Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Accessibility] Add required semantics flags #164585

Merged
merged 3 commits into from
Mar 14, 2025

Conversation

loic-sharma
Copy link
Member

@loic-sharma loic-sharma commented Mar 4, 2025

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.

Addresses #162139

Example app

⚠️ This example app includes a DropdownMenu which currently produces an incorrect semantics tree. That will be fixed by #163638.

Today, you wrap your control in a Semantics(required: true, child ...). For example:

Example app with required semantic flags...
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')),
        ),
      ],
    );
  }
}
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>

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

In the future, we can update Material and Cupertino widgets to automatically make their semantics node required when desirable.

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added a: tests "flutter test", flutter_test, or one of our tests platform-android Android applications specifically framework flutter/packages/flutter repository. See also f: labels. engine flutter/engine repository. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) platform-web Web applications specifically labels Mar 4, 2025
@loic-sharma loic-sharma force-pushed the a11y_required branch 2 times, most recently from bf897e0 to c7fa33d Compare March 6, 2025 00:20
@loic-sharma loic-sharma requested review from yjbanov and chunhtai March 6, 2025 01:53
@loic-sharma loic-sharma marked this pull request as ready for review March 6, 2025 01:53
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly lgtm, left a question

void update() {
if (semanticsObject.isFlagsDirty) {
if (semanticsObject.isRequirable) {
owner.setAttribute('aria-required', semanticsObject.isRequired);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to double check for roles that create additional dom element such as text_field, I think this will add aria-required to the wrapper div instead of the input tag

Copy link
Member Author

@loic-sharma loic-sharma Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. It looks like neither the input tag nor its wrapper div have the aria-required attribute, even though the Flutter semantics tree has isRequired. I'll investigate and add a test for this scenario!

Repro...

App:

import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Semantics(required: true, child: TextField()),
      ),
    ),
  );
  SemanticsBinding.instance.ensureSemantics();
}

Semantics tree:

SemanticsNode#0
 │ Rect.fromLTRB(0.0, 0.0, 2400.0, 1604.0)
 │
 └─SemanticsNode#1
   │ Rect.fromLTRB(0.0, 0.0, 1200.0, 802.0) scaled by 2.0x
   │ textDirection: ltr
   │
   └─SemanticsNode#2
     │ Rect.fromLTRB(0.0, 0.0, 1200.0, 802.0)
     │ sortKey: OrdinalSortKey#cda74(order: 0.0)
     │
     └─SemanticsNode#3
       │ Rect.fromLTRB(0.0, 0.0, 1200.0, 802.0)
       │ flags: scopesRoute
       │
       └─SemanticsNode#4
           Rect.fromLTRB(0.0, 0.0, 1200.0, 48.0)
           actions: didGainAccessibilityFocus, didLoseAccessibilityFocus,
             focus, tap
           flags: isTextField, hasEnabledState, isEnabled, hasRequiredState,
             isRequired
           textDirection: ltr
           text selection: [0, 0]
           currentValueLength: 0

HTML generated by Flutter Web:

<flutter-view flt-view-id="0" tabindex="0" style="...">
  <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"
                        style="...">
                    </flt-semantics>
                  </flt-semantics-container>
                </flt-semantics>
              </flt-semantics-container>
            </flt-semantics>
          </flt-semantics-container>
        </flt-semantics>
      </flt-semantics-container>
    </flt-semantics>
  </flt-semantics-host>
</flutter-view>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the responsibility for applying aria-required probably belongs in SemanticRole implementation classes: SemanticCheckable, SemanticIncrementable, SemanticTextField. Maybe also radiogroup? Not every role supports this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is not even on the div, it is probably that the semanticsRole doesn't use basic super constructor. You will need to manually add the behavior to those classes

Copy link
Member Author

@loic-sharma loic-sharma Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified the following:

  1. SemanticsCheckable - works as expected
  2. SemanticIncrementable - Do we have an incrementable control with optional values? The Material Slider.value is non-nullable, you cannot have a Slider without a value.
  3. SemanticsTextField - fixed!

I believe this is addressed, but please let me know if you have follow-up concerns!

@github-actions github-actions bot added the a: text input Entering text in a text field or keyboard related problems label Mar 6, 2025
@loic-sharma loic-sharma force-pushed the a11y_required branch 4 times, most recently from 26e3e08 to 7893e01 Compare March 11, 2025 14:33
@@ -640,6 +642,10 @@ abstract class SemanticRole {
addSemanticBehavior(Expandable(semanticsObject, this));
}

void addRequirableBehavior() {
addSemanticBehavior(Requirable(semanticsObject, this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance, can we add the behavior only if semanticsObject.isRequirable is true? Or do we expect that the value of isRequirable may change during the lifetime of a node? According to SemanticsConfiguration below, once isRequired is set, the node becomes requirable forever.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance, can we add the behavior only if semanticsObject.isRequirable is true? Or do we expect that the value of isRequirable may change during the lifetime of a node?

I expect the value of isRequirable can change during the lifetime of a node. For example, imagine I have a form that has a shipping address, a billing address, and a checkbox to use the shipping address as the billing address. If I uncheck the checkbox, the billing address becomes required.

According to SemanticsConfiguration below, once isRequired is set, the node becomes requirable forever.

That's correct. However, when a render object's semantics needs to update, the SemanticsConfiguration is thrown away and a new one is created:

void markNeedsUpdate() {
final SemanticsNode? producedSemanticsNode = cachedSemanticsNode;
// Dirty the semantics tree starting at `this` until we have reached a
// RenderObject that is a semantics boundary. All semantics past this
// RenderObject are still up-to date. Therefore, we will later only rebuild
// the semantics subtree starting at the identified semantics boundary.
final bool wasSemanticsBoundary =
producedSemanticsNode != null && configProvider.wasSemanticsBoundary;
configProvider.clear();

void clear() {
_isEffectiveConfigWritable = false;
_effectiveConfiguration = null;
_originalConfiguration = null;
}

/// The original config without any change through [updateConfig].
///
/// This is typically use to recalculate certain properties when mutating
/// [effective] since [effective] may contain stale data from previous update.
/// Examples are [SemanticsConfiguration.isBlockingUserActions] or
/// [SemanticsConfiguration.elevation]. Otherwise, use [effective] instead.
SemanticsConfiguration get original {
if (_originalConfiguration == null) {
_effectiveConfiguration = _originalConfiguration = SemanticsConfiguration();
_renderObject.describeSemanticsConfiguration(_originalConfiguration!);
assert(
!_originalConfiguration!.explicitChildNodes ||
_originalConfiguration!.childConfigurationsDelegate == null,
'A SemanticsConfiguration with explicitChildNode set to true cannot have a non-null childConfigsDelegate.',
);
}
return _originalConfiguration!;
}

SemanticsConfiguration mutation is used when its parent needs to update some information:

/// In some cases during [PipelineOwner.flushSemantics], the config has to be
/// mutated due to [_SemanticsParentData] update to propagate updated property
/// to semantics node. One should use [updateConfig] to update the config in this
/// case.

@chunhtai chunhtai self-requested a review March 12, 2025 16:15
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, once all comments are addressed

@loic-sharma loic-sharma added the autosubmit Merge PR when tree becomes green via auto submit App label Mar 14, 2025
@auto-submit auto-submit bot added this pull request to the merge queue Mar 14, 2025
Merged via the queue into flutter:master with commit f6f6030 Mar 14, 2025
170 of 171 checks passed
@flutter-dashboard flutter-dashboard bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Mar 14, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 15, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 15, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 17, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) a: tests "flutter test", flutter_test, or one of our tests a: text input Entering text in a text field or keyboard related problems engine flutter/engine repository. See also e: labels. framework flutter/packages/flutter repository. See also f: labels. platform-android Android applications specifically platform-web Web applications specifically
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants