Skip to content

Commit 7f546f0

Browse files
authored
Merge pull request #103 from github/feat-add-attr-decorator
Feat add attr decorator
2 parents 3acbb9a + b7c9bb8 commit 7f546f0

11 files changed

Lines changed: 518 additions & 18 deletions

File tree

docs/_guide/anti-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 11
2+
chapter: 12
33
subtitle: Anti Patterns
44
---
55

docs/_guide/attrs.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
---
2+
chapter: 7
3+
subtitle: Using attributes as configuration
4+
---
5+
6+
Components may sometimes manage state, or configuration. We encourage the use of DOM as state, rather than maintaining a separate state. One way to maintain state in the DOM is via [Attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).
7+
8+
As Catalyst elements are really just Web Components, they have the `hasAttribute`, `getAttribute`, `setAttribute`, `toggleAttribute`, and `removeAttribute` set of methods available, as well as [`dataset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/dataset), but these can be a little tedious to use; requiring null checking code with each call.
9+
10+
Catalyst includes the `@attr` decorator, which provides nice syntax sugar to simplify, standardise, and encourage use of attributes. `@attr` has the following benefits over the basic `*Attribute` methods:
11+
12+
- It maps whatever the property name is to `data-*`, [similar to how `dataset` does](https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/dataset#name_conversion), but with more intuitive naming (e.g. `URL` maps to `data-url` not `data--u-r-l`).
13+
- An `@attr` property is limited to `string`, `boolean`, or `number`, it will never be `null` or `undefined` - instead it has an "empty" value. No more null checking!
14+
- The attribute name is automatically [observed, meaning `attributeChangedCallback` will fire when it changes](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks).
15+
- Assigning a value in the class description will make that value the _default_ value, so when the element is connected that value is set (unless the element has the attribute defined already).
16+
17+
To use the `@attr` decorator, attach it to a class field, and it will get/set the value of the matching `data-*` attribute.
18+
19+
### Example
20+
21+
```js
22+
import { controller, attr } from "@github/catalyst"
23+
24+
@controller
25+
class HelloWorldElement extends HTMLElement {
26+
@attr foo = 'hello'
27+
}
28+
```
29+
30+
This is the equivalent to:
31+
32+
```js
33+
import { controller } from "@github/catalyst"
34+
35+
@controller
36+
class HelloWorldElement extends HTMLElement {
37+
get foo(): string {
38+
return this.getAttribute('data-foo') || ''
39+
}
40+
41+
set foo(value: string): void {
42+
return this.setAttribute('data-foo', value)
43+
}
44+
45+
connectedCallback() {
46+
if (!this.hasAttribute('data-foo')) this.foo = 'Hello'
47+
}
48+
49+
static observedAttributes = ['data-foo']
50+
}
51+
```
52+
53+
### Attribute Types
54+
55+
The _type_ of an attribute is automatically inferred based on the type it is first set to. This means once a value is set it cannot change type; if it is set a `string` it will never be anything but a `string`. An attribute can only be one of either a `string`, `number`, or `boolean`. The types have small differences in how they behave in the DOM.
56+
57+
Below is a handy reference for the small differences, this is all explained in more detail below that.
58+
59+
| Type | "Empty" value | When `get` is called | When `set` is called |
60+
|:----------|:--------------|----------------------|:---------------------|
61+
| `string` | `''` | `getAttribute` | `setAttribute` |
62+
| `number` | `0` | `getAttribute` | `setAttribute` |
63+
| `boolean` | `false` | `hasAttribute` | `toggleAttribute` |
64+
65+
#### String Attributes
66+
67+
If an attribute is first set to a `string`, then it can only ever be a `string` during the lifetime of an element. The property will return an empty string (`''`) if the attribute doesn't exist, and trying to set it to something that isn't a string will turn it into one before assignment.
68+
69+
```js
70+
import { controller, attr } from "@github/catalyst"
71+
72+
@controller
73+
class HelloWorldElement extends HTMLElement {
74+
@attr foo = 'Hello'
75+
76+
connectedCallback() {
77+
console.assert(this.foo === 'Hello')
78+
this.foo = null // TypeScript won't like this!
79+
console.assert(this.foo === 'null')
80+
delete this.dataset.foo // Removes the attribute
81+
console.assert(this.foo === '') // If the attribute doesn't exist, its an empty string!
82+
}
83+
}
84+
```
85+
86+
#### Boolean Attributes
87+
88+
If an attribute is first set to a boolean, then it can only ever be a boolean during the lifetime of an element. Boolean properties check for _presence_ of an attribute, sort of like how [`required`, `disabled` & `readonly` attributes work on forms](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes) The property will return `false` if the attribute doesn't exist, and `true` if it does, regardless of the value. If the property is set to `false` then `removeAttribute` is called, whereas `setAttribute(name, '')` is called when setting to a truthy value.
89+
90+
```js
91+
import { controller, attr } from "@github/catalyst"
92+
93+
@controller
94+
class HelloWorldElement extends HTMLElement {
95+
@attr foo = false
96+
97+
connectedCallback() {
98+
console.assert(this.hasAttribute('data-foo') === false)
99+
this.foo = true
100+
console.assert(this.hasAttribute('data-foo') === true)
101+
this.setAttribute('data-foo', 'this value doesnt matter!')
102+
console.assert(this.foo === true)
103+
}
104+
}
105+
```
106+
107+
#### Number Attributes
108+
109+
If an attribute is first set to a number, then it can only ever be a number during the lifetime of an element. This is sort of like the [`maxlength` attribute on inputs](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength). The property will return `0` if the attribute doesn't exist, and will be coerced to `Number` if it does - this means it is _possible_ to get back `NaN`. Negative numbers and floats are also valid.
110+
111+
```js
112+
import { controller, attr } from "@github/catalyst"
113+
114+
@controller
115+
class HelloWorldElement extends HTMLElement {
116+
@attr foo = 1
117+
118+
connectedCallback() {
119+
console.assert(this.getAttribute('data-foo') === '1')
120+
this.setAttribute('data-foo', 'not a number')
121+
console.assert(Number.isNaN(this.foo))
122+
this.foo = -3.14
123+
console.assert(this.getAttribute('data-foo') === '-3.14')
124+
}
125+
}
126+
```
127+
128+
### Default Values
129+
130+
When an element gets connected to the DOM, the attr is initialized. During this phase Catalyst will determine if the default value should be applied. The default value is defined in the class property. The basic rules are as such:
131+
132+
- If the class property has a value, that is the _default_
133+
- When connected, if the element _does not_ have a matching attribute, the default _is_ applied.
134+
- When connected, if the element _does_ have a matching attribute, the default _is not_ applied, the property will be assigned to the value of the attribute instead.
135+
136+
<div class="d-flex border rounded-1 my-3 box-shadow-medium">
137+
<span class="d-flex flex-items-center bg-blue text-white rounded-left-1 p-3">
138+
<svg width="24" height="24" viewBox="0 0 14 16" class="octicon octicon-info" aria-hidden="true">
139+
<path
140+
fill-rule="evenodd"
141+
d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"
142+
/>
143+
</svg>
144+
</span>
145+
<div class="p-3">
146+
147+
Remember! The values defined in the class field are the _default_. They won't be set if the element is created and its attribute set to a custom value!
148+
149+
</div>
150+
</div>
151+
152+
The following example illustrates this behavior:
153+
154+
```js
155+
import { controller, attr } from "@github/catalyst"
156+
@controller
157+
class HelloWorldElement extends HTMLElement {
158+
@attr name = 'World'
159+
connectedCallback() {
160+
this.textContent = `Hello ${name}`
161+
}
162+
}
163+
```
164+
165+
```html
166+
<hello-world></hello-world>
167+
// This will render `Hello World`
168+
169+
<hello-world data-name="Catalyst"></hello-world>
170+
// This will render `Hello Catalyst`
171+
172+
<hello-world data-name=""></hello-world>
173+
// This will render `Hello `
174+
```
175+
176+
### What about without Decorators?
177+
178+
If you're not using decorators, then you won't be able to use the `@attr` decorator, but there is still a way to achieve the same result. Under the hood `@attr` simply tags a field, but `initializeAttrs` does all of the logic.
179+
180+
Calling `initializeAttrs` in your connected callback, with the list of properties you'd like to initialize can achieve the same result. The class fields can still be defined in your class, and they'll be overridden as described above. For example:
181+
182+
```js
183+
import {initializeAttrs} from '@github/catalyst'
184+
185+
class HelloWorldElement extends HTMLElement {
186+
foo = 1
187+
188+
connectedCallback() {
189+
initializeAttrs(this, ['foo'])
190+
}
191+
192+
}
193+
```
194+
195+
This example is functionally identical to:
196+
197+
```js
198+
import {controller, attr} from '@github/catalyst'
199+
200+
@controller
201+
class HelloWorldElement extends HTMLElement {
202+
@attr foo = 1
203+
}
204+
```

docs/_guide/conventions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 9
2+
chapter: 10
33
subtitle: Conventions
44
---
55

docs/_guide/patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 10
2+
chapter: 11
33
subtitle: Patterns
44
---
55

docs/_guide/rendering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 7
2+
chapter: 8
33
subtitle: Rendering HTML subtrees
44
---
55

src/attr.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {CustomElement} from './custom-element'
2+
3+
const attrs = new WeakMap<Record<PropertyKey, unknown>, 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, [])
15+
attrs.get(proto)!.push(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+
const name = attrToAttributeName(key)
42+
let descriptor: PropertyDescriptor = {
43+
get(this: HTMLElement): string {
44+
return this.getAttribute(name) || ''
45+
},
46+
set(this: HTMLElement, newValue: string) {
47+
this.setAttribute(name, newValue || '')
48+
}
49+
}
50+
if (typeof value === 'number') {
51+
descriptor = {
52+
get(this: HTMLElement): number {
53+
return Number(this.getAttribute(name) || 0)
54+
},
55+
set(this: HTMLElement, newValue: string) {
56+
this.setAttribute(name, newValue)
57+
}
58+
}
59+
} else if (typeof value === 'boolean') {
60+
descriptor = {
61+
get(this: HTMLElement): boolean {
62+
return this.hasAttribute(name)
63+
},
64+
set(this: HTMLElement, newValue: boolean) {
65+
this.toggleAttribute(name, newValue)
66+
}
67+
}
68+
}
69+
Object.defineProperty(instance, key, descriptor)
70+
if (key in instance && !instance.hasAttribute(name)) {
71+
descriptor.set!.call(instance, value)
72+
}
73+
}
74+
}
75+
76+
function attrToAttributeName(name: string): string {
77+
return `data-${name.replace(/([A-Z]($|[a-z]))/g, '-$1')}`.replace(/--/g, '-').toLowerCase()
78+
}
79+
80+
export function defineObservedAttributes(classObject: CustomElement): void {
81+
let observed = classObject.observedAttributes || []
82+
Object.defineProperty(classObject, 'observedAttributes', {
83+
get() {
84+
const attrMap = attrs.get(classObject.prototype)
85+
if (!attrMap) return observed
86+
return attrMap.map(attrToAttributeName).concat(observed)
87+
},
88+
set(attributes: string[]) {
89+
observed = attributes
90+
}
91+
})
92+
}

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.

0 commit comments

Comments
 (0)