Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/_guide/abilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
chapter: 14
subtitle: Abilities
hidden: true
---

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

### Using Abilities

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

```typescript
import {actionable} from '@github/catalyst'

@actionable
class HelloWorld extends HTMLElement {
}
```

### Using Marks

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:

```typescript
import {targetable, target, targets} from '@github/catalyst'

@targetable
class HelloWorldElement extends HTMLElement {
@target name
@targets people
}
```

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.

### Built-In Abilities

Catalyst ships with a set of built in abilities. The `@controller` decorator applies the following built-in abilities:

- `controllable` - the base ability which other abilities require for functionality.
- `targetable` - the ability to define `@target` and `@targets` properties. See [Targets]({{ site.baseurl }}/guide/targets) for more.
- `actionable` - the ability to automatically bind events based on `data-action` attributes. See [Actions]({{ site.baseurl }}/guide/actions) for more.
- `attrable` - the ability to define `@attr`s. See [Attrs]({{ site.baseurl }}/guide/attrs) for more.

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

The following abilities are shipped with Catalyst but require manually applying as they aren't considered critical functionality:

- `providable` - the ability to define `provider` and `consumer` properties. See [Providable]({{ side.baseurl }}/guide/providable) for more.
145 changes: 145 additions & 0 deletions docs/_guide/providable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
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...

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

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

```typescript
import {providable, consume, provide, controller} from '@github/catalyst'

@providable
@controller
class BlockUser extends HTMLElement {
// This will request `userId`, and default to '' if not provided.
@consume userId = ''
// This will request `userName`, and default to '' if not provided.
@consume userName = ''

async handleEvent() {
if (confirm(`Would you like to block ${this.userName}?`)) {
await fetch(`/users/${userId}/delete`)
}
}
}

@providable
@controller
class FollowUser extends HTMLElement {
// This will request `userId`, and default to '' if not provided.
@consume userId = ''
// This will request `userName`, and default to '' if not provided.
@consume userName = ''

async handleEvent() {
if (confirm(`Would you like to follow ${this.userName}?`)) {
await fetch(`/users/${userId}/delete`)
}
}
}

@providable
@controller
class UserRow extends HTMLElement {
// This will provide `userId` as '123' to any nested children that request it.
@provide userId = '123'
// This will provide `userName` as 'Alex' to any nested children that request it.
@provide userId = 'Alex'
}
```

```html
<user-row>
<follow-user><button data-action="click:follow-user"></follow-user>
<block-user><button data-action="click:block-user"></block-user>
</user-row>
```

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

```typescript
import {providable, consume, provide, @attr, controller} from '@github/catalyst'

@providable
@controller
class UserRow extends HTMLElement {
@provide @attr userId = ''
@provide @attr userName = ''
}
```
```html
<user-row user-id="123" user-name="Alex">
<follow-user><button data-action="click:follow-user"></follow-user>
<block-user><button data-action="click:block-user"></block-user>
</user-row>
<user-row user-id="864" user-name="Riley">
<follow-user><button data-action="click:follow-user"></follow-user>
<block-user><button data-action="click:block-user"></block-user>
</user-row>
```

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


```typescript
import {providable, consume, provide, target, attr, controller} from '@github/catalyst'

@providable
@controller
class UserList extends HTMLElement {
@provide @target dialog: UserDialogElement
}

@controller
class UserDialog extends HTMLElement {
setTitle(title: string) {
this.title.textContent = title
}
confirm() {
this.show()
return this.untilClosed()
}
//...
}

@providable
@controller
class FollowUser extends HTMLElement {
// This will request `userId`, and default to '' if not provided.
@consume userId = ''
// This will request `userName`, and default to '' if not provided.
@consume userName = ''
// This will request `dialog`, defaulting it to `null` if not provided:
@consume dialog: UserDialog | null = null

async handleEvent() {
if (!this.dialog) return
this.dialog.setTitle(`Would you like to follow ${this.userName}?`)
if (await this.dialog.confirm()) {
await fetch(`/users/${this.userId}/delete`)
}
}
}
```
```html
<user-list>
<user-row user-id="123" user-name="Alex">
<follow-user><button data-action="click:follow-user"></follow-user>
<block-user><button data-action="click:block-user"></block-user>
</user-row>
<user-row user-id="864" user-name="Riley">
<follow-user><button data-action="click:follow-user"></follow-user>
<block-user><button data-action="click:block-user"></block-user>
</user-row>

<user-dialog data-target="user-list.dialog"><!-- ... --></user-dialog>

</user-list>
```

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).
2 changes: 2 additions & 0 deletions docs/_includes/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
<nav class="position-sticky top-100px">
<ol class="f3-light ml-4">
{% for item in sidebarItems %}
{% unless item.hidden %}
<li class="py-1">
<a href="{{ site.baseurl }}{{ item.url }}">{{ item.title || item.name }}</a>
{% if item.subtitle %}
<span class="d-block text-gray-light f5">{{ item.subtitle }}</span>
{% endif %}
</li>
{% endunless %}
{% endfor %}
</ol>
</nav>
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@
{
"path": "lib/index.js",
"import": "{controller, attr, target, targets}",
"limit": "1.66kb"
"limit": "2.5kb"
},
{
"path": "lib/abilities.js",
"import": "{providable}",
"limit": "1.1kb"
Comment on lines +59 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! This makes sense.

}
]
}
1 change: 1 addition & 0 deletions src/abilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {provide, getProvide, consume, getConsume, providable} from './providable.js'
114 changes: 114 additions & 0 deletions src/providable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type {CustomElementClass, CustomElement} from './custom-element.js'
import {createMark} from './mark.js'
import {createAbility} from './ability.js'

export interface Context<T> {
name: PropertyKey
initialValue?: T
}
export type ContextCallback<ValueType> = (value: ValueType, dispose?: () => void) => void
export type ContextType<T extends Context<unknown>> = T extends Context<infer Y> ? Y : never

export class ContextEvent<T extends Context<unknown>> extends Event {
public constructor(
public readonly context: T,
public readonly callback: ContextCallback<ContextType<T>>,
public readonly multiple?: boolean
) {
super('context-request', {bubbles: true, composed: true})
}
}

function isContextEvent(event: unknown): event is ContextEvent<Context<unknown>> {
return (
event instanceof Event &&
event.type === 'context-request' &&
'context' in event &&
'callback' in event &&
'multiple' in event
)
}

const contexts = new WeakMap<CustomElement, Map<PropertyKey, Set<(value: unknown) => void>>>()
const [provide, getProvide, initProvide] = createMark<CustomElement>(
({name, kind}) => {
if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
},
(instance: CustomElement, {name, kind, access}) => {
return {
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
set: (newValue: unknown) => {
access.set?.call(instance, newValue)
for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
}
}
}
)
const [consume, getConsume, initConsume] = createMark<CustomElement>(
({name, kind}) => {
if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
},
(instance: CustomElement, {name, access}) => {
const initialValue: unknown = access.get?.call(instance) ?? access.value
let currentValue = initialValue
instance.dispatchEvent(
new ContextEvent(
{name, initialValue},
(value: unknown, dispose?: () => void) => {
if (!disposes.has(instance)) disposes.set(instance, new Map())
const instanceDisposes = disposes.get(instance)!
if (instanceDisposes.has(name)) instanceDisposes.get(name)!()
if (dispose) instanceDisposes.set(name, dispose)
currentValue = value
access.set?.call(instance, currentValue)
},
true
)
)
return {get: () => currentValue}
}
)

const disposes = new WeakMap<CustomElement, Map<PropertyKey, () => void>>()

export {consume, provide, getProvide, getConsume}
export const providable = createAbility(
<T extends CustomElementClass>(Class: T): T =>
class extends Class {
[key: PropertyKey]: unknown

// TS mandates Constructors that get mixins have `...args: any[]`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args)
initProvide(this)
if (getProvide(this).size) {
this.addEventListener('context-request', event => {
if (!isContextEvent(event)) return
const name = event.context.name
const value = this[name]
const callback = event.callback
if (event.multiple) {
if (!contexts.has(this)) contexts.set(this, new Map())
const instanceContexts = contexts.get(this)!
if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
instanceContexts.get(name)!.add(callback)
}
callback(value, () => contexts.get(this)?.get(name)?.delete(callback))
})
}
}

connectedCallback() {
initConsume(this)
super.connectedCallback?.()
}

disconnectedCallback() {
for (const dispose of disposes.get(this)?.values() || []) {
dispose()
}
}
}
)
Loading