Skip to content

Commit 679c0b9

Browse files
authored
Merge pull request #241 from github/add-providable-ability
Add providable ability
2 parents 7a4bdff + d945ecd commit 679c0b9

7 files changed

Lines changed: 542 additions & 1 deletion

File tree

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 userId = '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/providable.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type {CustomElementClass, CustomElement} from './custom-element.js'
2+
import {createMark} from './mark.js'
3+
import {createAbility} from './ability.js'
4+
5+
export interface Context<T> {
6+
name: PropertyKey
7+
initialValue?: T
8+
}
9+
export type ContextCallback<ValueType> = (value: ValueType, dispose?: () => void) => void
10+
export type ContextType<T extends Context<unknown>> = T extends Context<infer Y> ? Y : never
11+
12+
export class ContextEvent<T extends Context<unknown>> extends Event {
13+
public constructor(
14+
public readonly context: T,
15+
public readonly callback: ContextCallback<ContextType<T>>,
16+
public readonly multiple?: boolean
17+
) {
18+
super('context-request', {bubbles: true, composed: true})
19+
}
20+
}
21+
22+
function isContextEvent(event: unknown): event is ContextEvent<Context<unknown>> {
23+
return (
24+
event instanceof Event &&
25+
event.type === 'context-request' &&
26+
'context' in event &&
27+
'callback' in event &&
28+
'multiple' in event
29+
)
30+
}
31+
32+
const contexts = new WeakMap<CustomElement, Map<PropertyKey, Set<(value: unknown) => void>>>()
33+
const [provide, getProvide, initProvide] = createMark<CustomElement>(
34+
({name, kind}) => {
35+
if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
36+
if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
37+
},
38+
(instance: CustomElement, {name, kind, access}) => {
39+
return {
40+
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
41+
set: (newValue: unknown) => {
42+
access.set?.call(instance, newValue)
43+
for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
44+
}
45+
}
46+
}
47+
)
48+
const [consume, getConsume, initConsume] = createMark<CustomElement>(
49+
({name, kind}) => {
50+
if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
51+
},
52+
(instance: CustomElement, {name, access}) => {
53+
const initialValue: unknown = access.get?.call(instance) ?? access.value
54+
let currentValue = initialValue
55+
instance.dispatchEvent(
56+
new ContextEvent(
57+
{name, initialValue},
58+
(value: unknown, dispose?: () => void) => {
59+
if (!disposes.has(instance)) disposes.set(instance, new Map())
60+
const instanceDisposes = disposes.get(instance)!
61+
if (instanceDisposes.has(name)) instanceDisposes.get(name)!()
62+
if (dispose) instanceDisposes.set(name, dispose)
63+
currentValue = value
64+
access.set?.call(instance, currentValue)
65+
},
66+
true
67+
)
68+
)
69+
return {get: () => currentValue}
70+
}
71+
)
72+
73+
const disposes = new WeakMap<CustomElement, Map<PropertyKey, () => void>>()
74+
75+
export {consume, provide, getProvide, getConsume}
76+
export const providable = createAbility(
77+
<T extends CustomElementClass>(Class: T): T =>
78+
class extends Class {
79+
[key: PropertyKey]: unknown
80+
81+
// TS mandates Constructors that get mixins have `...args: any[]`
82+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
83+
constructor(...args: any[]) {
84+
super(...args)
85+
initProvide(this)
86+
if (getProvide(this).size) {
87+
this.addEventListener('context-request', event => {
88+
if (!isContextEvent(event)) return
89+
const name = event.context.name
90+
const value = this[name]
91+
const callback = event.callback
92+
if (event.multiple) {
93+
if (!contexts.has(this)) contexts.set(this, new Map())
94+
const instanceContexts = contexts.get(this)!
95+
if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
96+
instanceContexts.get(name)!.add(callback)
97+
}
98+
callback(value, () => contexts.get(this)?.get(name)?.delete(callback))
99+
})
100+
}
101+
}
102+
103+
connectedCallback() {
104+
initConsume(this)
105+
super.connectedCallback?.()
106+
}
107+
108+
disconnectedCallback() {
109+
for (const dispose of disposes.get(this)?.values() || []) {
110+
dispose()
111+
}
112+
}
113+
}
114+
)

0 commit comments

Comments
 (0)