/** * @module CustomElement */ /** * Create a DOM Event without worrying about browser compatibility * * @param {string} eventName The name of the event to create * @param {*} [detail] The optional details to attach to the event * @return {Event} A valid Event instance that can be dispatched on a DOM node * @example * var event = CustomElement.makeEvent('change', { name: this._secretInput.value }) * this.dispatchEvent(event) */ exports.makeEvent = makeMakeEvent(); var makeClass = makeMakeClass(); function noOp() {} /** * Register a custom element using some straightforward and convenient configuration * * @param {Object} config * @param {string} config.tagName The name of the tag for which to create a custom element. Must contain at least one `-` and must not have already been defined. * @param {Object} config.properties Getters and setters for properties on the custom element that can be accessed and set with `Html.Attributes.property`. These should map a property name onto a `get` function that returns a value and a `set` function that applies a value from the only argument. * @param {Object} config.methods Functions that can be invoked by the custom element from anywhere. These methods are automatically bound to `this`. * @param {function} config.initialize Method invoked during setup that can be used to initialize internal state. This function is called whenever a new instance of the element is created. * @param {function} config.onConnect Method invoked whenever the element is inserted into the DOM. * @param {function} config.onDisconnect Method invoked whenever the element is removed from the DOM. * @param {function} config.onAttributeChange Method invoked whenever an observed attribute changes. Takes 3 arguments: the name of the attribute, the old value, and the new value. * @param {string[]} config.observedAttributes List of attributes that are watched such that onAttributeChange will be invoked when they change. * @example * CustomElements.create({ * // This is where you specify the tag for your custom element. You would * // use this custom element with `Html.node "my-custom-tag"`. * tagName: 'my-cool-button', * * // Initialize any local variables for the element or do whatever else you * // might do in a class constructor. Takes no arguments. * // NOTE: the element is NOT in the DOM at this point. * initialize: function() { * this._hello = 'world' * this._button = document.createElement('button') * }, * * // Let the custom element runtime know that you want to be notified of * // changes to the `hello` attribute * observedAttributes: ['hello'], * * // Do any updating when an attribute changes on the element. Note the * // difference between attributes and properties of an element (see: * // https://javascript.info/dom-attributes-and-properties). This is a * // proxy for `attributeChangedCallback` (see: * // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks). * // Takes the name of the attribute that changed, the previous string value, * // and the new string value. * onAttributeChange: function(name, previous, next) { * if (name === 'hello') this._hello = next * }, * * // Do any setup work after the element has been inserted into the DOM. * // Takes no arguments. This is a proxy for `connectedCallback` (see: * // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks) * onConnect: function() { * document.addEventListener('click', this._onDocClick) * this._button.addEventListener('click', this._onButtonClick) * this.appendChild(this._button) * }, * * // Do any teardown work after the element has been removed from the DOM. * // Takes no arguments. This is a proxy for `disconnectedCallback` (see: * // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks) * onDisconnect: function() { * document.removeEventListener('click', this._onDocClick) * }, * * // Set up properties. These allow you to expose data to Elm's virtual DOM. * // You can use any value that can be encoded as a `Json.Encode.Value`. * // You'll often want to implement updates to some visual detail of your element * // from within the setter of a property that controls it. Handlers will be * // automatically bound to the correct value of `@`, so you don't need to worry * // about method context. * properties: { * hello: { * get: function() { * return this._hello * }, * set: function(value) { * this._hello = value * this._button.textContent = value * } * } * }, * * // Set up methods that you can call from anywhere else in the configuration. * // Methods will be automatically bound to the correct value of `@`, so you * // don't need to worry about method context. * methods: { * _onDocClick: function() { * alert('document clicked') * }, * _onButtonClick: function() { * alert('clicked on ' + this._hello + ' button') * } * } * }) */ exports.create = function create(config) { if (customElements.get(config.tagName)) { throw Error( "Custom element with tag name " + config.tagName + " already exists." ); } var observedAttributes = config.observedAttributes || []; var methods = config.methods || {}; var properties = config.properties || {}; var initialize = config.initialize || noOp; var onConnect = config.onConnect || noOp; var onDisconnect = config.onDisconnect || noOp; var onAttributeChange = config.onAttributeChange || noOp; var Class = makeClass(); for (var key in methods) { if (!methods.hasOwnProperty(key)) continue; Class.prototype[key] = methods[key]; } Object.defineProperties(Class.prototype, properties); Class.prototype.connectedCallback = onConnect; Class.prototype.disconnectedCallback = onDisconnect; Class.prototype.attributeChangedCallback = onAttributeChange; if (Array.isArray(observedAttributes)) { Object.defineProperty(Class, "observedAttributes", { get: function () { return observedAttributes; }, }); } Class.displayName = "<" + config.tagName + "> custom element"; customElements.define(config.tagName, Class); }; /** * Attempt to make an ES6 class using the Function constructor rather than * ordinary class syntax. The string we pass to the Function constructor is * static so there is no script injection risk. It allows us to catch * syntax errors at runtime for older browsers that don't support class and * fall back to an ES5 constructor function. */ function makeMakeClass() { try { return new Function( [ "return class extends HTMLElement {", " constructor() {", " super()", " for (var key in this) {", " var value = this[key]", " if (typeof value !== 'function') continue", " this[key] = value.bind(this)", " }", " }", "}", ].join("\n") ); } catch (e) { return function () { function Class() { // This is the best we can do to trick modern browsers into thinking this // is a real, legitimate class constructor and not a plane old JS function. var _this = HTMLElement.call(this) || this; for (var key in _this) { var value = _this[key]; if (typeof value !== "function") continue; _this[key] = value.bind(_this); } return _this; } Class.prototype = Object.create(HTMLElement.prototype); Class.prototype.constructor = Class; return Class; }; } } /** * Return a function for making an event based on what the browser supports. * IE11 doesn't support Event constructor, and uses the old Java-style * methods instead */ function makeMakeEvent() { try { // if calling Event with new works, do it that way var testEvent = new CustomEvent("myEvent", { detail: 1 }); return function makeEventNewStyle(type, detail) { return new CustomEvent(type, { detail: detail }); }; } catch (_error) { // if calling CustomEvent with new throws an error, do it the old way return function makeEventOldStyle(type, detail) { var event = document.createEvent("CustomEvent"); event.initCustomEvent(type, false, false, detail); return event; }; } }