Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit dc326f0

Browse files
committed
reland click disambiguation with fix for nested tappables
1 parent a64b0ac commit dc326f0

File tree

6 files changed

+123
-25
lines changed

6 files changed

+123
-25
lines changed

lib/web_ui/lib/src/engine/pointer_binding.dart

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,7 @@ class ClickDebouncer {
272272
// recently and if the node is currently listening to event, forward to
273273
// the framework.
274274
if (isListening && _shouldSendClickEventToFramework(click)) {
275-
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
276-
semanticsNodeId, ui.SemanticsAction.tap, null);
275+
_sendSemanticsTapToFramework(click, semanticsNodeId);
277276
}
278277
return;
279278
}
@@ -285,8 +284,7 @@ class ClickDebouncer {
285284
final DebounceState state = _state!;
286285
_state = null;
287286
state.timer.cancel();
288-
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
289-
semanticsNodeId, ui.SemanticsAction.tap, null);
287+
_sendSemanticsTapToFramework(click, semanticsNodeId);
290288
} else {
291289
// The semantic node is not listening to taps. Flush the pointer events
292290
// for the framework to figure out what to do with them. It's possible
@@ -295,6 +293,20 @@ class ClickDebouncer {
295293
}
296294
}
297295

296+
void _sendSemanticsTapToFramework(DomEvent click, int semanticsNodeId) {
297+
// Tappable nodes can be nested inside other tappable nodes. If a click
298+
// lands on an inner element and is allowed to propagate, it will also
299+
// land on the ancestor tappable, leading to both the descendant and the
300+
// ancestor sending SemanticsAction.tap to the framework, creating a double
301+
// tap/click, which is wrong. More details:
302+
//
303+
// https://github.com/flutter/flutter/issues/134842
304+
click.stopPropagation();
305+
306+
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
307+
semanticsNodeId, ui.SemanticsAction.tap, null);
308+
}
309+
298310
void _startDebouncing(DomEvent event, List<ui.PointerData> data) {
299311
assert(
300312
_state == null,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ abstract class PrimaryRoleManager {
427427
/// Initializes a role for a [semanticsObject] that includes basic
428428
/// functionality for focus, labels, live regions, and route names.
429429
PrimaryRoleManager.withBasics(this.role, this.semanticsObject) {
430+
element = _initElement(createElement(), semanticsObject);
430431
addFocusManagement();
431432
addLiveRegion();
432433
addRouteName();
@@ -439,9 +440,11 @@ abstract class PrimaryRoleManager {
439440
/// Use this constructor for highly specialized cases where
440441
/// [RoleManager.withBasics] does not work, for example when the default focus
441442
/// management intereferes with the widget's functionality.
442-
PrimaryRoleManager.blank(this.role, this.semanticsObject);
443+
PrimaryRoleManager.blank(this.role, this.semanticsObject) {
444+
element = _initElement(createElement(), semanticsObject);
445+
}
443446

444-
late final DomElement element = _initElement(createElement(), semanticsObject);
447+
late final DomElement element;
445448

446449
/// The primary role identifier.
447450
final PrimaryRole role;

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class Tappable extends RoleManager {
3939
_isListening,
4040
);
4141
});
42-
semanticsObject.element.addEventListener('click', _clickListener);
42+
owner.element.addEventListener('click', _clickListener);
4343
}
4444

4545
DomEventListener? _clickListener;
@@ -60,9 +60,9 @@ class Tappable extends RoleManager {
6060
// contract is that the element that has this attribute is also the element
6161
// that receives pointer and "click" events.
6262
if (_isListening) {
63-
semanticsObject.element.setAttribute('flt-tappable', '');
63+
owner.element.setAttribute('flt-tappable', '');
6464
} else {
65-
semanticsObject.element.removeAttribute('flt-tappable');
65+
owner.element.removeAttribute('flt-tappable');
6666
}
6767
}
6868

lib/web_ui/test/common/matchers.dart

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -227,23 +227,23 @@ enum HtmlComparisonMode {
227227

228228
/// Rewrites [htmlContent] by removing irrelevant style attributes.
229229
///
230-
/// If [throwOnUnusedAttributes] is `true`, throws instead of rewriting. Set
231-
/// [throwOnUnusedAttributes] to `true` to check that expected HTML strings do
230+
/// If [throwOnUnusedStyleProperties] is `true`, throws instead of rewriting. Set
231+
/// [throwOnUnusedStyleProperties] to `true` to check that expected HTML strings do
232232
/// not contain irrelevant attributes. It is ok for actual HTML to contain all
233233
/// kinds of attributes. They only need to be filtered out before testing.
234234
String canonicalizeHtml(
235235
String htmlContent, {
236236
HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly,
237-
bool throwOnUnusedAttributes = false,
238-
List<String>? ignoredAttributes,
237+
bool throwOnUnusedStyleProperties = false,
238+
List<String>? ignoredStyleProperties,
239239
}) {
240240
if (htmlContent.trim().isEmpty) {
241241
return '';
242242
}
243243

244-
String? unusedAttribute(String name) {
245-
if (throwOnUnusedAttributes) {
246-
fail('Provided HTML contains style attribute "$name" which '
244+
String? unusedStyleProperty(String name) {
245+
if (throwOnUnusedStyleProperties) {
246+
fail('Provided HTML contains style property "$name" which '
247247
'is not used for comparison in the test. The HTML was:\n\n$htmlContent');
248248
}
249249

@@ -329,7 +329,7 @@ String canonicalizeHtml(
329329
if (parts.length == 2) {
330330
final String name = parts.first;
331331

332-
if (ignoredAttributes != null && ignoredAttributes.contains(name)) {
332+
if (ignoredStyleProperties != null && ignoredStyleProperties.contains(name)) {
333333
return null;
334334
}
335335

@@ -343,7 +343,7 @@ String canonicalizeHtml(
343343
].contains(name);
344344

345345
if (isStaticAttribute) {
346-
return unusedAttribute(name);
346+
return unusedStyleProperty(name);
347347
}
348348

349349
// Whether the attribute is set by the layout system.
@@ -363,7 +363,7 @@ String canonicalizeHtml(
363363

364364
if (forLayout && !isLayoutAttribute ||
365365
!forLayout && isLayoutAttribute) {
366-
return unusedAttribute(name);
366+
return unusedStyleProperty(name);
367367
}
368368
}
369369
}
@@ -378,7 +378,7 @@ String canonicalizeHtml(
378378
replacement.attributes['style'] = processedAttributes;
379379
}
380380
}
381-
} else if (throwOnUnusedAttributes && original.attributes.isNotEmpty) {
381+
} else if (throwOnUnusedStyleProperties && original.attributes.isNotEmpty) {
382382
fail('Provided HTML contains attributes. However, the comparison mode '
383383
'is $mode. The HTML was:\n\n$htmlContent');
384384
}
@@ -414,7 +414,7 @@ String canonicalizeHtml(
414414
void expectHtml(DomElement element, String expectedHtml,
415415
{HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly}) {
416416
expectedHtml =
417-
canonicalizeHtml(expectedHtml, mode: mode, throwOnUnusedAttributes: true);
417+
canonicalizeHtml(expectedHtml, mode: mode, throwOnUnusedStyleProperties: true);
418418
final String actualHtml = canonicalizeHtml(element.outerHTML!, mode: mode);
419419
expect(actualHtml, expectedHtml);
420420
}

lib/web_ui/test/engine/semantics/semantics_test.dart

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ void _testEngineSemanticsOwner() {
428428
expectSemanticsTree('''
429429
<sem style="$rootSemanticStyle">
430430
<sem-c>
431-
<sem aria-label="Hello"></sem>
431+
<sem aria-label="Hello" role="text"></sem>
432432
</sem-c>
433433
</sem>''');
434434

@@ -443,7 +443,7 @@ void _testEngineSemanticsOwner() {
443443
expectSemanticsTree('''
444444
<sem style="$rootSemanticStyle">
445445
<sem-c>
446-
<a aria-label="Hello" role="button" style="display: block;"></a>
446+
<a aria-label="Hello" style="display: block;"></a>
447447
</sem-c>
448448
</sem>''');
449449
expect(existingParent, tree[1]!.element.parent);
@@ -2106,6 +2106,89 @@ void _testTappable() {
21062106

21072107
semantics().semanticsEnabled = false;
21082108
});
2109+
2110+
// Regression test for: https://github.com/flutter/flutter/issues/134842
2111+
//
2112+
// If the click event is allowed to propagate through the hierarchy, then both
2113+
// the descendant and the parent will generate a SemanticsAction.tap, causing
2114+
// a double-tap to happen on the framework side.
2115+
test('inner tappable overrides ancestor tappable', () async {
2116+
semantics()
2117+
..debugOverrideTimestampFunction(() => _testTime)
2118+
..semanticsEnabled = true;
2119+
2120+
final List<CapturedAction> capturedActions = <CapturedAction>[];
2121+
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
2122+
capturedActions.add((event.nodeId, event.type, event.arguments));
2123+
};
2124+
2125+
final SemanticsTester tester = SemanticsTester(semantics());
2126+
tester.updateNode(
2127+
id: 0,
2128+
isFocusable: true,
2129+
hasTap: true,
2130+
hasEnabledState: true,
2131+
isEnabled: true,
2132+
isButton: true,
2133+
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
2134+
children: <SemanticsNodeUpdate>[
2135+
tester.updateNode(
2136+
id: 1,
2137+
isFocusable: true,
2138+
hasTap: true,
2139+
hasEnabledState: true,
2140+
isEnabled: true,
2141+
isButton: true,
2142+
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
2143+
),
2144+
],
2145+
);
2146+
tester.apply();
2147+
2148+
expectSemanticsTree('''
2149+
<sem flt-tappable role="button" style="$rootSemanticStyle">
2150+
<sem-c>
2151+
<sem flt-tappable role="button"></sem>
2152+
</sem-c>
2153+
</sem>
2154+
''');
2155+
2156+
// Tap on the outer element
2157+
{
2158+
final DomElement element = tester.getSemanticsObject(0).element;
2159+
final DomRect rect = element.getBoundingClientRect();
2160+
2161+
element.dispatchEvent(createDomMouseEvent('click', <Object?, Object?>{
2162+
'clientX': (rect.left + (rect.right - rect.left) / 2).floor(),
2163+
'clientY': (rect.top + (rect.bottom - rect.top) / 2).floor(),
2164+
}));
2165+
2166+
expect(capturedActions, <CapturedAction>[
2167+
(0, ui.SemanticsAction.tap, null),
2168+
]);
2169+
}
2170+
2171+
// Tap on the inner element
2172+
{
2173+
capturedActions.clear();
2174+
final DomElement element = tester.getSemanticsObject(1).element;
2175+
final DomRect rect = element.getBoundingClientRect();
2176+
2177+
element.dispatchEvent(createDomMouseEvent('click', <Object?, Object?>{
2178+
'bubbles': true,
2179+
'clientX': (rect.left + (rect.right - rect.left) / 2).floor(),
2180+
'clientY': (rect.top + (rect.bottom - rect.top) / 2).floor(),
2181+
}));
2182+
2183+
// The click on the inner element should not propagate to the parent to
2184+
// avoid sending a second SemanticsAction.tap action to the framework.
2185+
expect(capturedActions, <CapturedAction>[
2186+
(1, ui.SemanticsAction.tap, null),
2187+
]);
2188+
}
2189+
2190+
semantics().semanticsEnabled = false;
2191+
});
21092192
}
21102193

21112194
void _testImage() {

lib/web_ui/test/engine/semantics/semantics_tester.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,9 +352,9 @@ class SemanticsTester {
352352

353353
/// Verifies the HTML structure of the current semantics tree.
354354
void expectSemanticsTree(String semanticsHtml) {
355-
const List<String> ignoredAttributes = <String>['pointer-events'];
355+
const List<String> ignoredStyleProperties = <String>['pointer-events'];
356356
expect(
357-
canonicalizeHtml(appHostNode.querySelector('flt-semantics')!.outerHTML!, ignoredAttributes: ignoredAttributes),
357+
canonicalizeHtml(appHostNode.querySelector('flt-semantics')!.outerHTML!, ignoredStyleProperties: ignoredStyleProperties),
358358
canonicalizeHtml(semanticsHtml),
359359
);
360360
}

0 commit comments

Comments
 (0)