diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index dee53fa0..7cd07238 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -8,42 +8,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the project - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Use Node.js 14.x (LTS) - uses: actions/setup-node@v1 + - name: Use Node.js 16.x (LTS) + uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 16.x + cache: 'npm' + - run: npm ci - - name: Use Ruby 2.7 - uses: actions/setup-ruby@v1 + - name: Use Ruby 2.7.3 + uses: ruby/setup-ruby@v1 with: - ruby-version: '2.7' - - - name: Restore npm cache - uses: actions/cache@v2 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Restore Bundler cache - uses: actions/cache@v2 - with: - path: docs/vendor/bundle - key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-gems- - - - name: Configure Bundler cache - run: | - bundle config path vendor/bundle - - - name: Install dependencies - run: | - npm ci - cd docs && bundle install && cd .. + ruby-version: '2.7.3' + bundler-cache: true + working-directory: docs - name: Build docs run: npm run build:docs @@ -51,7 +30,7 @@ jobs: JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run Lighthouse CI - run: npx @lhci/cli@0.7.x autorun + run: npx @lhci/cli@0.9.x autorun env: JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} LHCI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index e907b9f3..6d84019f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -3,8 +3,6 @@ name: Build on: pull_request: push: - branches: - - main jobs: test-node: @@ -12,20 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the project - uses: actions/checkout@v2 - - name: Cache node modules - uses: actions/cache@v2 + uses: actions/checkout@v3 + - name: Use Node.js 16.x (LTS) + uses: actions/setup-node@v3 with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Use Node.js 13.11.0 - uses: actions/setup-node@v1 - with: - node-version: 13.11.0 - - name: Install dependencies - run: npm i + node-version: 16.x + cache: 'npm' + - run: npm ci - name: Lint Codebase run: npm run lint - name: Run Node.js Tests diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c3434a69..264bdd37 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,10 +8,12 @@ jobs: publish-npm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - name: Checkout the project + uses: actions/checkout@v3 + - name: Use Node.js 16.x (LTS) + uses: actions/setup-node@v3 with: - node-version: 14 + node-version: 16.x registry-url: https://registry.npmjs.org/ cache: npm - run: npm ci diff --git a/.gitignore b/.gitignore index 9d37538e..732d3771 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ _site lib/ .jekyll-cache .lighthouseci +coverage diff --git a/docs/Gemfile b/docs/Gemfile index 8487316a..433346ac 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -2,10 +2,10 @@ source 'https://rubygems.org' -gem 'jekyll', '~> 4.1.1' +gem 'jekyll' group :jekyll_plugins do - gem 'jekyll-commonmark-ghpages', '~> 0.1.5' + gem 'jekyll-commonmark-ghpages' gem 'jekyll-github-metadata' gem 'jekyll-gzip' end diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index cae5367f..3b3e7f35 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -4,92 +4,107 @@ GEM addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) colorator (1.1.0) - commonmarker (0.17.13) - ruby-enum (~> 0.5) - concurrent-ruby (1.1.7) - em-websocket (0.5.2) + commonmarker (0.23.4) + concurrent-ruby (1.1.10) + em-websocket (0.5.3) eventmachine (>= 0.12.9) - http_parser.rb (~> 0.6.0) + http_parser.rb (~> 0) eventmachine (1.2.7) - faraday (1.0.0) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) multipart-post (>= 1.2, < 3) - ffi (1.13.1) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + ffi (1.15.5) forwardable-extended (2.6.0) - http_parser.rb (0.6.0) - i18n (1.8.5) + http_parser.rb (0.8.0) + i18n (0.9.5) concurrent-ruby (~> 1.0) - jekyll (4.1.1) + jekyll (3.9.2) addressable (~> 2.4) colorator (~> 1.0) em-websocket (~> 0.5) - i18n (~> 1.0) - jekyll-sass-converter (~> 2.0) + i18n (~> 0.7) + jekyll-sass-converter (~> 1.0) jekyll-watch (~> 2.0) - kramdown (~> 2.1) - kramdown-parser-gfm (~> 1.0) + kramdown (>= 1.17, < 3) liquid (~> 4.0) - mercenary (~> 0.4.0) + mercenary (~> 0.3.3) pathutil (~> 0.9) - rouge (~> 3.0) + rouge (>= 1.7, < 4) safe_yaml (~> 1.0) - terminal-table (~> 1.8) - jekyll-commonmark (1.3.1) - commonmarker (~> 0.14) - jekyll (>= 3.7, < 5.0) - jekyll-commonmark-ghpages (0.1.6) - commonmarker (~> 0.17.6) - jekyll-commonmark (~> 1.2) + jekyll-commonmark (1.4.0) + commonmarker (~> 0.22) + jekyll-commonmark-ghpages (0.2.0) + commonmarker (~> 0.23.4) + jekyll (~> 3.9.0) + jekyll-commonmark (~> 1.4.0) rouge (>= 2.0, < 4.0) - jekyll-github-metadata (2.13.0) + jekyll-github-metadata (2.14.0) jekyll (>= 3.4, < 5.0) octokit (~> 4.0, != 4.4.0) - jekyll-gzip (2.4.2) + jekyll-gzip (2.5.1) jekyll (>= 3.0, < 5.0) - jekyll-sass-converter (2.1.0) - sassc (> 2.0.1, < 3.0) + jekyll-sass-converter (1.5.2) + sass (~> 3.4) jekyll-watch (2.2.1) listen (~> 3.0) - kramdown (2.3.1) + kramdown (2.4.0) rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) liquid (4.0.3) - listen (3.3.3) + listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - mercenary (0.4.0) + mercenary (0.3.6) multipart-post (2.1.1) - octokit (4.16.0) + octokit (4.22.0) faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) pathutil (0.16.2) forwardable-extended (~> 2.6) - public_suffix (4.0.6) - rb-fsevent (0.10.4) + public_suffix (4.0.7) + rb-fsevent (0.11.1) rb-inotify (0.10.1) ffi (~> 1.0) rexml (3.2.5) - rouge (3.25.0) - ruby-enum (0.7.2) - i18n + rouge (3.28.0) + ruby2_keywords (0.0.5) safe_yaml (1.0.5) - sassc (2.4.0) - ffi (~> 1.9) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - unicode-display_width (1.7.0) PLATFORMS - ruby + x86_64-linux DEPENDENCIES - jekyll (~> 4.1.1) - jekyll-commonmark-ghpages (~> 0.1.5) + jekyll + jekyll-commonmark-ghpages jekyll-github-metadata jekyll-gzip BUNDLED WITH - 2.1.4 + 2.3.13 diff --git a/docs/_config.yml b/docs/_config.yml index 9a330ec2..6defd492 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -11,6 +11,7 @@ exclude: - Gemfile - Gemfile.lock - node_modules + - vendor collections: guide: diff --git a/docs/_guide/abilities.md b/docs/_guide/abilities.md new file mode 100644 index 00000000..592751a8 --- /dev/null +++ b/docs/_guide/abilities.md @@ -0,0 +1,51 @@ +--- +chapter: 14 +subtitle: 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. + +### 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]({{ site.baseurl }}/guide/providable) for more. + +In addition to the provided abilities, Catalyst provides all of the tooling to create your own custom abilities. Take a look at the [Create Ability]({{ site.baseurl }}/guide/create-ability) documentation for more! diff --git a/docs/_guide/attrs.md b/docs/_guide/attrs.md index 1947af2e..38273340 100644 --- a/docs/_guide/attrs.md +++ b/docs/_guide/attrs.md @@ -7,19 +7,70 @@ Components may sometimes manage state, or configuration. We encourage the use of 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: +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 +``` - - It maps whatever the property name is to `data-*`, [similar to how `dataset` does](https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/dataset#name_conversion), but with more intuitive naming (e.g. `URL` maps to `data-url` not `data--u-r-l`). - - An `@attr` property is limited to `string`, `boolean`, or `number`, it will never be `null` or `undefined` - instead it has an "empty" value. No more null checking! - - The attribute name is automatically [observed, meaning `attributeChangedCallback` will fire when it changes](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks). - - Assigning a value in the class description will make that value the _default_ value, so when the element is connected that value is set (unless the element has the attribute defined already). +{% 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 `data-*` attribute. +
+ + + + +
+ +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 @@ -27,51 +78,50 @@ import { controller, attr } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - @attr foo = 'hello' + @attr fooBar = 'hello' } ``` -This is the equivalent to: +This is somewhat equivalent to: ```js import { controller } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - get foo(): string { - return this.getAttribute('data-foo') || '' + get fooBar(): string { + return this.getAttribute('foo-bar') || '' } - set foo(value: string): void { - return this.setAttribute('data-foo', value) + set fooBar(value: string): void { + return this.setAttribute('foo-bar', value) } connectedCallback() { - if (!this.hasAttribute('data-foo')) this.foo = 'Hello' + if (!this.hasAttribute('foo-bar')) this.fooBar = 'Hello' } - static observedAttributes = ['data-foo'] } ``` ### 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 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. +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 | "Empty" value | When `get` is called | When `set` is called | -|:----------|:--------------|----------------------|:---------------------| -| `string` | `''` | `getAttribute` | `setAttribute` | -| `number` | `0` | `getAttribute` | `setAttribute` | -| `boolean` | `false` | `hasAttribute` | `toggleAttribute` | +| 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 return an empty string (`''`) 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. +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 @@ -79,14 +129,17 @@ import { controller, attr } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - @attr foo = 'Hello' + @attr fooBar = 'Hello' connectedCallback() { - console.assert(this.foo === 'Hello') - this.foo = null // TypeScript won't like this! - console.assert(this.foo === 'null') - delete this.dataset.foo // Removes the attribute - console.assert(this.foo === '') // If the attribute doesn't exist, its an empty string! + 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') } } ``` @@ -104,24 +157,24 @@ import { controller, attr } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - @attr foo = false + @attr fooBar = false connectedCallback() { - console.assert(this.hasAttribute('data-foo') === false) - this.foo = true - console.assert(this.hasAttribute('data-foo') === true) - this.setAttribute('data-foo', 'this value doesnt matter!') - console.assert(this.foo === true) + 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 `0` 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. +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 @@ -129,14 +182,15 @@ import { controller, attr } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - @attr foo = 1 + @attr fooBar = 1 connectedCallback() { - console.assert(this.getAttribute('data-foo') === '1') - this.setAttribute('data-foo', 'not a number') - console.assert(Number.isNaN(this.foo)) - this.foo = -3.14 - console.assert(this.getAttribute('data-foo') === '-3.14') + 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') } } ``` @@ -146,7 +200,7 @@ class HelloWorldElement extends HTMLElement { 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 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 %} @@ -163,9 +217,9 @@ attr name: Maps to get/setAttribute('data-name') import { controller, attr } from "@github/catalyst" @controller class HelloWorldElement extends HTMLElement { - @attr name = 'World' + @attr dataName = 'World' connectedCallback() { - this.textContent = `Hello ${this.name}` + this.textContent = `Hello ${this.dataName}` } } ``` @@ -185,24 +239,45 @@ data-name ".*": Will set the value of `name` // This will render `Hello ` ``` -### What about without Decorators? +### Advanced usage -If you're not using decorators, then you won't be able to use the `@attr` decorator, but there is still a way to achieve the same result. Under the hood `@attr` simply tags a field, but `initializeAttrs` and `defineObservedAttributes` do all of the logic. +#### Determining when an @attr changes value -Calling `initializeAttrs` in your connected callback, with the list of properties you'd like to initialize, and calling `defineObservedAttributes` with the class, can achieve the same result as `@attr`. The class fields can still be defined in your class, and they'll be overridden as described above. For example: - -```js -import {initializeAttrs, defineObservedAttributes} from '@github/catalyst' +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 { - foo = 1 - connectedCallback() { - initializeAttrs(this, ['foo']) + @attr get dataName() { + return 'World' // Used to get the intial value } + // Called whenever `name` changes + @attr 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 } -defineObservedAttributes(HelloWorldElement, ['foo']) +) ``` This example is functionally identical to: @@ -212,6 +287,6 @@ import {controller, attr} from '@github/catalyst' @controller class HelloWorldElement extends HTMLElement { - @attr foo = 1 + @attr fooBar = 1 } ``` diff --git a/docs/_guide/conventions.md b/docs/_guide/conventions.md index 4d9d01fd..e38472af 100644 --- a/docs/_guide/conventions.md +++ b/docs/_guide/conventions.md @@ -5,13 +5,18 @@ subtitle: Conventions Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code: -### Use `Element` to suffix your controller class +### Suffix your controllers consistently, for symmetry -Built in HTML elements all extend from the `HTMLElement` constructor, and are all suffixed with `Element` (for example `HTMLElement`, `SVGElement`, `HTMLInputElement` and so on). Catalyst components should be no different, they should behave as closely to the built-ins as possible. +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 {} +class UserListElement extends HTMLElement {} // `` +``` + +```typescript +@controller +class UserListComponent extends HTMLElement {} // `` ``` ### The best class-names are two word descriptions diff --git a/docs/_guide/create-ability.md b/docs/_guide/create-ability.md new file mode 100644 index 00000000..7e0e93f5 --- /dev/null +++ b/docs/_guide/create-ability.md @@ -0,0 +1,228 @@ +--- +chapter: 16 +subtitle: Create your own abilities +--- + +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: + + - `createAbility` - a helper function to make new abilities (class decorators). + - `createMark` - a helper function to generate class field & method decorators. + - `tag-observer` - a set of helper functions to watch for tagged children in an element's subtree. + - `controllable` - the base ability which allows interacting with semi-private parts of an element. + +## createAbility + +This function allows you to make your own [Ability]({{ site.baseurl }}/guide/abilities). Abilities are really Class Decorators, but there's a couple of things that `createAbility` provides to simplify the ergonomics of Class Decorators: + + - TypeScript can be a little tricky when working with Class Decorators. `createAbility` simplifies this a bit. + - JavaScript does not copy over the `name` property when extending a class (e.g. via a decorator), and it can be a little cumbersome to do this, so `createAbility` does this for you. + - Abilities are [idempotent](https://en.wikipedia.org/wiki/Idempotence). Class decorators are not idempotent by default, which means applying a decorator multiple times can cause issues. `createAbility` mitigates this by memoizing the classes it has applied to, meaning applying an ability multiple times has no effect past the first application. + +The above three features of `createAbility` make it really useful when creating mixins for web components, and makes them much easier for developers as they can trust an ability to not be sensitive to these problems. + +To create an ability, call the `createAbility` method and pass in a callback function which takes a `CustomElementClass` and returns a new class. You can also provide extra types if your returned class adds new methods or fields. Here's an example, using TypeScript: + + +```typescript +import type {CustomElementClass} from '@github/catalyst' +import {createAbility} from '@github/catalyst' + +// by convention, abilities end in "able" +interface Fooable { + foo(): void // This interface has additional methods on top of `CustomElementClass`! +} + +// Fooable: automatically calls `foo()` on `connectedCallback` +export const fooable = createAbility( + // ↓ Notice the `& { new (): Fooable }` + (Class: T): T & { new (): Fooable } => + class extends Class { + connectedCallback() { + this.foo() + } + + foo() { + console.log('Foo was called!') + } + } +) +``` + +Inside the `class extends Class` block, you can author custom element logic that you might want to make reusable across a multitude of elements. You can also adjust the input type to subclass `CustomElementClass`, which can be useful for setting up a contract between your Ability and the classes that rely on it: + +```typescript +import type {CustomElementClass} from '@github/catalyst' +import {createAbility} from '@github/catalyst' + +// by convention, abilities end in "able" +interface Fooable { + foo(): void // This interface has additional methods on top of `CustomElementClass`! +} + +interface FooableClass { + new(...args: any[]): Fooable +} + +// Fooable: automatically calls `foo()` on `connectedCallback` +export const fooable = createAbility( + // ↓ Notice the `& FooableClass` + (Class: T): T => + class extends Class { + // TypeScript will expect the constructor to be defined for a mixin + constructor(...args: any[]) { + super(...args) + } + + connectedCallback() { + // Classes that apply this ability _must_ implement `foo()`. + super.foo() + } + } +``` + +If you're interested in some advanced examples, you can take a look at the Catalyst source code - every feature of Catalyst is an Ability! + +## createMark + +This function allows you to make annotations for fields (like `@attr` and `@target`). Marks are really Field/Method Decorators, but with simpler ergonomics: + + - Marks are only initialized on instances, which makes them easier to reason about. + - Marks are not configurable, which keeps them simple. + - They are built to ease a transition between TypeScript decorators and ECMAScript decorators, which will help as decorators become standardised. + +`createMark` can be called with a `validate` and an `init` function, and gives back a tuple of 3 functions: the decorator itself, a function to get a list of marks that an instance has, and a function that will initialise the marks on an instance. It can be used like so: + +```typescript + +// Makes the @prop decorator +const [prop, getProps, initProps] = createMark( + ({name, kind}) => { + // Validate the name and kind that a mark can have. + // Name will be the PropertyKey that was decorated, and `kind` will be one of: + // "method", "field", "getter", "setter". + if (kind === "method") { + throw new Error(`@prop cannot be used to mark a method`) + } + }, + (instance: CustomElement, {name, kind, access}) => { + // Put field initialization logic here. + // Return a property descriptor to define a field's functionality: + let value = kind === 'field' ? access.value : access.get?.call(instance) + return { + get() { return value } + set(newValue) { + value = newValue + instance.propChanged(name, newValue) + } + } + } +) +``` + +If you want to find some examples of how marks work, take a look at the Catalyst source code! All field decorators (`@attr`, `@target`, `@provide`, `@consume` and so on) use `createMark`. + +## tag-observer + +Tag Observer provides a set of functions to observe elements marked with well-known attributes across the DOM, allowing classes to be reactive to DOM mutations. These functions operate over a `MutationObserver` set up to detect new elements coming into the page that have a registered attribute. To call register a new tag you can use the `registerTag` function which takes an attribute name to observe, a parse function (that parses the attribute value), and a found function (which is called for each element that has the attribute): + +```typescript +registerTag( + `data-foo`, + (value: string) => value.split('.'), + (el: Element, controller: Element | ShadowRoot, ...meta: string[]) => { + // ... + } +) +``` + +Tag Observer also provides a `observeElementForTags` function, which can be called on an element to adopt it into observation. A good place to use this is in your Abilities `connectedCallback`. This function can also take a `shadowRoot` if you're interested in observing tags within the shadow DOM (recommended). This function will find the root element (`ownerDocument`) and begin observing it. + +```typescript +export const fooable = createAbility( + (Class: T): T => + class extends Class { + connectedCallback() { + observeElementForTags(this) // This elements ownerDocument will now look out for new tags + } + } +``` + +Whenever an element appears on the page with the matching attribute (e.g. `data-foo`), the value is extracted, split by whitespace, and each substring is then given to `parse` to turn into an array of strings. The first value in the array that the parse function returns must be a parent selector, which is then used to find the controller this attribute could pertain to. If the element is a child of the given controller selector, then the found function is called with the element, the controller, and any additional metadata that the parse function extracted. Let's see an example for how this might work, given the above registered tag: + +```html + +
+
+``` + +- Our `data-foo` attribute is found in the DOM, belonging to the `div` element. +- The value is extracted and split by whitespace. +- Our parse function gets called twice, firstly with `my-element.foo.bar` + - The parse function splits this by `.` which gets us `['my-element', 'foo', 'bar']`. + - Tag observer uses `my-element` as the parent selector and calls `div.closest('my-element')`, + - The `` controller is found. + - Our found function is called with `(
, , ['foo', 'bar'])` +- The parse function is also called with `other-element.baz.bing`. + - The parse function splits this by `.` which gets us `['other-element', 'baz', 'bing']`. + - Tag observer uses `other-element` as the parent selector and calls `div.closest('other-element')`, + - No parent element is found, so the found function is not called. + +To take a look at how Tag Observer is used in Catalyst, you can look at [`data-action` (the Actionable ability)]({{ site.baseurl }}/guide/actions) or [`data-target` & `data-targets` (the Targetable ability)]({{ site.baseurl }}/guide/targets). + +## controllable + +`controllable` is a basic ability which other abilities can use to simplify connecting to a custom elements private state. This ability isn't _required_ to be used when creating your own abilities, but it's very useful for abilities which expect to use either the [ShadowDOM](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) or [ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals). + +You can create an ability that itself uses the `controllable` ability like so: + +```typescript +import type {CustomElementClass} from '@github/catalyst' +import {createAbility, controllable} from '@github/catalyst' + +createAbility((Class: CustomElementClass) => class extends controllable(Class) { + // Your behaviour goes here! +} +``` + +The `controllable` ability provides 2 _custom_ callbacks which allow you to safely & robustly intercept the attachment of a ShadowRoot, and the attachment of ElementInternals. Let's look at each: + +### `[attachShadowCallback](shadowRoot: ShadowRoot)` + +```typescript +import type {CustomElementClass} from '@github/catalyst' +import {createAbility, attachShadowCallback, controllable} from '@github/catalyst' + +createAbility((Class: CustomElementClass) => class extends controllable(Class) { + [attachShadowCallback](shadowRoot: ShadowRoot) { + super[attachShadowCallback](shadowRoot) + // Do stuff with the `shadowRoot`. + } +} +``` + +`attachShadowCallback` is a special `Symbol()` which allows you to make a method mostly hidden from other classes. `controllable` will call this symbol method whenever a ShadowRoot is attached to the element, which can be attached in 2 different ways: + + - During the constructor, where the element might recieve a declarative ShadowDOM root (closed or open). + - Any time the `attachShadow()` function is called. + +This method is _usually_ called zero or once, but may be called twice if the element recieves a Declarative ShadowDOM root, and overrides this with another call to `attachShadow()`. + +Simply overriding `this.attachShadow` or trying to access `this.shadowRoot` can be a little tricky (if an element has a closed declarative shadow root this can be especially difficult to access within mixins), so this callback can be especially useful for working around the various ways a shadowRoot can be created. + +### `[attachInternalsCallback](internals: ElementInternals)` + +```typescript +import type {CustomElementClass} from '@github/catalyst' +import {createAbility, attachInternalsCallback, controllable} from '@github/catalyst' + +createAbility((Class: CustomElementClass) => class extends controllable(Class) { + [attachInternalsCallback](internals: ElementInternals) { + super[attachInternalsCallback](internals) + // Do stuff with the `internals`. + } +} +``` + +`attachInternalsCallback` is a special `Symbol()` which allows you to make a method mostly hidden from other classes. `controllable` will call this symbol method whenever an element is constructed, giving it the element's `ElementInternals`. This enables custom enablies [Abilities]({{ site.baseurl }}/guide/abilities) to also have access to `ElementInternals`. It does so while also preserving the ability for `attachInternals()` to be called again (usually `attachInternals()` will error if called twice). + +If you need access to the internals, then the `attachInternalsCallback` can be very useful as it protects you from calling `attachInternals` in a way which the concrete classes will then fail. diff --git a/docs/_guide/decorators.md b/docs/_guide/decorators.md index 806e30fa..10d08343 100644 --- a/docs/_guide/decorators.md +++ b/docs/_guide/decorators.md @@ -37,7 +37,51 @@ class HelloWorldElement extends HTMLElement { 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. -#### Supporting `strictPropertyInitialization` +### 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: @@ -63,28 +107,6 @@ TypeScript comes with various "strict" mode settings, one of which is `strictPro } ``` -### Method Decorators - -Catalyst doesn't currently ship with any method decorators, but you might see them in code. They work just like Field Decorators (in fact they're the same thing). Put them on top or on the left of the method, like so: - - -```js -class HelloWorldElement extends HTMLElement { - - @log - submit() { - // ... - } - - // Alternative style - - @log load() { - // ... - } - -} -``` - ### 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. diff --git a/docs/_guide/providable.md b/docs/_guide/providable.md new file mode 100644 index 00000000..ec51bcd6 --- /dev/null +++ b/docs/_guide/providable.md @@ -0,0 +1,144 @@ +--- +chapter: 15 +subtitle: The Provider pattern +--- + +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' + +@controller +@providable +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`) + } + } +} + +@controller +@providable +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`) + } + } +} + +@controller +@providable +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 userName = 'Alex' +} +``` + +```html + +