From 5d341acdc52c5ce52ae720cdfb2bcac78bce047c Mon Sep 17 00:00:00 2001 From: Yegor Date: Mon, 21 Oct 2024 13:24:10 -0700 Subject: [PATCH] [web] implement selectable semantics (flutter/engine#55970) Implement `SemanticsFlag.hasSelectedState` and `SemanticsFlag.isSelected` for web in terms of `aria-selected`. Fixes https://github.com/flutter/flutter/issues/66673 --- .../lib/src/engine/semantics/checkable.dart | 30 ++++- .../lib/src/engine/semantics/heading.dart | 1 + .../lib/src/engine/semantics/image.dart | 1 + .../lib/src/engine/semantics/semantics.dart | 36 ++++++ .../test/engine/semantics/semantics_test.dart | 111 ++++++++++++++++++ .../engine/semantics/semantics_tester.dart | 4 + 6 files changed, 182 insertions(+), 1 deletion(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart index dcc6b6c42c..af136fb7f7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart @@ -48,7 +48,10 @@ _CheckableKind _checkableKindFromSemanticsFlag( /// /// See also [ui.SemanticsFlag.hasCheckedState], [ui.SemanticsFlag.isChecked], /// [ui.SemanticsFlag.isInMutuallyExclusiveGroup], [ui.SemanticsFlag.isToggled], -/// [ui.SemanticsFlag.hasToggledState] +/// [ui.SemanticsFlag.hasToggledState]. +/// +/// See also [Selectable] behavior, which expresses a similar but different +/// boolean state of being "selected". class SemanticCheckable extends SemanticRole { SemanticCheckable(SemanticsObject semanticsObject) : _kind = _checkableKindFromSemanticsFlag(semanticsObject), @@ -113,3 +116,28 @@ class SemanticCheckable extends SemanticRole { @override bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; } + +/// Adds selectability behavior to a semantic node. +/// +/// A selectable node would have the `aria-selected` set to "true" if the node +/// is currently selected (i.e. [SemanticsObject.isSelected] is true), and set +/// to "false" if it's not selected (i.e. [SemanticsObject.isSelected] is +/// false). If the node is not selectable (i.e. [SemanticsObject.isSelectable] +/// is false), then `aria-selected` is unset. +/// +/// See also [SemanticCheckable], which expresses a similar but different +/// boolean state of being "checked" or "toggled". +class Selectable extends SemanticBehavior { + Selectable(super.semanticsObject, super.owner); + + @override + void update() { + if (semanticsObject.isFlagsDirty) { + if (semanticsObject.isSelectable) { + owner.setAttribute('aria-selected', semanticsObject.isSelected); + } else { + owner.removeAttribute('aria-selected'); + } + } + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/heading.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/heading.dart index 2088bacd26..3f00837db3 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/heading.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/heading.dart @@ -15,6 +15,7 @@ class SemanticHeading extends SemanticRole { addLiveRegion(); addRouteName(); addLabelAndValue(preferredRepresentation: LabelRepresentation.domText); + addSelectableBehavior(); } @override diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart index 6e79f9050d..b058934343 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart @@ -21,6 +21,7 @@ class SemanticImage extends SemanticRole { addLiveRegion(); addRouteName(); addTappable(); + addSelectableBehavior(); } @override 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 37071e44ce..710a068b83 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 @@ -424,6 +424,7 @@ abstract class SemanticRole { addLiveRegion(); addRouteName(); addLabelAndValue(preferredRepresentation: preferredLabelRepresentation); + addSelectableBehavior(); } /// Initializes a blank role for a [semanticsObject]. @@ -569,6 +570,16 @@ abstract class SemanticRole { addSemanticBehavior(Tappable(semanticsObject, this)); } + /// Adds the [Selectable] behavior, if the node is selectable but not checkable. + void addSelectableBehavior() { + // Do not use the [Selectable] behavior on checkables. Checkables use + // special ARIA roles and `aria-checked`. Adding `aria-selected` in addition + // to `aria-checked` would be confusing. + if (semanticsObject.isSelectable && !semanticsObject.isCheckable) { + addSemanticBehavior(Selectable(semanticsObject, this)); + } + } + /// Adds a semantic behavior to this role. /// /// This method should be called by concrete implementations of @@ -1778,10 +1789,35 @@ class SemanticsObject { /// "hamburger" menu, etc. bool get isTappable => hasAction(ui.SemanticsAction.tap); + /// If true, this node represents something that can be in a "checked" or + /// "toggled" state, such as checkboxes, radios, and switches. + /// + /// Because such widgets require the use of specific ARIA roles and HTML + /// elements, they are managed by the [SemanticCheckable] role, and they do + /// not use the [Selectable] behavior. bool get isCheckable => hasFlag(ui.SemanticsFlag.hasCheckedState) || hasFlag(ui.SemanticsFlag.hasToggledState); + /// If true, this node represents something that can be annotated as + /// "selected", such as a tab, or an item in a list. + /// + /// Selectability is managed by `aria-selected` and is compatible with + /// multiple ARIA roles (tabs, gridcells, options, rows, etc). It is therefore + /// mapped onto the [Selectable] behavior. + /// + /// [Selectable] and [SemanticCheckable] are not used together on the same + /// node. [SemanticCheckable] has precendence over [Selectable]. + /// + /// See also: + /// + /// * [isSelected], which indicates whether the node is currently selected. + bool get isSelectable => hasFlag(ui.SemanticsFlag.hasSelectedState); + + /// If [isSelectable] is true, indicates whether the node is currently + /// selected. + bool get isSelected => hasFlag(ui.SemanticsFlag.isSelected); + /// Role-specific adjustment of the vertical position of the child container. /// /// This is used, for example, by the [SemanticScrollable] to compensate for the 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 363d9989a6..36c3483c92 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 @@ -78,6 +78,9 @@ void runSemanticsTests() { group('checkboxes, radio buttons and switches', () { _testCheckables(); }); + group('selectables', () { + _testSelectables(); + }); group('tappable', () { _testTappable(); }); @@ -2285,6 +2288,114 @@ void _testCheckables() { }); } +void _testSelectables() { + test('renders and updates non-selectable, selected, and unselected 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: [ + tester.updateNode( + id: 1, + isSelectable: false, + rect: const ui.Rect.fromLTRB(0, 0, 100, 20), + ), + tester.updateNode( + id: 2, + isSelectable: true, + isSelected: false, + rect: const ui.Rect.fromLTRB(0, 20, 100, 40), + ), + tester.updateNode( + id: 3, + isSelectable: true, + isSelected: true, + rect: const ui.Rect.fromLTRB(0, 40, 100, 60), + ), + ], + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + + + +'''); + + // Missing attributes cannot be expressed using HTML patterns, so check directly. + final nonSelectable = owner().debugSemanticsTree![1]!.element; + expect(nonSelectable.getAttribute('aria-selected'), isNull); + + // Flip the values and check that that ARIA attribute is updated. + tester.updateNode( + id: 2, + isSelectable: true, + isSelected: true, + rect: const ui.Rect.fromLTRB(0, 20, 100, 40), + ); + tester.updateNode( + id: 3, + isSelectable: true, + isSelected: false, + rect: const ui.Rect.fromLTRB(0, 40, 100, 60), + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + + + +'''); + + semantics().semanticsEnabled = false; + }); + + test('Checkable takes precedence over selectable', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + isSelectable: true, + isSelected: true, + hasCheckedState: true, + isChecked: true, + hasTap: true, + rect: const ui.Rect.fromLTRB(0, 0, 100, 60), + ); + tester.apply(); + + expectSemanticsTree( + owner(), + '', + ); + + final node = owner().debugSemanticsTree![0]!; + expect(node.semanticRole!.kind, SemanticRoleKind.checkable); + expect( + node.semanticRole!.debugSemanticBehaviorTypes, + isNot(contains(Selectable)), + ); + expect(node.element.getAttribute('aria-selected'), isNull); + + semantics().semanticsEnabled = false; + }); +} + void _testTappable() { test('renders an enabled tappable widget', () async { semantics() 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 d639b7f72b..f003a7b27f 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 @@ -32,6 +32,7 @@ class SemanticsTester { int flags = 0, bool? hasCheckedState, bool? isChecked, + bool? isSelectable, bool? isSelected, bool? isButton, bool? isLink, @@ -122,6 +123,9 @@ class SemanticsTester { if (isChecked ?? false) { flags |= ui.SemanticsFlag.isChecked.index; } + if (isSelectable ?? false) { + flags |= ui.SemanticsFlag.hasSelectedState.index; + } if (isSelected ?? false) { flags |= ui.SemanticsFlag.isSelected.index; }