Skip to content

Instantly share code, notes, and snippets.

@nicolasparada
Created August 8, 2018 18:37
Show Gist options
  • Save nicolasparada/c8bfee2f0148e96310591bc4c2059bf9 to your computer and use it in GitHub Desktop.
Save nicolasparada/c8bfee2f0148e96310591bc4c2059bf9 to your computer and use it in GitHub Desktop.
Mixin for custom elements to easily declare properties.
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()
}
@nicolasparada
Copy link
Author

nicolasparada commented Aug 8, 2018

import { propertiesMixin } from './properties-mixin.js';

const template = document.createElement('template')
template.innerHTML = `
    <button class="js-inc-button">+</button>
    <span></span>
    <button class="js-dec-button">-</button>
`

class HTMLXCounterElement extends propertiesMixin(HTMLElement) {
    static get properties() {
        return { value: Number, disabled: Boolean }
    }

    constructor() {
        super()
        this.value = 0
        this.disabled = false
    }

    connectedCallback() {
        super.connectedCallback()
        this.appendChild(template.content.cloneNode(true))
        this._incButton = /** @type {HTMLButtonElement} */ (this.querySelector('.js-inc-button'))
        this._span = this.querySelector('span')
        this._decButton = /** @type {HTMLButtonElement} */ (this.querySelector('.js-dec-button'))
        this._incButton.onclick = () => { this.value++ }
        this._decButton.onclick = () => { this.value-- }
    }

    _render() {
        this._incButton.disabled = this.disabled
        this._span.textContent = String(this.value)
        this._decButton.disabled = this.disabled
    }
}

customElements.define('x-counter', HTMLXCounterElement)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment