Created
August 8, 2018 18:37
-
-
Save nicolasparada/c8bfee2f0148e96310591bc4c2059bf9 to your computer and use it in GitHub Desktop.
Mixin for custom elements to easily declare properties.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const rxUpper = /([A-Z])/g | |
const observersMap = /** @type {WeakMap<any, {[x: string]: (function(string=): void)}>} */ (new WeakMap()) | |
/** | |
* Mixin that provides a mechanism to declare properties and render when they change. | |
* It uses type constructors (String, Boolean, Number, Object, Function and Symbol) to check type validity. | |
* Set the type to undefined if you don't care about it. | |
* Properties of type String, Boolean and Number are automatically reflected to attributes. | |
* Make sure to call super.connectedCallback() if you are overriding the method. | |
* | |
* @example class HTMLXTagElement extends propertiesMixin(HTMLElement) { | |
* static get properties() { return { foo: String } } | |
* } | |
*/ | |
export function propertiesMixin(Base) { | |
return class WithProperties extends Base { | |
static get observedAttributes() { | |
const props = /** @type {{[x: string]: function}=} */ (this['properties']) | |
if (typeof props !== 'object' || props === null) { | |
return [] | |
} | |
const attrs = [] | |
for (const [prop, type] of Object.entries(props)) { | |
if (type === String || type === Boolean || type === Number) { | |
attrs.push(camelToKebab(prop)) | |
} | |
} | |
return attrs | |
} | |
constructor() { | |
super() | |
this._invalidating = false | |
this._invalidate = this._invalidate.bind(this) | |
this._render = this._render.bind(this) | |
const props = /** @type {{[x: string]: function}=} */ (this.constructor['properties']) | |
if (typeof props === 'object' && props !== null) { | |
const data = {} | |
const attrObservers = {} | |
for (const [prop, type] of Object.entries(props)) { | |
let setter = val => { | |
data[prop] = val | |
this._invalidate() | |
} | |
switch (type) { | |
case String: { | |
const attr = camelToKebab(prop) | |
attrObservers[attr] = val => { | |
if (val !== null) { | |
this[prop] = val | |
this._invalidate() | |
} | |
} | |
setter = val => { | |
if (typeof val !== 'string') { | |
throw new TypeError(`${prop} must be a string`) | |
} | |
data[prop] = val | |
this.setAttribute(attr, val) | |
} | |
break | |
} | |
case Boolean: { | |
const attr = camelToKebab(prop) | |
attrObservers[attr] = val => { | |
this[prop] = (val !== null) | |
this._invalidate() | |
} | |
setter = val => { | |
if (typeof val !== 'boolean') { | |
throw new TypeError(`${prop} must be boolean`) | |
} | |
data[prop] = val | |
if (val) { | |
this.setAttribute(attr, '') | |
} else { | |
this.removeAttribute(attr) | |
} | |
} | |
break | |
} | |
case Number: { | |
const attr = camelToKebab(prop) | |
attrObservers[attr] = val => { | |
if (val === null) { | |
return | |
} | |
val = val.includes('.') ? parseFloat(val) : parseInt(val, 10) | |
if (isNaN(val)) { | |
throw new TypeError(`${attr} must be a number`) | |
} | |
this[prop] = val | |
this._invalidate() | |
} | |
setter = val => { | |
if (typeof val !== 'number' || isNaN(val)) { | |
throw new TypeError(`${prop} must be a number`) | |
} | |
data[prop] = val | |
this.setAttribute(attr, String(val)) | |
} | |
break | |
} | |
case Object: { | |
setter = val => { | |
if (typeof val !== 'object' || val === null) { | |
throw new TypeError(`${prop} must be an object`) | |
} | |
data[prop] = val | |
this._invalidate() | |
} | |
break | |
} | |
case Function: { | |
setter = val => { | |
if (typeof val !== 'function') { | |
throw new TypeError(`${prop} must be a function`) | |
} | |
data[prop] = val | |
this._invalidate() | |
} | |
break | |
} | |
case Symbol: { | |
setter = val => { | |
if (typeof val !== 'symbol') { | |
throw new TypeError(`${prop} must be a symbol`) | |
} | |
data[prop] = val | |
this._invalidate() | |
} | |
break | |
} | |
} | |
Object.defineProperty(this, prop, { | |
enumerable: true, | |
get: () => data[prop], | |
set: setter, | |
}) | |
} | |
observersMap.set(this, attrObservers) | |
} | |
} | |
/** | |
* @param {string} attrName | |
* @param {string=} oldVal | |
* @param {string=} newVal | |
*/ | |
attributeChangedCallback(attrName, oldVal, newVal) { | |
if (newVal === oldVal) { | |
return | |
} | |
const attrObservers = observersMap.get(this) | |
if (attrObservers === undefined) { | |
return | |
} | |
const observer = attrObservers[attrName] | |
if (observer !== undefined) { | |
observer(newVal) | |
} | |
} | |
connectedCallback() { | |
this._invalidate() | |
} | |
_invalidate() { | |
if (this._invalidating) { | |
return | |
} | |
this._invalidating = true | |
Promise.resolve().then(() => { | |
this._render() | |
this._invalidating = false | |
}) | |
} | |
_render() { } | |
} | |
} | |
/** | |
* @param {string} str | |
*/ | |
function camelToKebab(str) { | |
return str.replace(rxUpper, '-$1').toLowerCase() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.