Skip to content

Instantly share code, notes, and snippets.

@mmis1000
Last active January 25, 2019 09:42
Show Gist options
  • Save mmis1000/26375f6816f68aebadbcc0ea5b7452f4 to your computer and use it in GitHub Desktop.
Save mmis1000/26375f6816f68aebadbcc0ea5b7452f4 to your computer and use it in GitHub Desktop.
Typescript to web component experiment
const camelToKebab = (string: string) => {
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
};
interface Callback {
(this: HTMLElement, newValue: string|null, oldValue: string|null): void
}
interface Transformer<T> {
fromAttribute(attr: string|null): T,
toAttribute(value: T): string|null
}
const defaultTransformer: Transformer<string|null> = {
fromAttribute(arg){ return arg },
toAttribute(arg) { return arg }
}
const callbacks = new WeakMap<HTMLElement, Map<string, Callback[]>>()
const propAttrMap = new WeakMap<HTMLElement, Map<string, string>>()
const attrPropMap = new WeakMap<HTMLElement, Map<string, string>>()
const observed = new WeakMap<HTMLElement, string[]>()
function typedAttribute<T>(transformer: Transformer<T>) {
return function attribute(
/** attribute name of the bound attribute, default to the property name if not specified */
name?: string
): PropertyDecorator {
return function (target: HTMLElement, propName: string) {
const attrName = name || camelToKebab(propName)
const observedList = observed.get(target) || []
observed.set(target, observedList)
const singlePropAttrKey = propAttrMap.get(target) || new Map<string, string>()
propAttrMap.set(target, singlePropAttrKey)
const singleAttrPropKey = attrPropMap.get(target) || new Map<string, string>()
attrPropMap.set(target, singleAttrPropKey)
observedList.push(attrName)
singlePropAttrKey.set(propName, attrName)
singleAttrPropKey.set(attrName, propName)
Object.defineProperty(target, propName, {
set(this: HTMLElement, newValue: T) {
const transformed = transformer.toAttribute(newValue)
if (transformed == null) {
this.removeAttribute(attrName)
} else {
this.setAttribute(attrName, transformed)
}
},
get(this: HTMLElement): T {
return transformer.fromAttribute(this.getAttribute(attrName))
}
})
} as any
}
}
function autoListen<T extends {new(...args:any[]): HTMLElement}>(constructor:T) {
return class Connected extends constructor {
attributeChangedCallback(name: string, oldValue: string|null, newValue: string) {
const listenerMap = callbacks.get(constructor.prototype) || new Map<string, Callback[]>()
const listeners = listenerMap.get(name) || [] as Callback[]
listeners.forEach(cb => {
cb.call(this, newValue, oldValue)
});
const superMethod = constructor.prototype.attributeChangedCallback;
if (superMethod) {
superMethod.call(this, name, oldValue, newValue)
}
}
connectedCallback() {
const superMethod = constructor.prototype.connectedCallback;
if (superMethod) {
superMethod.call(this)
}
const listenerMap = callbacks.get(constructor.prototype) || new Map<string, Callback[]>()
const singleAttrPropKey = attrPropMap.get(constructor.prototype) || new Map<string, string>()
for (let [attrName, callbacks] of listenerMap) {
for (let listener of callbacks) {
const propName = singleAttrPropKey.get(attrName)!!
console.log(propName)
listener.call(this, (this as any)[propName], null)
}
}
}
static get observedAttributes() {
const observedList = observed.get(constructor.prototype) || []
return observedList.concat( (constructor as any).observedAttributes || [])
}
}
}
function listener<T = any>(name: keyof T & string) {
return function (target: HTMLElement, propertyKey: string, descriptor: PropertyDescriptor) {
const attrName = propAttrMap.get(target)!!.get(name)!!
const listenerMap = callbacks.get(target) || new Map<string, Callback[]>()
callbacks.set(target, listenerMap)
const listeners = listenerMap.get(attrName) || [] as Callback[]
listenerMap.set(attrName, listeners)
listeners.push(descriptor.value)
};
}
const string = typedAttribute(defaultTransformer)
const number = typedAttribute<number|null>({
fromAttribute(attr: string|null): number|null {
if (attr != null) {
return parseInt(attr)
} else {
return null
}
},
toAttribute(value: number|null): string|null {
return value != null? value.toString(): null
}
})
const date = typedAttribute<Date|null>({
fromAttribute(attr: string|null): Date|null {
if (attr != null) {
return new Date(attr)
} else {
return null
}
},
toAttribute(value: Date|null): string|null {
if (value != null) {
return value.toUTCString()
} else {
return null
}
}
})
@autoListen
export default class MyElement extends HTMLElement {
@string("test-redirect")
test: string | undefined | null
@string()
testVar: string | undefined | null
@number()
test2: number | undefined | null
@date()
test3: Date | undefined | null
dom?: ShadowRoot
timerId?: ReturnType<typeof setTimeout>
constructor() {
super()
}
connectedCallback() {
// Create a shadow root
var shadow = this.attachShadow({mode: 'open'});
var text = document.createElement('span');
text.textContent = "test"
text.id = "date"
shadow.append(text)
var number = document.createElement('span');
number.textContent = "test"
number.id = "number"
shadow.append(number)
this.dom = shadow
this.timerId = setInterval(()=>{
if (this.test2 == null) {
this.test2 = 0
}
this.test2++;
}, 1000)
}
disconnectedCallback() {
clearInterval(this.timerId)
}
@listener<MyElement>("test")
onTestChange(newValue: string|null, oldValue:string|null) {
console.log("new value", newValue)
console.log("old value", oldValue)
}
@listener<MyElement>("test2")
onTest2Change(newValue: number|null, oldValue: number|null) {
console.log("new value", newValue)
console.log("old value", oldValue)
if (this.dom) {
const element = this.dom.querySelector("#number")
if (element) {
element.textContent = newValue + ""
}
}
}
@listener<MyElement>("test3")
onTest3Change(newValue: Date|null, oldValue:Date|null) {
console.log("new value", newValue)
console.log("old value", oldValue)
if (this.dom) {
const element = this.dom.querySelector("#date")
if (element) {
element.textContent = newValue ? newValue.toLocaleString(): "no date set "
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment