DEV: Normalize event handling to improve Glimmer + Classic component compat (Take 2) (#18742)
Classic Ember components (i.e. "@ember/component") rely upon "event delegation" to listen for events at the application root and then dispatch those events to any event handlers defined on individual Classic components. This coordination is handled by Ember's EventDispatcher. In contrast, Glimmer components (i.e. "@glimmer/component") expect event listeners to be added to elements using modifiers (such as `{{on "click"}}`). These event listeners are added directly to DOM elements using `addEventListener`. There is no need for an event dispatcher. Issues may arise when using Classic and Glimmer components together, since it requires reconciling the two event handling approaches. For instance, event propagation may not work as expected when a Classic component is nested inside a Glimmer component. `normalizeEmberEventHandling` helps an application standardize upon the Glimmer event handling approach by eliminating usage of event delegation and instead rewiring Classic components to directly use `addEventListener`. Specifically, it performs the following: - Invokes `eliminateClassicEventDelegation()` to remove all events associated with Ember's EventDispatcher to reduce its runtime overhead and ensure that it is effectively not in use. - Invokes `rewireClassicComponentEvents(app)` to rewire each Classic component to add its own event listeners for standard event handlers (e.g. `click`, `mouseDown`, `submit`, etc.). - Configures an instance initializer that invokes `rewireActionModifier(appInstance)` to redefine the `action` modifier with a substitute that uses `addEventListener`. Additional changes include: * d-button: only preventDefault / stopPropagation for handled actions This allows unhandled events to propagate as expected. * d-editor: avoid adding duplicate event listener for tests This extra event listener causes duplicate paste events in tests. * group-manage-email-settings: Monitor `input` instead of `change` event for checkboxes
This commit is contained in:
parent
20efd494ef
commit
0221855ba7
|
@ -1,6 +1,7 @@
|
||||||
import Application from "@ember/application";
|
import Application from "@ember/application";
|
||||||
import { buildResolver } from "discourse-common/resolver";
|
import { buildResolver } from "discourse-common/resolver";
|
||||||
import { isTesting } from "discourse-common/config/environment";
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
|
import { normalizeEmberEventHandling } from "./lib/ember-events";
|
||||||
|
|
||||||
const _pluginCallbacks = [];
|
const _pluginCallbacks = [];
|
||||||
let _unhandledThemeErrors = [];
|
let _unhandledThemeErrors = [];
|
||||||
|
@ -54,6 +55,10 @@ const Discourse = Application.extend({
|
||||||
start() {
|
start() {
|
||||||
document.querySelector("noscript")?.remove();
|
document.querySelector("noscript")?.remove();
|
||||||
|
|
||||||
|
// Rewire event handling to eliminate event delegation for better compat
|
||||||
|
// between Glimmer and Classic components.
|
||||||
|
normalizeEmberEventHandling(this);
|
||||||
|
|
||||||
if (Error.stackTraceLimit) {
|
if (Error.stackTraceLimit) {
|
||||||
// We need Errors to have full stack traces for `lib/source-identifier`
|
// We need Errors to have full stack traces for `lib/source-identifier`
|
||||||
Error.stackTraceLimit = Infinity;
|
Error.stackTraceLimit = Infinity;
|
||||||
|
|
|
@ -118,8 +118,7 @@ export default Component.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
click(event) {
|
click(event) {
|
||||||
this._triggerAction(event);
|
return this._triggerAction(event);
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mouseDown(event) {
|
mouseDown(event) {
|
||||||
|
@ -129,8 +128,9 @@ export default Component.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
_triggerAction(event) {
|
_triggerAction(event) {
|
||||||
let { action } = this;
|
let { action, route, href } = this;
|
||||||
|
|
||||||
|
if (action || route || href?.length) {
|
||||||
if (action) {
|
if (action) {
|
||||||
if (typeof action === "string") {
|
if (typeof action === "string") {
|
||||||
// Note: This is deprecated in new Embers and needs to be removed in the future.
|
// Note: This is deprecated in new Embers and needs to be removed in the future.
|
||||||
|
@ -151,15 +151,18 @@ export default Component.extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.route) {
|
if (route) {
|
||||||
this.router.transitionTo(this.route);
|
this.router.transitionTo(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.href && this.href.length) {
|
if (href?.length) {
|
||||||
DiscourseURL.routeTo(this.href);
|
DiscourseURL.routeTo(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -289,10 +289,6 @@ export default Component.extend(TextareaTextManipulation, {
|
||||||
"indentSelection"
|
"indentSelection"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTesting()) {
|
|
||||||
this.element.addEventListener("paste", this.paste);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
import Ember from "ember";
|
|
||||||
|
|
||||||
let initializedOnce = false;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "ember-events",
|
|
||||||
|
|
||||||
initialize() {
|
|
||||||
// By default Ember listens to too many events. This tells it the only events
|
|
||||||
// we're interested in. (it removes mousemove, touchstart and touchmove)
|
|
||||||
if (initializedOnce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ember.EventDispatcher.reopen({
|
|
||||||
events: {
|
|
||||||
touchend: "touchEnd",
|
|
||||||
touchcancel: "touchCancel",
|
|
||||||
keydown: "keyDown",
|
|
||||||
keyup: "keyUp",
|
|
||||||
keypress: "keyPress",
|
|
||||||
mousedown: "mouseDown",
|
|
||||||
mouseup: "mouseUp",
|
|
||||||
contextmenu: "contextMenu",
|
|
||||||
click: "click",
|
|
||||||
dblclick: "doubleClick",
|
|
||||||
focusin: "focusIn",
|
|
||||||
focusout: "focusOut",
|
|
||||||
mouseenter: "mouseEnter",
|
|
||||||
mouseleave: "mouseLeave",
|
|
||||||
submit: "submit",
|
|
||||||
input: "input",
|
|
||||||
change: "change",
|
|
||||||
dragstart: "dragStart",
|
|
||||||
drag: "drag",
|
|
||||||
dragenter: "dragEnter",
|
|
||||||
dragleave: "dragLeave",
|
|
||||||
dragover: "dragOver",
|
|
||||||
drop: "drop",
|
|
||||||
dragend: "dragEnd",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
initializedOnce = true;
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { modifier } from "ember-modifier";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a replacement for Ember's built-in `action` modifier that uses
|
||||||
|
* `addEventListener` directly instead of relying upon classic event delegation.
|
||||||
|
*
|
||||||
|
* This relies upon a deep override of Ember's rendering internals. If possible,
|
||||||
|
* consider eliminating usage of `action` as a modifier instead.
|
||||||
|
*
|
||||||
|
* Reference: https://github.com/emberjs/ember.js/blob/master/packages/%40ember/-internals/glimmer/lib/helpers/action.ts
|
||||||
|
*/
|
||||||
|
export const actionModifier = modifier(
|
||||||
|
(
|
||||||
|
element,
|
||||||
|
[context, callback, ...args],
|
||||||
|
{ on, bubbles, preventDefault, allowedKeys }
|
||||||
|
) => {
|
||||||
|
const handler = (event) => {
|
||||||
|
const fn = typeof callback === "string" ? context[callback] : callback;
|
||||||
|
if (fn === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"Unexpected callback for `action` modifier. Please provide either a function or the name of a method on the current context."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedEvent(event, allowedKeys)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preventDefault !== false) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldBubble = bubbles !== false;
|
||||||
|
if (!shouldBubble) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length > 0) {
|
||||||
|
return fn.call(context, ...args);
|
||||||
|
} else {
|
||||||
|
return fn.call(context, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventName = on ?? "click";
|
||||||
|
element.addEventListener(eventName, handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener(eventName, handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ eager: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isSimpleClick(event) {
|
||||||
|
if (!(event instanceof MouseEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let modKey = event.shiftKey || event.metaKey || event.altKey || event.ctrlKey;
|
||||||
|
let secondaryClick = event.which > 1; // IE9 may return undefined
|
||||||
|
|
||||||
|
return !modKey && !secondaryClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODIFIERS = ["alt", "shift", "meta", "ctrl"];
|
||||||
|
const POINTER_EVENT_TYPE_REGEX = /^click|mouse|touch/;
|
||||||
|
|
||||||
|
function isAllowedEvent(event, allowedKeys) {
|
||||||
|
if (allowedKeys === null || allowedKeys === undefined) {
|
||||||
|
if (POINTER_EVENT_TYPE_REGEX.test(event.type)) {
|
||||||
|
return isSimpleClick(event);
|
||||||
|
} else {
|
||||||
|
allowedKeys = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedKeys.indexOf("any") >= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < MODIFIERS.length; i++) {
|
||||||
|
if (
|
||||||
|
event[MODIFIERS[i] + "Key"] &&
|
||||||
|
allowedKeys.indexOf(MODIFIERS[i]) === -1
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
|
@ -0,0 +1,232 @@
|
||||||
|
// eslint-disable-next-line ember/no-classic-components
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import EmberObject from "@ember/object";
|
||||||
|
import { actionModifier } from "./ember-action-modifier";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic Ember components (i.e. "@ember/component") rely upon "event
|
||||||
|
* delegation" to listen for events at the application root and then dispatch
|
||||||
|
* those events to any event handlers defined on individual Classic components.
|
||||||
|
* This coordination is handled by Ember's EventDispatcher.
|
||||||
|
*
|
||||||
|
* In contrast, Glimmer components (i.e. "@glimmer/component") expect event
|
||||||
|
* listeners to be added to elements using modifiers (such as `{{on "click"}}`).
|
||||||
|
* These event listeners are added directly to DOM elements using
|
||||||
|
* `addEventListener`. There is no need for an event dispatcher.
|
||||||
|
*
|
||||||
|
* Issues may arise when using Classic and Glimmer components together, since it
|
||||||
|
* requires reconciling the two event handling approaches. For instance, event
|
||||||
|
* propagation may not work as expected when a Classic component is nested
|
||||||
|
* inside a Glimmer component.
|
||||||
|
*
|
||||||
|
* `normalizeEmberEventHandling` helps an application standardize upon the
|
||||||
|
* Glimmer event handling approach by eliminating usage of event delegation and
|
||||||
|
* instead rewiring Classic components to directly use `addEventListener`.
|
||||||
|
*
|
||||||
|
* Specifically, it performs the following:
|
||||||
|
*
|
||||||
|
* - Invokes `eliminateClassicEventDelegation()` to remove all events associated
|
||||||
|
* with Ember's EventDispatcher to reduce its runtime overhead and ensure that
|
||||||
|
* it is effectively not in use.
|
||||||
|
*
|
||||||
|
* - Invokes `rewireClassicComponentEvents(app)` to rewire each Classic
|
||||||
|
* component to add its own event listeners for standard event handlers (e.g.
|
||||||
|
* `click`, `mouseDown`, `submit`, etc.).
|
||||||
|
*
|
||||||
|
* - Configures an instance initializer that invokes
|
||||||
|
* `rewireActionModifier(appInstance)` to redefine the `action` modifier with
|
||||||
|
* a substitute that uses `addEventListener`.
|
||||||
|
*
|
||||||
|
* @param {Application} app
|
||||||
|
*/
|
||||||
|
export function normalizeEmberEventHandling(app) {
|
||||||
|
eliminateClassicEventDelegation();
|
||||||
|
rewireClassicComponentEvents(app);
|
||||||
|
app.instanceInitializer({
|
||||||
|
name: "rewire-action-modifier",
|
||||||
|
initialize: (appInstance) => rewireActionModifier(appInstance),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all events registered with Ember's EventDispatcher to reduce its
|
||||||
|
* runtime overhead.
|
||||||
|
*/
|
||||||
|
function eliminateClassicEventDelegation() {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
Ember.EventDispatcher.reopen({
|
||||||
|
events: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard Ember event handlers, keyed by matching DOM events.
|
||||||
|
*
|
||||||
|
* Source: https://github.com/emberjs/ember.js/blob/master/packages/@ember/-internals/views/lib/system/event_dispatcher.ts#L64-L89
|
||||||
|
*
|
||||||
|
* @type {Record<string, string>}
|
||||||
|
*/
|
||||||
|
const EVENTS = {
|
||||||
|
touchstart: "touchStart",
|
||||||
|
touchmove: "touchMove",
|
||||||
|
touchend: "touchEnd",
|
||||||
|
touchcancel: "touchCancel",
|
||||||
|
keydown: "keyDown",
|
||||||
|
keyup: "keyUp",
|
||||||
|
keypress: "keyPress",
|
||||||
|
mousedown: "mouseDown",
|
||||||
|
mouseup: "mouseUp",
|
||||||
|
contextmenu: "contextMenu",
|
||||||
|
click: "click",
|
||||||
|
dblclick: "doubleClick",
|
||||||
|
focusin: "focusIn",
|
||||||
|
focusout: "focusOut",
|
||||||
|
submit: "submit",
|
||||||
|
input: "input",
|
||||||
|
change: "change",
|
||||||
|
dragstart: "dragStart",
|
||||||
|
drag: "drag",
|
||||||
|
dragenter: "dragEnter",
|
||||||
|
dragleave: "dragLeave",
|
||||||
|
dragover: "dragOver",
|
||||||
|
drop: "drop",
|
||||||
|
dragend: "dragEnd",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {WeakMap<object, { event: string; listener: () => {} }[]>}
|
||||||
|
*/
|
||||||
|
const EVENT_LISTENERS = new WeakMap();
|
||||||
|
|
||||||
|
const INTERNAL = Symbol("INTERNAL");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewires classic component event handling to use `addEventListener` directly
|
||||||
|
* on inserted elements, instead of relying upon classic event delegation.
|
||||||
|
*
|
||||||
|
* This maximizes compatibility with glimmer components and event listeners
|
||||||
|
* added via the `on` modifier. In particular, using `addEventListener`
|
||||||
|
* consistently everywhere ensures that event propagation works as expected
|
||||||
|
* between parent and child elements.
|
||||||
|
*
|
||||||
|
* @param {Application} app
|
||||||
|
*/
|
||||||
|
function rewireClassicComponentEvents(app) {
|
||||||
|
const allEvents = { ...EVENTS };
|
||||||
|
|
||||||
|
if (app.customEvents) {
|
||||||
|
for (const [event, methodName] of Object.entries(app.customEvents)) {
|
||||||
|
allEvents[event] = methodName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEventMethods = {};
|
||||||
|
for (const [event, methodName] of Object.entries(allEvents)) {
|
||||||
|
allEventMethods[methodName] = event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid Component.reopen to stop `ember.component.reopen` deprecation warning
|
||||||
|
EmberObject.reopen.call(Component, {
|
||||||
|
/**
|
||||||
|
* @param {string | typeof INTERNAL} name
|
||||||
|
* @param {unknown[]} args
|
||||||
|
*/
|
||||||
|
trigger(name, ...args) {
|
||||||
|
if (name === INTERNAL) {
|
||||||
|
if (this.element) {
|
||||||
|
return this._super.call(this, ...args);
|
||||||
|
}
|
||||||
|
} else if (name.toLowerCase() in allEvents) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return this._super.call(this, name, ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// eslint-disable-next-line ember/no-component-lifecycle-hooks
|
||||||
|
didInsertElement() {
|
||||||
|
this._super(...arguments);
|
||||||
|
setupComponentEventListeners(this, allEventMethods);
|
||||||
|
},
|
||||||
|
|
||||||
|
// eslint-disable-next-line ember/no-component-lifecycle-hooks
|
||||||
|
willDestroyElement() {
|
||||||
|
teardownComponentEventListeners(this);
|
||||||
|
this._super(...arguments);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewires the `action` modifier to use `addEventListener` directly instead of
|
||||||
|
* relying upon classic event delegation.
|
||||||
|
*
|
||||||
|
* This relies upon a deep override of Ember's rendering internals. If possible,
|
||||||
|
* consider eliminating usage of `action` as a modifier instead.
|
||||||
|
*
|
||||||
|
* @param {ApplicationInstance} appInstance
|
||||||
|
*/
|
||||||
|
function rewireActionModifier(appInstance) {
|
||||||
|
// This is a deep runtime override, since neither the runtime resolver nor the
|
||||||
|
// built-in `action` modifier seem to be available otherwise.
|
||||||
|
//
|
||||||
|
// TODO: Investigate if a cleaner override is possible.
|
||||||
|
const renderer = appInstance.lookup("renderer:-dom");
|
||||||
|
const lookupModifier = renderer._runtimeResolver.lookupModifier;
|
||||||
|
renderer._runtimeResolver.lookupModifier = (name, owner) => {
|
||||||
|
if (name === "action") {
|
||||||
|
return actionModifier;
|
||||||
|
} else {
|
||||||
|
return lookupModifier(name, owner);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupComponentEventListeners(component, allEventMethods) {
|
||||||
|
let eventListeners;
|
||||||
|
const { element } = component;
|
||||||
|
|
||||||
|
for (const method of Object.keys(allEventMethods)) {
|
||||||
|
if (component.has(method)) {
|
||||||
|
const event = allEventMethods[method];
|
||||||
|
|
||||||
|
if (eventListeners === undefined) {
|
||||||
|
eventListeners = EVENT_LISTENERS.get(component);
|
||||||
|
if (eventListeners === undefined) {
|
||||||
|
eventListeners = [];
|
||||||
|
EVENT_LISTENERS.set(component, eventListeners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (e) => {
|
||||||
|
const ret = component.trigger.call(component, INTERNAL, method, e);
|
||||||
|
// If an event handler returns `false`, assume the intent is to stop
|
||||||
|
// propagation and default event handling, as per the behavior
|
||||||
|
// encoded in Ember's `EventDispatcher`.
|
||||||
|
//
|
||||||
|
// See: https://github.com/emberjs/ember.js/blob/7d9095f38911d30aebb0e67ceec13e4a9818088b/packages/%40ember/-internals/views/lib/system/event_dispatcher.ts#L331-L337
|
||||||
|
if (ret === false) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener(event, listener);
|
||||||
|
eventListeners.push({ event, listener });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownComponentEventListeners(component) {
|
||||||
|
const eventListeners = EVENT_LISTENERS.get(component);
|
||||||
|
if (eventListeners?.length > 0) {
|
||||||
|
const { element } = component;
|
||||||
|
if (element) {
|
||||||
|
for (const { event, listener } of eventListeners) {
|
||||||
|
element.removeEventListener(event, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EVENT_LISTENERS.delete(component);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
<p>{{i18n "groups.manage.email.smtp_instructions"}}</p>
|
<p>{{i18n "groups.manage.email.smtp_instructions"}}</p>
|
||||||
|
|
||||||
<label for="enable_smtp">
|
<label for="enable_smtp">
|
||||||
<Input @type="checkbox" @checked={{this.group.smtp_enabled}} id="enable_smtp" tabindex="1" {{on "change" this.smtpEnabledChange}} />
|
<Input @type="checkbox" @checked={{this.group.smtp_enabled}} id="enable_smtp" tabindex="1" {{on "input" this.smtpEnabledChange}} />
|
||||||
{{i18n "groups.manage.email.enable_smtp"}}
|
{{i18n "groups.manage.email.enable_smtp"}}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
<div class="alert alert-warning">{{i18n "groups.manage.email.imap_alpha_warning"}}</div>
|
<div class="alert alert-warning">{{i18n "groups.manage.email.imap_alpha_warning"}}</div>
|
||||||
|
|
||||||
<label for="enable_imap">
|
<label for="enable_imap">
|
||||||
<Input @type="checkbox" disabled={{not this.enableImapSettings}} @checked={{this.group.imap_enabled}} id="enable_imap" tabindex="8" {{on "change" this.imapEnabledChange}} />
|
<Input @type="checkbox" disabled={{not this.enableImapSettings}} @checked={{this.group.imap_enabled}} id="enable_imap" tabindex="8" {{on "input" this.imapEnabledChange}} />
|
||||||
{{i18n "groups.manage.email.enable_imap"}}
|
{{i18n "groups.manage.email.enable_imap"}}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { module, test } from "qunit";
|
||||||
|
import { setupRenderingTest } from "ember-qunit";
|
||||||
|
import { click, doubleClick, render } from "@ember/test-helpers";
|
||||||
|
import { hbs } from "ember-cli-htmlbars";
|
||||||
|
|
||||||
|
module("Unit | Lib | ember-action-modifer", function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test("`{{action}}` can target a function", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<button id="childButton" {{action this.onChildClick}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`{{action}}` can target a method on the current context by name", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<button id="childButton" {{action 'onChildClick'}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`{{action}}` will ignore clicks combined with modifier keys", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<button id="childButton" {{action 'onChildClick'}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton", { ctrlKey: true });
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`{{action}}` can specify an event other than `click` via `on`", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onDblClick: () => this.set("dblClicked", i++),
|
||||||
|
dblClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<button id="childButton" {{action this.onDblClick on='dblclick'}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await doubleClick("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.dblClicked, 0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { module, test } from "qunit";
|
||||||
|
import { setupRenderingTest } from "ember-qunit";
|
||||||
|
import { click, render } from "@ember/test-helpers";
|
||||||
|
import { hbs } from "ember-cli-htmlbars";
|
||||||
|
|
||||||
|
/* eslint-disable ember/require-tagless-components */
|
||||||
|
/* eslint-disable ember/no-classic-classes */
|
||||||
|
/* eslint-disable ember/no-classic-components */
|
||||||
|
import { default as ClassicComponent } from "@ember/component";
|
||||||
|
import { default as GlimmerComponent } from "@glimmer/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
|
||||||
|
// Configure test-local Classic and Glimmer components that
|
||||||
|
// will be immune from upgrades to actual Discourse components.
|
||||||
|
const ExampleClassicButton = ClassicComponent.extend({
|
||||||
|
tagName: "button",
|
||||||
|
type: "button",
|
||||||
|
preventEventPropagation: false,
|
||||||
|
onClick: null,
|
||||||
|
onMouseDown: null,
|
||||||
|
|
||||||
|
click(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.preventEventPropagation) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.onClick?.(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const exampleClassicButtonTemplate = hbs`{{yield}}`;
|
||||||
|
|
||||||
|
class ExampleGlimmerButton extends GlimmerComponent {
|
||||||
|
@action
|
||||||
|
click(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.args.preventEventPropagation) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.args.onClick?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exampleGlimmerButtonTemplate = hbs`
|
||||||
|
<button {{on 'click' this.click}} type='button' ...attributes>
|
||||||
|
{{yield}}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
module("Unit | Lib | ember-events", function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register(
|
||||||
|
"component:example-classic-button",
|
||||||
|
ExampleClassicButton
|
||||||
|
);
|
||||||
|
this.owner.register(
|
||||||
|
"template:components/example-classic-button",
|
||||||
|
exampleClassicButtonTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
this.owner.register(
|
||||||
|
"component:example-glimmer-button",
|
||||||
|
ExampleGlimmerButton
|
||||||
|
);
|
||||||
|
this.owner.register(
|
||||||
|
"template:components/example-glimmer-button",
|
||||||
|
exampleGlimmerButtonTemplate
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
module("classic component event configuration", function () {
|
||||||
|
test("it adds listeners for standard event handlers on the component prototype or the component itself", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onOneClick: () => this.set("oneClicked", i++),
|
||||||
|
onTwoClick: () => this.set("twoClicked", i++),
|
||||||
|
oneClicked: undefined,
|
||||||
|
twoClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="buttonOne" @onClick={{this.onOneClick}} />
|
||||||
|
<ExampleClassicButton id="buttonTwo" @click={{this.onTwoClick}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#buttonOne");
|
||||||
|
await click("#buttonTwo");
|
||||||
|
|
||||||
|
assert.strictEqual(this.oneClicked, 0);
|
||||||
|
assert.strictEqual(this.twoClicked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it adds listeners for standard event handlers on the component itself or the component prototype (order reversed)", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onOneClick: () => this.set("oneClicked", i++),
|
||||||
|
onTwoClick: () => this.set("twoClicked", i++),
|
||||||
|
oneClicked: undefined,
|
||||||
|
twoClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="buttonOne" @click={{this.onOneClick}} />
|
||||||
|
<ExampleClassicButton id="buttonTwo" @onClick={{this.onTwoClick}} />
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#buttonOne");
|
||||||
|
await click("#buttonTwo");
|
||||||
|
|
||||||
|
assert.strictEqual(this.oneClicked, 0);
|
||||||
|
assert.strictEqual(this.twoClicked, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module("nested glimmer inside classic", function () {
|
||||||
|
test("it handles click events and allows propagation by default", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<ExampleGlimmerButton id="childButton" @onClick={{this.onChildClick}} />
|
||||||
|
</ExampleClassicButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it handles click events and can prevent event propagation", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<ExampleGlimmerButton id="childButton" @preventEventPropagation={{true}} @onClick={{this.onChildClick}} />
|
||||||
|
</ExampleClassicButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module("nested classic inside glimmer", function () {
|
||||||
|
test("it handles click events and allows propagation by default", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleGlimmerButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<ExampleClassicButton id="childButton" @onClick={{this.onChildClick}} />
|
||||||
|
</ExampleGlimmerButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it handles click events and can prevent event propagation", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleGlimmerButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<ExampleClassicButton id="childButton" @preventEventPropagation={{true}} @onClick={{this.onChildClick}} />
|
||||||
|
</ExampleGlimmerButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module("nested `{{action}}` usage inside classic", function () {
|
||||||
|
test("it handles click events and allows propagation by default", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<button id="childButton" {{action this.onChildClick}} />
|
||||||
|
</ExampleClassicButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it handles click events and can prevent event propagation", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleClassicButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<button id="childButton" {{action this.onChildClick bubbles=false}} />
|
||||||
|
</ExampleClassicButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module("nested `{{action}}` usage inside glimmer", function () {
|
||||||
|
test("it handles click events and allows propagation by default", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleGlimmerButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<button id="childButton" {{action this.onChildClick}} />
|
||||||
|
</ExampleGlimmerButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it handles click events and can prevent event propagation", async function (assert) {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
onParentClick: () => this.set("parentClicked", i++),
|
||||||
|
onChildClick: () => this.set("childClicked", i++),
|
||||||
|
parentClicked: undefined,
|
||||||
|
childClicked: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<ExampleGlimmerButton id="parentButton" @onClick={{this.onParentClick}}>
|
||||||
|
<button id="childButton" {{action this.onChildClick bubbles=false}} />
|
||||||
|
</ExampleGlimmerButton>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click("#childButton");
|
||||||
|
|
||||||
|
assert.strictEqual(this.childClicked, 0);
|
||||||
|
assert.strictEqual(this.parentClicked, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue