Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: github/catalyst
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: bdcbd42b01a5fd98238389c30e72ff17de5d2a19
Choose a base ref
..
head repository: github/catalyst
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 24565f5e6df6dd5acc27ac18c83456e4602430c7
Choose a head ref
1 change: 0 additions & 1 deletion docs/_guide/abilities.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
chapter: 14
subtitle: Abilities
hidden: true
---

Under the hood Catalyst's controller decorator is comprised of a handful of separate "abilities". An "ability" is essentially a mixin or perhaps "higher order class". An ability takes a class and returns an extended class that adds additional behaviours. By convention all abilities exported by Catalyst are suffixed with `able` which we think is a nice way to denote that something is an ability and should be used as such.
189 changes: 132 additions & 57 deletions docs/_guide/attrs.md
Original file line number Diff line number Diff line change
@@ -7,86 +7,139 @@ Components may sometimes manage state, or configuration. We encourage the use of

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.

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:
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:

- 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.
- 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!
- 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!
- 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.

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:

```ts
const input = document.createElement('input')
console.assert(input.type === 'text') // default value
console.assert(input.hasAttribute('type') === false) // no attribute to override
input.setAttribute('type', 'number')
console.assert(input.type === 'number') // overrides based on attribute
input.removeAttribute('type')
console.assert(input.type === 'text') // back to default value
```

- 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`).
- 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!
- 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).
- 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).
{% capture callout %}
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:
{% endcapture %}{% include callout.md %}

To use the `@attr` decorator, attach it to a class field, and it will get/set the value of the matching `data-*` attribute.
<form>
<label>
<h4>I want my `@attr` to be named...</h4>
<input class="js-attr-dasherize-test mb-4">
</label>
<div hidden class="js-attr-dasherize-bad text-red">
{{ octx }} An attr name must be two words, so that the HTML version includes a dash!
</div>
<div hidden class="js-attr-dasherize-good text-green">
{{ octick }} This will be <code></code> in HTML.
</div>
<script type="module">
import {mustDasherize} from 'https://unpkg.com/@github/catalyst/lib/index.js'
document.querySelector('.js-attr-dasherize-test').addEventListener('input', () => {
let name = event.target.value
const goodEl = document.querySelector('.js-attr-dasherize-good')
const badEl = document.querySelector('.js-attr-dasherize-bad')
if (name === '') {
goodEl.hidden = true
badEl.hidden = true
return
}
let pass = true
try {
name = mustDasherize(name)
} catch (e) {
pass = false
}
goodEl.querySelector('code').textContent = name
goodEl.hidden = !pass
badEl.hidden = pass
})
</script>
</form>

To use the `@attr` decorator, attach it to a class field, and it will get/set the value of the matching dasherized HTML attribute.

### Example

<!-- annotations
attr foo: Maps to get/setAttribute('datafoo')
attr fooBar: Maps to get/setAttribute('foo-bar')
-->

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

@controller
class HelloWorldElement extends HTMLElement {
@attr foo = 'hello'
@attr fooBar = 'hello'
}
```

This is the equivalent to:
This is somewhat equivalent to:

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

@controller
class HelloWorldElement extends HTMLElement {
get foo(): string {
return this.getAttribute('data-foo') || ''
get fooBar(): string {
return this.getAttribute('foo-bar') || ''
}

set foo(value: string): void {
return this.setAttribute('data-foo', value)
set fooBar(value: string): void {
return this.setAttribute('foo-bar', value)
}

connectedCallback() {
if (!this.hasAttribute('data-foo')) this.foo = 'Hello'
if (!this.hasAttribute('foo-bar')) this.fooBar = 'Hello'
}

static observedAttributes = ['data-foo']
}
```

### Attribute Types

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

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

| Type | "Empty" value | When `get` is called | When `set` is called |
|:----------|:--------------|----------------------|:---------------------|
| `string` | `''` | `getAttribute` | `setAttribute` |
| `number` | `0` | `getAttribute` | `setAttribute` |
| `boolean` | `false` | `hasAttribute` | `toggleAttribute` |
| Type | When `get` is called | When `set` is called |
|:----------|----------------------|:---------------------|
| `string` | `getAttribute` | `setAttribute` |
| `number` | `getAttribute` | `setAttribute` |
| `boolean` | `hasAttribute` | `toggleAttribute` |

#### String Attributes

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

<!-- annotations
attr foo: Maps to get/setAttribute('data-foo')
attr foo: Maps to get/setAttribute('foo-bar')
-->

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

@controller
class HelloWorldElement extends HTMLElement {
@attr foo = 'Hello'
@attr fooBar = 'Hello'

connectedCallback() {
console.assert(this.foo === 'Hello')
this.foo = null // TypeScript won't like this!
console.assert(this.foo === 'null')
delete this.dataset.foo // Removes the attribute
console.assert(this.foo === '') // If the attribute doesn't exist, its an empty string!
console.assert(this.fooBar === 'Hello')
this.fooBar = 'Goodbye'
console.assert(this.fooBar === 'Goodbye'')
console.assert(this.getAttribute('foo-bar') === 'Goodbye')
this.removeAttribute('foo-bar')
// If the attribute doesn't exist, it'll output the initial value!
console.assert(this.fooBar === 'Hello')
}
}
```
@@ -104,39 +157,40 @@ import { controller, attr } from "@github/catalyst"
@controller
class HelloWorldElement extends HTMLElement {
@attr foo = false
@attr fooBar = false
connectedCallback() {
console.assert(this.hasAttribute('data-foo') === false)
this.foo = true
console.assert(this.hasAttribute('data-foo') === true)
this.setAttribute('data-foo', 'this value doesnt matter!')
console.assert(this.foo === true)
console.assert(this.hasAttribute('foo-bar') === false)
this.fooBar = true
console.assert(this.hasAttribute('foo-bar') === true)
this.setAttribute('foo-bar', 'this value doesnt matter!')
console.assert(this.fooBar === true)
}
}
```

#### Number Attributes

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

<!-- annotations
attr foo: Maps to get/setAttribute('data-foo')
attr foo: Maps to get/setAttribute('foo-bar')
-->

```js
import { controller, attr } from "@github/catalyst"
@controller
class HelloWorldElement extends HTMLElement {
@attr foo = 1
@attr fooBar = 1
connectedCallback() {
console.assert(this.getAttribute('data-foo') === '1')
this.setAttribute('data-foo', 'not a number')
console.assert(Number.isNaN(this.foo))
this.foo = -3.14
console.assert(this.getAttribute('data-foo') === '-3.14')
this.fooBar = 2
console.assert(this.getAttribute('foo-bar') === '2')
this.setAttribute('foo-bar', 'not a number')
console.assert(Number.isNaN(this.fooBar))
this.fooBar = -3.14
console.assert(this.getAttribute('foo-bar') === '-3.14')
}
}
```
@@ -146,7 +200,7 @@ class HelloWorldElement extends HTMLElement {
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:

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

{% capture callout %}
@@ -163,9 +217,9 @@ attr name: Maps to get/setAttribute('data-name')
import { controller, attr } from "@github/catalyst"
@controller
class HelloWorldElement extends HTMLElement {
@attr name = 'World'
@attr dataName = 'World'
connectedCallback() {
this.textContent = `Hello ${this.name}`
this.textContent = `Hello ${this.dataName}`
}
}
```
@@ -185,24 +239,45 @@ data-name ".*": Will set the value of `name`
// This will render `Hello `
```
### What about without Decorators?
### Advanced usage
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.
#### Determining when an @attr changes value
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:

```js
import {initializeAttrs, defineObservedAttributes} from '@github/catalyst'
To be notified when an `@attr` changes value, you can use the decorator over
"setter" method instead, and the method will be called with the new value
whenever it is re-assigned, either through HTML or JavaScript:
```typescript
import { controller, attr } from "@github/catalyst"
@controller
class HelloWorldElement extends HTMLElement {
foo = 1
connectedCallback() {
initializeAttrs(this, ['foo'])
@attr get dataName() {
return 'World' // Used to get the intial value
}
// Called whenever `name` changes
set dataName(newValue: string) {
this.textContent = `Hello ${newValue}`
}
}
```
### What about without Decorators?
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:

```js
import {controller, attr} from '@github/catalyst'
controller(
class HelloWorldElement extends HTMLElement {
// Same as @attr fooBar
[attr.static] = ['fooBar']
// Field can still be defined
fooBar = 1
}
defineObservedAttributes(HelloWorldElement, ['foo'])
)
```

This example is functionally identical to:
@@ -212,6 +287,6 @@ import {controller, attr} from '@github/catalyst'
@controller
class HelloWorldElement extends HTMLElement {
@attr foo = 1
@attr fooBar = 1
}
```
11 changes: 8 additions & 3 deletions docs/_guide/conventions.md
Original file line number Diff line number Diff line change
@@ -5,13 +5,18 @@ subtitle: Conventions

Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code:

### Use `Element` to suffix your controller class
### Suffix your controllers consistently, for symmetry

Built in HTML elements all extend from the `HTMLElement` constructor, and are all suffixed with `Element` (for example `HTMLElement`, `SVGElement`, `HTMLInputElement` and so on). Catalyst components should be no different, they should behave as closely to the built-ins as possible.
Catalyst components can be suffixed with `Element`, `Component` or `Controller`. We think elements should behave as closely to the built-ins as possible, so we like to use `Element` (existing elements do this, for example `HTMLDivElement`, `SVGElement`). If you're using a server side comoponent framework such as [ViewComponent](https://viewcomponent.org/), it's probably better to suffix `Component` for symmetry with that framework.

```typescript
@controller
class UserListElement extends HTMLElement {}
class UserListElement extends HTMLElement {} // `<user-list />`
```

```typescript
@controller
class UserListComponent extends HTMLElement {} // `<user-list />`
```

### The best class-names are two word descriptions
1 change: 0 additions & 1 deletion docs/_guide/create-ability.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
chapter: 16
subtitle: Create your own abilities
hidden: true
---

Catalyst provides the functionality to create your own abilities, with a few helper methods and a `controllable` base-level ability. These are explained in detail below, but for a quick summary they are:
1 change: 0 additions & 1 deletion docs/_guide/providable.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
chapter: 15
subtitle: The Provider pattern
hidden: true
---

The [Provider pattern](https://www.patterns.dev/posts/provider-pattern/) allows for deeply nested children to ask ancestors for values. This can be useful for decoupling state inside a component, centralising it higher up in the DOM heirarchy. A top level container component might store values, and many children can consume those values, without having logic duplicated across the app. It's quite an abstract pattern so is better explained with examples...
Loading