Skip to content

Commit

Permalink
Add jQuery-based event dispatcher as part of RFC386
Browse files Browse the repository at this point in the history
  • Loading branch information
simonihmig committed Feb 7, 2019
1 parent 1daeb95 commit 295c3cc
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 22 deletions.
136 changes: 136 additions & 0 deletions addon/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
import { assert } from '@ember/debug';
import { get, set } from '@ember/object';
import Ember from 'ember';
import jQuery from 'jquery';

const ActionManager = Ember.__loader.require('@ember/-internals/views/lib/system/action_manager').default;

const ROOT_ELEMENT_CLASS = 'ember-application';
const ROOT_ELEMENT_SELECTOR = `.${ROOT_ELEMENT_CLASS}`;

export default Ember.EventDispatcher.extend({

rootElement: 'body',

init() {
this._super();

assert(
'EventDispatcher should never be instantiated in fastboot mode. Please report this as an Ember bug.',
(() => {
let owner = getOwner(this);
let environment = owner.lookup('-environment:main');

return environment.isInteractive;
})()
);

this._eventHandlers = Object.create(null);
},

setup(addedEvents, _rootElement) {
let events = (this._finalEvents = assign({}, get(this, 'events'), addedEvents));

if (_rootElement !== undefined && _rootElement !== null) {
set(this, 'rootElement', _rootElement);
}

let rootElementSelector = get(this, 'rootElement');
let rootElement = jQuery(rootElementSelector);
assert(
`You cannot use the same root element (${rootElement.selector ||
rootElement[0].tagName}) multiple times in an Ember.Application`,
!rootElement.is(ROOT_ELEMENT_SELECTOR)
);
assert(
'You cannot make a new Ember.Application using a root element that is a descendent of an existing Ember.Application',
!rootElement.closest(ROOT_ELEMENT_SELECTOR).length
);
assert(
'You cannot make a new Ember.Application using a root element that is an ancestor of an existing Ember.Application',
!rootElement.find(ROOT_ELEMENT_SELECTOR).length
);

rootElement.addClass(ROOT_ELEMENT_CLASS);

if (!rootElement.is(ROOT_ELEMENT_SELECTOR)) {
throw new TypeError(
`Unable to add '${ROOT_ELEMENT_CLASS}' class to root element (${rootElement.selector ||
rootElement[0]
.tagName}). Make sure you set rootElement to the body or an element in the body.`
);
}

let viewRegistry = this._getViewRegistry();

for (let event in events) {
if (events.hasOwnProperty(event)) {
this.setupHandler(rootElement, event, events[event], viewRegistry);
}
}
},

setupHandler(rootElement, event, eventName, viewRegistry) {
if (eventName === null) {
return;
}

rootElement.on(`${event}.ember`, '.ember-view', function(evt) {
let view = viewRegistry[this.id];
let result = true;

if (view) {
result = view.handleEvent(eventName, evt);
}

return result;
});

rootElement.on(`${event}.ember`, '[data-ember-action]', evt => {
let attributes = evt.currentTarget.attributes;
let handledActions = [];

for (let i = 0; i < attributes.length; i++) {
let attr = attributes.item(i);
let attrName = attr.name;

if (attrName.lastIndexOf('data-ember-action-', 0) !== -1) {
let action = ActionManager.registeredActions[attr.value];

// We have to check for action here since in some cases, jQuery will trigger
// an event on `removeChild` (i.e. focusout) after we've already torn down the
// action handlers for the view.
if (action && action.eventName === eventName && handledActions.indexOf(action) === -1) {
action.handler(evt);
// Action handlers can mutate state which in turn creates new attributes on the element.
// This effect could cause the `data-ember-action` attribute to shift down and be invoked twice.
// To avoid this, we keep track of which actions have been handled.
handledActions.push(action);
}
}
}
});
},

destroy() {
let rootElementSelector = get(this, 'rootElement');
let rootElement;
if (rootElementSelector.nodeType) {
rootElement = rootElementSelector;
} else {
rootElement = document.querySelector(rootElementSelector);
}

if (!rootElement) {
return;
}

jQuery(rootElementSelector).off('.ember', '**');

rootElement.classList.remove(ROOT_ELEMENT_CLASS);

return this._super(...arguments);
}
});
1 change: 1 addition & 0 deletions app/event_dispatcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@ember/jquery';
30 changes: 24 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
'use strict';

const EMBER_VERSION_WITH_JQUERY_DEPRECATION = '3.9.0-alpha.1';
const EMBER_VERSION_WITHOUT_JQUERY_SUPPORT = '4.0.0-alpha.1';

