Skip to content

Commit 5b3a79b

Browse files
committed
feat: add @attr decorator
This allows for mapping class properties to attributes
1 parent 10ce13a commit 5b3a79b

6 files changed

Lines changed: 327 additions & 14 deletions

File tree

src/attr.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {CustomElement} from './custom-element'
2+
3+
const attrs = new WeakMap<Record<PropertyKey, unknown>, Set<string>>()
4+
type attrValue = string | number | boolean
5+
6+
/**
7+
* Attr is a decorator which tags a property as one to be initialized via
8+
* `initializeAttrs`.
9+
*
10+
* The signature is typed such that the property must be one of a String,
11+
* Number or Boolean. This matches the behavior of `initializeAttrs`.
12+
*/
13+
export function attr<K extends string>(proto: Record<K, attrValue>, key: K): void {
14+
if (!attrs.has(proto)) attrs.set(proto, new Set())
15+
attrs.get(proto)!.add(key)
16+
}
17+
18+
/**
19+
* initializeAttrs is called with a set of class property names (if omitted, it
20+
* will look for any properties tagged with the `@attr` decorator). With this
21+
* list it defines property descriptors for each property that map to `data-*`
22+
* attributes on the HTMLElement instance.
23+
*
24+
* It works around Native Class Property semantics - which are equivalent to
25+
* calling `Object.defineProperty` on the instance upon creation, but before
26+
* `constructor()` is called.
27+
*
28+
* If a class property is assigned to the class body, it will infer the type
29+
* (using `typeof`) and define an appropriate getter/setter combo that aligns
30+
* to that type. This means class properties assigned to Numbers can only ever
31+
* be Numbers, assigned to Booleans can only ever be Booleans, and assigned to
32+
* Strings can only ever be Strings.
33+
*
34+
* This is automatically called as part of `@controller`. If a class uses the
35+
* `@controller` decorator it should not call this manually.
36+
*/
37+
export function initializeAttrs(instance: HTMLElement, names?: Iterable<string>): void {
38+
if (!names) names = attrs.get(Object.getPrototypeOf(instance)) || []
39+
for (const key of names) {
40+
const value = (<Record<PropertyKey, unknown>>(<unknown>instance))[key]
41+
let descriptor: PropertyDescriptor
42+
if (typeof value === 'number') {
43+
descriptor = numberProperty(key)
44+
} else if (typeof value === 'boolean') {
45+
descriptor = booleanProperty(key)
46+
} else {
47+
descriptor = stringProperty(key)
48+
}
49+
Object.defineProperty(instance, key, descriptor)
50+
if (key in instance && !instance.hasAttribute(attrToAttributeName(key))) {
51+
descriptor.set!.call(instance, value)
52+
}
53+
}
54+
}
55+
56+
function booleanProperty(key: string): PropertyDescriptor {
57+
const attributeName = attrToAttributeName(key)
58+
return {
59+
get(this: HTMLElement): boolean {
60+
return this.hasAttribute(attributeName)
61+
},
62+
set(this: HTMLElement, value: boolean) {
63+
this.toggleAttribute(attributeName, value)
64+
}
65+
}
66+
}
67+
68+
function stringProperty(key: string): PropertyDescriptor {
69+
const attributeName = attrToAttributeName(key)
70+
return {
71+
get(this: HTMLElement): string {
72+
return String(this.getAttribute(attributeName) || '')
73+
},
74+
set(this: HTMLElement, value: string) {
75+
this.setAttribute(attributeName, value || '')
76+
}
77+
}
78+
}
79+
80+
function numberProperty(key: string): PropertyDescriptor {
81+
const attributeName = attrToAttributeName(key)
82+
return {
83+
get(this: HTMLElement): number {
84+
return Number(this.getAttribute(attributeName) || 0)
85+
},
86+
set(this: HTMLElement, value: number) {
87+
this.setAttribute(attributeName, String(value))
88+
}
89+
}
90+
}
91+
92+
function attrToAttributeName(name: string): string {
93+
return `data-${name.replace(/([A-Z]($|[a-z]))/g, '-$1')}`.replace(/--/g, '-').toLowerCase()
94+
}
95+
96+
export function defineObservedAttributes(classObject: CustomElement): void {
97+
let observed = classObject.observedAttributes || []
98+
Object.defineProperty(classObject, 'observedAttributes', {
99+
get() {
100+
const attrMap = attrs.get(classObject.prototype)
101+
if (!attrMap) return observed
102+
return [...attrMap].map(attrToAttributeName).concat(observed)
103+
},
104+
set(attributes: string[]) {
105+
observed = attributes
106+
}
107+
})
108+
}

