Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add jQuery-based event dispatcher as part of RFC386 #31

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});