diff --git a/src/index.js b/src/index.js index 606486e..e18bb86 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,6 @@ "use strict"; var domino = require("domino"); -var validateElementName = require("validate-element-name"); /** * The DOM object (components.dom) exposes tradition DOM objects (normally globally available @@ -17,62 +16,39 @@ exports.dom = domino.impl; * with an element name, and options (typically including the prototype returned here as your * 'prototype' value). */ -exports.newElement = function newElement() { - return Object.create(domino.impl.HTMLElement.prototype); -}; +var CustomElementRegistry = require('./registry'); +exports.customElements = CustomElementRegistry.instance(); +exports.HTMLElement = CustomElementRegistry.HTMLElement; -var registeredElements = {}; +const _upgradedProp = '__$CE_upgraded'; /** - * Registers an element, so that it will be used when the given element name is found during parsing. - * - * Element names are required to contain a hyphen (to disambiguate them from existing element names), - * be entirely lower-case, and not start with a hyphen. + * Registers a transformer for a tag that is intended to run server-side. * - * The only option currently supported is 'prototype', which sets the prototype of the given element. - * This prototype will have its various callbacks called when it is found during document parsing, - * and properties of the prototype will be exposed within the DOM to other elements there in turn. + * At the moment, only one transformer is permitted per tag. */ -exports.registerElement = function registerElement(name, options) { - var nameValidationResult = validateElementName(name); - if (!nameValidationResult.isValid) { - throw new Error(`Registration failed for '${name}'. ${nameValidationResult.message}`); - } - - if (options && options.prototype) { - registeredElements[name] = options.prototype; - } else { - registeredElements[name] = exports.newElement(); - } - - return registeredElements[name].constructor; -}; +var transformers = {}; -/** - * Registers an element that is intended to run server-side only, and thus - * replaced with a resolved value or nothing. - * - * Server-side elements ARE NOT required to contain a hyphen. - */ -exports.registerServerElement = function registerServerElement(name, handler) { - if ( registeredElements[name] && typeof registeredElements[name] !== 'function' ) { - throw new Error(`Registration failed for '${name}'. Name is already taken by a non-server-side element.`); +exports.registerTransformer = function registerTransformer (name, handler) { + if ( transformers[name] && typeof transformers[name] !== 'function' ) { + throw new Error(`Registration failed for '${name}'. Name is already taken by another transformer.`); } - registeredElements[name] = handler; + transformers[name] = handler; return handler; }; +function transformTree(document, visitedNodes, currentNode, callback) { -function transformTree(document, currentNode, callback) { + var task = visitedNodes.has(currentNode) ? undefined : callback(currentNode); - var task = callback(currentNode); + visitedNodes.add(currentNode); if ( task !== undefined ) { let replaceNode = function replaceNode (results) { if (results === null) { - currentNode.parentNode.removeChild(currentNode) - return Promise.resolve() + currentNode.parentNode.removeChild(currentNode); + return Promise.resolve(); } if (typeof results === 'string') { var temp = document.createElement('template'); @@ -84,24 +60,24 @@ function transformTree(document, currentNode, callback) { var newNodes = results.length ? slice.call(results) : [results]; newNodes.map( (newNode) => { - newNode.parentNode === currentNode && currentNode.removeChild(newNode); + if (newNode.parentNode === currentNode) currentNode.removeChild(newNode); fragment.appendChild(newNode); }); currentNode.parentNode.replaceChild(fragment, currentNode); return Promise.all( - newNodes.map((child) => transformTree(document, child, callback)) + newNodes.map((child) => transformTree(document, visitedNodes, child, callback)) ); } else { return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) ); } }; if ( task === null ) { - return replaceNode(null) + return replaceNode(null); } if ( task.then ) { // Promise task; potential transformation @@ -116,7 +92,7 @@ function transformTree(document, currentNode, callback) { // This element has opted to do nothing to itself. // Recurse on its children. return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) ); } } @@ -155,35 +131,63 @@ function renderNode(rootNode) { let createdPromises = []; var document = getDocument(rootNode); + var visitedNodes = new Set(); + var upgradedNodes = new Set(); + var customElements = exports.customElements; + + return transformTree(document, visitedNodes, rootNode, function render (element) { + + var transformer = transformers[element.localName]; - return transformTree(document, rootNode, (foundNode) => { - if (foundNode.tagName) { - let nodeType = foundNode.tagName.toLowerCase(); - let customElement = registeredElements[nodeType]; + if (transformer && ! element.serverTransformed) { + let result = transformer(element, document); + element.serverTransformed = true; - if (customElement && typeof customElement === 'function') { - var subResult = customElement(foundNode, document); + let handleTransformerResult = (result) => { + if ( result === undefined && customElements.get(element.localName) ) { + // Re-render the transformed element as a custom element, + // since a corresponding custom tag is defined. + return render(element); + } + if ( result === undefined ) { + // Replace the element with its children; its server-side duties are fulfilled. + return element.childNodes; + } + else { + // The transformer has opted to do something specific. + return result; + } + }; - // Replace with children by default - return (subResult === undefined) ? null : subResult; + if ( result && result.then ) { + return result.then(handleTransformerResult); } - else if (customElement) { - // TODO: Should probably clone node, not change prototype, for performance - Object.setPrototypeOf(foundNode, customElement); - - if (customElement.createdCallback) { - try { - var result = customElement.createdCallback.call(foundNode, document); - if ( result && result.then ) { - // Client-side custom elements never replace themselves; - // resolve with undefined to prevent such a scenario. - return result.then( () => undefined ); - } - } - catch (err) { - return Promise.reject(err); + else { + return handleTransformerResult(result); + } + } + + const definition = customElements.getDefinition(element.localName); + + if (definition) { + if ( upgradedNodes.has(element[_upgradedProp]) ) { + return; + } + upgradeElement(element, definition, true); + upgradedNodes.add(element); + + if (definition.connectedCallback) { + try { + let result = definition.connectedCallback.call(element, document); + if ( result && result.then ) { + // Client-side custom elements never replace themselves; + // resolve with undefined to prevent such a scenario. + return result.then( () => undefined ); } } + catch (err) { + return Promise.reject(err); + } } } }) @@ -236,6 +240,35 @@ function getDocument(rootNode) { } } +function upgradeElement (element, definition, callConstructor) { + const prototype = definition.constructor.prototype; + Object.setPrototypeOf(element, prototype); + if (callConstructor) { + CustomElementRegistry.instance()._setNewInstance(element); + new (definition.constructor)(); + element[_upgradedProp] = true; + console.assert(CustomElementRegistry.instance()._newInstance === null); + } + + const observedAttributes = definition.observedAttributes; + const attributeChangedCallback = definition.attributeChangedCallback; + if (attributeChangedCallback && observedAttributes.length > 0) { + + // Trigger attributeChangedCallback for existing attributes. + // https://html.spec.whatwg.org/multipage/scripting.html#upgrades + for (let i = 0; i < observedAttributes.length; i++) { + const name = observedAttributes[i]; + if (element.hasAttribute(name)) { + const value = element.getAttribute(name); + attributeChangedCallback.call(element, name, null, value, null); + } + } + } + } + +// +// Helpers +// function map (arrayLike, fn) { var results = []; for (var i=0; i < arrayLike.length; i++) { @@ -244,4 +277,8 @@ function map (arrayLike, fn) { return results; } +function isClass(v) { + return typeof v === 'function' && /^\s*class\s+/.test(v.toString()); +} + var slice = Array.prototype.slice; diff --git a/src/registry.js b/src/registry.js new file mode 100644 index 0000000..e7c3cc2 --- /dev/null +++ b/src/registry.js @@ -0,0 +1,315 @@ +var domino = require("domino"); +var Document = require('domino/lib/Document'); +var Element = require('domino/lib/Element'); + +const _upgradedProp = '__$CE_upgraded'; + +const _customElements = () => CustomElementRegistry.instance(); + +/** + * A registry of custom element definitions. + * + * See https://html.spec.whatwg.org/multipage/scripting.html#customelementsregistry + * + * Implementation based on https://github.com/webcomponents/custom-elements/blob/master/src/custom-elements.js + * + */ +var _instance = null; +class CustomElementRegistry { + + static instance () { + if ( ! _instance ) _instance = new CustomElementRegistry(); + return _instance; + } + + constructor() { + this._definitions = new Map(); + this._constructors = new Map(); + this._whenDefinedMap = new Map(); + + this._newInstance = null; + } + + // HTML spec part 4.13.4 + // https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-define + /** + * @param {string} name + * @param {function(new:HTMLElement)} constructor + * @param {{extends: string}} options + * @return {undefined} + */ + define(name, constructor, options) { + // 1: + if (typeof constructor !== 'function') { + throw new TypeError('constructor must be a Constructor'); + } + + // 2. If constructor is an interface object whose corresponding interface + // either is HTMLElement or has HTMLElement in its set of inherited + // interfaces, throw a TypeError and abort these steps. + // + // It doesn't appear possible to check this condition from script + + // 3: + const nameError = checkValidCustomElementName(name); + if (nameError) throw nameError; + + // 4, 5: + // Note: we don't track being-defined names and constructors because + // define() isn't normally reentrant. The only time user code can run + // during define() is when getting callbacks off the prototype, which + // would be highly-unusual. We can make define() reentrant-safe if needed. + if (this._definitions.has(name)) { + throw new Error(`An element with name '${name}' is already defined`); + } + + // 6, 7: + if (this._constructors.has(constructor)) { + throw new Error(`Definition failed for '${name}': ` + + `The constructor is already used.`); + } + + // 8: + /** @type {string} */ + const localName = name; + + // 9, 10: We do not support extends currently. + + // 11, 12, 13: Our define() isn't rentrant-safe + + // 14.1: + const prototype = constructor.prototype; + + // 14.2: + if (typeof prototype !== 'object') { + throw new TypeError(`Definition failed for '${name}': ` + + `constructor.prototype must be an object`); + } + + function getCallback(callbackName) { + const callback = prototype[callbackName]; + if (callback !== undefined && typeof callback !== 'function') { + throw new Error(`${localName} '${callbackName}' is not a Function`); + } + return callback; + } + + // 3, 4: + const connectedCallback = getCallback('connectedCallback'); + + // 5, 6: + const disconnectedCallback = getCallback('disconnectedCallback'); + + // Divergence from spec: we always throw if attributeChangedCallback is + // not a function. + + // 7, 9.1: + const attributeChangedCallback = getCallback('attributeChangedCallback'); + + // 8, 9.2, 9.3: + const observedAttributes = + (attributeChangedCallback && constructor.observedAttributes) || []; + + // 15: + /** @type {CustomElementDefinition} */ + const definition = { + name: name, + localName: localName, + constructor: constructor, + connectedCallback: connectedCallback, + disconnectedCallback: disconnectedCallback, + attributeChangedCallback: attributeChangedCallback, + observedAttributes: observedAttributes, + }; + + // 16: + this._definitions.set(localName, definition); + this._constructors.set(constructor, localName); + + // 17, 18, 19: + // Since we are rendering server-side, no need to upgrade doc; + // custom elements will be defined before rendering takes place. + // this._upgradeDoc(); + + // 20: + const deferred = this._whenDefinedMap.get(localName); + if (deferred) { + deferred.resolve(undefined); + this._whenDefinedMap.delete(localName); + } + } + + /** + * Returns the constructor defined for `name`, or `null`. + * + * @param {string} name + * @return {Function|undefined} + */ + get(name) { + // https://html.spec.whatwg.org/multipage/scripting.html#custom-elements-api + const def = this._definitions.get(name); + return def ? def.constructor : undefined; + } + + /** + * Returns a `Promise` that resolves when a custom element for `name` has + * been defined. + * + * @param {string} name + * @return {!Promise} + */ + whenDefined(name) { + // https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-whendefined + const nameError = checkValidCustomElementName(name); + if (nameError) return Promise.reject(nameError); + if (this._definitions.has(name)) return Promise.resolve(); + + let deferred = this._whenDefinedMap.get(name); + if (deferred) return deferred.promise; + + let resolve; + const promise = new Promise(function(_resolve, _) { + resolve = _resolve; + }); + deferred = {promise, resolve}; + this._whenDefinedMap.set(name, deferred); + return promise; + } + + /** + * @param {?HTMLElement} instance + * @private + */ + _setNewInstance(instance) { + this._newInstance = instance; + } + + /** + * WARNING: NOT PART OF THE SPEC + * + * @param {string} localName + * @return {?CustomElementDefinition} + */ + getDefinition(localName) { + return this._definitions.get(localName); + } + + /** + * WARNING: NOT PART OF THE SPEC + * + * @param {string} localName + * @return {undefined} + */ + undefine(localName) { + this._definitions.delete(localName); + this._constructors.delete(localName); + this._whenDefinedMap.delete(localName); + } +} +exports = module.exports = CustomElementRegistry; + + +// +// Overwrite domino's new element constructor +// +const origHTMLElement = domino.impl.HTMLElement; + +const newHTMLElement = function HTMLElement() { + const customElements = _customElements(); + + // If there's an being upgraded, return that + if (customElements._newInstance) { + const i = customElements._newInstance; + customElements._newInstance = null; + return i; + } + if (this.constructor) { + // Find the tagname of the constructor and create a new element with it + const tagName = customElements._constructors.get(this.constructor); + return _createElement(doc, tagName, undefined, false); + } + throw new Error('Unknown constructor. Did you call customElements.define()?'); +}; +exports.HTMLElement = newHTMLElement; +exports.HTMLElement.prototype = Object.create(domino.impl.HTMLElement.prototype, { + constructor: {value: exports.HTMLElement, configurable: true, writable: true}, +}); + + +// +// Patch document.createElement +// +const _origCreateElement = Document.prototype.createElement; + +/** + * Creates a new element and upgrades it if it's a custom element. + * @param {!Document} doc + * @param {!string} tagName + * @param {Object|undefined} options + * @param {boolean} callConstructor whether or not to call the elements + * constructor after upgrading. If an element is created by calling its + * constructor, then `callConstructor` should be false to prevent double + * initialization. + */ +function _createElement(doc, tagName, options, callConstructor) { + const customElements = _customElements(); + const element = options ? _origCreateElement.call(doc, tagName, options) : + _origCreateElement.call(doc, tagName); + const definition = customElements._definitions.get(tagName.toLowerCase()); + if (definition) { + customElements._upgradeElement(element, definition, callConstructor); + } + return element; +} +Document.prototype.createElement = function(tagName, options) { + return _createElement(this, tagName, options, true); +}; + +// +// Patch doc.createElementNS +// +const HTMLNS = 'http://www.w3.org/1999/xhtml'; +const _origCreateElementNS = Document.prototype.createElementNS; + +Document.prototype.createElementNS = function(namespaceURI, qualifiedName) { + if (namespaceURI === 'http://www.w3.org/1999/xhtml') { + return this.createElement(qualifiedName); + } else { + return _origCreateElementNS.call(this, namespaceURI, qualifiedName); + } +}; + + +/** + * 2.3 + * http://w3c.github.io/webcomponents/spec/custom/#dfn-element-definition + * @typedef {{ + * name: string, + * localName: string, + * constructor: function(new:HTMLElement), + * connectedCallback: (Function|undefined), + * disconnectedCallback: (Function|undefined), + * attributeChangedCallback: (Function|undefined), + * observedAttributes: Array, + * }} + */ +let CustomElementDefinition; + + +const reservedTagList = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', +]; + +function checkValidCustomElementName(name) { + if (!(/^[a-z][.0-9_a-z]*-[\-.0-9_a-z]*$/.test(name) && + reservedTagList.indexOf(name) === -1)) { + return new Error(`The element name '${name}' is not valid.`); + } +} diff --git a/test/asynchrony-test.js b/test/asynchrony-test.js index 4d6bf88..8584491 100644 --- a/test/asynchrony-test.js +++ b/test/asynchrony-test.js @@ -4,16 +4,17 @@ var components = require("../src/index.js"); describe("An asynchronous element", () => { it("blocks rendering until they complete", () => { - var SlowElement = components.newElement(); - SlowElement.createdCallback = function () { - return new Promise((resolve, reject) => { - setTimeout(() => { - this.textContent = "loaded!"; - resolve(); - }, 1); - }); - }; - components.registerElement("slow-element", { prototype: SlowElement }); + class SlowElement extends components.HTMLElement { + connectedCallback() { + return new Promise((resolve, reject) => { + setTimeout(() => { + this.textContent = "loaded!"; + resolve(); + }, 1); + }); + } + } + components.customElements.define("slow-element", SlowElement); return components.renderFragment("").then((output) => { expect(output).to.equal("loaded!"); @@ -21,9 +22,12 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render synchronously", () => { - var FailingElement = components.newElement(); - FailingElement.createdCallback = () => { throw new Error(); }; - components.registerElement("failing-element", { prototype: FailingElement }); + class FailingElement extends components.HTMLElement { + connectedCallback() { + throw new Error(); + } + } + components.customElements.define("failing-element", FailingElement); return components.renderFragment( "" @@ -33,9 +37,13 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render asynchronously", () => { - var FailingElement = components.newElement(); - FailingElement.createdCallback = () => Promise.reject(new Error()); - components.registerElement("failing-element", { prototype: FailingElement }); + class FailingElement extends components.HTMLElement { + connectedCallback() { + return Promise.reject(new Error()); + } + } + components.customElements.undefine("failing-element"); + components.customElements.define("failing-element", FailingElement); return components.renderFragment( "" diff --git a/test/basics-test.js b/test/basics-test.js index 3282670..f037a7a 100644 --- a/test/basics-test.js +++ b/test/basics-test.js @@ -12,9 +12,12 @@ describe("Basic component functionality", () => { }); it("replaces components with their rendered result", () => { - var NewElement = components.newElement(); - NewElement.createdCallback = function () { this.textContent = "hi there"; }; - components.registerElement("my-element", { prototype: NewElement }); + class NewElement extends components.HTMLElement { + connectedCallback() { + this.textContent = "hi there"; + } + } + components.customElements.define("my-element", NewElement); return components.renderFragment("").then((output) => { expect(output).to.equal("hi there"); @@ -22,13 +25,12 @@ describe("Basic component functionality", () => { }); it("can wrap existing content", () => { - var PrefixedElement = components.newElement(); - PrefixedElement.createdCallback = function () { - this.innerHTML = "prefix:" + this.innerHTML; - }; - components.registerElement("prefixed-element", { - prototype: PrefixedElement - }); + class PrefixedElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "prefix:" + this.innerHTML; + } + } + components.customElements.define("prefixed-element", PrefixedElement); return components.renderFragment( "existing-content" @@ -38,12 +40,13 @@ describe("Basic component functionality", () => { }); it("allows attribute access", () => { - var BadgeElement = components.newElement(); - BadgeElement.createdCallback = function () { - var name = this.getAttribute("name"); - this.innerHTML = "My name is:
" + name + "
"; - }; - components.registerElement("name-badge", { prototype: BadgeElement }); + class BadgeElement extends components.HTMLElement { + connectedCallback() { + var name = this.getAttribute("name"); + this.innerHTML = "My name is:
" + name + "
"; + } + } + components.customElements.define("name-badge", BadgeElement); return components.renderFragment( '' @@ -53,13 +56,14 @@ describe("Basic component functionality", () => { }); it("can use normal document methods like QuerySelector", () => { - var SelfFindingElement = components.newElement(); - SelfFindingElement.createdCallback = function (document) { - var hopefullyThis = document.querySelector("self-finding-element"); - if (hopefullyThis === this) this.innerHTML = "Found!"; - else this.innerHTML = "Not found, found " + hopefullyThis; - }; - components.registerElement("self-finding-element", { prototype: SelfFindingElement }); + class SelfFindingElement extends components.HTMLElement { + connectedCallback(document) { + var hopefullyThis = document.querySelector("self-finding-element"); + if (hopefullyThis === this) this.innerHTML = "Found!"; + else this.innerHTML = "Not found, found " + hopefullyThis; + } + } + components.customElements.define("self-finding-element", SelfFindingElement); return components.renderFragment( '' diff --git a/test/element-validation-test.js b/test/element-validation-test.js index d3523c3..9cfba9c 100644 --- a/test/element-validation-test.js +++ b/test/element-validation-test.js @@ -3,45 +3,39 @@ var expect = require('chai').expect; var components = require("../src/index.js"); describe("Custom element validation", () => { - it("allows elements without options", () => { - components.registerElement("my-element"); - - return components.renderFragment(" { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("", { prototype: InvalidElement }); + components.customElements.define("", InvalidElement); }).to.throw( - /Registration failed for ''. Missing element name./ + /The element name '' is not valid./ ); }); it("requires a hyphen in the element name", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("invalidname", { prototype: InvalidElement }); + components.customElements.define("invalidname", InvalidElement); }).to.throw( - /Registration failed for 'invalidname'. Custom element names must contain a hyphen./ + /The element name 'invalidname' is not valid./ ); }); it("doesn't allow elements to start with a hyphen", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("-invalid-name", { prototype: InvalidElement }); + components.customElements.define("-invalid-name", InvalidElement); }).to.throw( - /Registration failed for '-invalid-name'. Custom element names must not start with a hyphen./ + /The element name '-invalid-name' is not valid./ ); }); it("requires element names to be lower case", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("INVALID-NAME", { prototype: InvalidElement }); + components.customElements.define("INVALID-NAME", InvalidElement); }).to.throw( - /Registration failed for 'INVALID-NAME'. Custom element names must not contain uppercase ASCII characters./ + /The element name 'INVALID-NAME' is not valid./ ); }); }); diff --git a/test/example-components.js b/test/example-components.js index 67f03af..7d1d657 100644 --- a/test/example-components.js +++ b/test/example-components.js @@ -6,12 +6,13 @@ var linkify = require("linkifyjs/element"); describe("An example component:", () => { describe("using static rendering", () => { before(() => { - var StaticElement = components.newElement(); - StaticElement.createdCallback = function () { - this.innerHTML = "Hi there"; - }; - - components.registerElement("my-greeting", { prototype: StaticElement }); + class StaticElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "Hi there"; + } + } + components.customElements.undefine("my-greeting"); + components.customElements.define("my-greeting", StaticElement); }); it("replaces its content with the given text", () => { @@ -23,15 +24,16 @@ describe("An example component:", () => { describe("using dynamic logic for rendering", () => { before(() => { - var CounterElement = components.newElement(); var currentCount = 0; - CounterElement.createdCallback = function () { - currentCount += 1; - this.innerHTML = "There have been " + currentCount + " visitors."; - }; - - components.registerElement("visitor-counter", { prototype: CounterElement }); + class CounterElement extends components.HTMLElement { + connectedCallback() { + currentCount += 1; + this.innerHTML = "There have been " + currentCount + " visitors."; + } + } + components.customElements.undefine("visitor-counter"); + components.customElements.define("visitor-counter", CounterElement); }); it("dynamically changes its content", () => { @@ -49,14 +51,14 @@ describe("An example component:", () => { describe("parameterised by HTML content", () => { before(() => { - var LinkifyElement = components.newElement(); - - LinkifyElement.createdCallback = function (document) { - // Delegate the whole thing to a real normal front-end library! - linkify(this, { target: () => null, linkClass: "autolinked" }, document); - }; - - components.registerElement("linkify-urls", { prototype: LinkifyElement }); + class LinkifyElement extends components.HTMLElement { + connectedCallback(document) { + // Delegate the whole thing to a real front-end library! + linkify(this, { target: () => null, linkClass: "autolinked" }, document); + } + } + components.customElements.undefine("linkify-urls"); + components.customElements.define("linkify-urls", LinkifyElement); }); it("should be able to parse and manipulate it's content", () => { diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index b067e91..350db80 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -5,13 +5,13 @@ var components = require("../src/index.js"); describe("When multiple DOM elements are present", () => { describe("nested elements", () => { it("are rendered correctly", () => { - var PrefixedElement = components.newElement(); - PrefixedElement.createdCallback = function () { - this.innerHTML = "prefix:" + this.innerHTML; - }; - components.registerElement("prefixed-element", { - prototype: PrefixedElement - }); + class PrefixedElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "prefix:" + this.innerHTML; + } + } + components.customElements.undefine("prefixed-element"); + components.customElements.define("prefixed-element", PrefixedElement); return components.renderFragment( "existing-content" @@ -25,13 +25,14 @@ describe("When multiple DOM elements are present", () => { describe("parent elements", () => { it("can see child elements", () => { - var ChildCountElement = components.newElement(); - ChildCountElement.createdCallback = function () { - var newNode = this.doc.createElement("div"); - newNode.textContent = this.childNodes.length + " children"; - this.insertBefore(newNode, this.firstChild); - }; - components.registerElement("child-count", { prototype: ChildCountElement }); + class ChildCountElement extends components.HTMLElement { + connectedCallback() { + var newNode = this.doc.createElement("div"); + newNode.textContent = this.childNodes.length + " children"; + this.insertBefore(newNode, this.firstChild); + } + } + components.customElements.define("child-count", ChildCountElement); return components.renderFragment( "
A child
Another child
" @@ -42,54 +43,55 @@ describe("When multiple DOM elements are present", () => { }); }); - // Pending until we implement custom elements v1 + // Pending until we decide on a good solution xit("can read attributes from custom child element's prototypes", () => { - var DataSource = components.newElement(); - DataSource.data = [1, 2, 3]; - components.registerElement("data-source", { prototype: DataSource }); + class DataSource extends components.HTMLElement { + connectedCallback() { + return new Promise((resolve) => { + // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ + // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. + setTimeout(() => { + var data = this.childNodes[0].data; + this.textContent = "Data: " + JSON.stringify(data); + resolve(); + }, 0); + }); + } + } + DataSource.data = [10, 20, 30]; - var DataDisplayer = components.newElement(); - DataDisplayer.createdCallback = function () { - return new Promise((resolve) => { - // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ - // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. - setTimeout(() => { - var data = this.childNodes[0].data; - this.textContent = "Data: " + JSON.stringify(data); - resolve(); - }, 0); - }); - }; - components.registerElement("data-displayer", { prototype: DataDisplayer }); + components.customElements.define("data-displayer", DataDisplayer); return components.renderFragment( "" ).then((output) => { expect(output).to.equal( - "Data: [1,2,3]" + "Data: [10,20,30]" ); }); }); it("receive bubbling events from child elements", () => { - var EventRecorder = components.newElement(); - EventRecorder.createdCallback = function (document) { - var resultsNode = document.createElement("p"); - this.appendChild(resultsNode); + class EventRecorder extends components.HTMLElement { + connectedCallback(document) { + var resultsNode = document.createElement("p"); + this.appendChild(resultsNode); - this.addEventListener("my-event", (event) => { - resultsNode.innerHTML = "Event received"; - }); - }; - components.registerElement("event-recorder", { prototype: EventRecorder }); + this.addEventListener("my-event", (event) => { + resultsNode.innerHTML = "Event received"; + }); + } + } + components.customElements.define("event-recorder", EventRecorder); - var EventElement = components.newElement(); - EventElement.createdCallback = function () { - this.dispatchEvent(new components.dom.CustomEvent('my-event', { - bubbles: true - })); - }; - components.registerElement("event-source", { prototype: EventElement }); + class EventElement extends components.HTMLElement { + connectedCallback() { + this.dispatchEvent(new components.dom.CustomEvent('my-event', { + bubbles: true + })); + } + } + components.customElements.define("event-source", EventElement); return components.renderFragment( "" diff --git a/test/programmatic-usage-test.js b/test/programmatic-usage-test.js index 076bfca..ef5b73c 100644 --- a/test/programmatic-usage-test.js +++ b/test/programmatic-usage-test.js @@ -3,9 +3,11 @@ var expect = require('chai').expect; var components = require("../src/index.js"); describe("Programmatic usage", () => { - it("returns the element constructor from the registration call", () => { - var NewElement = components.newElement(); - var registrationResult = components.registerElement("my-element", { prototype: NewElement }); + + // Pending until we decide what we want from this + xit("returns the element constructor from the registration call", () => { + class NewElement extends components.HTMLElement {} + var registrationResult = components.customElements.define("test-element", NewElement); expect(NewElement.constructor).to.equal(registrationResult); }); }); diff --git a/test/serverside-components-test.js b/test/serverside-components-test.js index d359f79..4f0de63 100644 --- a/test/serverside-components-test.js +++ b/test/serverside-components-test.js @@ -4,23 +4,55 @@ var components = require("../src/index.js"); describe("A component that renders on the server", () => { - it("removes itself by default", () => { + it("replaces itself with its children by default", () => { var itRan = false; - components.registerServerElement("my-analytics", function (node) { + components.registerTransformer("my-analytics", function (node) { itRan = true; }); return components.renderFragment( - "

OneTwo

Three
" + "

OneTwoThings

Three
" ).then((output) => { expect(itRan).to.equal(true); - expect(output).to.equal("

One

Three
"); + expect(output).to.equal("

OneTwoThings

Three
"); + }); + }); + + it("replaces itself with its children by default (async)", () => { + var itRan = false; + + components.registerTransformer("my-analytics", function (node) { + itRan = true; + return Promise.resolve(); + }); + + return components.renderFragment( + "

OneTwoThings

Three
" + ).then((output) => { + expect(itRan).to.equal(true); + expect(output).to.equal("

OneTwoThings

Three
"); + }); + }); + + it("can remove itself and its children", () => { + var itRan = false; + + components.registerTransformer("ghost", function (node) { + itRan = true; + return null; + }); + + return components.renderFragment( + "

One

Two

Three

" + ).then((output) => { + expect(itRan).to.equal(true); + expect(output).to.equal("

One

Three

"); }); }); it("can replace itself with a new node via an HTML string", () => { - components.registerServerElement("my-timestamp", function (node) { + components.registerTransformer("my-timestamp", function (node) { return `
123
`; }); @@ -32,7 +64,7 @@ describe("A component that renders on the server", () => { }); it("can replace itself with a child", () => { - components.registerServerElement("latter", function (node) { + components.registerTransformer("latter", function (node) { return node.children[1]; }); @@ -44,8 +76,8 @@ describe("A component that renders on the server", () => { }); it("can replace itself with its children", () => { - var somethingHappened = false - components.registerServerElement("log", function (node) { + var somethingHappened = false; + components.registerTransformer("log", function (node) { somethingHappened = true; return node.children; }); @@ -55,13 +87,13 @@ describe("A component that renders on the server", () => { ).then((output) => { expect(output).to.equal("

One

Two

"); }); - }) + }); it("can make async requests", () => { - components.registerServerElement("user-count", function (node) { + components.registerTransformer("user-count", function (node) { return new Promise((resolve) => { - setTimeout(() => resolve("10"), 25) - }) + setTimeout(() => resolve("10"), 25); + }); }); return components.renderFragment( @@ -69,5 +101,37 @@ describe("A component that renders on the server", () => { ).then((output) => { expect(output).to.equal("10"); }); - }) + }); + + it("can transform custom elements", () => { + var itRan = false; + var itRanToo = false; + + components.registerTransformer("double-render", function (node) { + itRan = true; + return new Promise((resolve) => { + setTimeout(function () { + node.setAttribute('data-preset', JSON.stringify({ x: 10 })); + resolve(); + }, 5); + }); + }); + + class MyElement extends components.HTMLElement { + connectedCallback() { + itRanToo = true; + this.textContent = this.getAttribute('data-preset'); + this.setAttribute('data-preset', '99'); + } + } + components.customElements.define("double-render", MyElement); + + return components.renderFragment( + "" + ).then((output) => { + expect(itRan).to.equal(true); + expect(itRanToo).to.equal(true); + expect(output).to.equal(`{"x":10}`); + }); + }); });