[web] Makes Tappable semantics behavior adaptive (#182167)

<!--
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
-->

fixes https://github.com/flutter/flutter/issues/175119

mainly change how tappable and other classes work so that it can self
shutoff when the semantics object change

## 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.

<!-- 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:
chunhtai 2026-02-12 16:45:01 -08:00 committed by GitHub
parent e1a890d577
commit 6ac6d3ec0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 485 additions and 48 deletions

View File

@ -157,8 +157,11 @@ class Selectable extends SemanticBehavior {
@override
void update() {
// 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.isFlagsDirty) {
if (semanticsObject.isSelectable) {
if (semanticsObject.isSelectable && !semanticsObject.isCheckable) {
final ui.SemanticsRole currentRole = semanticsObject.role;
final bool isSelected = semanticsObject.isSelected;

View File

@ -34,7 +34,7 @@ class SemanticScrollable extends SemanticRole {
/// content under this scrollable area. This element is sized based on the
/// total scroll extent calculated by scrollExtentMax - scrollExtentMin + rect.height
/// of the [SemanticsObject] managed by this scrollable.
final DomElement _scrollOverflowElement = createDomElement('flt-semantics-scroll-overflow');
DomElement? _scrollOverflowElement;
/// Listens to HTML "scroll" gestures detected by the browser.
///
@ -109,18 +109,24 @@ class SemanticScrollable extends SemanticRole {
// that may still experience overscroll issues when macOS inserts scrollbars
// into the application.
semanticsObject.element.style.scrollbarWidth = 'none';
_scrollOverflowElement.style
..position = 'absolute'
..transformOrigin = '0 0 0'
// Ignore pointer events since this is a dummy element.
..pointerEvents = 'none';
append(_scrollOverflowElement);
}
@override
void update() {
super.update();
if (!_canScroll) {
_cleanUp();
return;
}
if (_scrollOverflowElement == null) {
_scrollOverflowElement = createDomElement('flt-semantics-scroll-overflow');
_scrollOverflowElement!.style
..position = 'absolute'
..transformOrigin = '0 0 0'
// Ignore pointer events since this is a dummy element.
..pointerEvents = 'none';
append(_scrollOverflowElement!);
}
semanticsObject.owner.addOneTimePostUpdateCallback(() {
if (_canScroll) {
@ -197,7 +203,7 @@ class SemanticScrollable extends SemanticRole {
// and size it based on the total scroll extent so the browser
// knows how much scrollable content there is.
if (semanticsObject.isVerticalScrollContainer) {
_scrollOverflowElement.style
_scrollOverflowElement!.style
// The cross axis size should be non-zero so it is taken into
// account in the scrollable elements scrollHeight.
..width = '1px'
@ -206,7 +212,7 @@ class SemanticScrollable extends SemanticRole {
..verticalScrollAdjustment = element.scrollTop
..horizontalScrollAdjustment = 0.0;
} else if (semanticsObject.isHorizontalScrollContainer) {
_scrollOverflowElement.style
_scrollOverflowElement!.style
..width = '${scrollExtentTotal.toStringAsFixed(1)}px'
// The cross axis size should be non-zero so it is taken into
// account in the scrollable elements scrollHeight.
@ -215,7 +221,7 @@ class SemanticScrollable extends SemanticRole {
..verticalScrollAdjustment = 0.0
..horizontalScrollAdjustment = element.scrollLeft;
} else {
_scrollOverflowElement.style
_scrollOverflowElement!.style
..transform = 'translate(0px,0px)'
..width = '0px'
..height = '0px';
@ -257,8 +263,13 @@ class SemanticScrollable extends SemanticRole {
@override
void dispose() {
super.dispose();
_cleanUp();
}
void _cleanUp() {
_scrollOverflowElement?.remove();
_scrollOverflowElement = null;
final DomCSSStyleDeclaration style = element.style;
assert(_gestureModeListener != null);
style.removeProperty('overflowY');
style.removeProperty('overflowX');
style.removeProperty('touch-action');

View File

@ -848,12 +848,7 @@ abstract class SemanticRole {
/// 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));
}
addSemanticBehavior(Selectable(semanticsObject, this));
}
void addExpandableBehavior() {
@ -1066,9 +1061,7 @@ final class GenericRole extends SemanticRole {
// tappable. For example, the dismiss barrier of a pop-up menu is a tappable
// ancestor of the menu itself, while the menu may contain tappable
// children.
if (semanticsObject.isTappable) {
addTappable();
}
addTappable();
}
@override

View File

@ -43,45 +43,47 @@ class SemanticButton extends SemanticRole {
///
/// See also [ClickDebouncer].
class Tappable extends SemanticBehavior {
Tappable(super.semanticsObject, super.owner) {
_clickListener = createDomEventListener((DomEvent click) {
PointerBinding.clickDebouncer.onClick(click, viewId, semanticsObject.id, _isListening);
});
owner.element.addEventListener('click', _clickListener);
}
Tappable(super.semanticsObject, super.owner);
@override
bool get shouldAcceptPointerEvents => true;
bool get shouldAcceptPointerEvents => _isListening;
DomEventListener? _clickListener;
bool _isListening = false;
@override
void update() {
final bool wasListening = _isListening;
_isListening =
final bool shouldListen =
semanticsObject.enabledState() != EnabledState.disabled && semanticsObject.isTappable;
if (wasListening != _isListening) {
_updateAttribute();
if (_isListening == shouldListen) {
return;
}
if (shouldListen) {
_clickListener = createDomEventListener((DomEvent click) {
PointerBinding.clickDebouncer.onClick(click, viewId, semanticsObject.id, _isListening);
});
owner.element.addEventListener('click', _clickListener);
owner.element.setAttribute('flt-tappable', '');
_isListening = true;
} else {
_cleanUp();
}
}
void _updateAttribute() {
// The `flt-tappable` attribute marks the element for the ClickDebouncer to
// to know that it should debounce click events on this element. The
// contract is that the element that has this attribute is also the element
// that receives pointer and "click" events.
if (_isListening) {
owner.element.setAttribute('flt-tappable', '');
} else {
owner.element.removeAttribute('flt-tappable');
}
void _cleanUp() {
owner.element.removeEventListener('click', _clickListener);
owner.element.removeAttribute('flt-tappable');
_clickListener = null;
_isListening = false;
}
@override
void dispose() {
owner.removeEventListener('click', _clickListener);
_clickListener = null;
if (_isListening) {
_cleanUp();
}
super.dispose();
}
}

View File

@ -0,0 +1,96 @@
// 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 'dart:async';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../../common/test_initialization.dart';
import 'semantics_tester.dart';
DateTime _testTime = DateTime(2018, 12, 17);
EngineSemantics semantics() => EngineSemantics.instance;
EngineSemanticsOwner owner() => EnginePlatformDispatcher.instance.implicitView!.semantics;
void main() {
internalBootstrapBrowserTest(() {
return testMain;
});
}
Future<void> testMain() async {
await bootstrapAndRunApp(withImplicitView: true);
setUp(() {
EngineSemantics.debugResetSemantics();
});
test('SemanticScrollable transitions from non-scrollable to scrollable', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: Generic node, NOT scrollable
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(
isEnabled: ui.Tristate.isTrue,
// No scroll container flags
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 100),
);
tester.apply();
// Should NOT have flt-semantics-scroll-overflow
expectSemanticsTree(owner(), '<sem></sem>');
expect(owner().semanticsHost.querySelector('flt-semantics-scroll-overflow'), isNull);
// Make it Vertical Scrollable
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isEnabled: ui.Tristate.isTrue, hasImplicitScrolling: true),
scrollChildren: 10,
scrollIndex: 0,
scrollExtentMin: 0.0,
scrollExtentMax: 500.0,
scrollPosition: 0.0,
rect: const ui.Rect.fromLTRB(0, 0, 100, 100),
hasScrollUp: true,
hasScrollDown: true,
);
tester.apply();
// Now it should be a scrollable and have the overflow element.
expectSemanticsTree(
owner(),
'<sem role="group"><flt-semantics-scroll-overflow></flt-semantics-scroll-overflow></sem>',
);
expect(owner().semanticsHost.querySelector('flt-semantics-scroll-overflow'), isNotNull);
// Transition back to NON-scrollable provided (remove actions)
// BUT keep hasImplicitScrolling: true so it stays as SemanticScrollable role
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isEnabled: ui.Tristate.isTrue, hasImplicitScrolling: true),
rect: const ui.Rect.fromLTRB(0, 0, 100, 100),
// Implicitly actions = 0 (no scroll flags set)
);
tester.apply();
// Should still have role="group" (from SemanticScrollable)
expectSemanticsTree(owner(), '<sem role="group"></sem>');
// Should NOT have overflow element because it cannot scroll
expect(owner().semanticsHost.querySelector('flt-semantics-scroll-overflow'), isNull);
// Verify it is indeed SemanticScrollable
expect(tester.getSemanticsObject(0).semanticRole, isA<SemanticScrollable>());
semantics().semanticsEnabled = false;
});
}

View File

@ -0,0 +1,169 @@
// 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 'dart:async';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../../common/test_initialization.dart';
import 'semantics_tester.dart';
DateTime _testTime = DateTime(2018, 12, 17);
EngineSemantics semantics() => EngineSemantics.instance;
EngineSemanticsOwner owner() => EnginePlatformDispatcher.instance.implicitView!.semantics;
void main() {
internalBootstrapBrowserTest(() {
return testMain;
});
}
Future<void> testMain() async {
await bootstrapAndRunApp(withImplicitView: true);
setUp(() {
EngineSemantics.debugResetSemantics();
});
test(
'Selectable behavior adds/removes aria-current based on isSelectable flag for img role',
() async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: Image, NOT selectable
// SemanticImage adds Selectable behavior in constructor.
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isImage: true, isEnabled: ui.Tristate.isTrue),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should be an img
expectSemanticsTree(owner(), '<sem role="img"></sem>');
expect(owner().semanticsHost.querySelector('[aria-current]'), isNull);
// Make Selectable (hasSelectedState = true)
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(
isImage: true,
isEnabled: ui.Tristate.isTrue,
isSelected: ui.Tristate.isFalse, // implies hasSelectedState
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should now have aria-current="false"
expectSemanticsTree(owner(), '<sem role="img" aria-current="false"></sem>');
expect(owner().semanticsHost.querySelector('[aria-current]'), isNotNull);
// Select it
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(
isImage: true,
isEnabled: ui.Tristate.isTrue,
isSelected: ui.Tristate.isTrue, // implies hasSelectedState
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '<sem role="img" aria-current="true"></sem>');
// Make NOT Selectable again hiding "hasSelectedState" by setting isSelected to none
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(
isImage: true,
isEnabled: ui.Tristate.isTrue,
// hasSelectedState removed (default is Tristate.none)
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should remove aria-current
expectSemanticsTree(owner(), '<sem role="img"></sem>');
expect(owner().semanticsHost.querySelector('[aria-current]'), isNull);
semantics().semanticsEnabled = false;
},
);
test(
'Selectable behavior adds/removes aria-selected based on isSelectable flag for tab role',
() async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: Tab, Selectable but NOT selected
// SemanticTab adds Selectable behavior in constructor?
// Let's assume we can just use GenericRole or manually constructed node with role tab?
// Actually SemanticRole.tab exists?
// Let's check constructor or how to spawn it.
// UpdateNode with role: ui.SemanticsRole.tab should do it if mapping exists.
// The test helper `updateNode` takes `role`.
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tab,
flags: const ui.SemanticsFlags(
isEnabled: ui.Tristate.isTrue,
isSelected: ui.Tristate.isFalse, // implies hasSelectedState (selectable)
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should be a tab
expectSemanticsTree(owner(), '<sem role="tab" aria-selected="false"></sem>');
expect(owner().semanticsHost.querySelector('[aria-selected="false"]'), isNotNull);
// Select it
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tab,
flags: const ui.SemanticsFlags(
isEnabled: ui.Tristate.isTrue,
isSelected: ui.Tristate.isTrue,
),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should be selected
expectSemanticsTree(owner(), '<sem role="tab" aria-selected="true"></sem>');
expect(owner().semanticsHost.querySelector('[aria-selected="true"]'), isNotNull);
// Make NOT Selectable
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tab,
flags: const ui.SemanticsFlags(isEnabled: ui.Tristate.isTrue),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should remove aria-selected
expectSemanticsTree(owner(), '<sem role="tab"></sem>');
expect(owner().semanticsHost.querySelector('[aria-current]'), isNull);
expect(owner().semanticsHost.querySelector('[aria-selected]'), isNull);
semantics().semanticsEnabled = false;
},
);
}

View File

@ -1669,7 +1669,11 @@ void _testContainer() {
test('checkable leaf nodes accept pointer events', () async {
final builder = ui.SemanticsUpdateBuilder();
updateNode(builder, flags: const ui.SemanticsFlags(isChecked: ui.CheckedState.isFalse));
updateNode(
builder,
flags: const ui.SemanticsFlags(isChecked: ui.CheckedState.isFalse),
actions: ui.SemanticsAction.tap.index,
);
owner().updateSemantics(builder.build());
@ -1701,7 +1705,11 @@ void _testContainer() {
test('link leaf nodes accept pointer events', () async {
final builder = ui.SemanticsUpdateBuilder();
updateNode(builder, flags: const ui.SemanticsFlags(isLink: true));
updateNode(
builder,
flags: const ui.SemanticsFlags(isLink: true),
actions: ui.SemanticsAction.tap.index,
);
owner().updateSemantics(builder.build());
@ -2163,6 +2171,7 @@ void _testVerticalScrolling() {
updateNode(
builder,
flags: const ui.SemanticsFlags(hasImplicitScrolling: true),
actions: ui.SemanticsAction.scrollUp.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
);
@ -2187,6 +2196,7 @@ void _testVerticalScrolling() {
updateNode(
builder,
flags: const ui.SemanticsFlags(hasImplicitScrolling: true),
actions: ui.SemanticsAction.scrollLeft.index, // Only have a horizontal scroll action.
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
);
@ -3279,7 +3289,6 @@ void _testSelectables() {
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole!.kind, EngineSemanticsRole.checkable);
expect(node.semanticRole!.debugSemanticBehaviorTypes, isNot(contains(Selectable)));
expect(node.element.getAttribute('aria-selected'), isNull);
semantics().semanticsEnabled = false;

View File

@ -0,0 +1,154 @@
// 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 'dart:async';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../../common/test_initialization.dart';
import 'semantics_tester.dart';
DateTime _testTime = DateTime(2018, 12, 17);
EngineSemantics semantics() => EngineSemantics.instance;
EngineSemanticsOwner owner() => EnginePlatformDispatcher.instance.implicitView!.semantics;
void main() {
internalBootstrapBrowserTest(() {
return testMain;
});
}
Future<void> testMain() async {
await bootstrapAndRunApp(withImplicitView: true);
setUp(() {
EngineSemantics.debugResetSemantics();
});
test('Tappable adds and removes listener on update', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: Tappable and Enabled
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isButton: true, isEnabled: ui.Tristate.isTrue),
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '<sem role="button" flt-tappable></sem>');
// Scan for flt-tappable attribute in DOM
final DomElement? element0 = owner().semanticsHost.querySelector('[flt-tappable]');
expect(element0, isNotNull);
// DISABLED -> Should remove flt-tappable
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isButton: true, isEnabled: ui.Tristate.isFalse),
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// When disabled, Tappable behavior removes flt-tappable
expectSemanticsTree(owner(), '<sem role="button" aria-disabled="true"></sem>');
expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNull);
// ENABLED again -> Should add flt-tappable
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isButton: true, isEnabled: ui.Tristate.isTrue),
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '<sem role="button" flt-tappable></sem>');
expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNotNull);
semantics().semanticsEnabled = false;
});
test('Tappable behavior stops listening when not tappable even if enabled', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: Tappable and Enabled
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isButton: true, isEnabled: ui.Tristate.isTrue),
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '<sem role="button" flt-tappable></sem>');
// Make NOT Tappable (remove hasTap, but keep isButton? isButton usually implies tappable but technically hasTap is the action)
// Actually SemanticButton adds Tappable unconditionally now, but Tappable checks semanticsObject.isTappable.
// semanticsObject.isTappable returns true if it has any tap-related actions or flags.
// Let's remove hasTap action.
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isButton: true, isEnabled: ui.Tristate.isTrue),
hasTap: false, // No tap action
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should NOT have flt-tappable because isTappable should be false (assuming no other tappable flags/actions)
// Note: isButton might imply some accessibility role but Tappable behavior checks semanticsObject.isTappable.
expectSemanticsTree(owner(), '<sem role="button"></sem>');
expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNull);
semantics().semanticsEnabled = false;
});
test('GenericRole starts untappable and becomes tappable', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final tester = SemanticsTester(owner());
// Initial state: GenericRole (no specific role), Untappable
// Use ID 1 to avoid conflict with previous test if state leaks (though setUp resets semantics)
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isEnabled: ui.Tristate.isTrue),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should NOT have flt-tappable
expectSemanticsTree(owner(), '<sem></sem>');
expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNull);
// Update to be Tappable
tester.updateNode(
id: 0,
flags: const ui.SemanticsFlags(isEnabled: ui.Tristate.isTrue),
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
// Should NOW have flt-tappable
expectSemanticsTree(owner(), '<sem flt-tappable></sem>');
expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNotNull);
semantics().semanticsEnabled = false;
});
}