Skip to content

Commit 2981516

Browse files
authored
Feat: Add a11y for loading indicators (#165173)
Feat: Add a11y for loading indicators fixes: #161631 ## 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.
1 parent 9c6fbe2 commit 2981516

27 files changed

Lines changed: 501 additions & 35 deletions

File tree

engine/src/flutter/lib/ui/fixtures/ui_test.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ void sendSemanticsUpdate() {
258258
controlsNodes: null,
259259
inputType: SemanticsInputType.none,
260260
locale: null,
261+
minValue: '0',
262+
maxValue: '0',
261263
);
262264
_semanticsUpdate(builder.build());
263265
}
@@ -319,6 +321,8 @@ void sendSemanticsUpdateWithRole() {
319321
controlsNodes: null,
320322
inputType: SemanticsInputType.none,
321323
locale: null,
324+
minValue: '0',
325+
maxValue: '0',
322326
);
323327
_semanticsUpdate(builder.build());
324328
}
@@ -380,6 +384,8 @@ void sendSemanticsUpdateWithLocale() {
380384
controlsNodes: null,
381385
inputType: SemanticsInputType.none,
382386
locale: Locale('es', 'MX'),
387+
minValue: '0',
388+
maxValue: '0',
383389
);
384390
_semanticsUpdate(builder.build());
385391
}
@@ -436,6 +442,8 @@ void sendSemanticsUpdateWithIsLink() {
436442
controlsNodes: null,
437443
inputType: SemanticsInputType.none,
438444
locale: Locale('es', 'MX'),
445+
minValue: '0',
446+
maxValue: '0',
439447
);
440448
_semanticsUpdate(builder.build());
441449
}

engine/src/flutter/lib/ui/semantics.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1975,6 +1975,8 @@ abstract class SemanticsUpdateBuilder {
19751975
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
19761976
required SemanticsInputType inputType,
19771977
required Locale? locale,
1978+
required String minValue,
1979+
required String maxValue,
19781980
});
19791981

19801982
/// Update the custom semantics action associated with the given `id`.
@@ -2056,6 +2058,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
20562058
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
20572059
required SemanticsInputType inputType,
20582060
required Locale? locale,
2061+
required String minValue,
2062+
required String maxValue,
20592063
}) {
20602064
assert(_matrix4IsValid(transform));
20612065
assert(
@@ -2107,6 +2111,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
21072111
hitTestBehavior.index,
21082112
inputType.index,
21092113
locale?.toLanguageTag() ?? '',
2114+
minValue,
2115+
maxValue,
21102116
);
21112117
}
21122118

@@ -2157,6 +2163,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
21572163
Int32,
21582164
Int32,
21592165
Handle,
2166+
Handle,
2167+
Handle,
21602168
)
21612169
>(symbol: 'SemanticsUpdateBuilder::updateNode')
21622170
external void _updateNode(
@@ -2204,6 +2212,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
22042212
int hitTestBehaviorIndex,
22052213
int inputType,
22062214
String locale,
2215+
String minValue,
2216+
String maxValue,
22072217
);
22082218

22092219
@override

