diff --git a/addon/index.js b/addon/index.js new file mode 100644 index 0000000..dc0b792 --- /dev/null +++ b/addon/index.js @@ -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); + } +}); diff --git a/app/event_dispatcher.js b/app/event_dispatcher.js new file mode 100644 index 0000000..4fae5de --- /dev/null +++ b/app/event_dispatcher.js @@ -0,0 +1 @@ +export { default } from '@ember/jquery'; diff --git a/index.js b/index.js index 4f203c7..ec81ea5 100644 --- a/index.js +++ b/index.js @@ -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"); @@ -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'); } @@ -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'); diff --git a/tests/integration/components/event-dispatcher-test.js b/tests/integration/components/event-dispatcher-test.js new file mode 100644 index 0000000..e5afef2 --- /dev/null +++ b/tests/integration/components/event-dispatcher-test.js @@ -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``); + + 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``); + + 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`
`); + + 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``); + 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``); + 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``); + await click('button'); + }); +});