diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 43abf1a6..34383bb7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,9 +4,8 @@ ARG VARIANT="16" FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} -# [Optional] Uncomment this section to install additional OS packages. -# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ -# && apt-get -y install --no-install-recommends +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends bundler # [Optional] Uncomment if you want to install an additional version of node using nvm # ARG EXTRA_NODE_VERSION=10 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bf736f96..7f65a505 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "yarn install", + "postCreateCommand": "npm i && cd docs && sudo bundle install", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node", diff --git a/CODEOWNERS b/CODEOWNERS index e6bf5558..9f4cf0ad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @github/web-systems-reviewers +* @github/primer-reviewers @koddsson diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..d60f2db9 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +catalyst.rocks \ No newline at end of file diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 3b3e7f35..55e2a9cf 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -4,7 +4,7 @@ GEM addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) colorator (1.1.0) - commonmarker (0.23.4) + commonmarker (0.23.7) concurrent-ruby (1.1.10) em-websocket (0.5.3) eventmachine (>= 0.12.9) diff --git a/docs/_guide/abilities.md b/docs/_guide/abilities.md index c25d874c..1f4b888b 100644 --- a/docs/_guide/abilities.md +++ b/docs/_guide/abilities.md @@ -1,7 +1,9 @@ --- -chapter: 14 +version: 2 +chapter: 4 +title: Abilities subtitle: Abilities -hidden: true +permalink: /guide-v2/abilities --- 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. diff --git a/docs/_guide/actions-2.md b/docs/_guide/actions-2.md new file mode 100644 index 00000000..07bec665 --- /dev/null +++ b/docs/_guide/actions-2.md @@ -0,0 +1,201 @@ +--- +version: 2 +chapter: 6 +title: Actionable +subtitle: Binding Events +permalink: /guide-v2/actions +--- + +Catalyst Components automatically bind actions upon instantiation. Automatically as part of the `connectedCallback`, a component will search for any children with the `data-action` attribute, and bind events based on the value of this attribute. Any _public method_ on a Controller can be bound to via `data-action`. + +{% capture callout %} +Remember! Actions are _automatically_ bound using the `@controller` decorator. There's no extra JavaScript code needed. +{% endcapture %}{% include callout.md %} + +### Example + +
+
+ + + +```html + + + + + + + + +``` + +
+
+ + + +```js +import { controller, target } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @target name: HTMLElement + @target output: HTMLElement + + greetSomeone() { + this.output.textContent = + `Hello, ${this.name.value}!` + } +} +``` + +
+
+ +### Actions Syntax + +The actions syntax follows a pattern of `event:controller#method`. + + - `event` must be the name of a [_DOM Event_](https://developer.mozilla.org/en-US/docs/Web/Events), e.g. `click`. + - `controller` must be the name of a controller ascendant to the element. + - `method` (optional) must be a _public_ _method_ attached to a controller's prototype. Static methods will not work. + +If method is not supplied, it will default to `handleEvent`. + +Some examples of Actions Syntax: + +- `click:my-element#foo` -> `click` events will call `foo` on `my-element` elements. +- `submit:my-element#foo` -> `submit` events will call `foo` on `my-element` elements. +- `click:user-list` -> `click` events will call `handleEvent` on `user-list` elements. +- `click:user-list#` -> `click` events will call `handleEvent` on `user-list` elements. +- `click:top-header-user-profile#` -> `click` events will call `handleEvent` on `top-header-user-profile` elements. +- `nav:keydown:user-list` -> `navigation:keydown` events will call `handleEvent` on `user-list` elements. + +### Multiple Actions + +Multiple actions can be bound to multiple events, methods, and controllers. For example: + + + +```html + + + + + + + +``` + +### Custom Events + +A Controller may emit custom events, which may be listened to by other Controllers using the same Actions Syntax. There is no extra syntax needed for this. For example a `lazy-loader` Controller might dispatch a `loaded` event, once its contents are loaded, and other controllers can listen to this event: + + + +```html + + + + + +``` + + + +```js +import {controller} from '@github/catalyst' + +@controller +class LazyLoader extends HTMLElement { + + connectedCallback() { + this.innerHTML = await (await fetch(this.dataset.url)).text() + this.dispatchEvent(new CustomEvent('loaded')) + } + +} + +@controller +class HoverCard extends HTMLElement { + + enable() { + this.disabled = false + } + +} +``` + +### Targets and "ShadowRoots" + +Custom elements can create encapsulated DOM trees known as "Shadow" DOM. Catalyst actions support Shadow DOM by traversing the `shadowRoot`, if present, and also automatically watching shadowRoots for changes; auto-binding new elements as they are added. + +### What about without Decorators? + +If you're using decorators, then the `@controller` decorator automatically handles binding of actions to a Controller. + +If you're not using decorators, then you'll need to call `bind(this)` somewhere inside of `connectedCallback()`. + +```js +import {bind} from '@github/catalyst' + +class HelloWorldElement extends HTMLElement { + connectedCallback() { + bind(this) + } +} +``` + +### Binding dynamically added actions + +Catalyst automatically listens for elements that are dynamically injected into the DOM, and will bind any element's `data-action` attributes. It does this by calling `listenForBind(controller.ownerDocument)`. If for some reason you need to observe other documents (such as mutations within an iframe), then you can call the `listenForBind` manually, passing a `Node` to listen to DOM mutations on. + +```js +import {listenForBind} from '@github/catalyst' + +@controller +class HelloWorldElement extends HTMLElement { + @target iframe: HTMLIFrameElement + + connectedCallback() { + // listenForBind(this.ownerDocument) is automatically called. + + listenForBind(this.iframe.document.body) + } +} +``` diff --git a/docs/_guide/actions.md b/docs/_guide/actions.md index 0fcf4f77..d22fd252 100644 --- a/docs/_guide/actions.md +++ b/docs/_guide/actions.md @@ -1,5 +1,7 @@ --- -chapter: 6 +version: 1 +chapter: 5 +title: Actions subtitle: Binding Events --- diff --git a/docs/_guide/anti-patterns-2.md b/docs/_guide/anti-patterns-2.md new file mode 100644 index 00000000..84ad3b1b --- /dev/null +++ b/docs/_guide/anti-patterns-2.md @@ -0,0 +1,367 @@ +--- +version: 2 +chapter: 15 +title: Anti Patterns +subtitle: Things to avoid building components +permalink: /guide-v2/anti-patterns +--- + +{% capture octx %}{% endcapture %} +{% capture octick %}{% endcapture %} +{% capture discouraged %}

