Support custom events and non-bubbling standard events (#722)
* 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
This commit is contained in:
parent
f61ed4df4f
commit
be8f2d43ee
|
|
@ -1,5 +1,10 @@
|
||||||
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
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 {
|
export interface OnEventCallback {
|
||||||
(event: Event, componentId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>): void;
|
(event: Event, componentId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>): void;
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +69,7 @@ export class EventDelegator {
|
||||||
// Scan up the element hierarchy, looking for any matching registered event handlers
|
// Scan up the element hierarchy, looking for any matching registered event handlers
|
||||||
let candidateElement = evt.target as Element | null;
|
let candidateElement = evt.target as Element | null;
|
||||||
let eventArgs: EventForDotNet<UIEventArgs> | null = null; // Populate lazily
|
let eventArgs: EventForDotNet<UIEventArgs> | null = null; // Populate lazily
|
||||||
|
const eventIsNonBubbling = nonBubblingEvents.hasOwnProperty(evt.type);
|
||||||
while (candidateElement) {
|
while (candidateElement) {
|
||||||
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
|
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
|
||||||
const handlerInfos = candidateElement[this.eventsCollectionKey];
|
const handlerInfos = candidateElement[this.eventsCollectionKey];
|
||||||
|
|
@ -78,7 +84,7 @@ export class EventDelegator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateElement = candidateElement.parentElement;
|
candidateElement = eventIsNonBubbling ? null : candidateElement.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +111,11 @@ class EventInfoStore {
|
||||||
this.countByEventName[eventName]++;
|
this.countByEventName[eventName]++;
|
||||||
} else {
|
} else {
|
||||||
this.countByEventName[eventName] = 1;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,3 +164,9 @@ interface EventHandlerInfo {
|
||||||
componentId: number;
|
componentId: number;
|
||||||
eventHandlerId: number;
|
eventHandlerId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLookup(items: string[]): { [key: string]: boolean } {
|
||||||
|
const result = {};
|
||||||
|
items.forEach(value => { result[value] = true; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Rendering
|
||||||
return JsonUtil.Deserialize<UIKeyboardEventArgs>(eventArgsJson);
|
return JsonUtil.Deserialize<UIKeyboardEventArgs>(eventArgsJson);
|
||||||
case "change":
|
case "change":
|
||||||
return JsonUtil.Deserialize<UIChangeEventArgs>(eventArgsJson);
|
return JsonUtil.Deserialize<UIChangeEventArgs>(eventArgsJson);
|
||||||
|
case "unknown":
|
||||||
|
return JsonUtil.Deserialize<UIEventArgs>(eventArgsJson);
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException($"Unsupported value '{eventArgsType}'.", nameof(eventArgsType));
|
throw new ArgumentException($"Unsupported value '{eventArgsType}'.", nameof(eventArgsType));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }})" +
|
||||||
|
$")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
<option value="BasicTestApp.ElementRefComponent">Element ref component</option>
|
<option value="BasicTestApp.ElementRefComponent">Element ref component</option>
|
||||||
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
|
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
|
||||||
<option value="BasicTestApp.AfterRenderInteropComponent">After-render interop 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 -->
|
<!--<option value="BasicTestApp.RouterTest.Default">Router</option> Excluded because it requires additional setup to work correctly when loaded manually -->
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue