diff --git a/cjs/interface/document.js b/cjs/interface/document.js index c75eb57b..01fdc8e3 100644 --- a/cjs/interface/document.js +++ b/cjs/interface/document.js @@ -159,6 +159,13 @@ class Document extends NonElementParentNode { get isConnected() { return true; } + /** + * @protected + */ + _getParent() { + return this[EVENT_TARGET]; + } + createAttribute(name) { return new Attr(this, name); } createComment(textContent) { return new Comment(this, textContent); } createDocumentFragment() { return new DocumentFragment(this); } diff --git a/cjs/interface/event-target.js b/cjs/interface/event-target.js index cc94df79..063a8b82 100644 --- a/cjs/interface/event-target.js +++ b/cjs/interface/event-target.js @@ -1,12 +1,50 @@ 'use strict'; // https://dom.spec.whatwg.org/#interface-eventtarget -const EventTarget = (m => /* c8 ignore start */ m.__esModule ? m.default : m /* c8 ignore stop */)(require('@ungap/event-target')); +const wm = new WeakMap(); + +function dispatch({ target, listener}) { + if (typeof listener === 'function') { + listener.call(target, this); + } else { + listener.handleEvent(this); + } + return this._stopImmediatePropagationFlag; +} + +function invokeListeners({currentTarget, target}) { + const map = wm.get(currentTarget); + if (map && map.has(this.type)) { + const listeners = map.get(this.type); + if (currentTarget === target) { + this.eventPhase = this.AT_TARGET; + } else { + this.eventPhase = this.BUBBLING_PHASE; + } + + this.currentTarget = currentTarget; + this.target = target; + for (const [listener, options] of listeners) { + if (options && options.once) + listeners.delete(listener); + if (dispatch.call(this, {target: this, listener})) + break; + } + delete this.currentTarget; + delete this.target; + return this.cancelBubble; + } +} + /** * @implements globalThis.EventTarget */ -class DOMEventTarget extends EventTarget { +class DOMEventTarget { + + constructor() { + wm.set(this, new Map); + } /** * @protected @@ -15,24 +53,39 @@ class DOMEventTarget extends EventTarget { return null; } + addEventListener(type, listener, options) { + const map = wm.get(this); + if (!map.has(type)) + map.set(type, new Map); + map.get(type).set(listener, options); + } + + removeEventListener(type, listener) { + const map = wm.get(this); + if (map.has(type)) { + const listeners = map.get(type); + if (listeners.delete(listener) && !listeners.size) { + map.delete(type); + } + } + } + dispatchEvent(event) { - const dispatched = super.dispatchEvent(event); + let node = this; + event.eventPhase = event.CAPTURING_PHASE; // intentionally simplified, specs imply way more code: https://dom.spec.whatwg.org/#event-path - if (dispatched && event.bubbles && !event.cancelBubble) { - const parent = this._getParent(); - if (parent && parent.dispatchEvent) { - const options = { - bubbles: event.bubbles, - cancelable: event.cancelable, - composed: event.composed, - }; - // in Node 16.5 the same event can't be used for another dispatch - return parent.dispatchEvent(new event.constructor(event.type, options)); - } + while (node) { + if (node.dispatchEvent) + event._path.push({currentTarget: node, target: this}); + node = event.bubbles && node._getParent && node._getParent(); } - return dispatched; + event._path.some(invokeListeners, event); + event._path = []; + event.eventPhase = event.NONE; + return !event.defaultPrevented; } + } exports.EventTarget = DOMEventTarget; diff --git a/cjs/interface/event.js b/cjs/interface/event.js index ae77cef7..70ed25a6 100644 --- a/cjs/interface/event.js +++ b/cjs/interface/event.js @@ -4,18 +4,19 @@ /* c8 ignore start */ // Node 15 has Event but 14 and 12 don't - const BUBBLING_PHASE = 3; +const AT_TARGET = 2; const CAPTURING_PHASE = 1; +const NONE = 0; /** * @implements globalThis.Event */ -const GlobalEvent = typeof Event === 'function' ? - Event : - class Event { +class GlobalEvent { static get BUBBLING_PHASE() { return BUBBLING_PHASE; } + static get AT_TARGET() { return AT_TARGET; } static get CAPTURING_PHASE() { return CAPTURING_PHASE; } + static get NONE() { return NONE; } constructor(type, eventInitDict = {}) { this.type = type; @@ -23,47 +24,38 @@ const GlobalEvent = typeof Event === 'function' ? this.cancelBubble = false; this._stopImmediatePropagationFlag = false; this.cancelable = !!eventInitDict.cancelable; - this.eventPhase = this.BUBBLING_PHASE; + this.eventPhase = this.NONE; this.timeStamp = Date.now(); this.defaultPrevented = false; this.originalTarget = null; this.returnValue = null; this.srcElement = null; this.target = null; + this._path = []; } get BUBBLING_PHASE() { return BUBBLING_PHASE; } + get AT_TARGET() { return AT_TARGET; } get CAPTURING_PHASE() { return CAPTURING_PHASE; } + get NONE() { return NONE; } preventDefault() { this.defaultPrevented = true; } - // TODO: what do these do in native NodeJS Event ? + // simplified implementation, should be https://dom.spec.whatwg.org/#dom-event-composedpath + composedPath() { + return this._path; + } + stopPropagation() { this.cancelBubble = true; } stopImmediatePropagation() { + this.stopPropagation(); this._stopImmediatePropagationFlag = true; } - }; - - - -/** - * @implements globalThis.Event - */ -class DOMEvent extends GlobalEvent { - // specs: "set this’s stop propagation flag and this’s stop immediate propagation flag" - // https://dom.spec.whatwg.org/#dom-event-stopimmediatepropagation - // but Node don't do that so for now we extend it - stopImmediatePropagation() { - super.stopPropagation(); - if (typeof super.stopImmediatePropagation === 'function') - super.stopImmediatePropagation(); - } } - -exports.Event = DOMEvent; +exports.Event = GlobalEvent; /* c8 ignore stop */ diff --git a/esm/interface/document.js b/esm/interface/document.js index dbc32390..9bdc0b10 100644 --- a/esm/interface/document.js +++ b/esm/interface/document.js @@ -159,6 +159,13 @@ export class Document extends NonElementParentNode { get isConnected() { return true; } + /** + * @protected + */ + _getParent() { + return this[EVENT_TARGET]; + } + createAttribute(name) { return new Attr(this, name); } createComment(textContent) { return new Comment(this, textContent); } createDocumentFragment() { return new DocumentFragment(this); } diff --git a/esm/interface/event-target.js b/esm/interface/event-target.js index c07a092e..25edf24e 100644 --- a/esm/interface/event-target.js +++ b/esm/interface/event-target.js @@ -1,11 +1,49 @@ // https://dom.spec.whatwg.org/#interface-eventtarget -import EventTarget from '@ungap/event-target'; +const wm = new WeakMap(); + +function dispatch({ target, listener}) { + if (typeof listener === 'function') { + listener.call(target, this); + } else { + listener.handleEvent(this); + } + return this._stopImmediatePropagationFlag; +} + +function invokeListeners({currentTarget, target}) { + const map = wm.get(currentTarget); + if (map && map.has(this.type)) { + const listeners = map.get(this.type); + if (currentTarget === target) { + this.eventPhase = this.AT_TARGET; + } else { + this.eventPhase = this.BUBBLING_PHASE; + } + + this.currentTarget = currentTarget; + this.target = target; + for (const [listener, options] of listeners) { + if (options && options.once) + listeners.delete(listener); + if (dispatch.call(this, {target: this, listener})) + break; + } + delete this.currentTarget; + delete this.target; + return this.cancelBubble; + } +} + /** * @implements globalThis.EventTarget */ -class DOMEventTarget extends EventTarget { +class DOMEventTarget { + + constructor() { + wm.set(this, new Map); + } /** * @protected @@ -14,24 +52,39 @@ class DOMEventTarget extends EventTarget { return null; } + addEventListener(type, listener, options) { + const map = wm.get(this); + if (!map.has(type)) + map.set(type, new Map); + map.get(type).set(listener, options); + } + + removeEventListener(type, listener) { + const map = wm.get(this); + if (map.has(type)) { + const listeners = map.get(type); + if (listeners.delete(listener) && !listeners.size) { + map.delete(type); + } + } + } + dispatchEvent(event) { - const dispatched = super.dispatchEvent(event); + let node = this; + event.eventPhase = event.CAPTURING_PHASE; // intentionally simplified, specs imply way more code: https://dom.spec.whatwg.org/#event-path - if (dispatched && event.bubbles && !event.cancelBubble) { - const parent = this._getParent(); - if (parent && parent.dispatchEvent) { - const options = { - bubbles: event.bubbles, - cancelable: event.cancelable, - composed: event.composed, - }; - // in Node 16.5 the same event can't be used for another dispatch - return parent.dispatchEvent(new event.constructor(event.type, options)); - } + while (node) { + if (node.dispatchEvent) + event._path.push({currentTarget: node, target: this}); + node = event.bubbles && node._getParent && node._getParent(); } - return dispatched; + event._path.some(invokeListeners, event); + event._path = []; + event.eventPhase = event.NONE; + return !event.defaultPrevented; } + } export { DOMEventTarget as EventTarget }; diff --git a/esm/interface/event.js b/esm/interface/event.js index d2675793..d26705cb 100644 --- a/esm/interface/event.js +++ b/esm/interface/event.js @@ -3,18 +3,19 @@ /* c8 ignore start */ // Node 15 has Event but 14 and 12 don't - const BUBBLING_PHASE = 3; +const AT_TARGET = 2; const CAPTURING_PHASE = 1; +const NONE = 0; /** * @implements globalThis.Event */ -const GlobalEvent = typeof Event === 'function' ? - Event : - class Event { +class GlobalEvent { static get BUBBLING_PHASE() { return BUBBLING_PHASE; } + static get AT_TARGET() { return AT_TARGET; } static get CAPTURING_PHASE() { return CAPTURING_PHASE; } + static get NONE() { return NONE; } constructor(type, eventInitDict = {}) { this.type = type; @@ -22,47 +23,38 @@ const GlobalEvent = typeof Event === 'function' ? this.cancelBubble = false; this._stopImmediatePropagationFlag = false; this.cancelable = !!eventInitDict.cancelable; - this.eventPhase = this.BUBBLING_PHASE; + this.eventPhase = this.NONE; this.timeStamp = Date.now(); this.defaultPrevented = false; this.originalTarget = null; this.returnValue = null; this.srcElement = null; this.target = null; + this._path = []; } get BUBBLING_PHASE() { return BUBBLING_PHASE; } + get AT_TARGET() { return AT_TARGET; } get CAPTURING_PHASE() { return CAPTURING_PHASE; } + get NONE() { return NONE; } preventDefault() { this.defaultPrevented = true; } - // TODO: what do these do in native NodeJS Event ? + // simplified implementation, should be https://dom.spec.whatwg.org/#dom-event-composedpath + composedPath() { + return this._path; + } + stopPropagation() { this.cancelBubble = true; } stopImmediatePropagation() { + this.stopPropagation(); this._stopImmediatePropagationFlag = true; } - }; - - - -/** - * @implements globalThis.Event - */ -class DOMEvent extends GlobalEvent { - // specs: "set this’s stop propagation flag and this’s stop immediate propagation flag" - // https://dom.spec.whatwg.org/#dom-event-stopimmediatepropagation - // but Node don't do that so for now we extend it - stopImmediatePropagation() { - super.stopPropagation(); - if (typeof super.stopImmediatePropagation === 'function') - super.stopImmediatePropagation(); - } } - -export {DOMEvent as Event}; +export {GlobalEvent as Event}; /* c8 ignore stop */ diff --git a/package-lock.json b/package-lock.json index 7788b342..8b75eafd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,9 @@ "requires": true, "packages": { "": { - "name": "linkedom", "version": "0.12.1", "license": "ISC", "dependencies": { - "@ungap/event-target": "^0.2.3", "css-select": "^4.1.3", "cssom": "^0.5.0", "html-escaper": "^3.0.3", @@ -300,11 +298,6 @@ "@types/node": "*" } }, - "node_modules/@ungap/event-target": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@ungap/event-target/-/event-target-0.2.3.tgz", - "integrity": "sha512-7Bz0qdvxNGV9n0f+xcMKU7wsEfK6PNzo8IdAcOiBgMNyCuU0Mk9dv0Hbd/Kgr+MFFfn4xLHFbuOt820egT5qEA==" - }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -2275,11 +2268,6 @@ "@types/node": "*" } }, - "@ungap/event-target": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@ungap/event-target/-/event-target-0.2.3.tgz", - "integrity": "sha512-7Bz0qdvxNGV9n0f+xcMKU7wsEfK6PNzo8IdAcOiBgMNyCuU0Mk9dv0Hbd/Kgr+MFFfn4xLHFbuOt820egT5qEA==" - }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", diff --git a/package.json b/package.json index b5eb2391..c4d27fa8 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "./worker": "./worker.js" }, "dependencies": { - "@ungap/event-target": "^0.2.3", "css-select": "^4.1.3", "cssom": "^0.5.0", "html-escaper": "^3.0.3", diff --git a/test/interface/event-target.js b/test/interface/event-target.js index 4e0d67a3..37d80dd3 100644 --- a/test/interface/event-target.js +++ b/test/interface/event-target.js @@ -2,7 +2,7 @@ const assert = require('../assert.js').for('EventTarget'); const { parseHTML } = global[Symbol.for('linkedom')]; -const { Event, document, EventTarget } = parseHTML( +const { Event, document, window, EventTarget } = parseHTML( '
', ); @@ -16,8 +16,10 @@ const basicHandler = () => { const eventTarget = new EventTarget(); eventTarget.addEventListener('foo', basicHandler); +eventTarget.addEventListener('foo', basicHandler); +eventTarget.addEventListener('foo', () => {}); eventTarget.dispatchEvent(new Event('foo')); -assert(callCount, 1, 'basicHandler should have been called'); +assert(callCount, 1, 'basicHandler should have been called once'); assert( eventTarget.dispatchEvent(new Event('click')), @@ -27,6 +29,7 @@ assert( assert(callCount, 1, 'Dispatching an event type should only call appropriate listeners'); eventTarget.removeEventListener('foo', basicHandler); +eventTarget.removeEventListener('click', basicHandler); eventTarget.dispatchEvent(new Event('foo')); assert(callCount, 1, 'basicHandler should not have been called after being removed'); @@ -34,21 +37,114 @@ assert(eventTarget._getParent(), null, 'getParent should return null'); // check propagation now +const BUBBLING_PHASE = 3; +const AT_TARGET = 2; + callCount = 0; const buttonTarget = document.getElementById('buttonTarget'); const containerTarget = document.getElementById('container'); const bodyTarget = document; -buttonTarget.addEventListener('click', basicHandler, { once: true }); -containerTarget.addEventListener('click', basicHandler, { once: true }); -bodyTarget.addEventListener('click', basicHandler, { once: true }); -buttonTarget.dispatchEvent(new Event('click', { bubbles: true })); -assert(callCount, 3, 'Event bubbling, listener should be called 3 times'); +buttonTarget.addEventListener( + 'click', + event => { + basicHandler(); + assert( + event.target, + buttonTarget, + 'Event bubbling, target should be the button' + ); + assert( + event.currentTarget, + buttonTarget, + 'Event bubbling, current target should be the button' + ); + assert( + event.composedPath().length, + 5, + 'Event bubbling, composed path should have 5 EventTarget' + ); + assert( + event.eventPhase, + AT_TARGET, + 'Event bubbling, eventPhase should be AT_TARGET' + ); + }, + { once: true } +); + +containerTarget.addEventListener('click', event => { + basicHandler(); + assert( + event.target, + buttonTarget, + 'Event bubbling, target should be the button' + ); + assert( + event.currentTarget, + containerTarget, + 'Event bubbling, current target should be the container' + ); + assert( + event.eventPhase, + BUBBLING_PHASE, + 'Event bubbling, eventPhase should be BUBBLING_PHASE' + ); +}, { once: true }); + +containerTarget.addEventListener('click', () => { + basicHandler(); +}, { once: true }); + +bodyTarget.addEventListener('click', event => { + basicHandler(); + assert( + event.target, + buttonTarget, + 'Event bubbling, target should be the button' + ); + assert( + event.currentTarget, + bodyTarget, + 'Event bubbling, current target should be the body' + ); +}, { once: true }); + + +document.addEventListener('click', event => { + basicHandler(); + assert( + event.target, + buttonTarget, + 'Event bubbling, target should be the button' + ); + assert( + event.currentTarget, + document, + 'Event bubbling, current target should be the document' + ); +}, { once: true }); + +window.addEventListener('click', event => { + basicHandler(); + assert( + event.target, + buttonTarget, + 'Event bubbling, target should be the button' + ); + assert( + event.currentTarget !== buttonTarget, + true, + 'Event bubbling, current target should be the window' + ); +}, { once: true }); +buttonTarget.dispatchEvent(new Event('click', { bubbles: true })); +assert(callCount, 6, 'Event bubbling, listener should be called 6 times'); // ensure once removed listeners buttonTarget.dispatchEvent(new Event('click', { bubbles: true })); -assert(callCount, 3, 'listeners should only have been called once then removed'); +assert(callCount, 6, 'listeners should only have been called once then removed'); // check no bubbling callCount = 0; diff --git a/types/interface/custom-event.d.ts b/types/interface/custom-event.d.ts index 07ab20a1..5e12d94a 100644 --- a/types/interface/custom-event.d.ts +++ b/types/interface/custom-event.d.ts @@ -3,8 +3,35 @@ export { GlobalCustomEvent as CustomEvent }; * @implements globalThis.CustomEvent */ declare const GlobalCustomEvent: { + new