diff --git a/docs/_guide/actions.md b/docs/_guide/actions.md index 86e4b734..55041cf2 100644 --- a/docs/_guide/actions.md +++ b/docs/_guide/actions.md @@ -27,21 +27,21 @@ Remember! Actions are _automatically_ bound using the `@controller` decorator. T
```html - + + data-target="hello-world.output"> -
+ ``` @@ -51,7 +51,7 @@ Remember! Actions are _automatically_ bound using the `@controller` decorator. T import { controller, target } from "@github/catalyst" @controller -class HelloController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @target nameTarget: HTMLElement @target outputTarget: HTMLElement @@ -78,59 +78,61 @@ The actions syntax follows a pattern of `event:controller#method`. 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-controller` may dispatch a `loaded` event, once its contents are loaded, and other controllers can listen to this event: +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 - + - + ``` -### Private Methods - -Actions can always be bound to any method that is available on the Controller's prototype. If you need a method on a class that _must not_ be invoked within Actions, then you can instead use a [_class field_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields) or a [_private class field_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields#Private_fields). - ```js import {controller} from '@github/catalyst' @controller -class HelloController extends HTMLElement { +class LazyLoader extends HTMLElement { - hidden = () => { - console.log('data-action cannot call this hidden method, but other JavaScript can!') + connectedCallback() { + this.innerHTML = await (await fetch(this.dataset.url)).text() + this.dispatchEvent(new CustomEvent('loaded')) } - #reallyHidden = () => { - console.log('data-action cannot call this hidden method, neither can other JavaScript!') +} + +@controller +class HoverCard extenda HTMLElement { + + enable() { + this.disabled = false } } @@ -145,7 +147,7 @@ If you're not using decorators, then you'll need to call `bind(this)` somewhere ``` import {bind} from '@github/catalyst' -class HelloController extends HTMLElement { +class HelloWorldElement extends HTMLElement { connectedCallback() { bind(this) diff --git a/docs/_guide/anti-patterns.md b/docs/_guide/anti-patterns.md new file mode 100644 index 00000000..35a813a5 --- /dev/null +++ b/docs/_guide/anti-patterns.md @@ -0,0 +1,4 @@ +--- +chapter: 8 +subtitle: Anti Patterns +--- diff --git a/docs/_guide/decorators.md b/docs/_guide/decorators.md index fd55649a..a1f4a697 100644 --- a/docs/_guide/decorators.md +++ b/docs/_guide/decorators.md @@ -15,15 +15,15 @@ Catalyst comes with the `@controller` decorator. This gets put on top of the cla ```js @controller -class MyController extends HTMLElement {} +class HelloWorldElement extends HTMLElement {} ``` ### Class Field Decorators -Catalyst comes with the `@target` and `@targets` decorators for more [read about Targets](/guide/targets). These get added on top or to the left of the field name, like so: +Catalyst comes with the `@target` and `@targets` decorators (for more on these [read the Targets guide section](/guide/targets)). These get added on top or to the left of the field name, like so: ```js -class MyController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @target something @@ -43,7 +43,7 @@ Catalyst doesn't currently ship with any method decorators, but you might see th ```js -class MyController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @log submit() { @@ -59,12 +59,14 @@ class MyController extends HTMLElement { } ``` -### Function Call Decorators +### Function Calling Decorators -Some decorators are customisable - they get called with additional arguments, just like a function call. An example of this is the `@debounce` decorator in the [`@github/mini-throttle`](https://github.com/github/mini-throttle) package: +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 MyController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @debounce(100) handleInput() { diff --git a/docs/_guide/patterns.md b/docs/_guide/patterns.md new file mode 100644 index 00000000..8fab8252 --- /dev/null +++ b/docs/_guide/patterns.md @@ -0,0 +1,73 @@ +--- +chapter: 7 +subtitle: 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 convenient decorators to put on methods which will add throttling to them: + +```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) + } + +} +``` + +### 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 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. + + } +} +``` diff --git a/docs/_guide/targets.md b/docs/_guide/targets.md index 2e4cff74..e74a6a2e 100644 --- a/docs/_guide/targets.md +++ b/docs/_guide/targets.md @@ -3,9 +3,9 @@ chapter: 5 subtitle: Querying Descendants --- -One of the three [core patterns](/guide/introduction#three-core-concepts-observe-listen-query) is Querying. In Catalyst, Targets are the preferred way to query. Target use `querySelectorAll` under the hood, but make it a lot simpler to work with. +One of the three [core patterns](/guide/introduction#three-core-concepts-observe-listen-query) is Querying. In Catalyst, Targets are the preferred way to query. Targets use `querySelectorAll` under the hood, but in a way that makes it a lot simpler to work with. -Catalyst Components are really just Web Components, so you could simply use `querySelector` or `querySelectorAll` to select descendants of the element. Targets avoid some of the problems of `querySelector`; they provide a more consistent interface and handle nesting intuitively. Targets are also a little more ergonomic to reuse in a class. We'd recommend using Targets over `querySelector` wherever you can. +Catalyst Components are really just Web Components, so you could simply use `querySelector` or `querySelectorAll` to select descendants of the element. Targets avoid some of the problems of `querySelector`; they provide a more consistent interface, avoid coupling CSS classes or HTML tag names to JS, and they handle subtle issues like nested components. Targets are also a little more ergonomic to reuse in a class. We'd recommend using Targets over `querySelector` wherever you can. To create a Target, use the `@target` decorator on a class field, and add the matching `data-target` attribute to your HTML, like so: @@ -15,11 +15,11 @@ To create a Target, use the `@target` decorator on a class field, and add the ma
```html - + + data-target="hello-world.output"> -
+ ``` @@ -29,7 +29,7 @@ To create a Target, use the `@target` decorator on a class field, and add the ma import { controller, target } from "@github/catalyst" @controller -class HelloController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @target outputTarget!: HTMLElement greet() { @@ -66,38 +66,48 @@ Remember! There are two decorators available, `@target` which fetches only one e -The `@target` decorator will only ever return _one_ element, just like `querySelector`. If you want to get multiple Targets, you need the `@targets` decorator which works almost identically, but it'll return an _array_ of _N_ elements. +The `@target` decorator will only ever return _one_ element, just like `querySelector`. If you want to get multiple Targets, you need the `@targets` decorator which works almost identically, but it'll return an _array_ of elements. To put this into types: `@target` returns `Element|undefined` while `@targets` returns `Array` Elements can be referenced as multiple targets, and targets may be referenced multiple times within the HTML: ```html - - - - - - - - - - - - + + + + + + + + + + + + ```
```js -import { controller, targets } from "@github/catalyst" +import { controller, target, targets } from "@github/catalyst" @controller -class HelloController extends HTMLElement { - @targets readCheckbox!: HTMLElement - @targets writeCheckbox!: HTMLElement +class UserSettingsElement extends HTMLElement { + @target read!: HTMLInputElement + @target write!: HTMLInputElement - validate() { + valid() { // One checkbox must be checked! - return this.readCheckbox.length > 0 && this.writeCheckbox.length > 0 + return this.read.checked || this.write.checked + } +} + +@controller +class UserListElement extends HTMLElement { + @targets user!: HTMLElement + + valid() { + // Every user must be valid! + return this.user.every(user => user.valid()) } } ``` @@ -106,11 +116,11 @@ class HelloController extends HTMLElement { If you're using decorators, then the `@target` and `@targets` decorators will turn the decorated properties into getters. -If you're not using decorators, then you'll need to call `findTarget(this, key)` or `findTargets(this, key)` in the getter, for example: +If you're not using decorators, then you'll need to make a `getter`, and call `findTarget(this, key)` or `findTargets(this, key)` in the getter, for example: ```js import {findTarget, findTargets} from '@github/catalyst' -class MyController extends HTMLElement { +class HelloWorldElement extends HTMLElement { get outputTarget() { return findTarget(this, 'outputTarget') diff --git a/docs/_guide/your-first-component.md b/docs/_guide/your-first-component.md index 05ee88f0..e9ed382a 100644 --- a/docs/_guide/your-first-component.md +++ b/docs/_guide/your-first-component.md @@ -6,14 +6,14 @@ chapter: 2 Custom Elements allow you to create reusable components that you can declare in HTML, and [progressively enhance](https://en.wikipedia.org/wiki/Progressive_enhancement) within JavaScript. Custom Elements must named with a `-` in the HTML name, and the JS class must `extend HTMLElement`. When the browser connects each element class instance to the DOM node, `connectedCallback` is fired - this is where you can change parts of the element. Here's a basic example: ```html - + ```
@@ -31,19 +31,19 @@ Catalyst saves you writing some of this boilerplate, by automatically calling th ```js @controller -class MyController extends HTMLElement { +class HelloWorldElement extends HTMLElement { connectedCallback() { this.innerHTML = 'Hello World!' } } // No longer need this: -// window.customElements.register('my-controller', MyController) +// window.customElements.register('hello-world', HelloWorldElement) ```
-Catalyst will automatically "dasherize" the class name. All capital letters get lowercased and dash separated. +Catalyst will automatically convert the classes name; removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash. -By convention, Catalyst controllers end in `Controller`, but it's not required. +By convention, Catalyst controllers end in `Element`, and Catalyst will strip this for the tag name, but suffixing `Element` is not required. All examples in this guide use `Element` suffixed names. #### What about without Decorators? @@ -51,7 +51,7 @@ If you don't want to use decorators, you can simply wrap the class in a call to ```js controller( - class MyController extends HTMLElement { + class HelloWorldElement extends HTMLElement { //... } ) diff --git a/docs/index.html b/docs/index.html index 25d835de..e0a4960b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -28,16 +28,16 @@

Catalyse your Web Components

{% highlight html %} - - + + - - + - + {% endhighlight %}
@@ -45,7 +45,7 @@

Catalyse your Web Components

import { controller, target } from "@github/catalyst" @controller -class HelloController extends HTMLElement { +class HelloWorldElement extends HTMLElement { @target name!: HTMLElement @target output!: HTMLElement diff --git a/src/register.ts b/src/register.ts index 6f9deaf8..e1b51625 100644 --- a/src/register.ts +++ b/src/register.ts @@ -10,7 +10,10 @@ interface CustomElement { * Example: HelloController => hello-controller */ export function register(classObject: CustomElement): void { - const name = classObject.name.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase() + const name = classObject.name + .replace(/([a-zA-Z])(?=[A-Z])/g, '$1-') + .replace(/-Element$/, '') + .toLowerCase() if (!window.customElements.get(name)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/test/register.js b/test/register.js index 59786860..b18ed0ae 100644 --- a/test/register.js +++ b/test/register.js @@ -27,4 +27,10 @@ describe('register', () => { ThisIsAnExampleOfADasherisedClassName ) }) + + it('automatically drops the `Element` suffix', () => { + class ASuffixedElement {} + register(ASuffixedElement) + expect(window.customElements.get('a-suffixed')).to.equal(ASuffixedElement) + }) })