src/controller.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import {register} from './register'
22
import {bind} from './bind'
33
import {autoShadowRoot} from './auto-shadow-root'
4-
5-
interface CustomElement {
6-
new (): HTMLElement
7-
}
4+
import {defineObservedAttributes, initializeAttrs} from './attr'
5+
import {CustomElement} from './custom-element'
86

97
/**
108
* Controller is a decorator to be used over a class that extends HTMLElement.
@@ -17,8 +15,10 @@ export function controller(classObject: CustomElement): void {
1715
classObject.prototype.connectedCallback = function (this: HTMLElement) {
1816
this.toggleAttribute('data-catalyst', true)
1917
autoShadowRoot(this)
18+
initializeAttrs(this)
2019
if (connect) connect.call(this)
2120
bind(this)
2221
}
22+
defineObservedAttributes(classObject)
2323
register(classObject)
2424
}

src/custom-element.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface CustomElement {
2+
new (): HTMLElement
3+
observedAttributes?: string[]
4+
}

src/index.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import {bind, listenForBind} from './bind'
2-
import {register} from './register'
3-
import {findTarget, findTargets} from './findtarget'
4-
import {target, targets} from './target'
5-
import {controller} from './controller'
6-
7-
export {bind, listenForBind, register, findTarget, findTargets, target, targets, controller}
1+
export {bind, listenForBind} from './bind'
2+
export {register} from './register'
3+
export {findTarget, findTargets} from './findtarget'
4+
export {target, targets} from './target'
5+
export {controller} from './controller'
6+
export {attr} from './attr'

src/register.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
interface CustomElement {
2-
new (): HTMLElement
3-
}
1+
import {CustomElement} from './custom-element'
42

53
/**
64
* Register the controller as a custom element.

test/attr.js

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import {initializeAttrs, defineObservedAttributes, attr} from '../lib/attr.js'
2+
3+
describe('initializeAttrs', () => {
4+
class InitializeAttrTestElement extends HTMLElement {}
5+
window.customElements.define('initialize-attr-test-element', InitializeAttrTestElement)
6+
7+
it('creates a getter/setter pair for each given attr name', () => {
8+
const instance = document.createElement('initialize-attr-test-element')
9+
expect(instance).to.not.have.ownPropertyDescriptor('foo')
10+
initializeAttrs(instance, ['foo'])
11+
expect(instance).to.have.ownPropertyDescriptor('foo')
12+
})
13+
14+
it('reflects the `data-*` attribute name of the given key', () => {
15+
const instance = document.createElement('initialize-attr-test-element')
16+
initializeAttrs(instance, ['foo'])
17+
expect(instance.foo).to.equal('')
18+
instance.foo = 'bar'
19+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
20+
expect(instance.getAttribute('data-foo')).to.equal('bar')
21+
instance.setAttribute('data-foo', 'baz')
22+
expect(instance.foo).to.equal('baz')
23+
})
24+
25+
it('sets the attribute to a previously defined value on the key', () => {
26+
const instance = document.createElement('initialize-attr-test-element')
27+
instance.foo = 'hello'
28+
initializeAttrs(instance, ['foo'])
29+
expect(instance.foo).to.equal('hello')
30+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
31+
expect(instance.getAttribute('data-foo')).to.equal('hello')
32+
})
33+
34+
it('prioritises the value in the attribute over the property', () => {
35+
const instance = document.createElement('initialize-attr-test-element')
36+
instance.foo = 'goodbye'
37+
instance.setAttribute('data-foo', 'hello')
38+
initializeAttrs(instance, ['foo'])
39+
expect(instance.foo).to.equal('hello')
40+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
41+
expect(instance.getAttribute('data-foo')).to.equal('hello')
42+
})
43+
44+
describe('types', () => {
45+
it('infers number types from property and casts as number always', () => {
46+
const instance = document.createElement('initialize-attr-test-element')
47+
instance.foo = 1
48+
initializeAttrs(instance, ['foo'])
49+
expect(instance.foo).to.equal(1)
50+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
51+
expect(instance.getAttribute('data-foo')).to.equal('1')
52+
instance.setAttribute('data-foo', '7')
53+
expect(instance.foo).to.equal(7)
54+
instance.setAttribute('data-foo', '-3.14')
55+
expect(instance.foo).to.equal(-3.14)
56+
instance.setAttribute('data-foo', 'Not a Number')
57+
expect(Number.isNaN(instance.foo)).to.equal(true)
58+
instance.removeAttribute('data-foo')
59+
expect(instance.foo).to.equal(0)
60+
instance.foo = 3.14
61+
expect(instance.getAttribute('data-foo')).to.equal('3.14')
62+
})
63+
64+
it('infers boolean types from property and uses has/toggleAttribute', () => {
65+
const instance = document.createElement('initialize-attr-test-element')
66+
instance.foo = false
67+
initializeAttrs(instance, ['foo'])
68+
expect(instance.foo).to.equal(false)
69+
expect(instance.getAttributeNames()).to.eql([])
70+
expect(instance.getAttribute('data-foo')).to.equal(null)
71+
instance.setAttribute('data-foo', '7')
72+
expect(instance.foo).to.equal(true)
73+
instance.setAttribute('data-foo', 'hello')
74+
expect(instance.foo).to.equal(true)
75+
instance.setAttribute('data-foo', 'false')
76+
expect(instance.foo).to.equal(true)
77+
instance.removeAttribute('data-foo')
78+
expect(instance.foo).to.equal(false)
79+
instance.foo = '1'
80+
expect(instance.foo).to.equal(true)
81+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
82+
expect(instance.getAttribute('data-foo')).to.equal('')
83+
instance.foo = false
84+
expect(instance.getAttributeNames()).to.eql([])
85+
})
86+
87+
it('defaults to inferring string type for non-boolean non-number types', () => {
88+
const instance = document.createElement('initialize-attr-test-element')
89+
instance.foo = /^a regexp$/
90+
initializeAttrs(instance, ['foo'])
91+
expect(instance.foo).to.equal('/^a regexp$/')
92+
expect(instance.getAttributeNames()).to.eql(['data-foo'])
93+
expect(instance.getAttribute('data-foo')).to.equal('/^a regexp$/')
94+
})
95+
})
96+
97+
describe('naming', () => {
98+
it('converts camel cased property names to their HTML dasherized equivalents', () => {
99+
const instance = document.createElement('initialize-attr-test-element')
100+
initializeAttrs(instance, ['fooBarBazBing'])
101+
expect(instance.fooBarBazBing).to.equal('')
102+
instance.fooBarBazBing = 'bar'
103+
expect(instance.getAttributeNames()).to.eql(['data-foo-bar-baz-bing'])
104+
})
105+
106+
it('will intuitively dasherize acryonyms', () => {
107+
const instance = document.createElement('initialize-attr-test-element')
108+
initializeAttrs(instance, ['URLBar'])
109+
expect(instance.URLBar).to.equal('')
110+
instance.URLBar = 'bar'
111+
expect(instance.getAttributeNames()).to.eql(['data-url-bar'])
112+
})
113+
114+
it('dasherizes cap suffixed names correctly', () => {
115+
const instance = document.createElement('initialize-attr-test-element')
116+
initializeAttrs(instance, ['ClipX'])
117+
expect(instance.ClipX).to.equal('')
118+
instance.ClipX = 'bar'
119+
expect(instance.getAttributeNames()).to.eql(['data-clip-x'])
120+
})
121+
})
122+
123+
describe('class fields', () => {
124+
class ClassFieldAttrTestElement extends HTMLElement {
125+
foo = 1
126+
}
127+
customElements.define('class-field-attr-test-element', ClassFieldAttrTestElement)
128+
129+
it('overrides any getters assigned in constructor (like class fields)', () => {
130+
const instance = document.createElement('class-field-attr-test-element')
131+
initializeAttrs(instance, ['foo'])
132+
instance.foo = 2
133+
expect(instance.foo).to.equal(2)
134+
expect(instance.getAttribute('data-foo')).to.equal('2')
135+
instance.setAttribute('data-foo', '3')
136+
expect(instance.foo).to.equal(3)
137+
})
138+
139+
it('defaults to class field value attribute not present', () => {
140+
const instance = document.createElement('class-field-attr-test-element')
141+
initializeAttrs(instance, ['foo'])
142+
expect(instance.foo).to.equal(1)
143+
expect(instance.getAttribute('data-foo')).to.equal('1')
144+
})
145+
146+
it('ignores class field value if element has attribute already', () => {
147+
const instance = document.createElement('class-field-attr-test-element')
148+
instance.setAttribute('data-foo', '2')
149+
initializeAttrs(instance, ['foo'])
150+
expect(instance.foo).to.equal(2)
151+
expect(instance.getAttribute('data-foo')).to.equal('2')
152+
})
153+
})
154+
})
155+
156+
describe('attr', () => {
157+
class AttrTestElement extends HTMLElement {}
158+
window.customElements.define('attr-test-element', AttrTestElement)
159+
160+
it('populates the "default" list for initializeAttrs', () => {
161+
attr(AttrTestElement.prototype, 'foo')
162+
attr(AttrTestElement.prototype, 'bar')
163+
const instance = document.createElement('attr-test-element')
164+
instance.foo = 'hello'
165+
initializeAttrs(instance)
166+
expect(instance).to.have.property('foo', 'hello')
167+
expect(instance).to.have.property('bar', '')
168+
expect(instance.getAttributeNames()).to.eql(['data-foo', 'data-bar'])
169+
expect(instance.getAttribute('data-foo')).to.equal('hello')
170+
expect(instance.getAttribute('data-bar')).to.equal('')
171+
})
172+
173+
})
174+
175+
describe('defineObservedAttributes', () => {
176+
it('defines `observedAttributes` getter/setter on class', () => {
177+
class TestElement extends HTMLElement {}
178+
defineObservedAttributes(TestElement)
179+
expect(TestElement).to.have.ownPropertyDescriptor('observedAttributes')
180+
expect(TestElement.observedAttributes).to.eql([])
181+
})
182+
183+
it('can be set after definition', () => {
184+
class TestElement extends HTMLElement {}
185+
defineObservedAttributes(TestElement)
186+
TestElement.observedAttributes = ['a', 'b', 'c']
187+
expect(TestElement.observedAttributes).to.eql(['a', 'b', 'c'])
188+
})
189+
190+
it('will reflect values from attr calls', () => {
191+
class TestElement extends HTMLElement {}
192+
defineObservedAttributes(TestElement)
193+
attr(TestElement.prototype, 'foo')
194+
expect(TestElement.observedAttributes).to.eql(['data-foo'])
195+
})
196+
197+
it('will reflect values even if set after definition', () => {
198+
class TestElement extends HTMLElement {}
199+
defineObservedAttributes(TestElement)
200+
attr(TestElement.prototype, 'foo')
201+
TestElement.observedAttributes = ['a', 'b', 'c']
202+
expect(TestElement.observedAttributes).to.eql(['data-foo', 'a', 'b', 'c'])
203+
})
204+
})

0 commit comments

Comments
 (0)