Skip to content

Commit 21c81fb

Browse files
committed
add new attr docs
1 parent d142b92 commit 21c81fb

1 file changed

Lines changed: 132 additions & 57 deletions

File tree

docs/_guide/attrs.md

Lines changed: 132 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,86 +9,139 @@ Components may sometimes manage state, or configuration. We encourage the use of
99

1010
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.
1111

12-
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:
12+
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:
13+
14+
- It dasherizes a property name, making it safe for HTML serialization without conflicting with [built-in global attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes). This works the same as the class name, so for example `@attr pathName` will be `path-name` in HTML, `@attr srcURL` will be `src-url` in HTML.
15+
- An `@attr` property automatically casts based on the initial value - if the initial value is a `string`, `boolean`, or `number` - it will never be `null` or `undefined`. No more null checking!
16+
- It is automatically synced with the HTML attribute. This means setting the class property will update the HTML attribute, and setting the HTML attribute will update the class property!
17+
- Assigning a value in the class description will make that value the _default_ value so if the HTML attribute isn't set, or is set but later removed the _default_ value will apply.
18+
19+
This behaves similarly to existing HTML elements where the class field is synced with the html attribute, for example the `<input>` element's `type` field:
20+
21+
```ts
22+
const input = document.createElement('input')
23+
console.assert(input.type === 'text') // default value
24+
console.assert(input.hasAttribute('type') === false) // no attribute to override
25+
input.setAttribute('type', 'number')
26+
console.assert(input.type === 'number') // overrides based on attribute
27+
input.removeAttribute('type')
28+
console.assert(input.type === 'text') // back to default value
29+
```
1330

14-
- 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`).
15-
- 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!
16-
- 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).
17-
- 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).
31+
{% capture callout %}
32+
An important part of `@attr`s is that they _must_ comprise of two words, so that they get a dash when serialised to HTML. This is intentional, to avoid conflicting with [built-in global attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes). To see how JavaScript property names convert to HTML dasherized names, try typing the name of an `@attr` below:
33+
{% endcapture %}{% include callout.md %}
1834

19-
To use the `@attr` decorator, attach it to a class field, and it will get/set the value of the matching `data-*` attribute.
35+
<form>
36+
<label>
37+
<h4>I want my `@attr` to be named...</h4>
38+
<input class="js-attr-dasherize-test mb-4">
39+
</label>
40+
<div hidden class="js-attr-dasherize-bad text-red">
41+
{{ octx }} An attr name must be two words, so that the HTML version includes a dash!
42+
</div>
43+
<div hidden class="js-attr-dasherize-good text-green">
44+
{{ octick }} This will be <code></code> in HTML.
45+
</div>
46+
<script type="module">
47+
import {mustDasherize} from 'https://unpkg.com/@github/catalyst/lib/index.js'
48+
document.querySelector('.js-attr-dasherize-test').addEventListener('input', () => {
49+
let name = event.target.value
50+
const goodEl = document.querySelector('.js-attr-dasherize-good')
51+
const badEl = document.querySelector('.js-attr-dasherize-bad')
52+
if (name === '') {
53+
goodEl.hidden = true
54+
badEl.hidden = true
55+
return
56+
}
57+
let pass = true
58+
try {
59+
name = mustDasherize(name)
60+
} catch (e) {
61+
pass = false
62+
}
63+
goodEl.querySelector('code').textContent = name
64+
goodEl.hidden = !pass
65+
badEl.hidden = pass
66+
})
67+
</script>
68+
</form>
69+
70+
To use the `@attr` decorator, attach it to a class field, and it will get/set the value of the matching dasherized HTML attribute.
2071

2172
### Example
2273

2374
<!-- annotations
24-
attr foo: Maps to get/setAttribute('datafoo')
75+
attr fooBar: Maps to get/setAttribute('foo-bar')
2576
-->
2677

2778
```js
2879
import { controller, attr } from "@github/catalyst"
2980

3081
@controller
3182
class HelloWorldElement extends HTMLElement {
32-
@attr foo = 'hello'
83+
@attr fooBar = 'hello'
3384
}
3485
```
3586

36-
This is the equivalent to:
87+
This is somewhat equivalent to:
3788

3889
```js
3990
import { controller } from "@github/catalyst"
4091

