Skip to content

Commit 5050a15

Browse files
authored
Merge branch 'main' into align-jekyll-versions-to-github-pages
2 parents 9dc8b5d + 3250488 commit 5050a15

16 files changed

Lines changed: 1090 additions & 269 deletions

docs/_guide/abilities.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
chapter: 14
3+
subtitle: Abilities
4+
hidden: true
5+
---
6+
7+
Under the hood Catalyst's controller decorator is comprised of a handful of separate "abilities". Each of these abilities is created with the `createAbility` function. 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. Importantly abilities are idempotent, meaning applying an ability to a class multiple times is safe and the ability is only applied once. 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. Catalyst also exposes all of the tooling to create your own abilities in your own code which we'd encourage if you find yourself repeating patterns within components (if you think you've got a really useful ability, we'd love for you to contribute it)!
8+
9+
### Using Abilities
10+
11+
Abilities are fundementally just class decorators, and so can be used just like the `@controller` decorator. For example to add only the `actionable` decorator (which automatically binds events based on `data-action` attributes):
12+
13+
```typescript
14+
import {actionable} from '@github/catalyst'
15+
16+
@actionable
17+
class HelloWorld extends HTMLElement {
18+
}
19+
```
20+
21+
### Using Marks
22+
23+
Abilities also come with complementary field decorators which we call "marks" (we give them a distinctive name because they're a more restrictive subset of field decorators). Marks annotate fields which abilities can then extend with custom logic, both [Targets]({{ site.baseurl }}/guide/targets) and [Attrs]({{ site.baseurl }}/guide/attrs) are abilities that use marks. The `targetable` ability includes `target` & `targets` marks, and the `attrable` ability includes the `attr` mark. Marks decorate individual fields, like so:
24+
25+
```typescript
26+
import {targetable, target, targets} from '@github/catalyst'
27+
28+
@targetable
29+
class HelloWorldElement extends HTMLElement {
30+
@target name
31+
@targets people
32+
}
33+
```
34+
35+
Marks _can_ decorate over fields, get/set functions, or class methods - but individual marks can set their own validation logic, for example enforcing a naming pattern or disallowing application on methods.
36+
37+
### Built-In Abilities
38+
39+
Catalyst ships with a set of built in abilities. The `@controller` decorator applies the following built-in abilities:
40+
41+
- `controllable` - the base ability which other abilities require for functionality.
42+
- `targetable` - the ability to define `@target` and `@targets` properties. See [Targets]({{ site.baseurl }}/guide/targets) for more.
43+
- `actionable` - the ability to automatically bind events based on `data-action` attributes. See [Actions]({{ site.baseurl }}/guide/actions) for more.
44+
- `attrable` - the ability to define `@attr`s. See [Attrs]({{ site.baseurl }}/guide/attrs) for more.
45+
46+
The `@controller` decorator also applies the `@register` decorator which automatically registers the element in the Custom Element registry, however this decorator isn't an "ability".
47+
48+
The following abilities are shipped with Catalyst but require manually applying as they aren't considered critical functionality:
49+
50+
- `providable` - the ability to define `provider` and `consumer` properties. See [Providable]({{ side.baseurl }}/guide/providable) for more.

docs/_guide/providable.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
---
2+
chapter: 15
3+
subtitle: The Provider pattern
4+
hidden: true
5+
---
6+
7+
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...
8+
9+
Say for example a set of your components are built to perform actions on a user, but need a User ID. One way to handle this is to set the User ID as an attribute on each element, but this can lead to a lot of duplication. Instead these actions can request the ID from a parent component, which can provide the User ID without creating an explicit relationship (which can lead to brittle code).
10+
11+
The `@providable` ability allows a Catalyst controller to become a provider or consumer (or both) of one or many properties. To provide a property to nested controllers that ask for it, mark a property as `@provide`. To consume a property from a parent, mark a property as `@consume`. Let's try implementing the user actions using `@providable`:
12+
13+
```typescript
14+
import {providable, consume, provide, controller} from '@github/catalyst'
15+
16+
@providable
17+
@controller
18+
class BlockUser extends HTMLElement {
19+
// This will request `userId`, and default to '' if not provided.
20+
@consume userId = ''
21+
// This will request `userName`, and default to '' if not provided.
22+
@consume userName = ''
23+
24+
async handleEvent() {
25+
if (confirm(`Would you like to block ${this.userName}?`)) {
26+
await fetch(`/users/${userId}/delete`)
27+
}
28+
}
29+
}
30+
31+
@providable
32+
@controller
33+
class FollowUser extends HTMLElement {
34+
// This will request `userId`, and default to '' if not provided.
35+
@consume userId = ''
36+
// This will request `userName`, and default to '' if not provided.
37+
@consume userName = ''
38+
39+
async handleEvent() {
40+
if (confirm(`Would you like to follow ${this.userName}?`)) {
41+
await fetch(`/users/${userId}/delete`)
42+
}
43+
}
44+
}
45+
46+
@providable
47+
@controller
48+
class UserRow extends HTMLElement {
49+
// This will provide `userId` as '123' to any nested children that request it.
50+
@provide userId = '123'
51+
// This will provide `userName` as 'Alex' to any nested children that request it.
52+
@provide userName = 'Alex'
53+
}
54+
```
55+
56+
```html
57+
<user-row>
58+
<follow-user><button data-action="click:follow-user"></follow-user>
59+
<block-user><button data-action="click:block-user"></block-user>
60+
</user-row>
61+
```
62+
63+
This shows how the basic pattern works, but `UserRow` having fixed strings isn't very useful. The `@provide` decorator can be combined with other decorators to make it more powerful, for example `@attr`:
64+
65+
```typescript
66+
import {providable, consume, provide, @attr, controller} from '@github/catalyst'
67+
68+
@providable
69+
@controller
70+
class UserRow extends HTMLElement {
71+
@provide @attr userId = ''
72+
@provide @attr userName = ''
73+
}
74+
```
75+
```html
76+
<user-row user-id="123" user-name="Alex">
77+
<follow-user><button data-action="click:follow-user"></follow-user>
78+
<block-user><button data-action="click:block-user"></block-user>
79+
</user-row>
80+
<user-row user-id="864" user-name="Riley">
81+
<follow-user><button data-action="click:follow-user"></follow-user>
82+
<block-user><button data-action="click:block-user"></block-user>
83+
</user-row>
84+
```
85+
86+
Values aren't just limited to strings, they can be any type; for example functions, classes, or even other controllers! We could implement a custom dialog component which exists as a sibling and invoke it using providers and `@target`:
87+
88+
89+
```typescript
90+
import {providable, consume, provide, target, attr, controller} from '@github/catalyst'
91+
92+
@providable
93+
@controller
94+
class UserList extends HTMLElement {
95+
@provide @target dialog: UserDialogElement
96+
}
97+
98+
@controller
99+
class UserDialog extends HTMLElement {
100+
setTitle(title: string) {
101+
this.title.textContent = title
102+
}
103+
confirm() {
104+
this.show()
105+
return this.untilClosed()
106+
}
107+
//...
108+
}
109+
110+
@providable
111+
@controller
112+
class FollowUser extends HTMLElement {
113+
// This will request `userId`, and default to '' if not provided.
114+
@consume userId = ''
115+
// This will request `userName`, and default to '' if not provided.
116+
@consume userName = ''
117+
// This will request `dialog`, defaulting it to `null` if not provided:
118+
@consume dialog: UserDialog | null = null
119+
120+
async handleEvent() {
121+
if (!this.dialog) return
122+
this.dialog.setTitle(`Would you like to follow ${this.userName}?`)
123+
if (await this.dialog.confirm()) {
124+
await fetch(`/users/${this.userId}/delete`)
125+
}
126+
}
127+
}
128+
```
129+
```html
130+
<user-list>
131+
<user-row user-id="123" user-name="Alex">
132+
<follow-user><button data-action="click:follow-user"></follow-user>
133+
<block-user><button data-action="click:block-user"></block-user>
134+
</user-row>
135+
<user-row user-id="864" user-name="Riley">
136+
<follow-user><button data-action="click:follow-user"></follow-user>
137+
<block-user><button data-action="click:block-user"></block-user>
138+
</user-row>
139+
140+
<user-dialog data-target="user-list.dialog"><!-- ... --></user-dialog>
141+
142+
</user-list>
143+
```
144+
145+
If you're interested to find out how the Provider pattern works, you can look at the [context community-protocol as part of webcomponents-cg](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).

docs/_includes/sidebar.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
<nav class="position-sticky top-100px">
33
<ol class="f3-light ml-4">
44
{% for item in sidebarItems %}
5+
{% unless item.hidden %}
56
<li class="py-1">
67
<a href="{{ site.baseurl }}{{ item.url }}">{{ item.title || item.name }}</a>
78
{% if item.subtitle %}
89
<span class="d-block text-gray-light f5">{{ item.subtitle }}</span>
910
{% endif %}
1011
</li>
12+
{% endunless %}
1113
{% endfor %}
1214
</ol>
1315
</nav>

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@
5454
{
5555
"path": "lib/index.js",
5656
"import": "{controller, attr, target, targets}",
57-
"limit": "1.66kb"
57+
"limit": "2.5kb"
58+
},
59+
{
60+
"path": "lib/abilities.js",
61+
"import": "{providable}",
62+
"limit": "1.1kb"
5863
}
5964
]
6065
}

src/abilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {provide, getProvide, consume, getConsume, providable} from './providable.js'

src/ability.ts

Lines changed: 13 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,18 @@
1-
import type {CustomElement} from './custom-element.js'
2-
3-
export interface Ability extends CustomElement {
4-
[attachShadowCallback]?(shadowRoot: ShadowRoot): void
5-
[attachInternalsCallback]?(internals: ElementInternals): void
6-
}
7-
8-
export interface AbilityClass {
9-
new (): Ability
10-
observedAttributes?: string[]
11-
formAssociated?: boolean
12-
}
13-
14-
export const attachShadowCallback = Symbol()
15-
export const attachInternalsCallback = Symbol()
16-
17-
type Decorator = (Class: AbilityClass) => AbilityClass
18-
const abilityMarkers = new WeakMap<AbilityClass, Set<Decorator>>()
19-
export const createAbility = (decorate: Decorator) => {
20-
return (Class: AbilityClass): AbilityClass => {
21-
if (!abilityMarkers.has(Class)) Class = abilitable(Class)
1+
import type {CustomElementClass} from './custom-element.js'
2+
3+
type Decorator = (Class: CustomElementClass) => unknown
4+
const abilityMarkers = new WeakMap<CustomElementClass, Set<Decorator>>()
5+
export const createAbility = <TExtend, TClass extends CustomElementClass>(
6+
decorate: (Class: TClass) => TExtend
7+
): ((Class: TClass) => TExtend) => {
8+
return (Class: TClass): TExtend => {
229
const markers = abilityMarkers.get(Class)
23-
if (markers?.has(decorate)) return Class
24-
const NewClass = decorate(Class as AbilityClass)
10+
if (markers?.has(decorate as Decorator)) return Class as unknown as TExtend
11+
const NewClass = decorate(Class) as TExtend
12+
Object.defineProperty(NewClass, 'name', {value: Class.name})
2513
const newMarkers = new Set(markers)
26-
newMarkers.add(decorate)
27-
abilityMarkers.set(NewClass, newMarkers)
14+
newMarkers.add(decorate as Decorator)
15+
abilityMarkers.set(NewClass as unknown as CustomElementClass, newMarkers)
2816
return NewClass
2917
}
3018
}
31-
32-
const shadows = new WeakMap<Ability, ShadowRoot | undefined>()
33-
const internals = new WeakMap<Ability, ElementInternals>()
34-
const internalsCalled = new WeakSet()
35-
const abilitable = (Class: AbilityClass): AbilityClass =>
36-
class extends Class {
37-
constructor() {
38-
super()
39-
const shadowRoot = this.shadowRoot
40-
if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot)
41-
if (!internalsCalled.has(this)) {
42-
try {
43-
this.attachInternals()
44-
} catch {
45-
// Ignore errors
46-
}
47-
}
48-
}
49-
50-
connectedCallback() {
51-
super.connectedCallback?.()
52-
this.setAttribute('data-catalyst', '')
53-
}
54-
55-
attachShadow(...args: [init: ShadowRootInit]): ShadowRoot {
56-
const shadowRoot = super.attachShadow(...args)
57-
this[attachShadowCallback](shadowRoot)
58-
return shadowRoot
59-
}
60-
61-
[attachShadowCallback](shadowRoot: ShadowRoot) {
62-
shadows.set(this, shadowRoot)
63-
}
64-
65-
attachInternals(): ElementInternals {
66-
if (internals.has(this) && !internalsCalled.has(this)) {
67-
internalsCalled.add(this)
68-
return internals.get(this)!
69-
}
70-
const elementInternals = super.attachInternals()
71-
this[attachInternalsCallback](elementInternals)
72-
internals.set(this, elementInternals)
73-
return elementInternals
74-
}
75-
76-
[attachInternalsCallback](elementInternals: ElementInternals) {
77-
const shadowRoot = elementInternals.shadowRoot
78-
if (shadowRoot && shadowRoot !== shadows.get(this)) {
79-
this[attachShadowCallback](shadowRoot)
80-
}
81-
}
82-
}

src/controllable.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type {CustomElementClass, CustomElement} from './custom-element.js'
2+
import {createAbility} from './ability.js'
3+
4+
export interface Controllable {
5+
[attachShadowCallback]?(shadowRoot: ShadowRoot): void
6+
[attachInternalsCallback]?(internals: ElementInternals): void
7+
}
8+
export interface ControllableClass {
9+
// TS mandates Constructors that get mixins have `...args: any[]`
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
new (...args: any[]): Controllable
12+
}
13+
14+
export const attachShadowCallback = Symbol()
15+
export const attachInternalsCallback = Symbol()
16+
17+
const shadows = new WeakMap<Controllable, ShadowRoot | undefined>()
18+
const internals = new WeakMap<Controllable, ElementInternals>()
19+
const internalsCalled = new WeakSet()
20+
export const controllable = createAbility(
21+
<T extends CustomElementClass>(Class: T): T & ControllableClass =>
22+
class extends Class {
23+
// TS mandates Constructors that get mixins have `...args: any[]`
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
constructor(...args: any[]) {
26+
super(...args)
27+
const shadowRoot = this.shadowRoot
28+
if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot)
29+
if (!internalsCalled.has(this)) {
30+
try {
31+
this.attachInternals()
32+
} catch {
33+
// Ignore errors
34+
}
35+
}
36+
}
37+
38+
connectedCallback() {
39+
this.setAttribute('data-catalyst', '')
40+
super.connectedCallback?.()
41+
}
42+
43+
attachShadow(...args: [init: ShadowRootInit]): ShadowRoot {
44+
const shadowRoot = super.attachShadow(...args)
45+
this[attachShadowCallback](shadowRoot)
46+
return shadowRoot
47+
}
48+
49+
[attachShadowCallback](this: CustomElement & Controllable, shadowRoot: ShadowRoot) {
50+
shadows.set(this, shadowRoot)
51+
}
52+
53+
attachInternals(): ElementInternals {
54+
if (internals.has(this) && !internalsCalled.has(this)) {
55+
internalsCalled.add(this)
56+
return internals.get(this)!
57+
}
58+
const elementInternals = super.attachInternals()
59+
this[attachInternalsCallback](elementInternals)
60+
internals.set(this, elementInternals)
61+
return elementInternals
62+
}
63+
64+
[attachInternalsCallback](elementInternals: ElementInternals) {
65+
const shadowRoot = elementInternals.shadowRoot
66+
if (shadowRoot && shadowRoot !== shadows.get(this)) {
67+
this[attachShadowCallback](shadowRoot)
68+
}
69+
}
70+
}
71+
)

0 commit comments

Comments
 (0)