{{ octx }} Discouraged

{% endcapture %} +{% capture encouraged %}

{{ octick }} Encouraged

{% endcapture %} + +Here are a few common anti-patterns which we've discovered as developers have used Catalyst. We consider these anti-patterns as they're best avoided, because of surprising edge-cases, or simply because there are easier ways to achieve the same goals. + +### Avoid doing any initialisation in the constructor + +With conventional classes, it is expected that initialisation will be done in the `constructor()` method. Custom Elements are slightly different, because the `constructor` is called _before_ the element has been put into the Document, which means any initialisation that expects to be connected to a DOM will fail. + +{{ discouraged }} + +```typescript +import { controller } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + constructor() { + // This will fire before DOM is connected, so will never bubble! + this.dispatchEvent(new CustomEvent('loaded')) + } +} +``` + +{{ encouraged }} + +```typescript +import { controller } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + connectedCallback() { + // This will fire _after_ DOM is connected, so will bubble up as expected + this.dispatchEvent(new CustomEvent('loaded')) + } +} +``` + + +### Avoid interacting with parents, use Events where possible + +Sometimes it's necessary to let ancestors know about the state of a child element, for example when an element loads or needs the parent to change somehow. Sometimes it can be tempting to use methods like `this.closest()` to get a reference to the parent element and interact with it directly, but this creates a fragile coupling to elements and is best avoided. Events can used here, instead: + +{{ discouraged }} + +
+
+ +```typescript +import { controller } from "@github/catalyst" + +@controller +class UserSettingsElement extends HTMLElement { + loading() { + // While this is loading we need to disable + // the whole User if `user-profile` ever + // changes, this code will break! + this + .closest('user-profile') + .disable() + } +} +``` + +
+ +```html + + + +``` + +
+
+ +Instead of interacting with the parent's API directly in JS, you can use `Events` which can be listened to with `data-action`, this moves any coupling into the HTML which already has the association, and so subsequent refactors will have far less risk of breaking the code: + +{{ encouraged }} + +
+
+ +```typescript +import { controller } from "@github/catalyst" + +@controller +class UserSettingsElement extends HTMLElement { + loading() { + this.dispatchEvent( + new CustomEvent('loading') + ) + } +} +``` + +
+ +```html + + + + +``` + +
+
+ +### Avoid shadowing method names + +When naming a method, you should avoid naming it something that already exists on the `HTMLElement` prototype; as doing so can lead to surprising behaviors. Test out the form below to see what method names are allowed or not: + +
+ + + + + +
+ +### Avoid naming methods after events, e.g. `onClick` + +When you have a method which is only called as an event, it is tempting to name that method based off of the event, e.g. `onClick`, `onInputFocus`, and so on. This name implies a coupling between the event and method, which later refactorings may break. Also names like `onClick` are very close to `onclick` which is already [part of the Element's API](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onclick). Instead we recommend naming the method after what it does, not how it is called, for example `resetForm`: + +{{ discouraged }} + +
+
+ +```js +import { controller } from "@github/catalyst" + +@controller +class UserLoginElement extends HTMLElement { + + // `onClick` is not clear + onClick() { + // Log the user in + } +} +``` + +
+
+ +```html + + + + +``` + +
+
+ +{{ encouraged }} + +
+
+ +```js +import { controller } from "@github/catalyst" + +@controller +class UserLoginElement extends HTMLElement { + + login() { + // Log the user in + } +} +``` + +
+
+ +```html + + + + +``` + +
+
+ +### Avoid querying against your element, use `@target` or `@targets` + +We find it very common for developers to return to habits and use `querySelector[All]` when needing to get elements. The `@target` and `@targets` decorators were designed to simplify `querySelector[All]` and avoid certain bugs with them (such as nesting issues, and unnecessary coupling) so it's a good idea to use them as much as possible: + +{{ discouraged }} + +```typescript +class UserListElement extends HTMLElement { + showAdmins() { + // Just need to get admins here... + for (const user of this.querySelector('[data-is-admin]')) { + user.hidden = false + } + } +} +``` + +{{ encouraged }} + +```typescript +class UserList { + @targets admins: HTMLElement[] + + showAdmins() { + // Just need to get admins here... + for (const user of this.admins) { + user.hidden = false + } + } +} +``` + + +### Avoid filtering `@targets`, use another `@target` or `@targets` + + +Sometimes you might need to get a subset of elements from a `@targets` selector. When doing this, simply use another `@target` or `@targets` attribute, it's okay to have many of these! Adding getters which simply return a `@targets` subset has various drawbacks which make it an anti pattern. + +For example let's say we have a list of filter checkboxes and checking the "all" checkbox unchecks all other checkboxes: + +{{ discouraged }} + +```typescript +@controller +class UserFilter { + @targets filters: HTMLInputElement[] + + get allFilter() { + return this.filters.find(el => el.matches('[data-filter="all"]')) + } + + filter(event: Event) { + if (event.target === this.allFilter) { + for(const filter of this.filters) { + if (filter !== this.allFilter) filter.checked = false + } + } + // ... + } + +} +``` + +```html + + + + + + +``` + +While this works well, it could be more easily solved with targets: + +{{ encouraged }} + +```typescript +@controller +class UserFilter { + @targets filters: HTMLInputElement[] + @target allFilter: HTMLInputElement + + filter(event: Event) { + if (event.target === this.allFilter) { + for (const filter of this.filters) { + if (filter !== this.allFilter) filter.checked = false + } + } + // ... + } + +} +``` + +```html + + + + + + +``` diff --git a/docs/_guide/anti-patterns.md b/docs/_guide/anti-patterns.md index 362f8844..f442ab46 100644 --- a/docs/_guide/anti-patterns.md +++ b/docs/_guide/anti-patterns.md @@ -1,6 +1,8 @@ --- -chapter: 12 -subtitle: Anti Patterns +version: 1 +chapter: 11 +title: Anti Patterns +subtitle: Things to avoid building components --- {% capture octx %}{% endcapture %} diff --git a/docs/_guide/attrs-2.md b/docs/_guide/attrs-2.md new file mode 100644 index 00000000..d1c76709 --- /dev/null +++ b/docs/_guide/attrs-2.md @@ -0,0 +1,296 @@ +--- +version: 2 +chapter: 7 +title: Attrable +subtitle: Using attributes as configuration +permalink: /guide-v2/attrs +--- + +Components may sometimes manage state, or configuration. We encourage the use of DOM as state, rather than maintaining a separate state. One way to maintain state in the DOM is via [Attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). + +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: + + - 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 `` 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 +``` + +{% 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 dasherized HTML attribute. + +### Example + + + +```js +import { controller, attr } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @attr fooBar = 'hello' +} +``` + +This is somewhat equivalent to: + +```js +import { controller } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + get fooBar(): string { + return this.getAttribute('foo-bar') || '' + } + + set fooBar(value: string): void { + return this.setAttribute('foo-bar', value) + } + + connectedCallback() { + if (!this.hasAttribute('foo-bar')) this.fooBar = 'Hello' + } + +} +``` + +### 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 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 | 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 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. + + + +```js +import { controller, attr } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @attr fooBar = 'Hello' + + connectedCallback() { + 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') + } +} +``` + +#### Boolean Attributes + +If an attribute is first set to a boolean, then it can only ever be a boolean during the lifetime of an element. Boolean properties check for _presence_ of an attribute, sort of like how [`required`, `disabled` & `readonly` attributes work on forms](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes) The property will return `false` if the attribute doesn't exist, and `true` if it does, regardless of the value. If the property is set to `false` then `removeAttribute` is called, whereas `setAttribute(name, '')` is called when setting to a truthy value. + + + +```js +import { controller, attr } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @attr fooBar = false + + connectedCallback() { + 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 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. + + + +```js +import { controller, attr } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @attr fooBar = 1 + + connectedCallback() { + 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') + } +} +``` + +### Default Values + +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_ have a matching attribute, the default _is not_ applied, the property will be assigned to the value of the attribute instead. + +{% capture callout %} +Remember! The values defined in the class field are the _default_. They won't be set if the element is created and its attribute set to a custom value! +{% endcapture %}{% include callout.md %} + +The following example illustrates this behavior: + + + +```js +import { controller, attr } from "@github/catalyst" +@controller +class HelloWorldElement extends HTMLElement { + @attr dataName = 'World' + connectedCallback() { + this.textContent = `Hello ${this.dataName}` + } +} +``` + + + +```html + +// This will render `Hello World` + + +// This will render `Hello Catalyst` + + +// This will render `Hello ` +``` + +### Advanced usage + +#### Determining when an @attr changes value + +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 { + + @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 +} +) +``` + +This example is functionally identical to: + +```js +import {controller, attr} from '@github/catalyst' + +@controller +class HelloWorldElement extends HTMLElement { + @attr fooBar = 1 +} +``` + diff --git a/docs/_guide/attrs.md b/docs/_guide/attrs.md index 1947af2e..2374054c 100644 --- a/docs/_guide/attrs.md +++ b/docs/_guide/attrs.md @@ -1,5 +1,7 @@ --- -chapter: 7 +version: 1 +chapter: 6 +title: Attrs subtitle: Using attributes as configuration --- diff --git a/docs/_guide/conventions-2.md b/docs/_guide/conventions-2.md new file mode 100644 index 00000000..3dd30ca6 --- /dev/null +++ b/docs/_guide/conventions-2.md @@ -0,0 +1,50 @@ +--- +version: 2 +chapter: 13 +title: Conventions +subtitle: Common naming and patterns +permalink: /guide-v2/conventions +--- + +Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code: + +### Suffix your controllers consistently, for symmetry + +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 {} // `` +``` + +```typescript +@controller +class UserListComponent extends HTMLElement {} // `` +``` + +### The best class-names are two word descriptions + +Custom elements are required to have a `-` inside the tag name. Catalyst's `@controller` will derive the tag name from the class name - and so as such the class name needs to have at least two capital letters, or to put it another way, it needs to consist of at least two CamelCased words. The element name should describe what it does succinctly in two words. Some examples: + + - `theme-picker` (`class ThemePickerElement`) + - `markdown-toolbar` (`class MarkdownToolbarElement`) + - `user-list` (`class UserListElement`) + - `content-pager` (`class ContentPagerElement`) + - `image-gallery` (`class ImageGalleryElement`) + +If you're struggling to come up with two words, think about one word being the "what" (what does it do?) and another being the "how" (how does it do it?). + +### Keep class-names short (but not too short) + +Brevity is good, element names are likely to be typed out a lot, especially throughout HTML in as tag names, and `data-target`, `data-action` attributes. A good rule of thumb is to try to keep element names down to less than 15 characters (excluding the `Element` suffix), and ideally less than 10. Also, longer words are generally harder to spell, which means mistakes might creep into your code. + +Be careful not to go too short! We'd recommend avoiding contracting words such as using `Img` to mean `Image`. It can create confusion, especially if there are inconsistencies across your code! + +### Method names should describe what they do + +A good method name, much like a good class name, describes what it does, not how it was invoked. While methods can be given most names, you should avoid names that conflict with existing methods on the `HTMLElement` prototype (more on that in [anti-patterns]({{ site.baseurl }}/guide/anti-patterns#avoid-shadowing-method-names)). Names like `onClick` are best avoided, overly generic names like `toggle` should also be avoided. Just like class names it is a good idea to ask "how" and "what", so for example `showAdmins`, `filterUsers`, `updateURL`. + +### `@target` should use singular naming, while `@targets` should use plural + +To help differentiate the two `@target`/`@targets` decorators, the properties should be named with respective to their cardinality. That is to say, if you're using an `@target` decorator, then the name should be singular (e.g. `user`, `field`) while the `@targets` decorator should be coupled with plural property names (e.g. `users`, `fields`). + diff --git a/docs/_guide/conventions.md b/docs/_guide/conventions.md index 4d9d01fd..18f7d054 100644 --- a/docs/_guide/conventions.md +++ b/docs/_guide/conventions.md @@ -1,6 +1,8 @@ --- -chapter: 10 -subtitle: Conventions +version: 1 +chapter: 9 +title: Conventions +subtitle: Common naming and patterns --- Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code: diff --git a/docs/_guide/create-ability.md b/docs/_guide/create-ability.md index 99fa309f..56f1f655 100644 --- a/docs/_guide/create-ability.md +++ b/docs/_guide/create-ability.md @@ -1,7 +1,9 @@ --- -chapter: 16 +version: 2 +chapter: 9 +title: Create Ability subtitle: Create your own abilities -hidden: true +permalink: /guide-v2/create-ability --- 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: diff --git a/docs/_guide/decorators-2.md b/docs/_guide/decorators-2.md new file mode 100644 index 00000000..7cd1ce35 --- /dev/null +++ b/docs/_guide/decorators-2.md @@ -0,0 +1,129 @@ +--- +version: 2 +chapter: 3 +title: Decorators +subtitle: Using TypeScript for ergonomics +permalink: /guide-v2/decorators +--- + +Decorators are used heavily in Catalyst, because they provide really clean ergonomics and makes using the library a lot easier. Decorators are a special, (currently) non standard, feature of TypeScript. You'll need to turn the `experimentalDecorators` option on inside of your TypeScript project to use them (if you're using `@babel/plugin-proposal-decorators` plugin, you need to use [`legacy` option](https://babeljs.io/docs/en/babel-plugin-proposal-decorators#legacy)). + +You can read more about [decorators in the TypeScript handbook](https://www.typescriptlang.org/docs/handbook/decorators.html), but here's quick guide: + +Decorators can be used three ways: + +### Class Decorators + +Catalyst comes with the `@controller` decorator. This gets put on top of the class, like so: + +```js +@controller +class HelloWorldElement extends HTMLElement {} +``` + +### Class Field Decorators + +Catalyst comes with the `@target` and `@targets` decorators (for more on these [read the Targets guide section]({{ site.baseurl }}/guide/targets)). These get added on top or to the left of the field name, like so: + +```js +class HelloWorldElement extends HTMLElement { + + @target something + + // Alternative style + @targets + others + +} +``` +
+ +Class Field decorators get given the class and the field name so they can add custom functionality to the field. Because they operate on the fields, they must be put on top of or to the left of the field. + +### Method Decorators + +Method decorators work just like Field Decorators. Put them on top or on the left of the method, like so: + + +```js +class HelloWorldElement extends HTMLElement { + + @log + submit() { + // ... + } + + // Alternative style + + @log load() { + // ... + } + +} +``` + +### Getter/Setter + +Decorators can also be used over a `get` or `set` field. These work just like Field Decorators, but you can put them over one or both the `get` or `set` field. Some decorators might throw an error if you put them over a `get` field, when they expect to be put over a `set` field: + + +```js +class HelloWorldElement extends HTMLElement { + + @target set something() { + // ... + } + + // Can be used over just one field + @attr get data() { + return {} + } + set data() { + + } +} +``` + +### Supporting `strictPropertyInitialization` + +TypeScript comes with various "strict" mode settings, one of which is `strictPropertyInitialization` which lets TypeScript catch potential class properties which might not be assigned during construction of a class. This option conflicts with Catalyst's `@target`/`@targets` decorators, which safely do the assignment but TypeScript's simple heuristics cannot detect this. There are two ways to work around this: + +1. Use TypeScript's [`declare` modifier](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier) to tell TypeScript that the decorated field will still be set up correctly: + + ```typescript + class HelloWorldElement extends HTMLElement { + @target declare something: HTMLElement + @targets declare items: HTMLElement[] + } + ``` + + Note that this only works on TypeScript 3.7+, so if you're on an older version, you can also use the [definite initialization operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-7.html#definite-assignment-assertions) to do the same thing. + +2. You can also disable the compiler option (other strict mode rules can still apply) in your `tsconfig.json` like so: + + ```json + { + "compilerOptions": { + "strict": true, + "strictPropertyInitialization": false + } + } + ``` + +### Function Calling Decorators + +You might see some decorators that look like function calls, and that's because they are! Some decorators allow for customisation; calling with additional arguments. Decorators that expect to be called are generally not interchangeable with the non-call variant, a decorators documentation should tell you how to use it. + +Catalyst doesn't ship with any decorators that can be called like a function; but an example of one can be found in the `@debounce` decorator in the [`@github/mini-throttle`](https://github.com/github/mini-throttle) package: + +```js +class HelloWorldElement extends HTMLElement { + + @debounce(100) + handleInput() { + // ... + } + +} +``` +
diff --git a/docs/_guide/decorators.md b/docs/_guide/decorators.md index 10d08343..e27f2f3d 100644 --- a/docs/_guide/decorators.md +++ b/docs/_guide/decorators.md @@ -1,5 +1,7 @@ --- -chapter: 4 +version: 1 +chapter: 3 +title: Decorators subtitle: Using TypeScript for ergonomics --- diff --git a/docs/_guide/introduction-2.md b/docs/_guide/introduction-2.md new file mode 100644 index 00000000..442b20a0 --- /dev/null +++ b/docs/_guide/introduction-2.md @@ -0,0 +1,29 @@ +--- +version: 2 +chapter: 1 +title: Introduction +subtitle: Origins & Concepts +permalink: /guide-v2/introduction +--- + +Catalyst is a set of patterns and techniques for developing _components_ within a complex application. At its core, Catalyst simply provides a small library of functions to make developing [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) easier. The library is an implementation detail, though. The concepts are what we're most interested in. + +## How did we get here? + +GitHub's first page interactions were written using jQuery, which was widely used at the time. Eventually, as browser compatibility increased and jQuery patterns such as the Selector Pattern & easy class manipulation became standard, [GitHub moved away from jQuery](https://github.blog/2018-09-06-removing-jquery-from-github-frontend/). + +Rather than moving to entirely new paradigms, GitHub continued to use the same concepts within jQuery. Event Delegation was still heavily used, as well as querySelector. The event delegation concept was also extended to "element delegation" - discovering when Elements were added to the DOM, using the [Selector Observer](https://github.com/josh/selector-observer) library. + +These patterns were reduced to first principles: _Observing_ elements on the page, _listening_ to the events these elements or their children emit, and _querying_ the children of an element to mutate or extend them. + +The Web Systems team at GitHub explored other tools that adopt these set of patterns and principles. The closest match to those goals was [Stimulus](https://stimulusjs.org/) (from which Catalyst is heavily inspired), but ultimately the desire to leverage technology that engineers at GitHub were already familiar with was the motivation to create Catalyst. + +## Three core concepts: Observe, Listen, Query + +Catalyst takes these three core concepts and delivers them in the lightest possible way they can be delivered. + + - **Observability** Catalyst solves observability by leveraging [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements). Custom Elements are given unique names within a system, and the browser will automatically use the Custom Element registry to observe these Elements entering and leaving the DOM. Read more about this in the Guide Section entitled [Your First Component]({{ site.baseurl }}/guide/your-first-component). + + - **Listening** Event Delegation makes a great deal of sense when observing events "high up the tree" - registering global event listeners on the Window element - but Custom Elements sit much closer to their children within the tree, and so Direct Event binding is preferred. Catalyst solves this by binding event listeners to any descendants with `data-action` attributes. Read more about this in the Guide Section entitled [Actions]({{ site.baseurl }}/guide/actions). + + - **Querying** Custom Elements largely solve querying, by simply calling `querySelector` - however CSS selectors are loosely disciplined and can create unnecessary coupling to the DOM structure (e.g. by querying tag names). Catalyst extends the `data-action` concept by also using `data-target` to declare descendants that the Custom Element is interested in querying. Read more about this in the Guide Section entitled [Targets]({{ site.baseurl }}/guide/targets). diff --git a/docs/_guide/introduction.md b/docs/_guide/introduction.md index 732e98a0..0fb3b97e 100644 --- a/docs/_guide/introduction.md +++ b/docs/_guide/introduction.md @@ -1,6 +1,8 @@ --- -subtitle: Origins & Concepts +version: 1 chapter: 1 +title: Introduction +subtitle: Origins & Concepts --- Catalyst is a set of patterns and techniques for developing _components_ within a complex application. At its core, Catalyst simply provides a small library of functions to make developing [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) easier. The library is an implementation detail, though. The concepts are what we're most interested in. diff --git a/docs/_guide/lazy-elements-2.md b/docs/_guide/lazy-elements-2.md new file mode 100644 index 00000000..1abfb0a9 --- /dev/null +++ b/docs/_guide/lazy-elements-2.md @@ -0,0 +1,35 @@ +--- +version: 2 +chapter: 16 +title: Lazy Elements +subtitle: Dynamically load elements just in time +permalink: /guide-v2/lazy-elements +--- + +A common practice in modern web development is to combine all JavaScript code into JS "bundles". By bundling the code together we avoid the network overhead of fetching each file. However the trade-off of bundling is that we might deliver JS code that will never run in the browser. + +![A screenshot from Chrome Devtools showing the Coverage panel. The panel has multiple request to JS assets and it shows that most of them have large chunks that are unused.](/catalyst/guide/devtools-coverage.png) + +An alternative solution to bundling is to load JavaScript just in time. Downloding the JavaScript for Catalyst controllers when the browser first encounters them can be done with the `lazyDefine` function. + +```typescript +import {lazyDefine} from '@github/catalyst' + +// Dynamically import the Catalyst controller when the `` tag is seen. +lazyDefine('user-avatar', () => import('./components/user-avatar')) +``` + +Serving this file allows us to defer loading of the component code until it's actually needed by the web page. The tradeoff of deferring loading is that the elements will be inert until the dynamic import of the component code resolves. Consider what your UI might look like while these components are resolving. Consider providing a loading indicator and disabling controls as the default state. The smaller the component, the faster it will resolve which means that your users might not notice a inert state. A good rule of thumb is that a component should load within 100ms on a "Fast 3G" connection. + +Generally we think it's a good idea to `lazyDefine` all elements and then prioritize eager loading of ciritical elements as needed. You might consider using code-generation to generate a file lazy defining all your components. + +By default the component will be loaded when the element is present in the document and the document has finished loading. This can happen before sub-resources such as scripts, images, stylesheets and frames have finished loading. It is possible to defer loading even later by adding a `data-load-on` attribute on your element. The value of which must be one of the following prefefined values: + +- `` (default) + - The element is loaded when the document has finished loading. This listens for changes to `document.readyState` and triggers when it's no longer loading. +- `` + - This element is loaded on the first user interaction with the page. This listens for `mousedown`, `touchstart`, `pointerdown` and `keydown` events on `document`. +- `` + - This element is loaded when it's close to being visible. Similar to `` . The functionality is driven by an `IntersectionObserver`. + +This functionality is similar to the ["Lazy Custom Element Definitions" spec proposal](https://github.com/WICG/webcomponents/issues/782) and as this proposal matures we see Catalyst conforming to the spec and leveraging this new API to lazy load elements. diff --git a/docs/_guide/lazy-elements.md b/docs/_guide/lazy-elements.md index 1d751122..06e9d410 100644 --- a/docs/_guide/lazy-elements.md +++ b/docs/_guide/lazy-elements.md @@ -1,5 +1,7 @@ --- -chapter: 17 +version: 1 +chapter: 13 +title: Lazy Elements subtitle: Dynamically load elements just in time --- diff --git a/docs/_guide/lifecycle-hooks-2.md b/docs/_guide/lifecycle-hooks-2.md new file mode 100644 index 00000000..955c385a --- /dev/null +++ b/docs/_guide/lifecycle-hooks-2.md @@ -0,0 +1,43 @@ +--- +version: 2 +chapter: 10 +title: Lifecycle Hooks +subtitle: Observing the life cycle of an element +permalink: /guide-v2/lifecycle-hooks +--- + +Catalyst Controllers - like many other frameworks - have several "well known" method names which are called periodically through the life cycle of the element, and let you observe when an element changes in various ways. Here is a comprehensive list of all life-cycle callbacks. Each one is suffixed `Callback`, to denote that it will be called by the framework. + +### `connectedCallback()` + +The [`connectedCallback()` is part of Custom Elements][ce-callbacks], and gets fired as soon as your element is _appended_ to the DOM. This callback is a good time to initialize any variables, perhaps add some global event listeners, or start making any early network requests. + +JavaScript traditionally uses the `constructor()` callback to listen for class creation. While this still works for Custom Elements, it is best avoided as the element won't be in the DOM when `constructor()` is fired, limiting its utility. + +#### Things to remember + +The `connectedCallback` is called _as soon as_ the element is attached to a `document`. This _may_ occur _before_ an element has any children appended to it, so you should be careful not expect an element to have children during a `connectedCallback` call. This means avoiding checking any `target`s or using other methods like `querySelector`. Instead use this function to initialize itself and avoid doing initialization work which depend on children existing. + +If your element depends heavily on its children existing, consider adding a [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) in the `connectedCallback` to track when your elements children change. + +### `attributeChangedCallback()` + +The [`attributeChangedCallback()` is part of Custom Elements][ce-callbacks], and gets fired when _observed attributes_ are added, changed, or removed from your element. It required you set a `static observedAttributes` array on your class, the values of which will be any attributes that will be observed for mutations. This is given a set of arguments, the signature of your function should be: + +```typescript +attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {} +``` + +#### Things to remember + +The `attributeChangedCallback` will fire whenever `setAttribute` is called with an observed attribute, even if the _new_ value is the same as the _old_ value. In other words, it is possible for `attributeChangedCallback` to be called when `oldValue === newValue`. In most cases this really won't matter much, and in some cases this is very helpful; but sometimes this can bite, especially if you have [non-idempotent](https://en.wikipedia.org/wiki/Idempotence#Computer_science_examples) code inside your `attributeChangedCallback`. Try to make sure operations inside `attributeChangedCallback` are idempotent, or perhaps consider adding a check to ensure `oldValue !== newValue` before performing operations which may be sensitive to this. + +### `disconnectedCallback()` + +The [`disconnectedCallback()` is part of Custom Elements][ce-callbacks], and gets fired as soon as your element is _removed_ from the DOM. Event listeners will automatically be cleaned up, and memory will be freed automatically from JavaScript, so you're unlikely to need this callback for much. + +### `adoptedCallback()` + +The [`adoptedCallback()` is part of Custom Elements][ce-callbacks], and gets called when your element moves from one `document` to another (such as an iframe). It's very unlikely to occur, you'll almost never need this. + +[ce-callbacks]: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks diff --git a/docs/_guide/lifecycle-hooks.md b/docs/_guide/lifecycle-hooks.md index 924880fc..b69a77ea 100644 --- a/docs/_guide/lifecycle-hooks.md +++ b/docs/_guide/lifecycle-hooks.md @@ -1,5 +1,7 @@ --- -chapter: 8 +version: 1 +chapter: 7 +title: Lifecycle Hooks subtitle: Observing the life cycle of an element --- diff --git a/docs/_guide/patterns-2.md b/docs/_guide/patterns-2.md new file mode 100644 index 00000000..6a494ba7 --- /dev/null +++ b/docs/_guide/patterns-2.md @@ -0,0 +1,132 @@ +--- +version: 2 +chapter: 14 +title: Patterns +subtitle: Best Practices for behaviours +permalink: /guide-v2/patterns +--- + +An aim of Catalyst is to be as light weight as possible, and so we often avoid including helper functions for otherwise fine code. We also want to keep Catalyst focussed, and so where some helper functions might be reasonable, we recommend judicious use of other small libraries. + +Here are a few common patterns which we've avoided introducing into the Catalyst code base, and instead encourage you to take the example code and run with that: + +### Debouncing or Throttling events + +Often times you'll want to do something computationally intensive (or network intensive) based on a user event. It's worth throttling the amount of times a function can be called for these events, to prevent saturation of the CPU or network. For this we can use the "debounce" or "throttle" patterns. We recommend using the [`@github/mini-throttle`](https://github.com/github/mini-throttle) library for this, which provides throttling decorators for methods: + +```typescript +import {controller} from '@github/catalyst' +import {debounce} from '@github/mini-throttle/decorators' + +@controller +class FuzzySearchElement extends HTMLElement { + + // Adding `@debounce(100)` here means this method will only be called once in a 100ms period. + @debounce(100) + search(event: Event) { + const value = event.currentTarget.value + // This function is very computationally intensive, so we should run it as little as possible + this.filterAllItemsWithValue(value) + } + +} +``` + +Alternatively, if you'd like more precise control over the exact way debouncing happens (for example you'd like to make the debounce timeout dynamic, or sometimes call _without_ debouncing), you can have two methods following the pattern of `foo`/`fooNow` or `foo`/`fooSync`, where the non-suffixed method dispatches asynchronously to the `Now`/`Sync` suffixed method, a little like this: + +```typescript +import {controller} from '@github/catalyst' + +@controller +class FuzzySearchElement extends HTMLElement { + + #searchAnimationFrame = 0 + search(event: Event) { + clearAnimationFrame(this.#searchAnimationFrame) + this.#searchAnimationFrame = requestAnimationFrame(() => this.searchNow(event: Event)) + } + + searchNow(event: Event) { + const value = event.currentTarget.value + // This function is very computationally intensive, so we should run it as little as possible + this.filterAllItemsWithValue(value) + } + +} +``` + +### Aborting Network Requests + +When making network requests using `fetch`, based on user input, you can cancel old requests as new ones come in. This is useful for performance as well as UI responsiveness, as old requests that aren't cancelled might complete later than newer ones, and causing the UI to jump around. Aborting network requests requires you to use [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) (a web platform feature). + +```typescript +@controller +class RemoveSearchElement extends HTMLElement { + + #remoteSearchController: AbortController|null + + async search(event: Event) { + // Abort the old Request + this.#remoteSearchController?.abort() + + // To start making a new request, construct an AbortController + const {signal} = (this.#remoteSearchController = new AbortController()) + + try { + const res = await fetch(myUrl, {signal}) + + // ... Add logic here with the completed network response + } catch (e) { + + // ... Add logic here if you need to report a failed network request. + // Do not rethrow for network errors! + + } + + if (signal.aborted) { + // Here you can add logic for if the request was cancelled, but + // usually what you want to do is just return early to avoid + // cleaning up the loading UI (bear in mind if the request is + // cancelled then another one will be in its place). + return + } + + // ... Add cleanup logic here, such as removing `loading` classes. + + } +} +``` + +### Registering global or many event listeners + +Generally speaking, you'll want to use ["Actions"]({{ site.baseurl }}/guide/actions) to register event listeners with your Controller, but Actions only work for components nested within your Controller. It may also be necessary to listen for events on the Document, Window, or across well-known adjacent elements. We can manually call `addEventListener` for these types, including during the `connectedCallback` phase. Cleanup for `addEventListener` can be a bit error prone, but [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) can be useful here to pass a signal that the element is cleaning up. AbortControllers should be created once per `connectedCallback`, as they are not re-usable, while Controllers _can_ be reused. + + +```typescript +@controller +class UnsavedChangesElement extends HTMLElement { + + #eventAbortController: AbortController|null = null + + connectedCallback(event: Event) { + // Create the new AbortController and get the new signal + const {signal} = (this.#eventAbortController = new AbortController()) + + // You can `signal` as an option to any `addEventListener` call: + window.addEventListener('hashchange', this, { signal }) + window.addEventListener('blur', this, { signal }) + window.addEventListener('popstate', this, { signal }) + window.addEventListener('pagehide', this, { signal }) + } + + disconnectedCallback() { + // This will clean up any `addEventListener` calls which were given the `signal` + this.#eventAbortController?.abort() + } + + handleEvent(event) { + // `handleEvent` will be called when each one of the event listeners + // defined in `connectedCallback` is dispatched. + } +} +``` diff --git a/docs/_guide/patterns.md b/docs/_guide/patterns.md index 3d14bbb7..48e767a7 100644 --- a/docs/_guide/patterns.md +++ b/docs/_guide/patterns.md @@ -1,6 +1,8 @@ --- -chapter: 11 -subtitle: Patterns +version: 1 +chapter: 10 +title: Patterns +subtitle: Best Practices for behaviours --- An aim of Catalyst is to be as light weight as possible, and so we often avoid including helper functions for otherwise fine code. We also want to keep Catalyst focussed, and so where some helper functions might be reasonable, we recommend judicious use of other small libraries. diff --git a/docs/_guide/providable.md b/docs/_guide/providable.md index bbc48de4..0b35745d 100644 --- a/docs/_guide/providable.md +++ b/docs/_guide/providable.md @@ -1,14 +1,16 @@ --- -chapter: 15 +version: 2 +chapter: 8 +title: Providable subtitle: The Provider pattern -hidden: true +permalink: /guide-v2/providable --- 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`: +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` or `@provideAsync`. 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' @@ -60,6 +62,8 @@ class UserRow extends HTMLElement { ``` +### Combining Providables with Attributes + 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 @@ -83,6 +87,8 @@ class UserRow extends HTMLElement { ``` +### Providing advanced values + 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`: @@ -142,4 +148,38 @@ class FollowUser extends HTMLElement {
``` +### Asynchronous Providers + +Sometimes you might want to have a provider do some asynchronous work - such as fetch some data over the network, and only provide the fully resolved value. In this case you can use the `@provideAsync` decorator. This decorator resolves the value before giving it to the consumer, so the consumer never deals with the Promise! + +```ts +import {providable, consume, provideAsync, target, attr, controller} from '@github/catalyst' + +@controller +@providable +class ServerState extends HTMLElement { + @provideAsync get hitCount(): Promise { + return (async () => { + const res = await fetch('/hitcount') + const json = await res.json() + return json.hits + })() + } +} + +@controller +class HitCount extends HTMLElement { + @consume set hitCount(count: number) { + this.innerHTML = html`${count} hits!` + } +} +``` +```html + + + Loading... + + +``` + 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). diff --git a/docs/_guide/rendering-2.md b/docs/_guide/rendering-2.md new file mode 100644 index 00000000..f8a577b1 --- /dev/null +++ b/docs/_guide/rendering-2.md @@ -0,0 +1,136 @@ +--- +version: 2 +chapter: 11 +title: Rendering +subtitle: Rendering HTML subtrees +permalink: /guide-v2/rendering +--- + +Sometimes it's necessary to render an HTML subtree as part of a component. This can be especially useful if a component is driving complex UI that is only interactive with JS. + +{% capture callout %} +Remember to _always_ make your JavaScript progressively enhanced, where possible. Using JS to render large portions of the UI, that could be rendered server-side is an anti-pattern; it can be difficult for users to interact with - especially users who disable JS, or when JS fails to load, or those using assistive technologies. Rendering on the client can also impact the [CLS Web Vital](https://web.dev/cls/). +{% endcapture %}{% include callout.md %} + +By leveraging the native [`ShadowDOM`](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) feature, Catalyst components can render complex sub-trees, fully encapsulated from the rest of the page. + +[Actions]({{ site.baseurl }}/guide/actions) and [Targets]({{ site.baseurl }}/guide/targets) all work within an elements ShadowRoot. + +You can also leverage the [declarative shadow DOM](https://web.dev/declarative-shadow-dom/) and render a template inline to your HTML, which will automatically be attached (this may require a polyfill for browsers which are yet to support this feature). + +### Example + +```html + + + +``` +```typescript +import { controller, target } from "@github/catalyst" + +@controller +class HelloWorldElement extends HTMLElement { + @target nameEl: HTMLElement + get name() { + return this.nameEl.textContent + } + set name(value: string) { + this.nameEl.textContent = value + } +} +``` + +{% capture callout %} +Remember that _all_ instances of your controller _must_ add the `