diff --git a/README.md b/README.md index e66cff1..c6771e7 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ and [HTML](https://html.spec.whatwg.org/) specifications are marked with the that `F.prototype.constructor === F` remains true. Otherwise, the polyfill will not be able to create or upgrade your custom elements. +## Customized built-in elements. +[Customized built-in elements](https://html.spec.whatwg.org/multipage/scripting.html#customized-built-in-element) +are implemented behind a flag and can be enabled by setting +`customElements.enableCustomizedBuiltins = true` before defining any customized +builtin elements. + +The status of customized built-in elements is somewhat controversial, which is why +the implementation is turned off by default. Although the v1 spec for custom +elements is finalized, there is not a consensus from browsers about whether +customized builtins will be implemented. For example, Webkit (Safari) has +stated that they will not implement customized builtins. + +You can read more in [this github issue](https://github.com/w3c/webcomponents/issues/509). + ### ES5 vs ES2015 The custom elements v1 spec is not compatible with ES5 style classes. This means diff --git a/externs/custom-elements.js b/externs/custom-elements.js index bc59b2b..bb40ec7 100644 --- a/externs/custom-elements.js +++ b/externs/custom-elements.js @@ -13,6 +13,7 @@ const CustomElementState = { /** * @typedef {{ + * name: string, * localName: string, * constructor: !Function, * connectedCallback: Function, @@ -59,3 +60,6 @@ Element.prototype.__CE_definition; /** @type {!DocumentFragment|undefined} */ Element.prototype.__CE_shadowRoot; + +/** @type {!String|undefined} */ +Element.prototype.__CE_is; diff --git a/src/CustomElementInternals.js b/src/CustomElementInternals.js index 3f08897..6620d99 100644 --- a/src/CustomElementInternals.js +++ b/src/CustomElementInternals.js @@ -4,7 +4,7 @@ import CEState from './CustomElementState.js'; export default class CustomElementInternals { constructor() { /** @type {!Map} */ - this._localNameToDefinition = new Map(); + this._nameToDefinition = new Map(); /** @type {!Map} */ this._constructorToDefinition = new Map(); @@ -17,20 +17,20 @@ export default class CustomElementInternals { } /** - * @param {string} localName + * @param {string} name * @param {!CustomElementDefinition} definition */ - setDefinition(localName, definition) { - this._localNameToDefinition.set(localName, definition); + setDefinition(name, definition) { + this._nameToDefinition.set(name, definition); this._constructorToDefinition.set(definition.constructor, definition); } /** - * @param {string} localName + * @param {string} name * @return {!CustomElementDefinition|undefined} */ - localNameToDefinition(localName) { - return this._localNameToDefinition.get(localName); + nameToDefinition(name) { + return this._nameToDefinition.get(name); } /** @@ -41,6 +41,20 @@ export default class CustomElementInternals { return this._constructorToDefinition.get(constructor); } + /** + * @param {!Node} node + * @return {!CustomElementDefinition|null} + */ + nodeToCustomElementDefinition(node) { + const name = node.__CE_is || node.localName; + const definition = this.nameToDefinition(name); + if (definition && definition.localName === node.localName) { + return definition; + } else { + return null; + } + } + /** * @param {!function(!Node)} listener */ @@ -173,6 +187,8 @@ export default class CustomElementInternals { const elements = []; const gatherElements = element => { + let isAttribute; + if (element.localName === 'link' && element.getAttribute('rel') === 'import') { // The HTML Imports polyfill sets a descendant element of the link to // the `import` property, specifically this is *not* a Document. @@ -208,6 +224,9 @@ export default class CustomElementInternals { this.patchAndUpgradeTree(importNode, visitedImports); }); } + } else if (isAttribute = element.getAttribute('is')) { + element.__CE_is = isAttribute; + elements.push(element); } else { elements.push(element); } @@ -234,8 +253,7 @@ export default class CustomElementInternals { upgradeElement(element) { const currentState = element.__CE_state; if (currentState !== undefined) return; - - const definition = this.localNameToDefinition(element.localName); + const definition = this.nodeToCustomElementDefinition(element); if (!definition) return; definition.constructionStack.push(element); diff --git a/src/CustomElementRegistry.js b/src/CustomElementRegistry.js index c83e4d3..3e376b4 100644 --- a/src/CustomElementRegistry.js +++ b/src/CustomElementRegistry.js @@ -47,7 +47,7 @@ export default class CustomElementRegistry { * @private * @type {!Array} */ - this._unflushedLocalNames = []; + this._unflushedNames = []; /** * @private @@ -57,20 +57,39 @@ export default class CustomElementRegistry { } /** - * @param {string} localName + * @param {string} name * @param {!Function} constructor */ - define(localName, constructor) { + define(name, constructor, options) { if (!(constructor instanceof Function)) { throw new TypeError('Custom element constructors must be functions.'); } - if (!Utilities.isValidCustomElementName(localName)) { - throw new SyntaxError(`The element name '${localName}' is not valid.`); + if (!Utilities.isValidCustomElementName(name)) { + throw new SyntaxError(`The element name '${name}' is not valid.`); } - if (this._internals.localNameToDefinition(localName)) { - throw new Error(`A custom element with name '${localName}' has already been defined.`); + if (this._internals.nameToDefinition(name)) { + throw new Error(`A custom element with name '${name}' has already been defined.`); + } + + let localName = name; + + if (options && options.extends) { + if (!this['enableCustomizedBuiltins']) { + throw new Error(`Customized builtin elements are disabled by default. Set customElements.enableCustomizedBuiltins = true.`); + } + + if (Utilities.isValidCustomElementName(options.extends)) { + throw new Error(`A customized builtin element may not extend a custom element.`); + } + + const el = document.createElement(options.extends); + if (el instanceof window['HTMLUnknownElement']) { + throw new Error(`Cannot extend '${options.extends}': is not a real HTML element`); + } + + localName = options.extends; } if (this._elementDefinitionIsRunning) { @@ -110,6 +129,7 @@ export default class CustomElementRegistry { } const definition = { + name, localName, constructor, connectedCallback, @@ -120,9 +140,9 @@ export default class CustomElementRegistry { constructionStack: [], }; - this._internals.setDefinition(localName, definition); + this._internals.setDefinition(name, definition); - this._unflushedLocalNames.push(localName); + this._unflushedNames.push(name); // If we've already called the flush callback and it hasn't called back yet, // don't call it again. @@ -141,9 +161,9 @@ export default class CustomElementRegistry { this._flushPending = false; this._internals.patchAndUpgradeTree(document); - while (this._unflushedLocalNames.length > 0) { - const localName = this._unflushedLocalNames.shift(); - const deferred = this._whenDefinedDeferred.get(localName); + while (this._unflushedNames.length > 0) { + const name = this._unflushedNames.shift(); + const deferred = this._whenDefinedDeferred.get(name); if (deferred) { deferred.resolve(undefined); } @@ -151,11 +171,11 @@ export default class CustomElementRegistry { } /** - * @param {string} localName + * @param {string} name * @return {Function|undefined} */ - get(localName) { - const definition = this._internals.localNameToDefinition(localName); + get(name) { + const definition = this._internals.nameToDefinition(name); if (definition) { return definition.constructor; } @@ -164,27 +184,27 @@ export default class CustomElementRegistry { } /** - * @param {string} localName + * @param {string} name * @return {!Promise} */ - whenDefined(localName) { - if (!Utilities.isValidCustomElementName(localName)) { - return Promise.reject(new SyntaxError(`'${localName}' is not a valid custom element name.`)); + whenDefined(name) { + if (!Utilities.isValidCustomElementName(name)) { + return Promise.reject(new SyntaxError(`'${name}' is not a valid custom element name.`)); } - const prior = this._whenDefinedDeferred.get(localName); + const prior = this._whenDefinedDeferred.get(name); if (prior) { return prior.toPromise(); } const deferred = new Deferred(); - this._whenDefinedDeferred.set(localName, deferred); + this._whenDefinedDeferred.set(name, deferred); - const definition = this._internals.localNameToDefinition(localName); + const definition = this._internals.nameToDefinition(name); // Resolve immediately only if the given local name has a definition *and* // the full document walk to upgrade elements with that local name has // already happened. - if (definition && this._unflushedLocalNames.indexOf(localName) === -1) { + if (definition && this._unflushedNames.indexOf(name) === -1) { deferred.resolve(undefined); } diff --git a/src/Patch/Document.js b/src/Patch/Document.js index f764362..21baa13 100644 --- a/src/Patch/Document.js +++ b/src/Patch/Document.js @@ -14,10 +14,11 @@ export default function(internals) { * @param {string} localName * @return {!Element} */ - function(localName) { + function(localName, options) { // Only create custom elements if this document is associated with the registry. if (this.__CE_hasRegistry) { - const definition = internals.localNameToDefinition(localName); + const name = options && options.is ? options.is : localName; + const definition = internals.nameToDefinition(name); if (definition) { return new (definition.constructor)(); } @@ -25,6 +26,10 @@ export default function(internals) { const result = /** @type {!Element} */ (Native.Document_createElement.call(this, localName)); + if (options && options.is) { + result.__CE_is = options.is; + result.setAttribute('is', options.is) + } internals.patch(result); return result; }); @@ -56,10 +61,11 @@ export default function(internals) { * @param {string} localName * @return {!Element} */ - function(namespace, localName) { + function(namespace, localName, options) { // Only create custom elements if this document is associated with the registry. if (this.__CE_hasRegistry && (namespace === null || namespace === NS_HTML)) { - const definition = internals.localNameToDefinition(localName); + const name = options && options.is ? options.is : localName; + const definition = internals.nameToDefinition(name); if (definition) { return new (definition.constructor)(); } @@ -67,6 +73,10 @@ export default function(internals) { const result = /** @type {!Element} */ (Native.Document_createElementNS.call(this, namespace, localName)); + if (options && options.is) { + result.__CE_is = options.is; + result.setAttribute('is', options.is) + } internals.patch(result); return result; }); diff --git a/src/Patch/HTMLElementSubclasses.js b/src/Patch/HTMLElementSubclasses.js new file mode 100644 index 0000000..9192d8f --- /dev/null +++ b/src/Patch/HTMLElementSubclasses.js @@ -0,0 +1,64 @@ +import Native from './Native.js'; +import CustomElementInternals from '../CustomElementInternals.js'; +import CEState from '../CustomElementState.js'; +import AlreadyConstructedMarker from '../AlreadyConstructedMarker.js'; + +/** + * @param {!CustomElementInternals} internals + */ +export default function(internals) { + for (let subclass in Native.HTMLElement_subclasses) { + patchElement(`HTML${subclass}Element`, Native.HTMLElement_subclasses[subclass]); + } + + function patchElement(constructorName, NativeElement) { + if (!NativeElement) { + return; + } + + window[constructorName] = (function() { + /** + * @type {function(new: HTMLElement): !HTMLElement} + */ + function PatchedElement() { + // This should really be `new.target` but `new.target` can't be emulated + // in ES5. Assuming the user keeps the default value of the constructor's + // prototype's `constructor` property, this is equivalent. + const constructor = /** @type {!Function} */ (this.constructor); + + const definition = internals.constructorToDefinition(constructor); + if (!definition) { + throw new Error('The custom element being constructed was not registered with `customElements`.'); + } + + const constructionStack = definition.constructionStack; + + if (constructionStack.length === 0) { + let element = Native.Document_createElement.call(document, definition.localName); + element.setAttribute('is', definition.name); + Object.setPrototypeOf(element, constructor.prototype); + element.__CE_state = CEState.custom; + element.__CE_definition = definition; + internals.patch(element); + return /** @type {!HTMLElement} */ (element); + } + + const lastIndex = constructionStack.length - 1; + const element = constructionStack[lastIndex]; + if (element === AlreadyConstructedMarker) { + throw new Error(`The ${constructorName} constructor was either called reentrantly for this constructor or called multiple times.`); + } + constructionStack[lastIndex] = AlreadyConstructedMarker; + + Object.setPrototypeOf(element, constructor.prototype); + internals.patch(/** @type {!HTMLElement} */ (element)); + + return /** @type {!HTMLElement} */ (element); + } + + PatchedElement.prototype = NativeElement.prototype; + + return PatchedElement; + })(); + } +}; diff --git a/src/Patch/Native.js b/src/Patch/Native.js index 8b761f3..e600efc 100644 --- a/src/Patch/Native.js +++ b/src/Patch/Native.js @@ -28,4 +28,71 @@ export default { HTMLElement: window.HTMLElement, HTMLElement_innerHTML: Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'innerHTML'), HTMLElement_insertAdjacentElement: window.HTMLElement.prototype['insertAdjacentElement'], + HTMLElement_subclasses: { + 'Button': window['HTMLButtonElement'], + 'Canvas': window['HTMLCanvasElement'], + 'Data': window['HTMLDataElement'], + 'Head': window['HTMLHeadElement'], + 'Mod': window['HTMLModElement'], + 'TableCell': window['HTMLTableCellElement'], + 'TableCol': window['HTMLTableColElement'], + 'Anchor': window['HTMLAnchorElement'], + 'Area': window['HTMLAreaElement'], + 'Base': window['HTMLBaseElement'], + 'Body': window['HTMLBodyElement'], + 'BR': window['HTMLBRElement'], + 'DataList': window['HTMLDataListElement'], + 'Details': window['HTMLDetailsElement'], + 'Dialog': window['HTMLDialogElement'], + 'Div': window['HTMLDivElement'], + 'DList': window['HTMLDListElement'], + 'Embed': window['HTMLEmbedElement'], + 'FieldSet': window['HTMLFieldSetElement'], + 'Form': window['HTMLFormElement'], + 'Heading': window['HTMLHeadingElement'], + 'HR': window['HTMLHRElement'], + 'Html': window['HTMLHtmlElement'], + 'IFrame': window['HTMLIFrameElement'], + 'Image': window['HTMLImageElement'], + 'Input': window['HTMLInputElement'], + 'Keygen': window['HTMLKeygenElement'], + 'Label': window['HTMLLabelElement'], + 'Legend': window['HTMLLegendElement'], + 'LI': window['HTMLLIElement'], + 'Link': window['HTMLLinkElement'], + 'Map': window['HTMLMapElement'], + 'Media': window['HTMLMediaElement'], + 'Menu': window['HTMLMenuElement'], + 'MenuItem': window['HTMLMenuItemElement'], + 'Meta': window['HTMLMetaElement'], + 'Meter': window['HTMLMeterElement'], + 'Object': window['HTMLObjectElement'], + 'OList': window['HTMLOListElement'], + 'OptGroup': window['HTMLOptGroupElement'], + 'Option': window['HTMLOptionElement'], + 'Output': window['HTMLOutputElement'], + 'Paragraph': window['HTMLParagraphElement'], + 'Param': window['HTMLParamElement'], + 'Picture': window['HTMLPictureElement'], + 'Pre': window['HTMLPreElement'], + 'Progress': window['HTMLProgressElement'], + 'Quote': window['HTMLQuoteElement'], + 'Script': window['HTMLScriptElement'], + 'Select': window['HTMLSelectElement'], + 'Slot': window['HTMLSlotElement'], + 'Source': window['HTMLSourceElement'], + 'Span': window['HTMLSpanElement'], + 'Style': window['HTMLStyleElement'], + 'TableCaption': window['HTMLTableCaptionElement'], + 'Table': window['HTMLTableElement'], + 'TableRow': window['HTMLTableRowElement'], + 'TableSection': window['HTMLTableSectionElement'], + 'Template': window['HTMLTemplateElement'], + 'TextArea': window['HTMLTextAreaElement'], + 'Time': window['HTMLTimeElement'], + 'Title': window['HTMLTitleElement'], + 'Track': window['HTMLTrackElement'], + 'UList': window['HTMLUListElement'], + 'Unknown': window['HTMLUnknownElement'], + }, }; diff --git a/src/custom-elements.js b/src/custom-elements.js index 7d8a375..e226940 100644 --- a/src/custom-elements.js +++ b/src/custom-elements.js @@ -12,6 +12,7 @@ import CustomElementInternals from './CustomElementInternals.js'; import CustomElementRegistry from './CustomElementRegistry.js'; import PatchHTMLElement from './Patch/HTMLElement.js'; +import PatchHTMLElementSubclasses from './Patch/HTMLElementSubclasses.js'; import PatchDocument from './Patch/Document.js'; import PatchNode from './Patch/Node.js'; import PatchElement from './Patch/Element.js'; @@ -26,6 +27,7 @@ if (!priorCustomElements || const internals = new CustomElementInternals(); PatchHTMLElement(internals); + PatchHTMLElementSubclasses(internals); PatchDocument(internals); PatchNode(internals); PatchElement(internals); @@ -35,6 +37,7 @@ if (!priorCustomElements || /** @type {!CustomElementRegistry} */ const customElements = new CustomElementRegistry(internals); + customElements['enableCustomizedBuiltins'] = priorCustomElements['enableCustomizedBuiltins']; Object.defineProperty(window, 'customElements', { configurable: true, diff --git a/tests/index.html b/tests/index.html index 681b9b9..d52c96d 100644 --- a/tests/index.html +++ b/tests/index.html @@ -12,6 +12,7 @@ diff --git a/tests/js/babel.js b/tests/js/babel.js index 4523a82..9ed8597 100644 --- a/tests/js/babel.js +++ b/tests/js/babel.js @@ -43,6 +43,38 @@ suite('Babel ES5 Output', function() { assert.instanceOf(e, XBabel); }); + test('Babel generated ES5 works via new() when extending builtin HTML Elements', function() { + "use strict"; + + function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + + function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } + + function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } + + var XBabelBuiltin = function (_HTMLButtonElement) { + _inherits(XBabelBuiltin, _HTMLButtonElement); + + function XBabelBuiltin() { + _classCallCheck(this, XBabelBuiltin); + + return _possibleConstructorReturn(this, (XBabelBuiltin.__proto__ || Object.getPrototypeOf(XBabelBuiltin)).call(this)); + } + + return XBabelBuiltin; + }(HTMLButtonElement); + + // register x-foo + customElements.define('x-babel-builtin', XBabelBuiltin, {extends: 'button'}); + // create an instance via new + var e = new XBabelBuiltin(); + // test localName + assert.equal(e.localName, 'button'); + // test instanceof + assert.instanceOf(e, XBabelBuiltin); + assert.instanceOf(e, HTMLButtonElement); + }); + test('Babel generated ES5 works via createElement', function() { 'use strict'; @@ -74,4 +106,32 @@ suite('Babel ES5 Output', function() { assert.instanceOf(e, XBabel2); }); + test('Babel generated ES5 works via createElement for customized builtins', function() { + "use strict"; + + function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + + function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } + + function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } + + var XBabel2Builtin = function (_HTMLButtonElement) { + _inherits(XBabel2Builtin, _HTMLButtonElement); + + function XBabel2Builtin() { + _classCallCheck(this, XBabel2Builtin); + + return _possibleConstructorReturn(this, (XBabel2Builtin.__proto__ || Object.getPrototypeOf(XBabel2Builtin)).call(this)); + } + + return XBabel2Builtin; + }(HTMLButtonElement); + + customElements.define('x-babel2-builtin', XBabel2Builtin, {extends: 'button'}); + var e = document.createElement('button', {is: 'x-babel2-builtin'}); + assert.equal(e.localName, 'button'); + assert.instanceOf(e, XBabel2Builtin); + assert.instanceOf(e, HTMLButtonElement); + }); + }); diff --git a/tests/js/instanceof.js b/tests/js/instanceof.js index 2fc596e..a0c2577 100644 --- a/tests/js/instanceof.js +++ b/tests/js/instanceof.js @@ -140,4 +140,18 @@ suite('Built-in Element instanceof', function() { }); + test('customized builtins are instance of the native elements they extend', function () { + class XInstanceofBuiltin extends HTMLStyleElement { + } + customElements.define('x-instanceof-builtin', XInstanceofBuiltin, {extends: 'style'}); + + var el = new XInstanceofBuiltin(); + assert.isTrue(el instanceof XInstanceofBuiltin); + assert.isTrue(el instanceof HTMLStyleElement); + + el = document.createElement('style', {is: 'x-instanceof-builtin'}); + assert.isTrue(el instanceof HTMLStyleElement); + assert.isTrue(el instanceof XInstanceofBuiltin); + }); + }); diff --git a/tests/js/reactions.js b/tests/js/reactions.js index 9959b8e..f6a53f6 100644 --- a/tests/js/reactions.js +++ b/tests/js/reactions.js @@ -33,6 +33,16 @@ suite('Custom Element Reactions', function() { assert.instanceOf(xnew, XNew); }); + test('new E() instantiates a new customized builtin element', function() { + class XNewBuiltin extends HTMLButtonElement {} + customElements.define('x-new-builtin', XNewBuiltin, {extends: 'button'}); + var xnewbuiltin = new XNewBuiltin(); + + assert.equal(xnewbuiltin.localName, 'button'); + assert.instanceOf(xnewbuiltin, XNewBuiltin); + assert.instanceOf(xnewbuiltin, HTMLElement); + }); + test('new E() instantiates subclasses', function() { class XSuper extends HTMLElement {} class XSub extends XSuper {} @@ -81,6 +91,39 @@ suite('Custom Element Reactions', function() { assert.isTrue(pass); }); + test('is attribute is added to customized builtins instantiated via createElement', function() { + class XIsAttributeBuiltin extends HTMLButtonElement { + } + customElements.define('x-is-attribute-builtin', XIsAttributeBuiltin, {extends: 'button'}); + var el = document.createElement('button', {is: 'x-is-attribute-builtin'}); + assert.equal(el.getAttribute('is'), 'x-is-attribute-builtin'); + }); + + test('is attribute is added to customized builtins instantiated via constructor', function() { + class XIsAttributeBuiltin2 extends HTMLButtonElement { + } + customElements.define('x-is-attribute-builtin2', XIsAttributeBuiltin2, {extends: 'button'}); + var el = new XIsAttributeBuiltin2(); + assert.equal(el.getAttribute('is'), 'x-is-attribute-builtin2'); + }); + + test('constructor is called on customized builtins when instantiated via createElement', function() { + var pass = false; + class XFoo2Builtin extends HTMLButtonElement { + constructor() { + super(); + pass = true; + } + } + customElements.define('x-foo2-builtin', XFoo2Builtin, {extends: 'button'}); + + var el = document.createElement('button', {is: 'x-foo2-builtin'}); + assert.equal(el.localName, 'button'); + assert.instanceOf(el, XFoo2Builtin); + assert.instanceOf(el, HTMLButtonElement); + assert.isTrue(pass); + }); + test('subclass constructor is called when instantiated via createElement', function() { class XSuper2 extends HTMLElement {} var pass = false; @@ -133,6 +176,21 @@ suite('Custom Element Reactions', function() { assert.isTrue(pass); }); + test('constructor for customized builtin is called when instantiated via createElementNS', function() { + var pass = false; + class XFoo3Builtin extends HTMLButtonElement { + constructor() { + super(); + pass = true; + } + } + + customElements.define('x-foo3-builtin', XFoo3Builtin, {extends: 'button'}); + var xfooBuiltin = document.createElementNS(HTMLNS, 'button', {is: 'x-foo3-builtin'}); + assert.instanceOf(xfooBuiltin, XFoo3Builtin); + assert.isTrue(pass); + }); + test('constructor is called for each instance', function() { var count = 0; class XFoo4 extends HTMLElement { @@ -162,6 +220,23 @@ suite('Custom Element Reactions', function() { assert.equal(count, 1); }); + test('calls constructor only once for customized builtins', function() { + var count = 0; + class XConstructorBuiltin extends HTMLInputElement { + constructor() { + super(); + count++; + } + } + customElements.define('x-constructor-builtin', XConstructorBuiltin, {extends: 'input'}); + var xconstructorBuiltin = new XConstructorBuiltin(); + assert.equal(count, 1); + + count = 0; + var el = document.createElement('input', {is: 'x-constructor-builtin'}); + assert.equal(count, 1); + }); + test('innerHTML on disconnected elements customizes contents', function() { var passed = false; class XInner extends HTMLElement { @@ -176,6 +251,35 @@ suite('Custom Element Reactions', function() { assert.isTrue(passed); }); + test('innerHTML on disconnected elements customizes contents with customized builtin elements', function() { + var passed = false; + class XInnerBuiltin extends HTMLDivElement { + constructor() { + super(); + passed = true; + } + } + customElements.define('x-inner-builtin', XInnerBuiltin, {extends: 'div'}); + var div = document.createElement('div'); + passed = false; + div.innerHTML = '
'; + assert.isTrue(passed); + }); + + test('doesn\'t upgrade a native element if it\'s not created with the {is: "x-foo"} option', function() { + var upgraded; + class XNativeBuiltin extends HTMLSpanElement { + constructor() { + super(); + upgraded = true; + } + } + customElements.define('x-native-builtin', XNativeBuiltin, {extends: 'span'}); + upgraded = false; + document.createElement('span'); + assert.isFalse(upgraded); + }); + }); suite('attributeChangedCallback', function() { @@ -209,6 +313,24 @@ suite('Custom Element Reactions', function() { xboo.setAttribute('foo', 'zot'); }); + test('called when setting observed attribute on customized built-in element', function(done) { + class XBooBuiltin extends HTMLInputElement { + static get observedAttributes() { + return ['foo']; + } + + attributeChangedCallback(inName, inOldValue) { + if (inName === 'foo' && inOldValue === 'bar' && this.attributes.foo.value === 'zot') { + done(); + } + } + } + customElements.define('x-boo-builtin', XBooBuiltin, {extends: 'input'}); + var xbooBuiltin = new XBooBuiltin(); + xbooBuiltin.setAttribute('foo', 'bar'); + xbooBuiltin.setAttribute('foo', 'zot'); + }); + test('called for existing observed attributes', function () { var changed = []; class XBoo extends HTMLElement { @@ -243,7 +365,7 @@ suite('Custom Element Reactions', function() { }); suite('connectedCallback', function() { - var connectedCount; + var connectedCount, builtinConnectedCount; class XConnected extends HTMLElement { connectedCallback() { connectedCount++; @@ -251,13 +373,24 @@ suite('Custom Element Reactions', function() { } customElements.define('x-connected', XConnected); + class XConnectedBuiltin extends HTMLButtonElement { + connectedCallback() { + builtinConnectedCount++; + } + } + customElements.define('x-connected-builtin', XConnectedBuiltin, {extends: 'button'}); + setup(function() { connectedCount = 0; + builtinConnectedCount = 0; }); test('is not called for disconnected custom elements', function() { new XConnected(); assert.equal(connectedCount, 0); + + new XConnectedBuiltin(); + assert.equal(builtinConnectedCount, 0); }); test('is not called for deeply disconnected custom elements', function() { @@ -265,11 +398,19 @@ suite('Custom Element Reactions', function() { var child = new XConnected(); parent.appendChild(child); assert.equal(connectedCount, 0); + + parent = new XConnectedBuiltin(); + child = new XConnectedBuiltin(); + parent.appendChild(child); + assert.equal(builtinConnectedCount, 0); }); test('called when appended to main document', function() { work.appendChild(new XConnected()); assert.equal(connectedCount, 1); + + work.appendChild(new XConnectedBuiltin()); + assert.equal(builtinConnectedCount, 1); }); test('called when re-appended to main document', function() { @@ -278,6 +419,12 @@ suite('Custom Element Reactions', function() { work.removeChild(el); work.appendChild(el); assert.equal(connectedCount, 2); + + el = new XConnectedBuiltin(); + work.appendChild(el); + work.removeChild(el); + work.appendChild(el); + assert.equal(builtinConnectedCount, 2); }); test('called in tree order', function() { @@ -289,7 +436,14 @@ suite('Custom Element Reactions', function() { } } + class XOrderingBuiltin extends HTMLDivElement { + connectedCallback() { + log.push(this.id); + } + } + customElements.define('x-ordering', XOrdering); + customElements.define('x-ordering-builtin', XOrderingBuiltin, {extends: 'div'}); work.innerHTML = '' + @@ -297,10 +451,11 @@ suite('Custom Element Reactions', function() { '' + '' + '' + + '
' + '
' + '
'; - assert.deepEqual(log, ['a', 'b', 'c', 'd', 'e']); + assert.deepEqual(log, ['a', 'b', 'c', 'd', 'e', 'f']); }); }); @@ -335,6 +490,22 @@ suite('Custom Element Reactions', function() { assert(removed, 'removed must be true [XBooBoo]'); }); + test('called on customized builtins when disconnected from main document', function() { + var removed = false; + class XZotBuiltin extends HTMLBRElement { + disconnectedCallback() { + removed = true; + } + } + customElements.define('x-zot-builtin', XZotBuiltin, {extends: 'br'}); + var xZotBuiltin = new XZotBuiltin(); + assert(!removed, 'XZotBuiltin shouldn\'t be removed before even connected'); + work.appendChild(xZotBuiltin); + assert(!removed, 'XZotBuiltin shouldn\'t be removed when it is connected'); + work.removeChild(xZotBuiltin); + assert(removed, 'XZotBuiltin should be removed when disconnected from dom'); + }); + test('called in tree order', function() { var log = []; class XOrdering2 extends HTMLElement { @@ -344,17 +515,25 @@ suite('Custom Element Reactions', function() { } customElements.define('x-ordering2', XOrdering2); + class XOrdering2Builtin extends HTMLTitleElement { + disconnectedCallback() { + log.push(this.id) + } + } + customElements.define('x-ordering2-builtin', XOrdering2Builtin, {extends: 'div'}); + work.innerHTML = '' + '' + '' + '' + '' + + '
' + '
' + '
'; work.removeChild(work.firstElementChild); - assert.deepEqual(['a', 'b', 'c', 'd', 'e'], log); + assert.deepEqual(['a', 'b', 'c', 'd', 'e', 'f'], log); }); }); diff --git a/tests/js/registry.js b/tests/js/registry.js index b2d52ae..888fc10 100644 --- a/tests/js/registry.js +++ b/tests/js/registry.js @@ -77,6 +77,24 @@ suite('CustomElementsRegistry', function() { }, '', 'customElements.define failed to throw with a constructor argument with no prototype'); }); + test('second argument with an invalid extends property', function() { + class XInvalidExtends extends HTMLInputElement {} + assert.throws(function() { + customElements.define('x-invalid-extends', XInvalidExtends, {extends: new Date()}); + }, '', 'customElements.define failed to throw with an invalid extends option'); + }); + + test('throws an error if a customized builtin is defined but the feature flag is off', function() { + class XBuiltinNotEnabled extends HTMLButtonElement {} + + customElements.enableCustomizedBuiltins = false; + + assert.throws(function() { + customElements.define('x-builtin-not-enabled', XBuiltinNotEnabled, {extends: 'button'}); + }, '', 'customElements.define failed to throw when enableCustomizedBuiltins is falsy'); + + customElements.enableCustomizedBuiltins = true; + }); }); suite('get', function() { @@ -91,6 +109,15 @@ suite('CustomElementsRegistry', function() { assert.isUndefined(customElements.get('x-undefined')); }); + test('returns a defined constructor for a customized builtin element', function () { + class XGetBuiltin extends HTMLLinkElement {} + customElements.define('x-get-builtin', XGetBuiltin, {extends: 'link'}); + // Customized builtin elements should be retrievable from the registry + assert.equal(XGetBuiltin, customElements.get('x-get-builtin')); + // The element being extended cannot be retrieved from the registry + assert.isUndefined(customElements.get('link')); + }); + }); suite('whenDefined', function() { diff --git a/tests/js/upgrade.js b/tests/js/upgrade.js index 653f902..bc72f9c 100644 --- a/tests/js/upgrade.js +++ b/tests/js/upgrade.js @@ -22,37 +22,47 @@ suite('Upgrades', function() { }); test('connected elements upgrade when defined', function() { - work.innerHTML = ''; + work.innerHTML = ''; var e1 = work.firstChild; var e2 = e1.firstChild; + var e3 = e2.firstChild; class X1 extends HTMLElement {} class X2 extends HTMLElement {} + class XMarkupBuiltin1 extends HTMLButtonElement {} customElements.define('x-markup-1', X1); customElements.define('x-markup-2', X2); + customElements.define('x-markup-builtin-1', XMarkupBuiltin1, {extends: 'button'}); assert.instanceOf(e1, X1); assert.instanceOf(e2, X2); + assert.instanceOf(e3, XMarkupBuiltin1); }); test('defined disconnected elements upgrade when connected', function() { var e1 = document.createElement('x-disconnected-1'); var e2 = document.createElement('x-disconnected-2'); + var e3 = document.createElement('span', {is: 'x-disconnected-builtin-1'}); e1.appendChild(e2); + e2.appendChild(e3); class X1 extends HTMLElement {} class X2 extends HTMLElement {} + class XDisconnectedBuiltin1 extends HTMLSpanElement {} customElements.define('x-disconnected-1', X1); customElements.define('x-disconnected-2', X2); + customElements.define('x-disconnected-builtin-1', XDisconnectedBuiltin1, {extends: 'span'}); // disconnected elements should not be upgraded assert.notInstanceOf(e1, X1); assert.notInstanceOf(e2, X2); + assert.notInstanceOf(e3, XDisconnectedBuiltin1); // they should upgrade when connected work.appendChild(e1); assert.instanceOf(e1, X1); assert.instanceOf(e2, X2); + assert.instanceOf(e3, XDisconnectedBuiltin1); }); });