Skip to content

Commit e0b9869

Browse files
authored
Adds aria-controls support (#163894)
<!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> adding a new property in semantics properties called controlsVisibilityOfNodes, where developer can assign SemanticsProperties.identifier of other nodes to indicates which nodes' visibilities this node controls fixes flutter/flutter#162125 ## 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]. <!-- 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
1 parent 66e910d commit e0b9869

File tree

18 files changed

+365
-12
lines changed

18 files changed

+365
-12
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ void sendSemanticsUpdate() {
234234
additionalActions: additionalActions,
235235
headingLevel: 0,
236236
linkUrl: '',
237+
controlsNodes: null,
237238
);
238239
_semanticsUpdate(builder.build());
239240
}
@@ -287,6 +288,7 @@ void sendSemanticsUpdateWithRole() {
287288
headingLevel: 0,
288289
linkUrl: '',
289290
role: SemanticsRole.tab,
291+
controlsNodes: null,
290292
);
291293
_semanticsUpdate(builder.build());
292294
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,7 @@ abstract class SemanticsUpdateBuilder {
11291129
int headingLevel = 0,
11301130
String linkUrl = '',
11311131
SemanticsRole role = SemanticsRole.none,
1132+
required List<String>? controlsNodes,
11321133
});
11331134

11341135
/// Update the custom semantics action associated with the given `id`.
@@ -1205,6 +1206,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
12051206
int headingLevel = 0,
12061207
String linkUrl = '',
12071208
SemanticsRole role = SemanticsRole.none,
1209+
required List<String>? controlsNodes,
12081210
}) {
12091211
assert(_matrix4IsValid(transform));
12101212
assert(
@@ -1251,6 +1253,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
12511253
headingLevel,
12521254
linkUrl,
12531255
role.index,
1256+
controlsNodes,
12541257
);
12551258
}
12561259

@@ -1296,6 +1299,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
12961299
Int32,
12971300
Handle,
12981301
Int32,
1302+
Handle,
12991303
)
13001304
>(symbol: 'SemanticsUpdateBuilder::updateNode')
13011305
external void _updateNode(
@@ -1338,6 +1342,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
13381342
int headingLevel,
13391343
String linkUrl,
13401344
int role,
1345+
List<String>? controlsNodes,
13411346
);
13421347

13431348
@override

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ void SemanticsUpdateBuilder::updateNode(
6969
const tonic::Int32List& localContextActions,
7070
int headingLevel,
7171
std::string linkUrl,
72-
int role) {
72+
int role,
73+
const std::vector<std::string>& controlsNodes) {
7374
FML_CHECK(scrollChildren == 0 ||
7475
(scrollChildren > 0 && childrenInHitTestOrder.data()))
7576
<< "Semantics update contained scrollChildren but did not have "

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ class SemanticsUpdateBuilder
6868
const tonic::Int32List& customAccessibilityActions,
6969
int headingLevel,
7070
std::string linkUrl,
71-
int role);
71+
int role,
72+
const std::vector<std::string>& controlsNodes);
7273

7374
void updateCustomAction(int id,
7475
std::string label,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ class SemanticsUpdateBuilder {
371371
int headingLevel = 0,
372372
String? linkUrl,
373373
SemanticsRole role = SemanticsRole.none,
374+
required List<String>? controlsNodes,
374375
}) {
375376
if (transform.length != 16) {
376377
throw ArgumentError('transform argument must have 16 entries.');
@@ -413,6 +414,7 @@ class SemanticsUpdateBuilder {
413414
headingLevel: headingLevel,
414415
linkUrl: linkUrl,
415416
role: role,
417+
controlsNodes: controlsNodes,
416418
),
417419
);
418420
}

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

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
6969

7070
@override
7171
String toString() {
72-
final List<String> features = <String>[];
72+
final features = <String>[];
7373
if (accessibleNavigation) {
7474
features.add('accessibleNavigation');
7575
}
@@ -239,6 +239,7 @@ class SemanticsNodeUpdate {
239239
required this.headingLevel,
240240
this.linkUrl,
241241
required this.role,
242+
required this.controlsNodes,
242243
});
243244

244245
/// See [ui.SemanticsUpdateBuilder.updateNode].
@@ -348,6 +349,9 @@ class SemanticsNodeUpdate {
348349

349350
/// See [ui.SemanticsUpdateBuilder.updateNode].
350351
final ui.SemanticsRole role;
352+
353+
/// See [ui.SemanticsUpdateBuilder.updateNode].
354+
final List<String>? controlsNodes;
351355
}
352356

353357
/// Identifies [SemanticRole] implementations.
@@ -672,6 +676,10 @@ abstract class SemanticRole {
672676
if (semanticsObject.isIdentifierDirty) {
673677
_updateIdentifier();
674678
}
679+
680+
if (semanticsObject.isControlsNodesDirty) {
681+
_updateControls();
682+
}
675683
}
676684

677685
void _updateIdentifier() {
@@ -682,6 +690,26 @@ abstract class SemanticRole {
682690
}
683691
}
684692

693+
void _updateControls() {
694+
if (semanticsObject.hasControlsNodes) {
695+
semanticsObject.owner.addOneTimePostUpdateCallback(() {
696+
final elementIds = <String>[];
697+
for (final String identifier in semanticsObject.controlsNodes!) {
698+
final int? semanticNodeId = semanticsObject.owner.identifiersToIds[identifier];
699+
if (semanticNodeId == null) {
700+
continue;
701+
}
702+
elementIds.add('flt-semantic-node-$semanticNodeId');
703+
}
704+
if (elementIds.isNotEmpty) {
705+
setAttribute('aria-controls', elementIds.join(' '));
706+
return;
707+
}
708+
});
709+
}
710+
removeAttribute('aria-controls');
711+
}
712+
685713
/// Whether this role was disposed of.
686714
bool get isDisposed => _isDisposed;
687715
bool _isDisposed = false;
@@ -1277,6 +1305,23 @@ class SemanticsObject {
12771305
/// The role of this node.
12781306
late ui.SemanticsRole role;
12791307

1308+
/// List of nodes whose contents are controlled by this node.
1309+
///
1310+
/// The list contains [identifier]s of those nodes.
1311+
List<String>? controlsNodes;
1312+
1313+
/// Whether this object controls at least one node.
1314+
bool get hasControlsNodes => controlsNodes != null && controlsNodes!.isNotEmpty;
1315+
1316+
static const int _controlsNodesIndex = 1 << 27;
1317+
1318+
/// Whether the [controlsNodes] field has been updated but has not been
1319+
/// applied to the DOM yet.
1320+
bool get isControlsNodesDirty => _isDirty(_controlsNodesIndex);
1321+
void _markControlsNodesDirty() {
1322+
_dirtyFields |= _controlsNodesIndex;
1323+
}
1324+
12801325
/// Bitfield showing which fields have been updated but have not yet been
12811326
/// applied to the DOM.
12821327
///
@@ -1423,7 +1468,13 @@ class SemanticsObject {
14231468
}
14241469

14251470
if (_identifier != update.identifier) {
1471+
if (_identifier?.isNotEmpty ?? false) {
1472+
owner.identifiersToIds.remove(_identifier);
1473+
}
14261474
_identifier = update.identifier;
1475+
if (_identifier?.isNotEmpty ?? false) {
1476+
owner.identifiersToIds[_identifier!] = id;
1477+
}
14271478
_markIdentifierDirty();
14281479
}
14291480

@@ -1569,6 +1620,11 @@ class SemanticsObject {
15691620

15701621
role = update.role;
15711622

1623+
if (!unorderedListEqual<String>(controlsNodes, update.controlsNodes)) {
1624+
controlsNodes = update.controlsNodes;
1625+
_markControlsNodesDirty();
1626+
}
1627+
15721628
// Apply updates to the DOM.
15731629
_updateRole();
15741630

@@ -1635,7 +1691,7 @@ class SemanticsObject {
16351691

16361692
// Always render in traversal order, because the accessibility traversal
16371693
// is determined by the DOM order of elements.
1638-
final List<SemanticsObject> childrenInRenderOrder = <SemanticsObject>[];
1694+
final childrenInRenderOrder = <SemanticsObject>[];
16391695
for (int i = 0; i < childCount; i++) {
16401696
childrenInRenderOrder.add(owner._semanticsTree[childrenInTraversalOrder[i]]!);
16411697
}
@@ -1669,7 +1725,7 @@ class SemanticsObject {
16691725
}
16701726

16711727
// At this point it is guaranteed to have had a non-empty previous child list.
1672-
final List<SemanticsObject> previousChildrenInRenderOrder = _currentChildrenInRenderOrder!;
1728+
final previousChildrenInRenderOrder = _currentChildrenInRenderOrder!;
16731729
final int previousCount = previousChildrenInRenderOrder.length;
16741730

16751731
// Both non-empty case.
@@ -1690,7 +1746,7 @@ class SemanticsObject {
16901746

16911747
// Indices into the old child list pointing at children that also exist in
16921748
// the new child list.
1693-
final List<int> intersectionIndicesOld = <int>[];
1749+
final intersectionIndicesOld = <int>[];
16941750

16951751
int newIndex = 0;
16961752

@@ -1724,7 +1780,7 @@ class SemanticsObject {
17241780
// The longest sub-sequence in the old list maximizes the number of children
17251781
// that do not need to be moved.
17261782
final List<int?> longestSequence = longestIncreasingSubsequence(intersectionIndicesOld);
1727-
final List<int> stationaryIds = <int>[];
1783+
final stationaryIds = <int>[];
17281784
for (int i = 0; i < longestSequence.length; i += 1) {
17291785
stationaryIds.add(
17301786
previousChildrenInRenderOrder[intersectionIndicesOld[longestSequence[i]!]].id,
@@ -2551,6 +2607,7 @@ class EngineSemanticsOwner {
25512607
SemanticsUpdatePhase _phase = SemanticsUpdatePhase.idle;
25522608

25532609
final Map<int, SemanticsObject> _semanticsTree = <int, SemanticsObject>{};
2610+
final Map<String, int> identifiersToIds = <String, int>{};
25542611

25552612
/// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to
25562613
/// this frame.
@@ -2851,8 +2908,8 @@ AFTER: $description
28512908
/// Complexity: n*log(n)
28522909
List<int> longestIncreasingSubsequence(List<int> list) {
28532910
final int len = list.length;
2854-
final List<int> predecessors = <int>[];
2855-
final List<int> mins = <int>[0];
2911+
final predecessors = <int>[];
2912+
final mins = <int>[0];
28562913
int longest = 0;
28572914
for (int i = 0; i < len; i++) {
28582915
// Binary search for the largest positive `j ≤ longest`
@@ -2885,7 +2942,7 @@ List<int> longestIncreasingSubsequence(List<int> list) {
28852942
}
28862943
}
28872944
// Reconstruct the longest subsequence
2888-
final List<int> seq = List<int>.filled(longest, 0);
2945+
final seq = List<int>.filled(longest, 0);
28892946
int k = mins[longest];
28902947
for (int i = longest - 1; i >= 0; i--) {
28912948
seq[i] = k;

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,57 @@ bool listEquals<T>(List<T>? a, List<T>? b) {
551551
return true;
552552
}
553553

554+
/// Determines if lists [a] and [b] are deep equivalent, regardless of their
555+
/// order.
556+
///
557+
/// Returns true if the lists are both null, or if they are both non-null, have
558+
/// the same length, and contain the same elements regardless of their order.
559+
/// Returns false otherwise.
560+
bool unorderedListEqual<T>(List<T>? a, List<T>? b) {
561+
if (a == b) {
562+
return true;
563+
}
564+
if ((a?.isEmpty ?? true) && (b?.isEmpty ?? true)) {
565+
return true;
566+
}
567+
568+
if ((a == null) != (b == null)) {
569+
return false;
570+
}
571+
// They most both be non-null now, and at least one of them is not empty.
572+
if (a!.length != b!.length) {
573+
return false;
574+
}
575+
576+
if (a.length == 1) {
577+
return a.first == b.first;
578+
}
579+
580+
if (a.length == 2) {
581+
return (a.first == b.first && a.last == b.last) || (a.last == b.first && a.first == b.last);
582+
}
583+
584+
// Complex cases.
585+
final Map<T, int> wordCounts = <T, int>{};
586+
for (final T word in a) {
587+
final int count = wordCounts[word] ?? 0;
588+
wordCounts[word] = count + 1;
589+
}
590+
591+
for (final T otherWord in b) {
592+
final int? count = wordCounts[otherWord];
593+
if (count == null || count == 0) {
594+
return false;
595+
}
596+
if (count == 1) {
597+
wordCounts.remove(otherWord);
598+
} else {
599+
wordCounts[otherWord] = count - 1;
600+
}
601+
}
602+
return wordCounts.isEmpty;
603+
}
604+
554605
// HTML only supports a single radius, but Flutter ImageFilter supports separate
555606
// horizontal and vertical radii. The best approximation we can provide is to
556607
// average the two radii together for a single compromise value.

0 commit comments

Comments
 (0)