4192
@controller
4293
class HelloWorldElement extends HTMLElement {
43-
get foo(): string {
44-
return this.getAttribute('data-foo') || ''
94+
get fooBar(): string {
95+
return this.getAttribute('foo-bar') || ''
4596
}
4697

47-
set foo(value: string): void {
48-
return this.setAttribute('data-foo', value)
98+
set fooBar(value: string): void {
99+
return this.setAttribute('foo-bar', value)
49100
}
50101

51102
connectedCallback() {
52-
if (!this.hasAttribute('data-foo')) this.foo = 'Hello'
103+
if (!this.hasAttribute('foo-bar')) this.fooBar = 'Hello'
53104
}
54105

55-
static observedAttributes = ['data-foo']
56106
}
57107
```
58108

59109
### Attribute Types
60110

61-
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.
111+
The _type_ of an attribute is automatically inferred based on the type it is first set to. This means once a value is initially 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.
62112

63113
Below is a handy reference for the small differences, this is all explained in more detail below that.
64114

65-
| Type | "Empty" value | When `get` is called | When `set` is called |
66-
|:----------|:--------------|----------------------|:---------------------|
67-
| `string` | `''` | `getAttribute` | `setAttribute` |
68-
| `number` | `0` | `getAttribute` | `setAttribute` |
69-
| `boolean` | `false` | `hasAttribute` | `toggleAttribute` |
115+
| Type | When `get` is called | When `set` is called |
116+
|:----------|----------------------|:---------------------|
117+
| `string` | `getAttribute` | `setAttribute` |
118+
| `number` | `getAttribute` | `setAttribute` |
119+
| `boolean` | `hasAttribute` | `toggleAttribute` |
70120

71121
#### String Attributes
72122

73-
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.
123+
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 revert to the initial value 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.
74124

75125
<!-- annotations
76-
attr foo: Maps to get/setAttribute('data-foo')
126+
attr foo: Maps to get/setAttribute('foo-bar')
77127
-->
78128

79129
```js
80130
import { controller, attr } from "@github/catalyst"
81131

82132
@controller
83133
class HelloWorldElement extends HTMLElement {
84-
@attr foo = 'Hello'
134+
@attr fooBar = 'Hello'
85135

86136
connectedCallback() {
87-
console.assert(this.foo === 'Hello')
88-
this.foo = null // TypeScript won't like this!
89-
console.assert(this.foo === 'null')
90-
delete this.dataset.foo // Removes the attribute
91-
console.assert(this.foo === '') // If the attribute doesn't exist, its an empty string!
137+
console.assert(this.fooBar === 'Hello')
138+
this.fooBar = 'Goodbye'
139+
console.assert(this.fooBar === 'Goodbye'')
140+
console.assert(this.getAttribute('foo-bar') === 'Goodbye')
141+
142+
this.removeAttribute('foo-bar')
143+
// If the attribute doesn't exist, it'll output the initial value!
144+
console.assert(this.fooBar === 'Hello')
92145
}
93146
}
94147
```
@@ -106,39 +159,40 @@ import { controller, attr } from "@github/catalyst"
106159
107160
@controller
108161
class HelloWorldElement extends HTMLElement {
109-
@attr foo = false
162+
@attr fooBar = false
110163
111164
connectedCallback() {
112-
console.assert(this.hasAttribute('data-foo') === false)
113-
this.foo = true
114-
console.assert(this.hasAttribute('data-foo') === true)
115-
this.setAttribute('data-foo', 'this value doesnt matter!')
116-
console.assert(this.foo === true)
165+
console.assert(this.hasAttribute('foo-bar') === false)
166+
this.fooBar = true
167+
console.assert(this.hasAttribute('foo-bar') === true)
168+
this.setAttribute('foo-bar', 'this value doesnt matter!')
169+
console.assert(this.fooBar === true)
117170
}
118171
}
119172
```
120173

121174
#### Number Attributes
122175

123-
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.
176+
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 the initial value 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.
124177

125178
<!-- annotations
126-
attr foo: Maps to get/setAttribute('data-foo')
179+
attr foo: Maps to get/setAttribute('foo-bar')
127180
-->
128181

129182
```js
130183
import { controller, attr } from "@github/catalyst"
131184
132185
@controller
133186
class HelloWorldElement extends HTMLElement {
134-
@attr foo = 1
187+
@attr fooBar = 1
135188
136189
connectedCallback() {
137-
console.assert(this.getAttribute('data-foo') === '1')
138-
this.setAttribute('data-foo', 'not a number')
139-
console.assert(Number.isNaN(this.foo))
140-
this.foo = -3.14
141-
console.assert(this.getAttribute('data-foo') === '-3.14')
190+
this.fooBar = 2
191+
console.assert(this.getAttribute('foo-bar') === '2')
192+
this.setAttribute('foo-bar', 'not a number')
193+
console.assert(Number.isNaN(this.fooBar))
194+
this.fooBar = -3.14
195+
console.assert(this.getAttribute('foo-bar') === '-3.14')
142196
}
143197
}
144198
```
@@ -148,7 +202,7 @@ class HelloWorldElement extends HTMLElement {
148202
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:
149203

150204
- If the class property has a value, that is the _default_
151-
- When connected, if the element _does not_ have a matching attribute, the default _is_ applied.
205+
- When connected, if the element _does not_ have a matching attribute, the _default is_ applied.
152206
- 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.
153207

154208
{% capture callout %}
@@ -165,9 +219,9 @@ attr name: Maps to get/setAttribute('data-name')
165219
import { controller, attr } from "@github/catalyst"
166220
@controller
167221
class HelloWorldElement extends HTMLElement {
168-
@attr name = 'World'
222+
@attr dataName = 'World'
169223
connectedCallback() {
170-
this.textContent = `Hello ${this.name}`
224+
this.textContent = `Hello ${this.dataName}`
171225
}
172226
}
173227
```
@@ -187,24 +241,45 @@ data-name ".*": Will set the value of `name`
187241
// This will render `Hello `
188242
```
189243
190-
### What about without Decorators?
244+
### Advanced usage
191245
192-
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` and `defineObservedAttributes` do all of the logic.
246+
#### Determining when an @attr changes value
193247
194-
Calling `initializeAttrs` in your connected callback, with the list of properties you'd like to initialize, and calling `defineObservedAttributes` with the class, can achieve the same result as `@attr`. The class fields can still be defined in your class, and they'll be overridden as described above. For example:
195-
196-
```js
197-
import {initializeAttrs, defineObservedAttributes} from '@github/catalyst'
248+
To be notified when an `@attr` changes value, you can use the decorator over
249+
"setter" method instead, and the method will be called with the new value
250+
whenever it is re-assigned, either through HTML or JavaScript:
198251
252+
```typescript
253+
import { controller, attr } from "@github/catalyst"
254+
@controller
199255
class HelloWorldElement extends HTMLElement {
200-
foo = 1
201256
202-
connectedCallback() {
203-
initializeAttrs(this, ['foo'])
257+
@attr get dataName() {
258+
return 'World' // Used to get the intial value
204259
}
260+
// Called whenever `name` changes
261+
@attr set dataName(newValue: string) {
262+
this.textContent = `Hello ${newValue}`
263+
}
264+
}
265+
```
266+
267+
### What about without Decorators?
268+
269+
If you're not using decorators, then the `@attr` decorator has an escape hatch: You can define a static class field using the `[attr.static]` computed property, as an array of key names. Like so:
270+
271+
```js
272+
import {controller, attr} from '@github/catalyst'
273+
274+
controller(
275+
class HelloWorldElement extends HTMLElement {
276+
// Same as @attr fooBar
277+
[attr.static] = ['fooBar']
205278
279+
// Field can still be defined
280+
fooBar = 1
206281
}
207-
defineObservedAttributes(HelloWorldElement, ['foo'])
282+
)
208283
```
209284

210285
This example is functionally identical to:
@@ -214,6 +289,6 @@ import {controller, attr} from '@github/catalyst'
214289
215290
@controller
216291
class HelloWorldElement extends HTMLElement {
217-
@attr foo = 1
292+
@attr fooBar = 1
218293
}
219294
```

0 commit comments

Comments
 (0)