engine/src/flutter/lib/ui/semantics/semantics_node.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ struct SemanticsNode {
145145
double scrollPosition = std::nan("");
146146
double scrollExtentMax = std::nan("");
147147
double scrollExtentMin = std::nan("");
148+
std::string minValue;
149+
std::string maxValue;
148150
std::string identifier;
149151
std::string label;
150152
StringAttributes labelAttributes;

engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ void SemanticsUpdateBuilder::updateNode(
7474
int validationResult,
7575
int hitTestBehavior,
7676
int inputType,
77-
std::string locale) {
77+
std::string locale,
78+
std::string minValue,
79+
std::string maxValue) {
7880
FML_CHECK(scrollChildren == 0 ||
7981
(scrollChildren > 0 && childrenInHitTestOrder.data()))
8082
<< "Semantics update contained scrollChildren but did not have "
@@ -96,6 +98,8 @@ void SemanticsUpdateBuilder::updateNode(
9698
node.scrollPosition = scrollPosition;
9799
node.scrollExtentMax = scrollExtentMax;
98100
node.scrollExtentMin = scrollExtentMin;
101+
node.minValue = std::move(minValue);
102+
node.maxValue = std::move(maxValue);
99103
node.rect = SkRect::MakeLTRB(SafeNarrow(left), SafeNarrow(top),
100104
SafeNarrow(right), SafeNarrow(bottom));
101105
node.identifier = std::move(identifier);

engine/src/flutter/lib/ui/semantics/semantics_update_builder.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ class SemanticsUpdateBuilder
7373
int validationResult,
7474
int hitTestBehavior,
7575
int inputType,
76-
std::string locale);
76+
std::string locale,
77+
std::string minValue,
78+
std::string maxValue);
7779

7880
void updateCustomAction(int id,
7981
std::string label,

engine/src/flutter/lib/web_ui/lib/semantics.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ class SemanticsUpdateBuilder {
753753
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
754754
required SemanticsInputType inputType,
755755
required Locale? locale,
756+
required String minValue,
757+
required String maxValue,
756758
}) {
757759
if (transform.length != 16) {
758760
throw ArgumentError('transform argument must have 16 entries.');
@@ -800,6 +802,8 @@ class SemanticsUpdateBuilder {
800802
hitTestBehavior: hitTestBehavior,
801803
inputType: inputType,
802804
locale: locale,
805+
minValue: minValue,
806+
maxValue: maxValue,
803807
),
804808
);
805809
}

engine/src/flutter/lib/web_ui/lib/src/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export 'engine/semantics/list.dart';
116116
export 'engine/semantics/live_region.dart';
117117
export 'engine/semantics/menus.dart';
118118
export 'engine/semantics/platform_view.dart';
119+
export 'engine/semantics/progress_bar.dart';
119120
export 'engine/semantics/requirable.dart';
120121
export 'engine/semantics/route.dart';
121122
export 'engine/semantics/scrollable.dart';

engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export 'semantics/list.dart';
2020
export 'semantics/live_region.dart';
2121
export 'semantics/menus.dart';
2222
export 'semantics/platform_view.dart';
23+
export 'semantics/progress_bar.dart';
2324
export 'semantics/requirable.dart';
2425
export 'semantics/scrollable.dart';
2526
export 'semantics/semantics.dart';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
import 'label_and_value.dart';
5+
import 'semantics.dart';
6+
7+
/// Indicates a progress bar element.
8+
///
9+
/// Uses aria progressbar role to convey this semantic information to the element.
10+
///
11+
/// Screen-readers take advantage of "aria-label" to describe the visual.
12+
class SemanticsProgressBar extends SemanticRole {
13+
SemanticsProgressBar(SemanticsObject semanticsObject)
14+
: super.withBasics(
15+
EngineSemanticsRole.progressBar,
16+
semanticsObject,
17+
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
18+
) {
19+
setAriaRole('progressbar');
20+
21+
// Set ARIA attributes for min, max and current value.
22+
if (semanticsObject.minValue != null) {
23+
setAttribute('aria-valuemin', semanticsObject.minValue!);
24+
}
25+
if (semanticsObject.maxValue != null) {
26+
setAttribute('aria-valuemax', semanticsObject.maxValue!);
27+
}
28+
29+
if (semanticsObject.value != null) {
30+
setAttribute('aria-valuenow', semanticsObject.value!);
31+
}
32+
}
33+
34+
@override
35+
void update() {
36+
super.update();
37+
38+
if (semanticsObject.minValue != null) {
39+
setAttribute('aria-valuemin', semanticsObject.minValue!);
40+
}
41+
42+
if (semanticsObject.maxValue != null) {
43+
setAttribute('aria-valuemax', semanticsObject.maxValue!);
44+
}
45+
46+
if (semanticsObject.value != null) {
47+
setAttribute('aria-valuenow', semanticsObject.value!);
48+
}
49+
}
50+
51+
@override
52+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
53+
}
54+
55+
/// Indicates a loading spinner element.
56+
class SementicsLoadingSpinner extends SemanticRole {
57+
SementicsLoadingSpinner(SemanticsObject semanticsObject)
58+
: super.withBasics(
59+
EngineSemanticsRole.loadingSpinner,
60+
semanticsObject,
61+
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
62+
);
63+
64+
@override
65+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
66+
}

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import 'list.dart';
3737
import 'live_region.dart';
3838
import 'menus.dart';
3939
import 'platform_view.dart';
40+
import 'progress_bar.dart';
4041
import 'requirable.dart';
4142
import 'route.dart';
4243
import 'scrollable.dart';
@@ -275,6 +276,8 @@ class SemanticsNodeUpdate {
275276
this.hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
276277
required this.inputType,
277278
required this.locale,
279+
required this.minValue,
280+
required this.maxValue,
278281
});
279282

