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 e8b5ab81cb5..4f498dcc90b 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 @@ -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; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart index 124c5ff3b94..f587449c9bb 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart @@ -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'); 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 4fb6efd2112..55cfdec3b34 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 @@ -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 diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart index aac16f0808d..99beab21967 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart @@ -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(); } } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/scrollable_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/scrollable_test.dart new file mode 100644 index 00000000000..4cc5c0770db --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/scrollable_test.dart @@ -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 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(), ''); + 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(), + '', + ); + 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(), ''); + // 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()); + + semantics().semanticsEnabled = false; + }); +} diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/selectable_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/selectable_test.dart new file mode 100644 index 00000000000..615658addab --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/selectable_test.dart @@ -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 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(), ''); + 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(), ''); + 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(), ''); + + // 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(), ''); + 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(), ''); + 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(), ''); + 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(), ''); + expect(owner().semanticsHost.querySelector('[aria-current]'), isNull); + expect(owner().semanticsHost.querySelector('[aria-selected]'), isNull); + + semantics().semanticsEnabled = false; + }, + ); +} 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 d6eabfd6c14..c90e55be830 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 @@ -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; diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/tappable_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/tappable_test.dart new file mode 100644 index 00000000000..7076c5ec574 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/tappable_test.dart @@ -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 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(), ''); + + // 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(), ''); + 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(), ''); + 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(), ''); + + // 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(), ''); + 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(), ''); + 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(), ''); + expect(owner().semanticsHost.querySelector('[flt-tappable]'), isNotNull); + + semantics().semanticsEnabled = false; + }); +}