Skip to content

Commit

Permalink
Support custom events and non-bubbling standard events (dotnet#722)
Browse files Browse the repository at this point in the history
* Support non-bubbling events

* Support responding to arbitrary events. E2E coverage of this and bubbling.

* Rename E2E test files to avoid clash with other PR
  • Loading branch information
SteveSandersonMS authored May 1, 2018
1 parent f61ed4d commit be8f2d4
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { EventForDotNet, UIEventArgs } from './EventForDotNet';

const nonBubblingEvents = toLookup([
'abort', 'blur', 'change', 'error', 'focus', 'load', 'loadend', 'loadstart', 'mouseenter', 'mouseleave',
'progress', 'reset', 'scroll', 'submit', 'unload', 'DOMNodeInsertedIntoDocument', 'DOMNodeRemovedFromDocument'
]);

export interface OnEventCallback {
(event: Event, componentId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>): void;
}
Expand Down Expand Up @@ -64,6 +69,7 @@ export class EventDelegator {
// Scan up the element hierarchy, looking for any matching registered event handlers
let candidateElement = evt.target as Element | null;
let eventArgs: EventForDotNet<UIEventArgs> | null = null; // Populate lazily
const eventIsNonBubbling = nonBubblingEvents.hasOwnProperty(evt.type);
while (candidateElement) {
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
const handlerInfos = candidateElement[this.eventsCollectionKey];
Expand All @@ -78,7 +84,7 @@ export class EventDelegator {
}
}

candidateElement = candidateElement.parentElement;
candidateElement = eventIsNonBubbling ? null : candidateElement.parentElement;
}
}
}
Expand All @@ -105,7 +111,11 @@ class EventInfoStore {
this.countByEventName[eventName]++;
} else {
this.countByEventName[eventName] = 1;
document.addEventListener(eventName, this.globalListener);

// 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 = nonBubblingEvents.hasOwnProperty(eventName);
document.addEventListener(eventName, this.globalListener, useCapture);
}
}

Expand Down Expand Up @@ -154,3 +164,9 @@ interface EventHandlerInfo {
componentId: number;
eventHandlerId: number;
}

function toLookup(items: string[]): { [key: string]: boolean } {
const result = {};
items.forEach(value => { result[value] = true; });
return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ private static UIEventArgs ParseEventArgsJson(string eventArgsType, string event
return JsonUtil.Deserialize<UIKeyboardEventArgs>(eventArgsJson);
case "change":
return JsonUtil.Deserialize<UIChangeEventArgs>(eventArgsJson);
case "unknown":
return JsonUtil.Deserialize<UIEventArgs>(eventArgsJson);
default:
throw new ArgumentException($"Unsupported value '{eventArgsType}'.", nameof(eventArgsType));
}
Expand Down
109 changes: 109 additions & 0 deletions test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/EventBubblingTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using BasicTestApp;
using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure.ServerFixtures;
using OpenQA.Selenium;
using System;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
{
public class EventBubblingTest : BasicTestAppTestBase
{
// Note that currently we only support custom events if they have bubble:true.
// That's because the event delegator doesn't know which custom events bubble and which don't,
// so it doesn't know whether to register a normal handler or a capturing one. If this becomes
// a problem, we could consider registering both types of handler and just bailing out from
// the one that doesn't match the 'bubbles' flag on the received event object.

public EventBubblingTest(
BrowserFixture browserFixture,
DevHostServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
Navigate(ServerPathBase, noReload: true);
MountTestComponent<EventBubblingComponent>();
}

[Fact]
public void BubblingStandardEvent_FiredOnElementWithHandler()
{
Browser.FindElement(By.Id("button-with-onclick")).Click();

// Triggers event on target and ancestors with handler in upwards direction
Assert.Equal(
new[] { "target onclick", "parent onclick" },
GetLogLines());
}

[Fact]
public void BubblingStandardEvent_FiredOnElementWithoutHandler()
{
Browser.FindElement(By.Id("button-without-onclick")).Click();

// Triggers event on ancestors with handler in upwards direction
Assert.Equal(
new[] { "parent onclick" },
GetLogLines());
}

[Fact]
public void BubblingCustomEvent_FiredOnElementWithHandler()
{
TriggerCustomBubblingEvent("element-with-onsneeze", "sneeze");

// Triggers event on target and ancestors with handler in upwards direction
Assert.Equal(
new[] { "target onsneeze", "parent onsneeze" },
GetLogLines());
}

[Fact]
public void BubblingCustomEvent_FiredOnElementWithoutHandler()
{
TriggerCustomBubblingEvent("element-without-onsneeze", "sneeze");

// Triggers event on ancestors with handler in upwards direction
Assert.Equal(
new[] { "parent onsneeze" },
GetLogLines());
}

[Fact]
public void NonBubblingEvent_FiredOnElementWithHandler()
{
Browser.FindElement(By.Id("input-with-onfocus")).Click();

// Triggers event only on target, not other ancestors with event handler
Assert.Equal(new[] { "target onfocus" }, GetLogLines());
}

[Fact]
public void NonBubblingEvent_FiredOnElementWithoutHandler()
{
Browser.FindElement(By.Id("input-without-onfocus")).Click();

// Triggers no event
Assert.Empty(GetLogLines());
}

private string[] GetLogLines()
=> Browser.FindElement(By.TagName("textarea"))
.GetAttribute("value")
.Replace("\r\n", "\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries);

private void TriggerCustomBubblingEvent(string elementId, string eventName)
{
var jsExecutor = (IJavaScriptExecutor)Browser;
jsExecutor.ExecuteScript(
$"document.getElementById('{elementId}').dispatchEvent(" +
$" new Event('{eventName}', {{ bubbles: true }})" +
$")");
}
}
}
34 changes: 34 additions & 0 deletions test/testapps/BasicTestApp/EventBubblingComponent.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<h3>Bubbling standard event</h3>

<div onclick="@(() => LogEvent("parent onclick"))">
<button id="button-with-onclick" onclick="@(() => LogEvent("target onclick"))">Button with onclick handler</button>
<button id="button-without-onclick" >Button without onclick handler</button>
</div>

<h3>Bubbling custom event</h3>

<div onsneeze="@(new Action(() => LogEvent("parent onsneeze")))">
<div id="element-with-onsneeze" onsneeze="@(new Action(() => LogEvent("target onsneeze")))">Element with onsneeze handler</div>
<div id="element-without-onsneeze" >Element without onsneeze handler</div>
</div>

<h3>Non-bubbling standard event</h3>

<!-- The new Action(...) is needed until we add support for onfocus -->
<div onfocus="@(new Action(() => LogEvent("parent onfocus")))">
<p>With onfocus: <input id="input-with-onfocus" onfocus="@(new Action(() => LogEvent("target onfocus")))" /></p>
<p>Without onfocus: <input id="input-without-onfocus" /></p>
</div>

<h3>Event log</h3>

<textarea readonly bind="@logValue"></textarea>

@functions {
string logValue = string.Empty;

void LogEvent(string message)
{
logValue += message + Environment.NewLine;
}
}
1 change: 1 addition & 0 deletions test/testapps/BasicTestApp/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<option value="BasicTestApp.ElementRefComponent">Element ref component</option>
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
<option value="BasicTestApp.AfterRenderInteropComponent">After-render interop component</option>
<option value="BasicTestApp.EventCasesComponent">Event cases</option>
<!--<option value="BasicTestApp.RouterTest.Default">Router</option> Excluded because it requires additional setup to work correctly when loaded manually -->
</select>
&nbsp;
Expand Down

0 comments on commit be8f2d4

Please sign in to comment.