[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:
parent
635ed5b778
commit
f6f6030b20
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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 =
|
||||
|
@ -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);
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
///
|
||||
|
@ -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() {
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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!;
|
||||
}
|
||||
|
@ -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.
|
||||
///
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user