From 423a30323c19cc302e3937b6dc367fe93b506ff0 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:43:24 -0800 Subject: [PATCH] Relands "Feat: Add a11y for loading indicators (#165173)" (#178402) This reverts commit ef29db350f0951ab976e2fdb5d092e65578329e5. reland https://github.com/flutter/flutter/pull/165173 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [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 --- .../src/flutter/lib/ui/fixtures/ui_test.dart | 8 + engine/src/flutter/lib/ui/semantics.dart | 10 ++ .../flutter/lib/ui/semantics/semantics_node.h | 2 + .../ui/semantics/semantics_update_builder.cc | 6 +- .../ui/semantics/semantics_update_builder.h | 4 +- .../src/flutter/lib/web_ui/lib/semantics.dart | 4 + .../flutter/lib/web_ui/lib/src/engine.dart | 1 + .../lib/web_ui/lib/src/engine/semantics.dart | 1 + .../src/engine/semantics/progress_bar.dart | 57 +++++++ .../lib/src/engine/semantics/semantics.dart | 58 ++++++- .../test/engine/semantics/semantics_test.dart | 59 +++++++ .../engine/semantics/semantics_tester.dart | 4 + .../runtime/fixtures/runtime_test.dart | 2 + .../platform/embedder/fixtures/main.dart | 12 ++ .../shell/platform/windows/fixtures/main.dart | 2 + .../lib/src/locale_initialization.dart | 4 + .../lib/src/material/progress_indicator.dart | 52 ++++--- .../lib/src/rendering/custom_paint.dart | 6 + .../flutter/lib/src/rendering/object.dart | 6 + .../flutter/lib/src/semantics/semantics.dart | 147 +++++++++++++++++- packages/flutter/lib/src/widgets/basic.dart | 8 + .../material/progress_indicator_test.dart | 29 ++-- .../test/semantics/semantics_test.dart | 8 +- .../test/semantics/semantics_update_test.dart | 2 + .../test/widgets/semantics_tester.dart | 21 +++ packages/flutter_test/lib/src/matchers.dart | 24 +++ packages/flutter_test/test/matchers_test.dart | 16 ++ 27 files changed, 512 insertions(+), 41 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart diff --git a/engine/src/flutter/lib/ui/fixtures/ui_test.dart b/engine/src/flutter/lib/ui/fixtures/ui_test.dart index ed9470da9c5..128d9fd2a57 100644 --- a/engine/src/flutter/lib/ui/fixtures/ui_test.dart +++ b/engine/src/flutter/lib/ui/fixtures/ui_test.dart @@ -298,6 +298,8 @@ void sendSemanticsUpdate() { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); _semanticsUpdate(builder.build()); } @@ -359,6 +361,8 @@ void sendSemanticsUpdateWithRole() { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); _semanticsUpdate(builder.build()); } @@ -420,6 +424,8 @@ void sendSemanticsUpdateWithLocale() { controlsNodes: null, inputType: SemanticsInputType.none, locale: Locale('es', 'MX'), + minValue: '0', + maxValue: '0', ); _semanticsUpdate(builder.build()); } @@ -476,6 +482,8 @@ void sendSemanticsUpdateWithIsLink() { controlsNodes: null, inputType: SemanticsInputType.none, locale: Locale('es', 'MX'), + minValue: '0', + maxValue: '0', ); _semanticsUpdate(builder.build()); } diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index 99dce73768b..3392ef8a099 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -1975,6 +1975,8 @@ abstract class SemanticsUpdateBuilder { SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer, required SemanticsInputType inputType, required Locale? locale, + required String minValue, + required String maxValue, }); /// Update the custom semantics action associated with the given `id`. @@ -2056,6 +2058,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer, required SemanticsInputType inputType, required Locale? locale, + required String minValue, + required String maxValue, }) { assert(_matrix4IsValid(transform)); assert( @@ -2107,6 +2111,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 hitTestBehavior.index, inputType.index, locale?.toLanguageTag() ?? '', + minValue, + maxValue, ); } @@ -2157,6 +2163,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Int32, Int32, Handle, + Handle, + Handle, ) >(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( @@ -2204,6 +2212,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 int hitTestBehaviorIndex, int inputType, String locale, + String minValue, + String maxValue, ); @override diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 6bbf7e0eb5c..1bdec7cade7 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -145,6 +145,8 @@ struct SemanticsNode { double scrollPosition = std::nan(""); double scrollExtentMax = std::nan(""); double scrollExtentMin = std::nan(""); + std::string minValue; + std::string maxValue; std::string identifier; std::string label; StringAttributes labelAttributes; diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc index a2cc6d65043..b23096cb6ac 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc @@ -74,7 +74,9 @@ void SemanticsUpdateBuilder::updateNode( int validationResult, int hitTestBehavior, int inputType, - std::string locale) { + std::string locale, + std::string minValue, + std::string maxValue) { FML_CHECK(scrollChildren == 0 || (scrollChildren > 0 && childrenInHitTestOrder.data())) << "Semantics update contained scrollChildren but did not have " @@ -96,6 +98,8 @@ void SemanticsUpdateBuilder::updateNode( node.scrollPosition = scrollPosition; node.scrollExtentMax = scrollExtentMax; node.scrollExtentMin = scrollExtentMin; + node.minValue = std::move(minValue); + node.maxValue = std::move(maxValue); node.rect = SkRect::MakeLTRB(SafeNarrow(left), SafeNarrow(top), SafeNarrow(right), SafeNarrow(bottom)); node.identifier = std::move(identifier); diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h index 14dfccaf7bd..e53b9c08b64 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h @@ -73,7 +73,9 @@ class SemanticsUpdateBuilder int validationResult, int hitTestBehavior, int inputType, - std::string locale); + std::string locale, + std::string minValue, + std::string maxValue); void updateCustomAction(int id, std::string label, diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 74352902430..395cbc49d69 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -753,6 +753,8 @@ class SemanticsUpdateBuilder { SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer, required SemanticsInputType inputType, required Locale? locale, + required String minValue, + required String maxValue, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -800,6 +802,8 @@ class SemanticsUpdateBuilder { hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, + minValue: minValue, + maxValue: maxValue, ), ); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index d56b9ec0602..1eac87647ff 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -116,6 +116,7 @@ export 'engine/semantics/list.dart'; export 'engine/semantics/live_region.dart'; export 'engine/semantics/menus.dart'; export 'engine/semantics/platform_view.dart'; +export 'engine/semantics/progress_bar.dart'; export 'engine/semantics/requirable.dart'; export 'engine/semantics/route.dart'; export 'engine/semantics/scrollable.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart index 600fc3dd6e4..ea30b9e21dd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart @@ -20,6 +20,7 @@ export 'semantics/list.dart'; export 'semantics/live_region.dart'; export 'semantics/menus.dart'; export 'semantics/platform_view.dart'; +export 'semantics/progress_bar.dart'; export 'semantics/requirable.dart'; export 'semantics/scrollable.dart'; export 'semantics/semantics.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart new file mode 100644 index 00000000000..5205b86e255 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart @@ -0,0 +1,57 @@ +// 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 'label_and_value.dart'; +import 'semantics.dart'; + +/// Indicates a progress bar element. +/// +/// Uses aria progressbar role to convey this semantic information to the element. +/// +/// Screen-readers take advantage of "aria-label" to describe the visual. +class SemanticsProgressBar extends SemanticRole { + SemanticsProgressBar(SemanticsObject semanticsObject) + : super.withBasics( + EngineSemanticsRole.progressBar, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ) { + setAriaRole('progressbar'); + _updateAriaAttributes(); + } + + void _updateAriaAttributes() { + // Set ARIA attributes for min, max and current value. + if (semanticsObject.minValue?.isNotEmpty ?? false) { + setAttribute('aria-valuemin', semanticsObject.minValue!); + } + if (semanticsObject.maxValue?.isNotEmpty ?? false) { + setAttribute('aria-valuemax', semanticsObject.maxValue!); + } + if (semanticsObject.value?.isNotEmpty ?? false) { + setAttribute('aria-valuenow', semanticsObject.value!); + } + } + + @override + void update() { + super.update(); + _updateAriaAttributes(); + } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; +} + +/// Indicates a loading spinner element. +class SemanticsLoadingSpinner extends SemanticRole { + SemanticsLoadingSpinner(SemanticsObject semanticsObject) + : super.withBasics( + EngineSemanticsRole.loadingSpinner, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ); + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 911b206b5c1..30fb16d95ac 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -37,6 +37,7 @@ import 'list.dart'; import 'live_region.dart'; import 'menus.dart'; import 'platform_view.dart'; +import 'progress_bar.dart'; import 'requirable.dart'; import 'route.dart'; import 'scrollable.dart'; @@ -275,6 +276,8 @@ class SemanticsNodeUpdate { this.hitTestBehavior = ui.SemanticsHitTestBehavior.defer, required this.inputType, required this.locale, + required this.minValue, + required this.maxValue, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -399,6 +402,12 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final ui.Locale? locale; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final String minValue; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final String maxValue; } /// Identifies [SemanticRole] implementations. @@ -503,6 +512,12 @@ enum EngineSemanticsRole { /// An item in a [list]. listItem, + /// A graphic object that shows progress with a numeric number. + progressBar, + + /// A graphic object that spins to indicate the application is busy. + loadingSpinner, + /// A role used when a more specific role cannot be assigend to /// a [SemanticsObject]. /// @@ -1550,6 +1565,31 @@ class SemanticsObject { _dirtyFields |= _hitTestBehaviorIndex; } + String? get minValue => _minValue; + String? _minValue; + + static const int _minValueIndex = 1 << 29; + + /// Whether the [minValue] field has been updated but has not been + /// applied to the DOM yet. + bool get isMinValueDirty => _isDirty(_minValueIndex); + void _markMinValueDirty() { + _dirtyFields |= _minValueIndex; + } + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + String? get maxValue => _maxValue; + String? _maxValue; + + static const int _maxValueIndex = 1 << 30; + + /// Whether the [maxValue] field has been updated but has not been + /// applied to the DOM yet. + bool get isMaxValueDirty => _isDirty(_maxValueIndex); + void _markMaxValueDirty() { + _dirtyFields |= _maxValueIndex; + } + /// A unique permanent identifier of the semantics node in the tree. final int id; @@ -1887,6 +1927,16 @@ class SemanticsObject { _markHitTestBehaviorDirty(); } + if (_minValue != update.minValue) { + _minValue = update.minValue; + _markMinValueDirty(); + } + + if (_maxValue != update.maxValue) { + _maxValue = update.maxValue; + _markMaxValueDirty(); + } + role = update.role; inputType = update.inputType; @@ -2139,14 +2189,16 @@ class SemanticsObject { return EngineSemanticsRole.region; case ui.SemanticsRole.form: return EngineSemanticsRole.form; + case ui.SemanticsRole.loadingSpinner: + return EngineSemanticsRole.loadingSpinner; + case ui.SemanticsRole.progressBar: + return EngineSemanticsRole.progressBar; // TODO(chunhtai): implement these roles. // https://github.com/flutter/flutter/issues/159741. case ui.SemanticsRole.dragHandle: case ui.SemanticsRole.spinButton: case ui.SemanticsRole.comboBox: case ui.SemanticsRole.tooltip: - case ui.SemanticsRole.loadingSpinner: - case ui.SemanticsRole.progressBar: case ui.SemanticsRole.hotKey: case ui.SemanticsRole.none: // fallback to checking semantics properties. @@ -2213,6 +2265,8 @@ class SemanticsObject { EngineSemanticsRole.menuItemRadio => SemanticMenuItemRadio(this), EngineSemanticsRole.alert => SemanticAlert(this), EngineSemanticsRole.status => SemanticStatus(this), + EngineSemanticsRole.progressBar => SemanticsProgressBar(this), + EngineSemanticsRole.loadingSpinner => SemanticsLoadingSpinner(this), EngineSemanticsRole.generic => GenericRole(this), EngineSemanticsRole.complementary => SemanticComplementary(this), EngineSemanticsRole.contentInfo => SemanticContentInfo(this), diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 6ec795148ea..2b655df58b1 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -160,6 +160,12 @@ void runSemanticsTests() { group('forms', () { _testForms(); }); + group('progressBar', () { + _testProgressBar(); + }); + group('loadingSpinner', () { + _testLoadingSpinner(); + }); } void _testSemanticRole() { @@ -6151,6 +6157,55 @@ void _testForms() { semantics().semanticsEnabled = false; } +void _testProgressBar() { + test('nodes with progress bar role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.progressBar, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.progressBar); + expect(object.element.getAttribute('role'), 'progressbar'); + }); + + semantics().semanticsEnabled = false; +} + +void _testLoadingSpinner() { + test('nodes with loading spinner role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.loadingSpinner, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.loadingSpinner); + }); + + semantics().semanticsEnabled = false; +} + /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that /// supplies default values for semantics attributes. void updateNode( @@ -6195,6 +6250,8 @@ void updateNode( ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer, ui.SemanticsInputType inputType = ui.SemanticsInputType.none, ui.Locale? locale, + String minValue = '0', + String maxValue = '0', }) { transform ??= Float64List.fromList(Matrix4.identity().storage); hitTestTransform ??= Float64List.fromList(Matrix4.identity().storage); @@ -6242,6 +6299,8 @@ void updateNode( hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, + minValue: minValue, + maxValue: maxValue, ); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index ed7fcae84c8..33612f9c411 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -92,6 +92,8 @@ class SemanticsTester { ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer, ui.SemanticsInputType inputType = ui.SemanticsInputType.none, ui.Locale? locale, + String? minValue, + String? maxValue, }) { // Actions if (hasTap ?? false) { @@ -228,6 +230,8 @@ class SemanticsTester { hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, + minValue: minValue ?? '0', + maxValue: maxValue ?? '0', ); _nodeUpdates.add(update); return update; diff --git a/engine/src/flutter/runtime/fixtures/runtime_test.dart b/engine/src/flutter/runtime/fixtures/runtime_test.dart index 309ff2ad214..3f2cbaf4319 100644 --- a/engine/src/flutter/runtime/fixtures/runtime_test.dart +++ b/engine/src/flutter/runtime/fixtures/runtime_test.dart @@ -316,6 +316,8 @@ void sendSemanticsUpdate() { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); _semanticsUpdate(builder.build()); } diff --git a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart index 67df7baa684..ea4f16371a5 100644 --- a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart @@ -200,6 +200,8 @@ Future a11y_main() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ) ..updateNode( id: 84, @@ -238,6 +240,8 @@ Future a11y_main() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ) ..updateNode( id: 96, @@ -276,6 +280,8 @@ Future a11y_main() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ) ..updateNode( id: 128, @@ -314,6 +320,8 @@ Future a11y_main() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ) ..updateCustomAction(id: 21, label: 'Archive', hint: 'archive message'); @@ -399,6 +407,8 @@ Future a11y_string_attributes() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); PlatformDispatcher.instance.setSemanticsTreeEnabled(true); @@ -1689,6 +1699,8 @@ Future a11y_main_multi_view() async { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); } diff --git a/engine/src/flutter/shell/platform/windows/fixtures/main.dart b/engine/src/flutter/shell/platform/windows/fixtures/main.dart index 290ccec4fd8..306e62afd9f 100644 --- a/engine/src/flutter/shell/platform/windows/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/windows/fixtures/main.dart @@ -477,6 +477,8 @@ Future sendSemanticsTreeInfo() async { controlsNodes: null, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); return builder.build(); } diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart index eb5556d80a1..d952c5ef8b2 100644 --- a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart @@ -77,6 +77,8 @@ class LocaleInitialization extends Scenario { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); @@ -139,6 +141,8 @@ class LocaleInitialization extends Scenario { controlsNodes: null, inputType: SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); diff --git a/packages/flutter/lib/src/material/progress_indicator.dart b/packages/flutter/lib/src/material/progress_indicator.dart index 5022cee5f83..a6f16c77d41 100644 --- a/packages/flutter/lib/src/material/progress_indicator.dart +++ b/packages/flutter/lib/src/material/progress_indicator.dart @@ -8,6 +8,7 @@ library; import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -143,11 +144,20 @@ abstract class ProgressIndicator extends StatefulWidget { } Widget _buildSemanticsWrapper({required BuildContext context, required Widget child}) { + var isProgressBar = false; String? expandedSemanticsValue = semanticsValue; if (value != null) { - expandedSemanticsValue ??= '${(_effectiveValue! * 100).round()}%'; + expandedSemanticsValue ??= '${(_effectiveValue! * 100).round()}'; + isProgressBar = true; } - return Semantics(label: semanticsLabel, value: expandedSemanticsValue, child: child); + return Semantics( + label: semanticsLabel, + role: isProgressBar ? SemanticsRole.progressBar : SemanticsRole.loadingSpinner, + minValue: isProgressBar ? '0' : null, + maxValue: isProgressBar ? '100' : null, + value: expandedSemanticsValue, + child: child, + ); } } @@ -1194,28 +1204,32 @@ class _CircularProgressIndicatorState extends State @override Widget build(BuildContext context) { - switch (widget._indicatorType) { - case _ActivityIndicatorType.material: - if (widget._effectiveValue != null) { - return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0); - } - return _buildAnimation(); - case _ActivityIndicatorType.adaptive: - final ThemeData theme = Theme.of(context); - switch (theme.platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - return _buildCupertinoIndicator(context); - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: + return Builder( + builder: (BuildContext context) { + switch (widget._indicatorType) { + case _ActivityIndicatorType.material: if (widget._effectiveValue != null) { return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0); } return _buildAnimation(); + case _ActivityIndicatorType.adaptive: + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _buildCupertinoIndicator(context); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (widget._effectiveValue != null) { + return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0); + } + return _buildAnimation(); + } } - } + }, + ); } } diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index d10dfe738a3..b2480822493 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -1049,6 +1049,12 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.inputType != null) { config.inputType = properties.inputType!; } + if (properties.minValue != null) { + config.minValue = properties.minValue; + } + if (properties.maxValue != null) { + config.maxValue = properties.maxValue; + } if (properties.onTap != null) { config.onTap = properties.onTap; } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 15da70fd1af..2a01de7f330 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -4945,6 +4945,12 @@ mixin SemanticsAnnotationsMixin on RenderObject { if (_properties.inputType != null) { config.inputType = _properties.inputType!; } + if (_properties.minValue != null) { + config.minValue = _properties.minValue; + } + if (_properties.maxValue != null) { + config.maxValue = _properties.maxValue; + } // Registering _perform* as action handlers instead of the user provided // ones to ensure that changing a user provided handler from a non-null to diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 0c545e4e49e..f6eac48cabd 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -183,14 +183,14 @@ sealed class _DebugSemanticsRoleChecks { SemanticsRole.navigation => _semanticsNavigation, SemanticsRole.region => _semanticsRegion, SemanticsRole.form => _noCheckRequired, + SemanticsRole.loadingSpinner => _noCheckRequired, + SemanticsRole.progressBar => _semanticsProgressBar, // TODO(chunhtai): add checks when the roles are used in framework. // https://github.com/flutter/flutter/issues/159741. SemanticsRole.dragHandle => _unimplemented, SemanticsRole.spinButton => _unimplemented, SemanticsRole.comboBox => _unimplemented, SemanticsRole.tooltip => _unimplemented, - SemanticsRole.loadingSpinner => _unimplemented, - SemanticsRole.progressBar => _unimplemented, SemanticsRole.hotKey => _unimplemented, }(node); @@ -206,6 +206,48 @@ sealed class _DebugSemanticsRoleChecks { static FlutterError? _noCheckRequired(SemanticsNode node) => null; + static FlutterError? _semanticsProgressBar(SemanticsNode node) { + final SemanticsData data = node.getSemanticsData(); + + // Check if value is present + if (data.value.isEmpty) { + return FlutterError('A progress bar must have a value'); + } + + // Check if minValue and maxValue are present + if (data.minValue?.isEmpty ?? true) { + return FlutterError('A progress bar must have a minValue'); + } + + if (data.maxValue?.isEmpty ?? true) { + return FlutterError('A progress bar must have a maxValue'); + } + + // Validate that value is within min and max range + try { + final double currentValue = double.parse(data.value); + final double minVal = double.parse(data.minValue!); + final double maxVal = double.parse(data.maxValue!); + + if (currentValue < minVal || currentValue > maxVal) { + return FlutterError( + 'Progress bar value ($currentValue) must be between minValue ($minVal) and maxValue ($maxVal)', + ); + } + + if (minVal >= maxVal) { + return FlutterError('Progress bar minValue ($minVal) must be less than maxValue ($maxVal)'); + } + } catch (e) { + return FlutterError( + 'Progress bar value, minValue, and maxValue must be valid numbers. ' + 'value: "${data.value}", minValue: "${data.minValue}", maxValue: "${data.maxValue}"', + ); + } + + return null; + } + static FlutterError? _semanticsTab(SemanticsNode node) { final SemanticsData data = node.getSemanticsData(); if (data.flagsCollection.isSelected == Tristate.none) { @@ -1024,6 +1066,8 @@ class SemanticsData with Diagnosticable { required this.hitTestBehavior, required this.inputType, required this.locale, + required this.minValue, + required this.maxValue, this.tags, this.transform, this.customSemanticsActionIds, @@ -1302,6 +1346,12 @@ class SemanticsData with Diagnosticable { /// content of this semantics node. final Locale? locale; + /// {@macro flutter.semantics.SemanticsProperties.maxValue} + final String? maxValue; + + /// {@macro flutter.semantics.SemanticsProperties.minValue} + final String? minValue; + /// Whether [flags] contains the given flag. @Deprecated( 'Use flagsCollection instead. ' @@ -1386,6 +1436,8 @@ class SemanticsData with Diagnosticable { ), ); } + properties.add(StringProperty('minValue', minValue, defaultValue: null)); + properties.add(StringProperty('maxValue', maxValue, defaultValue: null)); } @override @@ -1422,7 +1474,11 @@ class SemanticsData with Diagnosticable { other.inputType == inputType && other.hitTestBehavior == hitTestBehavior && _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && - setEquals(controlsNodes, other.controlsNodes); + setEquals(controlsNodes, other.controlsNodes) && + other.traversalParentIdentifier == traversalParentIdentifier && + other.traversalChildIdentifier == traversalChildIdentifier && + other.minValue == minValue && + other.maxValue == maxValue; } @override @@ -1458,6 +1514,10 @@ class SemanticsData with Diagnosticable { controlsNodes == null ? null : Object.hashAll(controlsNodes!), inputType, hitTestBehavior, + traversalParentIdentifier, + traversalChildIdentifier, + minValue, + maxValue, ), ); @@ -1641,6 +1701,8 @@ class SemanticsProperties extends DiagnosticableTree { this.onExpand, this.onCollapse, this.customSemanticsActions, + this.minValue, + this.maxValue, }) : assert( label == null || attributedLabel == null, 'Only one of label or attributedLabel should be provided', @@ -2579,6 +2641,28 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final SemanticsInputType? inputType; + /// {@template flutter.semantics.SemanticsProperties.maxValue} + /// The maximum value of the node. + /// + /// Used in conjunction with [value] to define the current value and range + /// of a node. A typical usage is for progress indicators, where [value] + /// represents the current progress and [maxValue] defines the maximum + /// possible value. + /// + /// {@endtemplate} + final String? maxValue; + + /// {@template flutter.semantics.SemanticsProperties.minValue} + /// The minimum value of the node. + /// + /// Used in conjunction with [value] to define the current value and range + /// of a node. A typical usage is for progress indicators, where [value] + /// represents the current progress and [minValue] defines the minimum + /// possible value. + /// + /// {@endtemplate} + final String? minValue; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -3249,7 +3333,11 @@ class SemanticsNode with DiagnosticableTreeMixin { _linkUrl != config._linkUrl || _role != config.role || _validationResult != config.validationResult || - _hitTestBehavior != config.hitTestBehavior; + _hitTestBehavior != config.hitTestBehavior || + _traversalChildIdentifier != config._traversalChildIdentifier || + _traversalParentIdentifier != config._traversalParentIdentifier || + _minValue != config._minValue || + _maxValue != config._maxValue; } // TAGS, LABELS, ACTIONS @@ -3539,6 +3627,14 @@ class SemanticsNode with DiagnosticableTreeMixin { Set? get controlsNodes => _controlsNodes; Set? _controlsNodes = _kEmptyConfig.controlsNodes; + /// {@macro flutter.semantics.SemanticsProperties.minValue} + String? get minValue => _minValue; + String? _minValue; + + /// {@macro flutter.semantics.SemanticsProperties.maxValue} + String? get maxValue => _maxValue; + String? _maxValue; + /// {@macro flutter.semantics.SemanticsProperties.validationResult} SemanticsValidationResult get validationResult => _validationResult; SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult; @@ -3631,6 +3727,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _inputType = config._inputType; _locale = config.locale; + _minValue = config.minValue; + _maxValue = config.maxValue; _replaceChildren(childrenInInversePaintOrder ?? const []); if (mergeAllDescendantsIntoThisNodeValueChanged) { @@ -3686,6 +3784,8 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsInputType inputType = _inputType; final Locale? locale = _locale; final customSemanticsActionIds = {}; + String? minValue = _minValue; + String? maxValue = _maxValue; for (final CustomSemanticsAction action in _customSemanticsActions.keys) { customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action)); } @@ -3795,6 +3895,9 @@ class SemanticsNode with DiagnosticableTreeMixin { controlsNodes = {...controlsNodes!, ...node._controlsNodes!}; } + minValue ??= node._minValue; + maxValue ??= node._maxValue; + if (validationResult == SemanticsValidationResult.none) { validationResult = node._validationResult; } else if (validationResult == SemanticsValidationResult.valid) { @@ -3844,6 +3947,8 @@ class SemanticsNode with DiagnosticableTreeMixin { hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, + minValue: minValue, + maxValue: maxValue, ); } @@ -4016,6 +4121,8 @@ class SemanticsNode with DiagnosticableTreeMixin { hitTestBehavior: data.hitTestBehavior, inputType: data.inputType, locale: data.locale, + minValue: data.minValue ?? '', + maxValue: data.maxValue ?? '', ); _dirty = false; } @@ -4308,6 +4415,8 @@ class SemanticsNode with DiagnosticableTreeMixin { ), ); } + properties.add(StringProperty('minValue', _minValue, defaultValue: null)); + properties.add(StringProperty('maxValue', _maxValue, defaultValue: null)); } /// Returns a string representation of this node and its descendants. @@ -6453,6 +6562,22 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.maxValue} + String? get maxValue => _maxValue; + String? _maxValue; + set maxValue(String? value) { + _maxValue = value; + _hasBeenAnnotated = true; + } + + /// {@macro flutter.semantics.SemanticsProperties.minValue} + String? get minValue => _minValue; + String? _minValue; + set minValue(String? value) { + _minValue = value; + _hasBeenAnnotated = true; + } + // TAGS /// The set of tags that this configuration wants to add to all child @@ -6560,6 +6685,12 @@ class SemanticsConfiguration { other._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { return false; } + if (_minValue != null && other._minValue != null) { + return false; + } + if (_maxValue != null && other._maxValue != null) { + return false; + } return true; } @@ -6669,6 +6800,8 @@ class SemanticsConfiguration { _accessiblityFocusBlockType = _accessiblityFocusBlockType._merge( child._accessiblityFocusBlockType, ); + _minValue ??= child._minValue; + _maxValue ??= child._maxValue; if (_hitTestBehavior == ui.SemanticsHitTestBehavior.defer && child._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { @@ -6721,7 +6854,11 @@ class SemanticsConfiguration { .._controlsNodes = _controlsNodes .._validationResult = _validationResult .._inputType = _inputType - .._hitTestBehavior = _hitTestBehavior; + .._hitTestBehavior = _hitTestBehavior + .._traversalChildIdentifier = _traversalChildIdentifier + .._traversalParentIdentifier = _traversalParentIdentifier + .._minValue = _minValue + .._maxValue = _maxValue; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 9fe39791139..66c7914b3a0 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4108,6 +4108,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required ui.SemanticsHitTestBehavior? hitTestBehavior, required ui.SemanticsInputType? inputType, required Locale? localeForSubtree, + required String? minValue, + required String? maxValue, }) : this.fromProperties( key: key, child: child, @@ -4193,6 +4195,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { validationResult: validationResult, hitTestBehavior: hitTestBehavior, inputType: inputType, + minValue: minValue, + maxValue: maxValue, ), ); @@ -4440,6 +4444,8 @@ class SliverSemantics extends _SemanticsBase { super.hitTestBehavior, super.inputType, super.localeForSubtree, + super.minValue, + super.maxValue, }) : super(child: sliver); /// {@macro flutter.widgets.SemanticsBase.fromProperties} @@ -8023,6 +8029,8 @@ class Semantics extends _SemanticsBase { super.hitTestBehavior, super.inputType, super.localeForSubtree, + super.minValue, + super.maxValue, }); /// {@macro flutter.widgets.SemanticsBase.fromProperties} diff --git a/packages/flutter/test/material/progress_indicator_test.dart b/packages/flutter/test/material/progress_indicator_test.dart index 4044d7b6b2a..15516fb27f9 100644 --- a/packages/flutter/test/material/progress_indicator_test.dart +++ b/packages/flutter/test/material/progress_indicator_test.dart @@ -378,7 +378,12 @@ void main() { expect( tester.getSemantics(find.byType(CircularProgressIndicator)), - matchesSemantics(value: '0%', textDirection: TextDirection.ltr), + matchesSemantics( + value: '0', + textDirection: TextDirection.ltr, + minValue: '0', + maxValue: '100', + ), ); handle.dispose(); }, @@ -936,7 +941,8 @@ void main() { final SemanticsHandle handle = tester.ensureSemantics(); final GlobalKey key = GlobalKey(); const label = 'Label'; - const value = '25%'; + const value = '25'; + await tester.pumpWidget( Theme( data: theme, @@ -978,7 +984,7 @@ void main() { expect( tester.getSemantics(find.byKey(key)), - matchesSemantics(textDirection: TextDirection.ltr, label: label, value: '25%'), + matchesSemantics(textDirection: TextDirection.ltr, label: label, value: '25'), ); handle.dispose(); @@ -1033,7 +1039,8 @@ void main() { final SemanticsHandle handle = tester.ensureSemantics(); final GlobalKey key = GlobalKey(); const label = 'Label'; - const value = '25%'; + const value = '25'; + await tester.pumpWidget( Theme( data: theme, @@ -1061,7 +1068,7 @@ void main() { final SemanticsHandle handle = tester.ensureSemantics(); final GlobalKey key = GlobalKey(); const label = 'Label'; - const value = '25%'; + const value = '25'; await tester.pumpWidget( Theme( data: theme, @@ -1995,7 +2002,7 @@ void main() { expect( tester.getSemantics(find.byType(LinearProgressIndicator)), - matchesSemantics(value: '100%', textDirection: TextDirection.ltr), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), ); // Test value < 0.0 @@ -2005,7 +2012,7 @@ void main() { expect( tester.getSemantics(find.byType(LinearProgressIndicator)), - matchesSemantics(value: '0%', textDirection: TextDirection.ltr), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), ); handle.dispose(); @@ -2021,7 +2028,7 @@ void main() { expect( tester.getSemantics(find.byType(CircularProgressIndicator)), - matchesSemantics(value: '100%', textDirection: TextDirection.ltr), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), ); // Test value < 0.0 @@ -2031,7 +2038,7 @@ void main() { expect( tester.getSemantics(find.byType(CircularProgressIndicator)), - matchesSemantics(value: '0%', textDirection: TextDirection.ltr), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), ); handle.dispose(); @@ -2047,7 +2054,7 @@ void main() { expect( tester.getSemantics(find.byType(RefreshProgressIndicator)), - matchesSemantics(value: '100%', textDirection: TextDirection.ltr), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), ); // Test value < 0.0 @@ -2057,7 +2064,7 @@ void main() { expect( tester.getSemantics(find.byType(RefreshProgressIndicator)), - matchesSemantics(value: '0%', textDirection: TextDirection.ltr), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), ); handle.dispose(); diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart index b34f74baf94..7c89b10c813 100644 --- a/packages/flutter/test/semantics/semantics_test.dart +++ b/packages/flutter/test/semantics/semantics_test.dart @@ -720,7 +720,9 @@ void main() { ' scrollPosition: null\n' ' scrollExtentMax: null\n' ' indexInParent: null\n' - ' headingLevel: 0\n', + ' headingLevel: 0\n' + ' minValue: null\n' + ' maxValue: null\n', ); final config = SemanticsConfiguration() @@ -868,7 +870,9 @@ void main() { ' scrollPosition: null\n' ' scrollExtentMax: null\n' ' indexInParent: null\n' - ' headingLevel: 0\n', + ' headingLevel: 0\n' + ' minValue: null\n' + ' maxValue: null\n', ); }); diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart index ec7c738d1eb..9399b5cb58d 100644 --- a/packages/flutter/test/semantics/semantics_update_test.dart +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -322,6 +322,8 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer, required ui.SemanticsInputType inputType, required ui.Locale? locale, + required String minValue, + required String maxValue, }) { // Makes sure we don't send the same id twice. assert(!observations.containsKey(id)); diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index f03a4ec9ad0..b144d0a50a9 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -726,6 +726,8 @@ class SemanticsTester { double? scrollExtentMin, int? currentValueLength, int? maxValueLength, + String? maxValue, + String? minValue, SemanticsNode? ancestor, SemanticsInputType? inputType, }) { @@ -823,6 +825,12 @@ class SemanticsTester { if (inputType != null && node.inputType != inputType) { return false; } + if (maxValue != null && node.maxValue != maxValue) { + return false; + } + if (minValue != null && node.minValue != minValue) { + return false; + } return true; } @@ -1133,6 +1141,8 @@ class _IncludesNodeWith extends Matcher { this.maxValueLength, this.currentValueLength, this.inputType, + this.minValue, + this.maxValue, }) : assert( label != null || value != null || @@ -1148,6 +1158,7 @@ class _IncludesNodeWith extends Matcher { maxValueLength != null || currentValueLength != null || inputType != null, + minValue != null || maxValue != null, ); final AttributedString? attributedLabel; final AttributedString? attributedValue; @@ -1168,6 +1179,8 @@ class _IncludesNodeWith extends Matcher { final int? currentValueLength; final int? maxValueLength; final SemanticsInputType? inputType; + final String? minValue; + final String? maxValue; @override bool matches(covariant SemanticsTester item, Map matchState) { @@ -1192,6 +1205,8 @@ class _IncludesNodeWith extends Matcher { currentValueLength: currentValueLength, maxValueLength: maxValueLength, inputType: inputType, + minValue: minValue, + maxValue: maxValue, ) .isNotEmpty; } @@ -1228,6 +1243,8 @@ class _IncludesNodeWith extends Matcher { if (currentValueLength != null) 'currentValueLength "$currentValueLength"', if (maxValueLength != null) 'maxValueLength "$maxValueLength"', if (inputType != null) 'inputType $inputType', + if (minValue != null) 'minValue "$minValue"', + if (maxValue != null) 'maxValue "$maxValue"', ]; return strings.join(', '); } @@ -1257,6 +1274,8 @@ Matcher includesNodeWith({ int? maxValueLength, int? currentValueLength, SemanticsInputType? inputType, + String? minValue, + String? maxValue, }) { return _IncludesNodeWith( label: label, @@ -1278,5 +1297,7 @@ Matcher includesNodeWith({ maxValueLength: maxValueLength, currentValueLength: currentValueLength, inputType: inputType, + minValue: minValue, + maxValue: maxValue, ); } diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 389d29c4a51..7faf4266b8b 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -687,6 +687,8 @@ Matcher matchesSemantics({ int? currentValueLength, SemanticsValidationResult validationResult = SemanticsValidationResult.none, ui.SemanticsInputType? inputType, + String? maxValue, + String? minValue, // Flags // bool hasCheckedState = false, bool isChecked = false, @@ -772,6 +774,8 @@ Matcher matchesSemantics({ currentValueLength: currentValueLength, validationResult: validationResult, inputType: inputType, + minValue: minValue, + maxValue: maxValue, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -887,6 +891,8 @@ Matcher containsSemantics({ int? currentValueLength, SemanticsValidationResult? validationResult, ui.SemanticsInputType? inputType, + String? maxValue, + String? minValue, // Flags bool? hasCheckedState, bool? isChecked, @@ -972,6 +978,8 @@ Matcher containsSemantics({ currentValueLength: currentValueLength, validationResult: validationResult, inputType: inputType, + minValue: minValue, + maxValue: maxValue, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -2404,6 +2412,8 @@ class _MatchesSemanticsData extends Matcher { required this.currentValueLength, required this.validationResult, required this.inputType, + required this.minValue, + required this.maxValue, // Flags required bool? hasCheckedState, required bool? isChecked, @@ -2551,6 +2561,8 @@ class _MatchesSemanticsData extends Matcher { final ui.SemanticsInputType? inputType; final List? children; final SemanticsValidationResult? validationResult; + final String? maxValue; + final String? minValue; /// There are three possible states for these two maps: /// @@ -2664,6 +2676,12 @@ class _MatchesSemanticsData extends Matcher { if (validationResult != null) { description.add(' with validation result: $validationResult'); } + if (minValue != null) { + description.add(' with minValue: $minValue'); + } + if (maxValue != null) { + description.add(' with maxValue: $maxValue'); + } if (children != null) { description.add(' with children:\n '); final List<_MatchesSemanticsData> childMatches = children!.cast<_MatchesSemanticsData>(); @@ -2823,6 +2841,12 @@ class _MatchesSemanticsData extends Matcher { if (inputType != null && inputType != data.inputType) { return failWithDescription(matchState, 'inputType was: ${data.inputType}'); } + if (minValue != null && minValue != data.minValue) { + return failWithDescription(matchState, 'minValue was: ${data.minValue}'); + } + if (maxValue != null && maxValue != data.maxValue) { + return failWithDescription(matchState, 'maxValue was: ${data.maxValue}'); + } if (actions.isNotEmpty) { final unexpectedActions = []; final missingActions = []; diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index c8eabeb8212..a1d6058b162 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -756,6 +756,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final node = _FakeSemanticsNode(data); @@ -948,6 +950,8 @@ void main() { inputType: ui.SemanticsInputType.none, locale: null, hitTestBehavior: ui.SemanticsHitTestBehavior.defer, + maxValue: '', + minValue: '', ); final node = _FakeSemanticsNode(data); @@ -1114,6 +1118,8 @@ void main() { inputType: ui.SemanticsInputType.none, locale: null, hitTestBehavior: ui.SemanticsHitTestBehavior.defer, + minValue: '', + maxValue: '', ); final node = _FakeSemanticsNode(data); @@ -1417,6 +1423,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final node = _FakeSemanticsNode(data); @@ -1520,6 +1528,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final node = _FakeSemanticsNode(data); @@ -1628,6 +1638,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final emptyNode = _FakeSemanticsNode(emptyData); @@ -1664,6 +1676,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final fullNode = _FakeSemanticsNode(fullData); @@ -1799,6 +1813,8 @@ void main() { hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, + minValue: '0', + maxValue: '0', ); final node = _FakeSemanticsNode(data);