From 6ac6d3ec0f4fdac87f2c783e2d603d960ec832e8 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:45:01 -0800 Subject: [PATCH] [web] Makes Tappable semantics behavior adaptive (#182167) 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. [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 --- .../lib/src/engine/semantics/checkable.dart | 5 +- .../lib/src/engine/semantics/scrollable.dart | 35 ++-- .../lib/src/engine/semantics/semantics.dart | 11 +- .../lib/src/engine/semantics/tappable.dart | 48 ++--- .../engine/semantics/scrollable_test.dart | 96 ++++++++++ .../engine/semantics/selectable_test.dart | 169 ++++++++++++++++++ .../test/engine/semantics/semantics_test.dart | 15 +- .../test/engine/semantics/tappable_test.dart | 154 ++++++++++++++++ 8 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/test/engine/semantics/scrollable_test.dart create mode 100644 engine/src/flutter/lib/web_ui/test/engine/semantics/selectable_test.dart create mode 100644 engine/src/flutter/lib/web_ui/test/engine/semantics/tappable_test.dart 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; + }); +}