280283
/// See [ui.SemanticsUpdateBuilder.updateNode].
@@ -399,6 +402,12 @@ class SemanticsNodeUpdate {
399402

400403
/// See [ui.SemanticsUpdateBuilder.updateNode].
401404
final ui.Locale? locale;
405+
406+
/// See [ui.SemanticsUpdateBuilder.updateNode].
407+
final String minValue;
408+
409+
/// See [ui.SemanticsUpdateBuilder.updateNode].
410+
final String maxValue;
402411
}
403412

404413
/// Identifies [SemanticRole] implementations.
@@ -503,6 +512,12 @@ enum EngineSemanticsRole {
503512
/// An item in a [list].
504513
listItem,
505514

515+
/// A graphic object that shows progress with a numeric number.
516+
progressBar,
517+
518+
/// A graphic object that spins to indicate the application is busy.
519+
loadingSpinner,
520+
506521
/// A role used when a more specific role cannot be assigend to
507522
/// a [SemanticsObject].
508523
///
@@ -1550,6 +1565,31 @@ class SemanticsObject {
15501565
_dirtyFields |= _hitTestBehaviorIndex;
15511566
}
15521567

1568+
String? get minValue => _minValue;
1569+
String? _minValue;
1570+
1571+
static const int _minValueIndex = 1 << 29;
1572+
1573+
/// Whether the [minValue] field has been updated but has not been
1574+
/// applied to the DOM yet.
1575+
bool get isMinValueDirty => _isDirty(_minValueIndex);
1576+
void _markMinValueDirty() {
1577+
_dirtyFields |= _minValueIndex;
1578+
}
1579+
1580+
/// See [ui.SemanticsUpdateBuilder.updateNode].
1581+
String? get maxValue => _maxValue;
1582+
String? _maxValue;
1583+
1584+
static const int _maxValueIndex = 1 << 30;
1585+
1586+
/// Whether the [maxValue] field has been updated but has not been
1587+
/// applied to the DOM yet.
1588+
bool get isMaxValueDirty => _isDirty(_maxValueIndex);
1589+
void _markMaxValueDirty() {
1590+
_dirtyFields |= _maxValueIndex;
1591+
}
1592+
15531593
/// A unique permanent identifier of the semantics node in the tree.
15541594
final int id;
15551595

@@ -1887,6 +1927,16 @@ class SemanticsObject {
18871927
_markHitTestBehaviorDirty();
18881928
}
18891929

1930+
if (_minValue != update.minValue) {
1931+
_minValue = update.minValue;
1932+
_markMinValueDirty();
1933+
}
1934+
1935+
if (_maxValue != update.maxValue) {
1936+
_maxValue = update.maxValue;
1937+
_markMaxValueDirty();
1938+
}
1939+
18901940
role = update.role;
18911941

18921942
inputType = update.inputType;
@@ -2139,14 +2189,16 @@ class SemanticsObject {
21392189
return EngineSemanticsRole.region;
21402190
case ui.SemanticsRole.form:
21412191
return EngineSemanticsRole.form;
2192+
case ui.SemanticsRole.loadingSpinner:
2193+
return EngineSemanticsRole.loadingSpinner;
2194+
case ui.SemanticsRole.progressBar:
2195+
return EngineSemanticsRole.progressBar;
21422196
// TODO(chunhtai): implement these roles.
21432197
// https://github.com/flutter/flutter/issues/159741.
21442198
case ui.SemanticsRole.dragHandle:
21452199
case ui.SemanticsRole.spinButton:
21462200
case ui.SemanticsRole.comboBox:
21472201
case ui.SemanticsRole.tooltip:
2148-
case ui.SemanticsRole.loadingSpinner:
2149-
case ui.SemanticsRole.progressBar:
21502202
case ui.SemanticsRole.hotKey:
21512203
case ui.SemanticsRole.none:
21522204
// fallback to checking semantics properties.
@@ -2213,6 +2265,8 @@ class SemanticsObject {
22132265
EngineSemanticsRole.menuItemRadio => SemanticMenuItemRadio(this),
22142266
EngineSemanticsRole.alert => SemanticAlert(this),
22152267
EngineSemanticsRole.status => SemanticStatus(this),
2268+
EngineSemanticsRole.progressBar => SemanticsProgressBar(this),
2269+
EngineSemanticsRole.loadingSpinner => SementicsLoadingSpinner(this),
22162270
EngineSemanticsRole.generic => GenericRole(this),
22172271
EngineSemanticsRole.complementary => SemanticComplementary(this),
22182272
EngineSemanticsRole.contentInfo => SemanticContentInfo(this),

0 commit comments

Comments
 (0)