module.exports = {
name: require('./package').name,
included() {
this._super.included.apply(this, arguments);

init() {
this._super.init.apply(this, arguments);

const VersionChecker = require('ember-cli-version-checker');

let checker = new VersionChecker(this);
this._ember = checker.forEmber();
},

included() {
this._super.included.apply(this, arguments);

let app = this._findHost();
let optionalFeatures = app.project.findAddonByName("@ember/optional-features");

Expand All @@ -18,10 +27,7 @@ module.exports = {

app.import('vendor/shims/jquery.js');

let checker = new VersionChecker(this);
let ember = checker.forEmber();

if (ember.gte(EMBER_VERSION_WITH_JQUERY_DEPRECATION)) {
if (this._ember.gte(EMBER_VERSION_WITH_JQUERY_DEPRECATION)) {
app.import('vendor/jquery/component.dollar.js');
}

Expand All @@ -30,6 +36,18 @@ module.exports = {
}
},

treeForAddon() {
if (this._ember.gte(EMBER_VERSION_WITHOUT_JQUERY_SUPPORT)) {
return this._super.treeForAddon.apply(this, arguments);
}
},

treeForApp() {
if (this._ember.gte(EMBER_VERSION_WITHOUT_JQUERY_SUPPORT)) {
return this._super.treeForApp.apply(this, arguments);
}
},

treeForVendor: function(tree) {
const BroccoliMergeTrees = require('broccoli-merge-trees');
const Funnel = require('broccoli-funnel');
Expand Down
133 changes: 133 additions & 0 deletions tests/integration/components/event-dispatcher-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import QUnit, { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, focus, blur } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import Component from '@ember/component';
import jQuery from 'jquery';

function assertJqEvent(event) {
let assert = QUnit.assert;
assert.ok(event, 'event was fired!');
assert.ok(event instanceof jQuery.Event, 'event is a jQuery event');
assert.ok(event.originalEvent, 'event has originalEvent');
}

module('Integration | EventDispatcher', function(hooks) {
setupRenderingTest(hooks);

test('a component can handle the click event', async function(assert) {
assert.expect(3);

this.owner.register('component:handles-click', Component.extend({
click(e) {
assertJqEvent(e);
}
}));
this.owner.register('template:components/handles-click', hbs`<button>Click me</button>`);

await render(hbs`{{handles-click id='clickey'}}`);
await click('#clickey');
});

test('actions are properly looked up when clicked directly', async function(assert) {
assert.expect(1);

this.owner.register('component:handles-click', Component.extend({
actions: {
handleClick() {
assert.ok(true, 'click was fired!');
}
}
}));
this.owner.register('template:components/handles-click', hbs`<button {{action 'handleClick'}}>Click me</button>`);

await render(hbs`{{handles-click id='clickey'}}`);
await click('button');
});

test('actions are properly looked up when clicking nested contents', async function(assert) {
assert.expect(1);

this.owner.register('component:handles-click', Component.extend({
actions: {
handleClick() {
assert.ok(true, 'click was fired!');
}
}
}));
this.owner.register('template:components/handles-click', hbs`<div {{action 'handleClick'}}><button>Click me</button></div>`);

await render(hbs`{{handles-click id='clickey'}}`);
await click('button');
});

test('unhandled events do not trigger an error', async function(assert) {
assert.expect(0);

await render(hbs`<button>Click Me!</button>`);
await click('button');
});

test('events bubble up', async function(assert) {
assert.expect(3);

this.owner.register('component:handles-focusout', Component.extend({
focusOut(e) {
assertJqEvent(e);
}
}));
this.owner.register('component:input-element', Component.extend({
tagName: 'input',

focusOut() {
}
}));

await render(hbs`{{#handles-focusout}}{{input-element}}{{/handles-focusout}}`);
await focus('input');
await blur('input');
});

test('events are not stopped by default', async function(assert) {
assert.expect(4);

this.set('submit', (e) => {
e.preventDefault();
assert.ok('submit was fired!');
});

this.owner.register('component:submit-button', Component.extend({
tagName: 'button',
attributeBindings: ['type'],
type: 'submit',
click(e) {
assertJqEvent(e);
}
}));

await render(hbs`<form onsubmit={{action submit}}>{{submit-button}}</form>`);
await click('button');
});

test('events are stopped when returning false from view handler', async function(assert) {
assert.expect(3);

this.set('submit', (e) => {
e.preventDefault();
assert.notOk(true, 'submit should not be fired!');
});

this.owner.register('component:submit-button', Component.extend({
tagName: 'button',
attributeBindings: ['type'],
type: 'submit',
click(e) {
assertJqEvent(e);
return false;
}
}));

await render(hbs`<form onsubmit={{action submit}}>{{submit-button}}</form>`);
await click('button');
});
});
16 changes: 0 additions & 16 deletions vendor/component.dollar.js

This file was deleted.

0 comments on commit 295c3cc

Please sign in to comment.