diff --git a/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts b/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts index 0edd38880775..b0fd5e7fe793 100644 --- a/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts +++ b/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts @@ -121,6 +121,7 @@ export class EventDelegator { for (const handlerInfo of infosForElement.enumerateHandlers()) { this.eventInfoStore.remove(handlerInfo.eventHandlerId); } + delete element[this.eventsCollectionKey]; } } @@ -135,12 +136,30 @@ export class EventDelegator { public setStopPropagation(element: Element, eventName: string, value: boolean): void { const infoForElement = this.getEventHandlerInfosForElement(element, true)!; + const currentValue = infoForElement.stopPropagation(eventName); infoForElement.stopPropagation(eventName, value); + + if (!currentValue && value) { + this.eventInfoStore.addGlobalListener(eventName); + } else if (currentValue && !value) { + this.eventInfoStore.decrementCountByEventName(eventName); + } } public setPreventDefault(element: Element, eventName: string, value: boolean): void { const infoForElement = this.getEventHandlerInfosForElement(element, true)!; + const currentValue = infoForElement.preventDefault(eventName); infoForElement.preventDefault(eventName, value); + + if (!currentValue && value) { + // To ensure that preventDefault works for wheel and touch events,, + // we need to register a listener with the passive mode explicitly disabled. + // Note that this does not change behavior for other events as those + // use active mode by default. + this.eventInfoStore.addActiveGlobalListener(eventName); + } else if (currentValue && !value) { + this.eventInfoStore.decrementCountByEventName(eventName); + } } private onGlobalEvent(evt: Event) { @@ -278,6 +297,25 @@ class EventInfoStore { } } + public addActiveGlobalListener(eventName: string) { + // If this event name is an alias, update the global listener for the corresponding browser event + eventName = getBrowserEventName(eventName); + + // If the listener for this event is already registered, we recreate it to ensure + // that it is using the active mode. + if (Object.prototype.hasOwnProperty.call(this.countByEventName, eventName)) { + this.countByEventName[eventName]++; + document.removeEventListener(eventName, this.globalListener); + } else { + this.countByEventName[eventName] = 1; + } + + // To make delegation work with non-bubbling events, register a 'capture' listener. + // We preserve the non-bubbling behavior by only dispatching such events to the targeted element. + const useCapture = Object.prototype.hasOwnProperty.call(nonBubblingEvents, eventName); + document.addEventListener(eventName, this.globalListener, { capture: useCapture, passive: false }); + } + public update(oldEventHandlerId: number, newEventHandlerId: number) { if (Object.prototype.hasOwnProperty.call(this.infosByEventHandlerId, newEventHandlerId)) { // Should never happen, but we want to know if it does @@ -298,16 +336,19 @@ class EventInfoStore { // If this event name is an alias, update the global listener for the corresponding browser event const eventName = getBrowserEventName(info.eventName); - - if (--this.countByEventName[eventName] === 0) { - delete this.countByEventName[eventName]; - document.removeEventListener(eventName, this.globalListener); - } + this.decrementCountByEventName(eventName); } return info; } + public decrementCountByEventName(eventName: string) { + if (--this.countByEventName[eventName] === 0) { + delete this.countByEventName[eventName]; + document.removeEventListener(eventName, this.globalListener); + } + } + private handleEventNameAliasAdded(aliasEventName, browserEventName) { // If an event name alias gets registered later, we need to update the global listener // registrations to match. This makes it equivalent to the alias having been registered diff --git a/src/Components/test/E2ETest/Tests/EventFlagsTest.cs b/src/Components/test/E2ETest/Tests/EventFlagsTest.cs new file mode 100644 index 000000000000..08099a2c85e4 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/EventFlagsTest.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class EventFlagsTest : ServerTestBase> +{ + public EventFlagsTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + Browser.MountTestComponent(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OnMouseDown_WithPreventDefaultEnabled_DoesNotFocusButton(bool handlersEnabled) + { + if (!handlersEnabled) + { + // Disable onmousedown handlers + var toggleHandlers = Browser.Exists(By.Id("toggle-handlers")); + toggleHandlers.Click(); + } + + var button = Browser.Exists(By.Id("mousedown-test-button")); + button.Click(); + + // Check that the button has not gained focus (should not be yellow) + var afterClickBackgroundColor = button.GetCssValue("background-color"); + Assert.DoesNotContain("255, 255, 0", afterClickBackgroundColor); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OnMouseDown_WithPreventDefaultDisabled_DoesFocusButton(bool handlersEnabled) + { + if (!handlersEnabled) + { + // Disable onmousedown handlers + var toggleHandlers = Browser.Exists(By.Id("toggle-handlers")); + toggleHandlers.Click(); + } + + // Disable preventDefault + var togglePreventDefault = Browser.Exists(By.Id("toggle-prevent-default")); + togglePreventDefault.Click(); + + var button = Browser.Exists(By.Id("mousedown-test-button")); + + // Get the initial background color and check that it is no yellow + var initialBackgroundColor = button.GetCssValue("background-color"); + Assert.DoesNotContain("255, 255, 0", initialBackgroundColor); + + button.Click(); + + // Check that the button has gained focus (yellow background) + var afterClickBackgroundColor = button.GetCssValue("background-color"); + Assert.Contains("255, 255, 0", afterClickBackgroundColor); + } + + [Fact] + public void OnClick_WithStopPropagationEnabled_DoesNotPropagateToParent() + { + var button = Browser.Exists(By.Id("stop-propagation-test-button")); + button.Click(); + + var eventLog = Browser.Exists(By.Id("event-log")); + Assert.Contains("mousedown handler called on child", eventLog.Text); + Assert.DoesNotContain("mousedown handler called on parent", eventLog.Text); + } + + [Fact] + public void OnClick_WithStopPropagationDisabled_PropagatesToParent() + { + // Disable stopPropagation + var toggleStopPropagation = Browser.Exists(By.Id("toggle-stop-propagation")); + toggleStopPropagation.Click(); + + var button = Browser.Exists(By.Id("stop-propagation-test-button")); + button.Click(); + + var eventLog = Browser.Exists(By.Id("event-log")); + Assert.Contains("mousedown handler called on child", eventLog.Text); + Assert.Contains("mousedown handler called on parent", eventLog.Text); + } + + [Fact] + public void OnWheel_WithPreventDefaultEnabled_DoesNotScrollDiv() + { + var scrollableDiv = Browser.Exists(By.Id("wheel-test-area")); + + // Simulate a wheel scroll action + var scrollOrigin = new WheelInputDevice.ScrollOrigin + { + Element = scrollableDiv, + }; + new Actions(Browser) + .ScrollFromOrigin(scrollOrigin, 0, 200) + .Perform(); + + // The Selenium scrolling action always changes the scrollTop property even when the event is prevented. + // For this reason, we do not check for equality with zero. + var newScrollTop = int.Parse(scrollableDiv.GetDomProperty("scrollTop"), CultureInfo.InvariantCulture); + Assert.True(newScrollTop < 3); + } + + [Fact] + public void OnWheel_WithPreventDefaultDisabled_DoesScrollDiv() + { + // Disable preventDefault + var togglePreventDefault = Browser.Exists(By.Id("toggle-prevent-default")); + togglePreventDefault.Click(); + + var scrollableDiv = Browser.Exists(By.Id("wheel-test-area")); + + // Simulate a wheel scroll action + var scrollOrigin = new WheelInputDevice.ScrollOrigin + { + Element = scrollableDiv, + }; + new Actions(Browser) + .ScrollFromOrigin(scrollOrigin, 0, 200) + .Perform(); + + // The Selenium scrolling action is not precise and changes the scrollTop property to e.g. 202 instead of 200. + // For this reason, we do not check for equality with specific value. + var newScrollTop = int.Parse(scrollableDiv.GetDomProperty("scrollTop"), CultureInfo.InvariantCulture); + Assert.True(newScrollTop >= 200); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/EventFlagsComponent.razor b/src/Components/test/testassets/BasicTestApp/EventFlagsComponent.razor new file mode 100644 index 000000000000..0ddba430c567 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/EventFlagsComponent.razor @@ -0,0 +1,117 @@ + + +

Event flags

+ +
+

Test Controls

+ + + +
+ +@if (eventHandlersEnabled) +{ +

OnMouseDown handler present

+

OnWheel handler present

+} +else +{ +

OnMouseDown handler not present

+

OnWheel handler not present

+} + +
+

Test Scenarios

+ +
+

Scenario 1: onmousedown:preventDefault

+ +
+ +
+

Scenario 2: onclick:stopPropagation

+
+

Parent container

+ +
+
+ +
+

Scenario 3: wheel preventDefault (passive vs active)

+
+

Try scrolling with mouse wheel in this area.

+

If preventDefault is true, scrolling should be blocked.

+

If preventDefault is false, scrolling should work normally.

+
+

This content makes the area scrollable

+

More

+

More

+
+
+
+
+ +
@eventLog
+ + +@code { + private bool preventDefaultEnabled = true; + private bool stopPropagationEnabled = true; + private bool eventHandlersEnabled = true; + + private string eventLog = string.Empty; + + private void TogglePreventDefault() + { + preventDefaultEnabled = !preventDefaultEnabled; + StateHasChanged(); + } + + private void ToggleStopPropagation() + { + stopPropagationEnabled = !stopPropagationEnabled; + StateHasChanged(); + } + + private void ToggleEventHandlers() + { + eventHandlersEnabled = !eventHandlersEnabled; + StateHasChanged(); + } + + void LogEvent(string message) + { + if (eventLog != string.Empty) + { + eventLog += Environment.NewLine; + } + + eventLog += message; + } + + private void OnChildMouseDown() + { + LogEvent("mousedown handler called on child"); + } + + private void OnParentMouseDown() + { + LogEvent("mousedown handler called on parent"); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 65a22ade2513..6e8d20b391a2 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -36,7 +36,8 @@ - + +