-
Notifications
You must be signed in to change notification settings - Fork 57
Add providable ability #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export {provide, getProvide, consume, getConsume, providable} from './providable.js' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| } | ||
| } | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! This makes sense.