├── .nvmrc ├── CODEOWNERS ├── docs ├── fonts │ ├── Inter-Bold.woff │ ├── Inter-Medium.woff │ └── Inter-Regular.woff ├── icons │ ├── icon-48x48.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── icon-144x144.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ └── icon-512x512.png ├── _guide │ ├── devtools-coverage.png │ ├── conventions.md │ ├── lazy-elements.md │ ├── abilities.md │ ├── lazy-elements-2.md │ ├── introduction.md │ ├── introduction-2.md │ ├── conventions-2.md │ ├── lifecycle-hooks.md │ ├── lifecycle-hooks-2.md │ ├── your-first-component-2.md │ ├── your-first-component.md │ ├── testing.md │ ├── testing-2.md │ ├── you-will-need.md │ ├── rendering.md │ ├── decorators.md │ ├── rendering-2.md │ ├── decorators-2.md │ ├── targets.md │ ├── patterns.md │ ├── patterns-2.md │ ├── targets-2.md │ └── actions-2.md ├── Gemfile ├── _includes │ ├── reference_sidebar.html │ ├── callout.md │ ├── sidebar.html │ └── type.html ├── 404.html ├── _config.yml ├── _layouts │ ├── guide.html │ └── default.html ├── Gemfile.lock ├── custom.css ├── index.js ├── github-syntax.css └── index.html ├── src ├── abilities.ts ├── get-property-descriptor.ts ├── index.ts ├── auto-shadow-root.ts ├── dasherize.ts ├── controller.ts ├── ability.ts ├── custom-element.ts ├── register.ts ├── target.ts ├── findtarget.ts ├── mark.ts ├── tag-observer.ts ├── controllable.ts ├── core.ts ├── bind.ts ├── attr.ts ├── lazy-define.ts └── providable.ts ├── .gitignore ├── SECURITY.md ├── web-test-runner.config.js ├── tsconfig.build.json ├── tsconfig.json ├── README.md ├── test ├── dasherize.ts ├── register.ts ├── auto-shadow-root.ts ├── tag-observer.ts ├── ability.ts ├── lazy-define.ts ├── target.ts └── controller.ts ├── .github └── workflows │ ├── nodejs.yml │ ├── publish.yml │ └── lighthouse.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.json ├── lighthouserc.json ├── LICENSE ├── CONTRIBUTING.md ├── package.json └── CODE_OF_CONDUCT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 13.11.0 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers @koddsson 2 | -------------------------------------------------------------------------------- /docs/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /docs/icons/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-48x48.png -------------------------------------------------------------------------------- /docs/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-72x72.png -------------------------------------------------------------------------------- /docs/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-96x96.png -------------------------------------------------------------------------------- /docs/fonts/Inter-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/fonts/Inter-Medium.woff -------------------------------------------------------------------------------- /docs/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-144x144.png -------------------------------------------------------------------------------- /docs/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-192x192.png -------------------------------------------------------------------------------- /docs/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-256x256.png -------------------------------------------------------------------------------- /docs/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-384x384.png -------------------------------------------------------------------------------- /docs/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/abilities.ts: -------------------------------------------------------------------------------- 1 | export {provide, getProvide, consume, getConsume, providable} from './providable.js' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _site 3 | *.tsbuildinfo 4 | lib/ 5 | .jekyll-cache 6 | .lighthouseci 7 | coverage 8 | -------------------------------------------------------------------------------- /docs/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /docs/_guide/devtools-coverage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/catalyst/main/docs/_guide/devtools-coverage.png -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | If you discover a security issue in this repository, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github). 2 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import {esbuildPlugin} from '@web/dev-server-esbuild' 2 | 3 | export default { 4 | files: ['test/*'], 5 | nodeResolve: true, 6 | plugins: [esbuildPlugin({ts: true, target: 'es2020'})] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["test"], 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "outDir": "./lib", 8 | "noEmit": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'jekyll' 6 | 7 | group :jekyll_plugins do 8 | gem 'jekyll-commonmark-ghpages' 9 | gem 'jekyll-github-metadata' 10 | gem 'jekyll-gzip' 11 | end 12 | -------------------------------------------------------------------------------- /src/get-property-descriptor.ts: -------------------------------------------------------------------------------- 1 | export const getPropertyDescriptor = (instance: unknown, key: PropertyKey): PropertyDescriptor | undefined => { 2 | while (instance) { 3 | const descriptor = Object.getOwnPropertyDescriptor(instance, key) 4 | if (descriptor) return descriptor 5 | instance = Object.getPrototypeOf(instance) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/_includes/reference_sidebar.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "test"], 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "lib": ["es2020", "dom", "dom.iterable"], 8 | "module": "ESNext", 9 | "moduleResolution": "node", 10 | "noEmit": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "ES2020" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {bind, listenForBind} from './bind.js' 2 | export {register} from './register.js' 3 | export {findTarget, findTargets} from './findtarget.js' 4 | export {target, targets} from './target.js' 5 | export {controller} from './controller.js' 6 | export {attr, initializeAttrs, defineObservedAttributes} from './attr.js' 7 | export {autoShadowRoot} from './auto-shadow-root.js' 8 | export {lazyDefine} from './lazy-define.js' 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Catalyst 2 | 3 | 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. 4 | 5 | For more see the [Catalyst Website](https://github.github.io/catalyst/) which includes a [Guide To using Catalyst](https://github.github.io/catalyst/guide/introduction). 6 | -------------------------------------------------------------------------------- /src/auto-shadow-root.ts: -------------------------------------------------------------------------------- 1 | export function autoShadowRoot(element: HTMLElement): void { 2 | for (const template of element.querySelectorAll('template[data-shadowroot]')) { 3 | if (template.parentElement === element) { 4 | element 5 | .attachShadow({ 6 | mode: template.getAttribute('data-shadowroot') === 'closed' ? 'closed' : 'open' 7 | }) 8 | .append(template.content.cloneNode(true)) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | 18 | 19 |
20 |

404

21 | 22 |

Page not found :(

23 |

The requested page could not be found.

24 |
25 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: Catalyst 2 | 3 | markdown: CommonMarkGhPages 4 | 5 | commonmark: 6 | extensions: ['autolink', 'table'] 7 | 8 | permalink: pretty 9 | 10 | exclude: 11 | - Gemfile 12 | - Gemfile.lock 13 | - node_modules 14 | - vendor 15 | 16 | collections: 17 | guide: 18 | output: true 19 | 20 | defaults: 21 | - scope: 22 | type: guide 23 | values: 24 | layout: guide 25 | 26 | repository: github/catalyst 27 | 28 | plugins: 29 | - 'jekyll-github-metadata' 30 | - 'jekyll-gzip' 31 | -------------------------------------------------------------------------------- /src/dasherize.ts: -------------------------------------------------------------------------------- 1 | export const dasherize = (str: unknown): string => 2 | String(typeof str === 'symbol' ? str.description : str) 3 | .replace(/([A-Z]($|[a-z]))/g, '-$1') 4 | .replace(/--/g, '-') 5 | .replace(/^-|-$/, '') 6 | .toLowerCase() 7 | 8 | export const mustDasherize = (str: unknown, type = 'property'): string => { 9 | const dashed = dasherize(str) 10 | if (!dashed.includes('-')) { 11 | throw new DOMException(`${type}: ${String(str)} is not a valid ${type} name`, 'SyntaxError') 12 | } 13 | return dashed 14 | } 15 | -------------------------------------------------------------------------------- /src/controller.ts: -------------------------------------------------------------------------------- 1 | import {CatalystDelegate} from './core.js' 2 | import type {CustomElementClass} from './custom-element.js' 3 | /** 4 | * Controller is a decorator to be used over a class that extends HTMLElement. 5 | * It will automatically `register()` the component in the customElement 6 | * registry, as well as ensuring `bind(this)` is called on `connectedCallback`, 7 | * wrapping the classes `connectedCallback` method if needed. 8 | */ 9 | export function controller(classObject: CustomElementClass): void { 10 | new CatalystDelegate(classObject) 11 | } 12 | -------------------------------------------------------------------------------- /test/dasherize.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@open-wc/testing' 2 | import {dasherize} from '../src/dasherize.js' 3 | 4 | describe('dasherize', () => { 5 | const tests: Array<[PropertyKey, string]> = [ 6 | ['json', 'json'], 7 | ['fooBar', 'foo-bar'], 8 | ['FooBar', 'foo-bar'], 9 | ['autofocusWhenReady', 'autofocus-when-ready'], 10 | ['URLBar', 'url-bar'], 11 | ['ClipX', 'clip-x'], 12 | [Symbol('helloWorld'), 'hello-world'] 13 | ] 14 | 15 | tests.map(([input, output]) => 16 | it(`transforms ${String(input)} to ${output}`, () => expect(dasherize(input)).to.equal(output)) 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | test-node: 9 | name: Test on Node.js 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the project 13 | uses: actions/checkout@v3 14 | - name: Use Node.js 16.x (LTS) 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 16.x 18 | cache: 'npm' 19 | - run: npm ci 20 | - name: Lint Codebase 21 | run: npm run lint 22 | - name: Run Node.js Tests 23 | run: npm run test 24 | - name: Check Bundle Size 25 | run: npm run size 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout the project 12 | uses: actions/checkout@v3 13 | - name: Use Node.js 16.x (LTS) 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16.x 17 | registry-url: https://registry.npmjs.org/ 18 | cache: npm 19 | - run: npm ci 20 | - run: npm test 21 | - run: npm version ${TAG_NAME} --git-tag-version=false 22 | env: 23 | TAG_NAME: ${{ github.event.release.tag_name }} 24 | - run: npm whoami; npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 27 | -------------------------------------------------------------------------------- /src/ability.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElementClass} from './custom-element.js' 2 | 3 | type Decorator = (Class: CustomElementClass) => unknown 4 | const abilityMarkers = new WeakMap>() 5 | export const createAbility = ( 6 | decorate: (Class: TClass) => TExtend 7 | ): ((Class: TClass) => TExtend) => { 8 | return (Class: TClass): TExtend => { 9 | const markers = abilityMarkers.get(Class) 10 | if (markers?.has(decorate as Decorator)) return Class as unknown as TExtend 11 | const NewClass = decorate(Class) as TExtend 12 | Object.defineProperty(NewClass, 'name', {value: Class.name}) 13 | const newMarkers = new Set(markers) 14 | newMarkers.add(decorate as Decorator) 15 | abilityMarkers.set(NewClass as unknown as CustomElementClass, newMarkers) 16 | return NewClass 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/custom-element.ts: -------------------------------------------------------------------------------- 1 | export interface CustomElement extends HTMLElement { 2 | connectedCallback?(): void 3 | attributeChangedCallback?(name: string, oldValue: string | null, newValue: string | null): void 4 | disconnectedCallback?(): void 5 | adoptedCallback?(): void 6 | formAssociatedCallback?(form: HTMLFormElement): void 7 | formDisabledCallback?(disabled: boolean): void 8 | formResetCallback?(): void 9 | formStateRestoreCallback?(state: unknown, reason: 'autocomplete' | 'restore'): void 10 | } 11 | 12 | export interface CustomElementClass { 13 | // TS mandates Constructors that get mixins have `...args: any[]` 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | new (...args: any[]): CustomElement 16 | observedAttributes?: string[] 17 | disabledFeatures?: string[] 18 | formAssociated?: boolean 19 | 20 | attrPrefix?: string 21 | } 22 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 8 | && apt-get -y install --no-install-recommends bundler 9 | 10 | # [Optional] Uncomment if you want to install an additional version of node using nvm 11 | # ARG EXTRA_NODE_VERSION=10 12 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 13 | 14 | # [Optional] Uncomment if you want to install more global node modules 15 | # RUN su node -c "npm install -g " 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": ["github"], 4 | "extends": ["plugin:github/recommended", "plugin:github/typescript", "plugin:github/browser"], 5 | "rules": { 6 | "import/no-unresolved": "off", 7 | "github/no-inner-html": "off", 8 | "i18n-text/no-en": "off", 9 | "import/extensions": ["error", "ignorePackages"], 10 | "@typescript-eslint/consistent-type-imports": ["error", {"prefer": "type-imports"}] 11 | }, 12 | "overrides": [ 13 | { 14 | "files": "test/*", 15 | "rules": { 16 | "@typescript-eslint/no-empty-function": "off" 17 | }, 18 | "globals": { 19 | "chai": false, 20 | "expect": false 21 | }, 22 | "env": { 23 | "mocha": true 24 | } 25 | }, 26 | { 27 | "files": "*.cjs", 28 | "rules": { 29 | "import/no-commonjs": "off" 30 | }, 31 | "env": { 32 | "node": true 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /docs/_includes/callout.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 10 | 11 |
12 | 13 | {{ callout }} 14 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /lighthouserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "collect": { 4 | "url": ["http://localhost:4000/?prefers-color-scheme=light", "http://localhost:4000/?prefers-color-scheme=dark"], 5 | "startServerCommand": "cd docs && bundle exec jekyll serve", 6 | "startServerReadyPattern": "Server running..." 7 | }, 8 | "assert": { 9 | "preset": "lighthouse:no-pwa", 10 | "assertions": { 11 | "unused-css-rules": "off", 12 | "uses-text-compression": "off", 13 | "render-blocking-resources": "off", 14 | "uses-rel-preload": "off", 15 | "first-contentful-paint": ["error", {"minScore": 0.6}], 16 | "first-meaningful-paint": ["error", {"maxNumericValue": 3000}], 17 | "largest-contentful-paint": ["error", {"maxNumericValue": 3000}], 18 | "deprecations": "off", 19 | "csp-xss": "off" 20 | } 21 | }, 22 | "upload": { 23 | "target": "temporary-public-storage" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElementClass} from './custom-element.js' 2 | import {dasherize} from './dasherize.js' 3 | 4 | /** 5 | * Register the controller as a custom element. 6 | * 7 | * The classname is converted to a appropriate tag name. 8 | * 9 | * Example: HelloController => hello-controller 10 | */ 11 | export function register(classObject: CustomElementClass): CustomElementClass { 12 | const name = dasherize(classObject.name).replace(/-element$/, '') 13 | 14 | try { 15 | window.customElements.define(name, classObject) 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | // @ts-ignore 18 | window[classObject.name] = customElements.get(name) 19 | } catch (e: unknown) { 20 | // The only reason for window.customElements.define to throw a `NotSupportedError` 21 | // is if the element has already been defined. 22 | if (!(e instanceof DOMException && e.name === 'NotSupportedError')) throw e 23 | } 24 | return classObject 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/lighthouse.yml: -------------------------------------------------------------------------------- 1 | name: Lighthouse 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lhci: 7 | name: Lighthouse 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout the project 11 | uses: actions/checkout@v3 12 | 13 | - name: Use Node.js 16.x (LTS) 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16.x 17 | cache: 'npm' 18 | - run: npm ci 19 | 20 | - name: Use Ruby 2.7.3 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: '2.7.3' 24 | bundler-cache: true 25 | working-directory: docs 26 | 27 | - name: Build docs 28 | run: npm run build:docs 29 | env: 30 | JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Run Lighthouse CI 33 | run: npx @lhci/cli@0.9.x autorun 34 | env: 35 | JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | LHCI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /docs/_includes/sidebar.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | "postCreateCommand": "npm i && cd docs && sudo bundle install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/target.ts: -------------------------------------------------------------------------------- 1 | import {findTarget, findTargets} from './findtarget.js' 2 | import {meta} from './core.js' 3 | 4 | /** 5 | * Target is a decorator which - when assigned to a property field on the 6 | * class - will override that class field, turning it into a Getter which 7 | * returns a call to `findTarget(this, key)` where `key` is the name of the 8 | * property field. In other words, `@target foo` becomes a getter for 9 | * `findTarget(this, 'foo')`. 10 | */ 11 | export function target(proto: Record, key: K): void { 12 | meta(proto, 'target').add(key) 13 | Object.defineProperty(proto, key, { 14 | configurable: true, 15 | get() { 16 | return findTarget(this, key) 17 | } 18 | }) 19 | } 20 | 21 | /** 22 | * Targets is a decorator which - when assigned to a property field on the 23 | * class - will override that class field, turning it into a Getter which 24 | * returns a call to `findTargets(this, key)` where `key` is the name of the 25 | * property field. In other words, `@targets foo` becomes a getter for 26 | * `findTargets(this, 'foo')`. 27 | */ 28 | export function targets(proto: Record, key: K): void { 29 | meta(proto, 'targets').add(key) 30 | Object.defineProperty(proto, key, { 31 | configurable: true, 32 | get() { 33 | return findTargets(this, key) 34 | } 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/findtarget.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * findTarget will run `querySelectorAll` against the given controller, plus 3 | * its shadowRoot, returning any the first child that: 4 | * 5 | * - Matches the selector of `[data-target~="tag.name"]` where tag is the 6 | * tagName of the given HTMLElement, and `name` is the given `name` argument. 7 | * 8 | * - Closest ascendant of the element, that matches the tagname of the 9 | * controller, is the specific instance of the controller itself - in other 10 | * words it is not nested in other controllers of the same type. 11 | * 12 | */ 13 | export function findTarget(controller: HTMLElement, name: string): Element | undefined { 14 | const tag = controller.tagName.toLowerCase() 15 | if (controller.shadowRoot) { 16 | for (const el of controller.shadowRoot.querySelectorAll(`[data-target~="${tag}.${name}"]`)) { 17 | if (!el.closest(tag)) return el 18 | } 19 | } 20 | for (const el of controller.querySelectorAll(`[data-target~="${tag}.${name}"]`)) { 21 | if (el.closest(tag) === controller) return el 22 | } 23 | } 24 | 25 | export function findTargets(controller: HTMLElement, name: string): Element[] { 26 | const tag = controller.tagName.toLowerCase() 27 | const targets = [] 28 | if (controller.shadowRoot) { 29 | for (const el of controller.shadowRoot.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) { 30 | if (!el.closest(tag)) targets.push(el) 31 | } 32 | } 33 | for (const el of controller.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) { 34 | if (el.closest(tag) === controller) targets.push(el) 35 | } 36 | return targets 37 | } 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/catalyst/fork 4 | [pr]: https://github.com/github/catalyst/compare 5 | [style]: https://github.com/styleguide/ruby 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). 11 | 12 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 13 | 14 | ## Submitting a pull request 15 | 16 | 0. [Fork][fork] and clone the repository 17 | 0. Configure and install the dependencies: `npm i` 18 | 0. Make sure the tests pass on your machine: `npm t` 19 | 0. Create a new branch: `git checkout -b my-branch-name` 20 | 0. Make your change, add tests, and make sure the tests still pass 21 | 0. Push to your fork and [submit a pull request][pr] 22 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 23 | 24 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 25 | 26 | - Follow the [style guide][style]. 27 | - Write tests. 28 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 29 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 30 | 31 | ## Resources 32 | 33 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 34 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 35 | - [GitHub Help](https://help.github.com) 36 | -------------------------------------------------------------------------------- /docs/_layouts/guide.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | {% assign sidebarItems = site.guide | where_exp: "item", "item.version == page.version" | sort: 'chapter' %} 6 | 7 | {% for item in sidebarItems %} 8 | {% if item.title == page.title %} 9 | {% unless forloop.first %} 10 | {% assign prevIndex = forloop.index| minus: 2 %} 11 | {% assign prev = sidebarItems[prevIndex] %} 12 | {% endunless %} 13 | {% unless forloop.last %} 14 | {% assign nextIndex = forloop.index %} 15 | {% assign next = sidebarItems[nextIndex] %} 16 | {% endunless %} 17 | {% endif %} 18 | {% endfor %} 19 | 20 |
21 | {% include sidebar.html %} 22 | 23 |
24 |
25 | {% if page.version == 2 %} 26 |
27 | You are reading the documentation for the Alpha version of Catalyst. The API and documentation is subject to change. 28 | The documentation for the stable version can be found here. 29 |
30 | {% endif %} 31 | 32 |

{{ page.title }}

33 | {{ content }} 34 |
35 | 48 |
49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /docs/_includes/type.html: -------------------------------------------------------------------------------- 1 | {%- capture output %} 2 | {%- case type.type %} 3 | {%- when "intrinsic" %} 4 | {{- }}{{- type.name }} 5 | {%- when "reference" %} 6 | {{- }}{{- type.name }} 7 | {%- when "array" %} 8 | {%- assign type = type.elementType %} 9 | {%- include type.html %}[] 10 | {%- when "union" %} 11 | {%- for type in type.types %} 12 | {%- include type.html %}{% if forloop.last != true %} | {%- endif %} 13 | {%- endfor %} 14 | {%- when "reflection" %} 15 | {%- assign type = type.declaration %} 16 | {%- include type.html %} 17 | {%- when "typeParameter" %} 18 | {%- assign name = type.name %} 19 | {%- assign type = type.constraint %} 20 | {{- ''}}{{ name }} 21 | {%- else %} 22 | {%- if type.signatures %} 23 | {%- assign rootType = type %} 24 | {%- for signature in type.signatures %} 25 | {%- if signature.name != "__call" %}{{ signature.name }}{% endif %} 26 | {{- ''}}( 27 | {%- for parameter in signature.parameters %} 28 | {{- ''}}{%- if parameter.flags.isRest %}...{% endif %}{{ parameter.name }}:  29 | {%- assign type = parameter.type %} 30 | {%- include type.html %} 31 | {%- if forloop.last != true %}, {%- endif %} 32 | {%- endfor %} 33 | {{-''}}) 34 | {%- if rootType.name == "__type" %} =>  35 | {%- else %}: {% endif %} 36 | {%- assign type = signature.type %} 37 | {%- include type.html %} 38 | {%- endfor %} 39 | {%- else %} 40 |
{{- type | jsonify }}
41 | {%- endif %} 42 | {%- endcase %} 43 | {%- endcapture %}{{- output | strip_newlines }} 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/catalyst", 3 | "version": "1.3.0", 4 | "description": "Helpers for creating HTML Elements as Controllers", 5 | "homepage": "https://github.github.io/catalyst", 6 | "bugs": { 7 | "url": "https://github.com/github/catalyst/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/github/catalyst.git" 12 | }, 13 | "license": "MIT", 14 | "author": "GitHub Inc.", 15 | "contributors": [ 16 | "Keith Cirkel (https://keithcirkel.co.uk/)", 17 | "Kristján Oddsson " 18 | ], 19 | "type": "module", 20 | "main": "lib/index.js", 21 | "module": "lib/index.js", 22 | "files": [ 23 | "lib" 24 | ], 25 | "scripts": { 26 | "build": "tsc --build tsconfig.build.json", 27 | "build:docs": "cd docs && JEKYLL_ENV=production bundle exec jekyll build", 28 | "clean": "tsc --build --clean tsconfig.build.json", 29 | "lint": "eslint . --ignore-path .gitignore", 30 | "postlint": "tsc", 31 | "prepack": "npm run build", 32 | "presize": "npm run build", 33 | "size": "size-limit", 34 | "test": "web-test-runner" 35 | }, 36 | "prettier": "@github/prettier-config", 37 | "devDependencies": { 38 | "@github/prettier-config": "^0.0.4", 39 | "@lhci/cli": "^0.10.0", 40 | "@open-wc/testing": "^3.1.6", 41 | "@size-limit/preset-small-lib": "^8.0.1", 42 | "@typescript-eslint/eslint-plugin": "^5.36.1", 43 | "@typescript-eslint/parser": "^5.36.1", 44 | "@web/dev-server-esbuild": "^0.3.2", 45 | "@web/test-runner": "^0.14.0", 46 | "eslint": "^8.23.0", 47 | "eslint-plugin-github": "^4.3.7", 48 | "sinon": "^14.0.0", 49 | "size-limit": "^8.0.1", 50 | "tslib": "^2.4.0", 51 | "typescript": "^4.8.2" 52 | }, 53 | "size-limit": [ 54 | { 55 | "path": "lib/index.js", 56 | "import": "{controller, attr, target, targets}", 57 | "limit": "2.5kb" 58 | }, 59 | { 60 | "path": "lib/abilities.js", 61 | "import": "{providable}", 62 | "limit": "1.5kb" 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /test/register.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@open-wc/testing' 2 | import {restore, replace, fake} from 'sinon' 3 | import {register} from '../src/register.js' 4 | 5 | describe('register', () => { 6 | afterEach(() => { 7 | restore() 8 | }) 9 | 10 | it('registers the class as a custom element, normalising the class name', () => { 11 | @register 12 | class MyFirstClass extends HTMLElement {} 13 | expect(window.customElements.get('my-first-class')).to.equal(MyFirstClass) 14 | }) 15 | 16 | it('does not register controllers that already exist', () => { 17 | { 18 | @register 19 | class MySecondClass extends HTMLElement {} 20 | expect(window.customElements.get('my-second-class')).to.equal(MySecondClass) 21 | } 22 | { 23 | @register 24 | class MySecondClass extends HTMLElement {} 25 | expect(window.customElements.get('my-second-class')).to.not.equal(MySecondClass) 26 | } 27 | }) 28 | 29 | it('will redefine controllers, catching on errors', () => { 30 | replace(customElements, 'define', fake()) 31 | replace( 32 | customElements, 33 | 'get', 34 | fake(() => class extends HTMLElement {}) 35 | ) 36 | { 37 | @register 38 | class MyThirdClass extends HTMLElement {} 39 | expect(customElements.define).to.be.calledOnceWithExactly('my-third-class', MyThirdClass) 40 | } 41 | expect(() => { 42 | @register 43 | class MyThirdClass extends HTMLElement {} 44 | expect(customElements.define).to.be.calledOnceWithExactly('my-third-class', MyThirdClass) 45 | }).to.throw(Error) 46 | }) 47 | 48 | it('dasherises class names', () => { 49 | @register 50 | class ThisIsAnExampleOfDasherisedClassNames extends HTMLElement {} 51 | expect(window.customElements.get('this-is-an-example-of-dasherised-class-names')).to.equal( 52 | ThisIsAnExampleOfDasherisedClassNames 53 | ) 54 | }) 55 | 56 | it('will intuitively dasherize acryonyms', () => { 57 | @register 58 | class URLBar extends HTMLElement {} 59 | expect(window.customElements.get('url-bar')).to.equal(URLBar) 60 | }) 61 | 62 | it('dasherizes cap suffixed names correctly', () => { 63 | @register 64 | class ClipX extends HTMLElement {} 65 | expect(window.customElements.get('clip-x')).to.equal(ClipX) 66 | }) 67 | 68 | it('automatically drops the `Element` suffix', () => { 69 | @register 70 | class FirstSuffixElement extends HTMLElement {} 71 | expect(window.customElements.get('first-suffix')).to.equal(FirstSuffixElement) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/mark.ts: -------------------------------------------------------------------------------- 1 | import {getPropertyDescriptor} from './get-property-descriptor.js' 2 | 3 | type PropertyType = 'field' | 'getter' | 'setter' | 'method' 4 | export interface PropertyDecorator { 5 | (proto: object, key: PropertyKey, descriptor?: PropertyDescriptor): void 6 | readonly static: unique symbol 7 | } 8 | type GetMarks = (instance: T) => Set 9 | type InitializeMarks = (instance: T) => void 10 | 11 | type Context = { 12 | kind: PropertyType 13 | name: PropertyKey 14 | access: PropertyDescriptor 15 | } 16 | 17 | const getType = (descriptor?: PropertyDescriptor): PropertyType => { 18 | if (descriptor) { 19 | if (typeof descriptor.value === 'function') return 'method' 20 | if (typeof descriptor.get === 'function') return 'getter' 21 | if (typeof descriptor.set === 'function') return 'setter' 22 | } 23 | return 'field' 24 | } 25 | 26 | export function createMark( 27 | validate: (context: {name: PropertyKey; kind: PropertyType}) => void, 28 | initialize: (instance: T, context: Context) => PropertyDescriptor | void 29 | ): [PropertyDecorator, GetMarks, InitializeMarks] { 30 | const marks = new WeakMap>() 31 | const get = (proto: object): Set => { 32 | if (!marks.has(proto)) { 33 | const parent = Object.getPrototypeOf(proto) 34 | marks.set(proto, new Set(parent ? get(parent) || [] : [])) 35 | } 36 | return marks.get(proto)! 37 | } 38 | const marker = (proto: object, name: PropertyKey, descriptor?: PropertyDescriptor): void => { 39 | if (get(proto).has(name)) return 40 | validate({name, kind: getType(descriptor)}) 41 | get(proto).add(name) 42 | } 43 | marker.static = Symbol() 44 | const getMarks = (instance: T): Set => { 45 | const proto = Object.getPrototypeOf(instance) 46 | for (const key of proto.constructor[marker.static] || []) { 47 | marker(proto, key, Object.getOwnPropertyDescriptor(proto, key)) 48 | } 49 | return new Set(get(proto)) 50 | } 51 | return [ 52 | marker as PropertyDecorator, 53 | getMarks, 54 | (instance: T): void => { 55 | for (const name of getMarks(instance)) { 56 | const access: PropertyDescriptor = getPropertyDescriptor(instance, name) || { 57 | value: void 0, 58 | configurable: true, 59 | writable: true, 60 | enumerable: true 61 | } 62 | const newDescriptor = initialize(instance, {name, kind: getType(access), access}) || access 63 | Object.defineProperty(instance, name, Object.assign({configurable: true, enumerable: true}, newDescriptor)) 64 | } 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/tag-observer.ts: -------------------------------------------------------------------------------- 1 | type Parse = (str: string) => string[] 2 | type Found = (el: Element, controller: Element | ShadowRoot, tag: string, ...parsed: string[]) => void 3 | 4 | function closestShadowPiercing(el: Element, tagName: string): Element | null { 5 | const closest: Element | null = el.closest(tagName) 6 | if (!closest) { 7 | const shadow = el.getRootNode() 8 | if (!(shadow instanceof ShadowRoot)) return null 9 | return shadow.host.closest(tagName) 10 | } 11 | return closest 12 | } 13 | 14 | export const parseElementTags = (el: Element, tag: string, parse: Parse) => 15 | (el.getAttribute(tag) || '') 16 | .trim() 17 | .split(/\s+/g) 18 | .map((tagPart: string) => parse(tagPart)) 19 | 20 | const registry = new Map() 21 | const observer = new MutationObserver((mutations: MutationRecord[]) => { 22 | for (const mutation of mutations) { 23 | if (mutation.type === 'attributes') { 24 | const tag = mutation.attributeName! 25 | const el = mutation.target 26 | 27 | if (el instanceof Element && registry.has(tag)) { 28 | const [parse, found] = registry.get(tag)! 29 | for (const [tagName, ...meta] of parseElementTags(el, tag, parse)) { 30 | const controller = closestShadowPiercing(el, tagName) 31 | if (controller) found(el, controller, tag, ...meta) 32 | } 33 | } 34 | } else if (mutation.addedNodes.length) { 35 | for (const node of mutation.addedNodes) { 36 | if (node instanceof Element) observeElementForTags(node) 37 | } 38 | } 39 | } 40 | }) 41 | 42 | export const registerTag = (tag: string, parse: Parse, found: Found) => { 43 | if (registry.has(tag)) throw new Error('duplicate tag') 44 | registry.set(tag, [parse, found]) 45 | } 46 | 47 | export const observeElementForTags = (root: Element | ShadowRoot) => { 48 | for (const [tag, [parse, found]] of registry) { 49 | for (const el of root.querySelectorAll(`[${tag}]`)) { 50 | for (const [tagName, ...meta] of parseElementTags(el, tag, parse)) { 51 | const controller = closestShadowPiercing(el, tagName) 52 | if (controller) found(el, controller, tag, ...meta) 53 | } 54 | } 55 | if (root instanceof Element && root.hasAttribute(tag)) { 56 | for (const [tagName, ...meta] of parseElementTags(root, tag, parse)) { 57 | const controller = closestShadowPiercing(root, tagName) 58 | if (controller) found(root, controller, tag, ...meta) 59 | } 60 | } 61 | } 62 | observer.observe(root instanceof Element ? root.ownerDocument : root, { 63 | childList: true, 64 | subtree: true, 65 | attributeFilter: Array.from(registry.keys()) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | 6 | {% if page.title %}{{ page.title }} - {% endif %}{{ site.title }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 29 | 48 |
49 | 50 | {{ content }} 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/controllable.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElementClass, CustomElement} from './custom-element.js' 2 | import {createAbility} from './ability.js' 3 | 4 | export interface Controllable { 5 | [attachShadowCallback]?(shadowRoot: ShadowRoot): void 6 | [attachInternalsCallback]?(internals: ElementInternals): void 7 | } 8 | export interface ControllableClass { 9 | // TS mandates Constructors that get mixins have `...args: any[]` 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | new (...args: any[]): Controllable 12 | } 13 | 14 | export const attachShadowCallback = Symbol() 15 | export const attachInternalsCallback = Symbol() 16 | 17 | const shadows = new WeakMap() 18 | const internals = new WeakMap() 19 | const internalsCalled = new WeakSet() 20 | export const controllable = createAbility( 21 | (Class: T): T & ControllableClass => 22 | class extends Class { 23 | // TS mandates Constructors that get mixins have `...args: any[]` 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | constructor(...args: any[]) { 26 | super(...args) 27 | const shadowRoot = this.shadowRoot 28 | if (shadowRoot && shadowRoot !== shadows.get(this)) this[attachShadowCallback](shadowRoot) 29 | if (!internalsCalled.has(this)) { 30 | try { 31 | this.attachInternals() 32 | } catch { 33 | // Ignore errors 34 | } 35 | } 36 | } 37 | 38 | connectedCallback() { 39 | this.setAttribute('data-catalyst', '') 40 | super.connectedCallback?.() 41 | } 42 | 43 | attachShadow(...args: [init: ShadowRootInit]): ShadowRoot { 44 | const shadowRoot = super.attachShadow(...args) 45 | this[attachShadowCallback](shadowRoot) 46 | return shadowRoot 47 | } 48 | 49 | [attachShadowCallback](this: CustomElement & Controllable, shadowRoot: ShadowRoot) { 50 | shadows.set(this, shadowRoot) 51 | } 52 | 53 | attachInternals(): ElementInternals { 54 | if (internals.has(this) && !internalsCalled.has(this)) { 55 | internalsCalled.add(this) 56 | return internals.get(this)! 57 | } 58 | const elementInternals = super.attachInternals() 59 | this[attachInternalsCallback](elementInternals) 60 | internals.set(this, elementInternals) 61 | return elementInternals 62 | } 63 | 64 | [attachInternalsCallback](elementInternals: ElementInternals) { 65 | const shadowRoot = elementInternals.shadowRoot 66 | if (shadowRoot && shadowRoot !== shadows.get(this)) { 67 | this[attachShadowCallback](shadowRoot) 68 | } 69 | } 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /test/auto-shadow-root.ts: -------------------------------------------------------------------------------- 1 | import {expect, fixture, html} from '@open-wc/testing' 2 | import {replace, fake} from 'sinon' 3 | import {autoShadowRoot} from '../src/auto-shadow-root.js' 4 | 5 | describe('autoShadowRoot', () => { 6 | class ShadowRootTestElement extends HTMLElement { 7 | declare shadowRoot: ShadowRoot 8 | } 9 | window.customElements.define('shadowroot-test-element', ShadowRootTestElement) 10 | 11 | let instance: ShadowRootTestElement 12 | beforeEach(async () => { 13 | instance = await fixture(html``) 14 | }) 15 | 16 | it('automatically declares shadowroot for elements with `template[data-shadowroot]` children', async () => { 17 | instance = await fixture(html` 18 | 19 | `) 20 | autoShadowRoot(instance) 21 | 22 | expect(instance).to.have.property('shadowRoot').not.equal(null) 23 | expect(instance.shadowRoot.textContent).to.equal('Hello World') 24 | }) 25 | 26 | it('does not attach shadowroot without a template`data-shadowroot` child', async () => { 27 | instance = await fixture(html` 28 | 29 |
World
30 |
`) 31 | 32 | autoShadowRoot(instance) 33 | 34 | expect(instance).to.have.property('shadowRoot').equal(null) 35 | }) 36 | 37 | it('does not attach shadowroots which are not direct children of the element', async () => { 38 | instance = await fixture(html` 39 |
40 | 41 |
42 |
`) 43 | 44 | autoShadowRoot(instance) 45 | 46 | expect(instance).to.have.property('shadowRoot').equal(null) 47 | }) 48 | 49 | it('attaches shadowRoot nodes open by default', async () => { 50 | instance = await fixture(html` 51 | 52 | `) 53 | 54 | autoShadowRoot(instance) 55 | 56 | expect(instance).to.have.property('shadowRoot').not.equal(null) 57 | expect(instance.shadowRoot.textContent).to.equal('Hello World') 58 | }) 59 | 60 | it('attaches shadowRoot nodes closed if `data-shadowroot` is `closed`', async () => { 61 | instance = await fixture(html` 62 | 63 | `) 64 | let shadowRoot: ShadowRoot | null = null 65 | replace( 66 | instance, 67 | 'attachShadow', 68 | fake((...args) => { 69 | shadowRoot = Element.prototype.attachShadow.apply(instance, args) 70 | return shadowRoot 71 | }) 72 | ) 73 | 74 | autoShadowRoot(instance) 75 | 76 | expect(instance).to.have.property('shadowRoot').equal(null) 77 | expect(instance.attachShadow).to.have.been.calledOnceWith({mode: 'closed'}) 78 | expect(shadowRoot!.textContent).to.equal('Hello World') 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /docs/_guide/conventions.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 9 4 | title: Conventions 5 | subtitle: Common naming and patterns 6 | --- 7 | 8 | Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code: 9 | 10 | ### Use `Element` to suffix your controller class 11 | 12 | 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. 13 | 14 | ```typescript 15 | @controller 16 | class UserListElement extends HTMLElement {} 17 | ``` 18 | 19 | ### The best class-names are two word descriptions 20 | 21 | 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: 22 | 23 | - `theme-picker` (`class ThemePickerElement`) 24 | - `markdown-toolbar` (`class MarkdownToolbarElement`) 25 | - `user-list` (`class UserListElement`) 26 | - `content-pager` (`class ContentPagerElement`) 27 | - `image-gallery` (`class ImageGalleryElement`) 28 | 29 | 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?). 30 | 31 | ### Keep class-names short (but not too short) 32 | 33 | 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. 34 | 35 | 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! 36 | 37 | ### Method names should describe what they do 38 | 39 | 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`. 40 | 41 | ### `@target` should use singular naming, while `@targets` should use plural 42 | 43 | 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`). 44 | -------------------------------------------------------------------------------- /docs/_guide/lazy-elements.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 13 4 | title: Lazy Elements 5 | subtitle: Dynamically load elements just in time 6 | --- 7 | 8 | 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. 9 | 10 | ![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) 11 | 12 | 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. 13 | 14 | ```typescript 15 | import {lazyDefine} from '@github/catalyst' 16 | 17 | // Dynamically import the Catalyst controller when the `` tag is seen. 18 | lazyDefine('user-avatar', () => import('./components/user-avatar')) 19 | ``` 20 | 21 | 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. 22 | 23 | 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. 24 | 25 | 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: 26 | 27 | - `` (default) 28 | - 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. 29 | - `` 30 | - This element is loaded on the first user interaction with the page. This listens for `mousedown`, `touchstart`, `pointerdown` and `keydown` events on `document`. 31 | - `` 32 | - This element is loaded when it's close to being visible. Similar to `` . The functionality is driven by an `IntersectionObserver`. 33 | 34 | 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. 35 | -------------------------------------------------------------------------------- /docs/_guide/abilities.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 4 4 | title: Abilities 5 | subtitle: Abilities 6 | permalink: /guide-v2/abilities 7 | --- 8 | 9 | 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. 10 | 11 | ### Using Abilities 12 | 13 | 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): 14 | 15 | ```typescript 16 | import {actionable} from '@github/catalyst' 17 | 18 | @actionable 19 | class HelloWorld extends HTMLElement { 20 | } 21 | ``` 22 | 23 | ### Using Marks 24 | 25 | 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: 26 | 27 | ```typescript 28 | import {targetable, target, targets} from '@github/catalyst' 29 | 30 | @targetable 31 | class HelloWorldElement extends HTMLElement { 32 | @target name 33 | @targets people 34 | } 35 | ``` 36 | 37 | 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. 38 | 39 | ### Built-In Abilities 40 | 41 | Catalyst ships with a set of built in abilities. The `@controller` decorator applies the following built-in abilities: 42 | 43 | - `controllable` - the base ability which other abilities require for functionality. 44 | - `targetable` - the ability to define `@target` and `@targets` properties. See [Targets]({{ site.baseurl }}/guide/targets) for more. 45 | - `actionable` - the ability to automatically bind events based on `data-action` attributes. See [Actions]({{ site.baseurl }}/guide/actions) for more. 46 | - `attrable` - the ability to define `@attr`s. See [Attrs]({{ site.baseurl }}/guide/attrs) for more. 47 | 48 | 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". 49 | 50 | The following abilities are shipped with Catalyst but require manually applying as they aren't considered critical functionality: 51 | 52 | - `providable` - the ability to define `provider` and `consumer` properties. See [Providable]({{ site.baseurl }}/guide/providable) for more. 53 | 54 | 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! 55 | -------------------------------------------------------------------------------- /docs/_guide/lazy-elements-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 16 4 | title: Lazy Elements 5 | subtitle: Dynamically load elements just in time 6 | permalink: /guide-v2/lazy-elements 7 | --- 8 | 9 | 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. 10 | 11 | ![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) 12 | 13 | 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. 14 | 15 | ```typescript 16 | import {lazyDefine} from '@github/catalyst' 17 | 18 | // Dynamically import the Catalyst controller when the `` tag is seen. 19 | lazyDefine('user-avatar', () => import('./components/user-avatar')) 20 | ``` 21 | 22 | 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. 23 | 24 | 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. 25 | 26 | 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: 27 | 28 | - `` (default) 29 | - 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. 30 | - `` 31 | - This element is loaded on the first user interaction with the page. This listens for `mousedown`, `touchstart`, `pointerdown` and `keydown` events on `document`. 32 | - `` 33 | - This element is loaded when it's close to being visible. Similar to `` . The functionality is driven by an `IntersectionObserver`. 34 | 35 | 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. 36 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | colorator (1.1.0) 7 | commonmarker (0.23.7) 8 | concurrent-ruby (1.1.10) 9 | em-websocket (0.5.3) 10 | eventmachine (>= 0.12.9) 11 | http_parser.rb (~> 0) 12 | eventmachine (1.2.7) 13 | faraday (1.10.0) 14 | faraday-em_http (~> 1.0) 15 | faraday-em_synchrony (~> 1.0) 16 | faraday-excon (~> 1.1) 17 | faraday-httpclient (~> 1.0) 18 | faraday-multipart (~> 1.0) 19 | faraday-net_http (~> 1.0) 20 | faraday-net_http_persistent (~> 1.0) 21 | faraday-patron (~> 1.0) 22 | faraday-rack (~> 1.0) 23 | faraday-retry (~> 1.0) 24 | ruby2_keywords (>= 0.0.4) 25 | faraday-em_http (1.0.0) 26 | faraday-em_synchrony (1.0.0) 27 | faraday-excon (1.1.0) 28 | faraday-httpclient (1.0.1) 29 | faraday-multipart (1.0.3) 30 | multipart-post (>= 1.2, < 3) 31 | faraday-net_http (1.0.1) 32 | faraday-net_http_persistent (1.2.0) 33 | faraday-patron (1.0.0) 34 | faraday-rack (1.0.0) 35 | faraday-retry (1.0.3) 36 | ffi (1.15.5) 37 | forwardable-extended (2.6.0) 38 | http_parser.rb (0.8.0) 39 | i18n (0.9.5) 40 | concurrent-ruby (~> 1.0) 41 | jekyll (3.9.2) 42 | addressable (~> 2.4) 43 | colorator (~> 1.0) 44 | em-websocket (~> 0.5) 45 | i18n (~> 0.7) 46 | jekyll-sass-converter (~> 1.0) 47 | jekyll-watch (~> 2.0) 48 | kramdown (>= 1.17, < 3) 49 | liquid (~> 4.0) 50 | mercenary (~> 0.3.3) 51 | pathutil (~> 0.9) 52 | rouge (>= 1.7, < 4) 53 | safe_yaml (~> 1.0) 54 | jekyll-commonmark (1.4.0) 55 | commonmarker (~> 0.22) 56 | jekyll-commonmark-ghpages (0.2.0) 57 | commonmarker (~> 0.23.4) 58 | jekyll (~> 3.9.0) 59 | jekyll-commonmark (~> 1.4.0) 60 | rouge (>= 2.0, < 4.0) 61 | jekyll-github-metadata (2.14.0) 62 | jekyll (>= 3.4, < 5.0) 63 | octokit (~> 4.0, != 4.4.0) 64 | jekyll-gzip (2.5.1) 65 | jekyll (>= 3.0, < 5.0) 66 | jekyll-sass-converter (1.5.2) 67 | sass (~> 3.4) 68 | jekyll-watch (2.2.1) 69 | listen (~> 3.0) 70 | kramdown (2.4.0) 71 | rexml 72 | liquid (4.0.3) 73 | listen (3.7.1) 74 | rb-fsevent (~> 0.10, >= 0.10.3) 75 | rb-inotify (~> 0.9, >= 0.9.10) 76 | mercenary (0.3.6) 77 | multipart-post (2.1.1) 78 | octokit (4.22.0) 79 | faraday (>= 0.9) 80 | sawyer (~> 0.8.0, >= 0.5.3) 81 | pathutil (0.16.2) 82 | forwardable-extended (~> 2.6) 83 | public_suffix (4.0.7) 84 | rb-fsevent (0.11.1) 85 | rb-inotify (0.10.1) 86 | ffi (~> 1.0) 87 | rexml (3.2.5) 88 | rouge (3.28.0) 89 | ruby2_keywords (0.0.5) 90 | safe_yaml (1.0.5) 91 | sass (3.7.4) 92 | sass-listen (~> 4.0.0) 93 | sass-listen (4.0.0) 94 | rb-fsevent (~> 0.9, >= 0.9.4) 95 | rb-inotify (~> 0.9, >= 0.9.7) 96 | sawyer (0.8.2) 97 | addressable (>= 2.3.5) 98 | faraday (> 0.8, < 2.0) 99 | 100 | PLATFORMS 101 | x86_64-linux 102 | 103 | DEPENDENCIES 104 | jekyll 105 | jekyll-commonmark-ghpages 106 | jekyll-github-metadata 107 | jekyll-gzip 108 | 109 | BUNDLED WITH 110 | 2.3.13 111 | -------------------------------------------------------------------------------- /docs/_guide/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 1 4 | title: Introduction 5 | subtitle: Origins & Concepts 6 | --- 7 | 8 | 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. 9 | 10 | ## How did we get here? 11 | 12 | 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/). 13 | 14 | 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. 15 | 16 | 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. 17 | 18 | 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. 19 | 20 | ## Three core concepts: Observe, Listen, Query 21 | 22 | Catalyst takes these three core concepts and delivers them in the lightest possible way they can be delivered. 23 | 24 | - **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). 25 | 26 | - **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). 27 | 28 | - **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). 29 | -------------------------------------------------------------------------------- /docs/_guide/introduction-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 1 4 | title: Introduction 5 | subtitle: Origins & Concepts 6 | permalink: /guide-v2/introduction 7 | --- 8 | 9 | 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. 10 | 11 | ## How did we get here? 12 | 13 | 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/). 14 | 15 | 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. 16 | 17 | 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. 18 | 19 | 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. 20 | 21 | ## Three core concepts: Observe, Listen, Query 22 | 23 | Catalyst takes these three core concepts and delivers them in the lightest possible way they can be delivered. 24 | 25 | - **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). 26 | 27 | - **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). 28 | 29 | - **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). 30 | -------------------------------------------------------------------------------- /docs/_guide/conventions-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 13 4 | title: Conventions 5 | subtitle: Common naming and patterns 6 | permalink: /guide-v2/conventions 7 | --- 8 | 9 | Catalyst strives for convention over code. Here are a few conventions we recommend when writing Catalyst code: 10 | 11 | ### Suffix your controllers consistently, for symmetry 12 | 13 | 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. 14 | 15 | ```typescript 16 | @controller 17 | class UserListElement extends HTMLElement {} // `` 18 | ``` 19 | 20 | ```typescript 21 | @controller 22 | class UserListComponent extends HTMLElement {} // `` 23 | ``` 24 | 25 | ### The best class-names are two word descriptions 26 | 27 | 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: 28 | 29 | - `theme-picker` (`class ThemePickerElement`) 30 | - `markdown-toolbar` (`class MarkdownToolbarElement`) 31 | - `user-list` (`class UserListElement`) 32 | - `content-pager` (`class ContentPagerElement`) 33 | - `image-gallery` (`class ImageGalleryElement`) 34 | 35 | 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?). 36 | 37 | ### Keep class-names short (but not too short) 38 | 39 | 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. 40 | 41 | 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! 42 | 43 | ### Method names should describe what they do 44 | 45 | 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`. 46 | 47 | ### `@target` should use singular naming, while `@targets` should use plural 48 | 49 | 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`). 50 | 51 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import {register} from './register.js' 2 | import {bind, bindShadow} from './bind.js' 3 | import {autoShadowRoot} from './auto-shadow-root.js' 4 | import {defineObservedAttributes, initializeAttrs} from './attr.js' 5 | import type {CustomElementClass} from './custom-element.js' 6 | import {observe} from './lazy-define.js' 7 | 8 | const symbol = Symbol.for('catalyst') 9 | 10 | export class CatalystDelegate { 11 | constructor(classObject: CustomElementClass) { 12 | // eslint-disable-next-line @typescript-eslint/no-this-alias 13 | const delegate = this 14 | 15 | const connectedCallback = classObject.prototype.connectedCallback 16 | classObject.prototype.connectedCallback = function (this: HTMLElement) { 17 | delegate.connectedCallback(this, connectedCallback) 18 | } 19 | 20 | const disconnectedCallback = classObject.prototype.disconnectedCallback 21 | classObject.prototype.disconnectedCallback = function (this: HTMLElement) { 22 | delegate.disconnectedCallback(this, disconnectedCallback) 23 | } 24 | 25 | const attributeChangedCallback = classObject.prototype.attributeChangedCallback 26 | classObject.prototype.attributeChangedCallback = function ( 27 | this: HTMLElement, 28 | name: string, 29 | oldValue: string | null, 30 | newValue: string | null 31 | ) { 32 | delegate.attributeChangedCallback(this, name, oldValue, newValue, attributeChangedCallback) 33 | } 34 | 35 | let observedAttributes = classObject.observedAttributes || [] 36 | Object.defineProperty(classObject, 'observedAttributes', { 37 | configurable: true, 38 | get() { 39 | return delegate.observedAttributes(this, observedAttributes) 40 | }, 41 | set(attributes: string[]) { 42 | observedAttributes = attributes 43 | } 44 | }) 45 | 46 | defineObservedAttributes(classObject) 47 | register(classObject) 48 | } 49 | 50 | observedAttributes(instance: HTMLElement, observedAttributes: string[]) { 51 | return observedAttributes 52 | } 53 | 54 | connectedCallback(instance: HTMLElement, connectedCallback: () => void) { 55 | instance.toggleAttribute('data-catalyst', true) 56 | customElements.upgrade(instance) 57 | autoShadowRoot(instance) 58 | initializeAttrs(instance) 59 | bind(instance) 60 | connectedCallback?.call(instance) 61 | if (instance.shadowRoot) { 62 | bindShadow(instance.shadowRoot) 63 | observe(instance.shadowRoot) 64 | } 65 | } 66 | 67 | disconnectedCallback(element: HTMLElement, disconnectedCallback: () => void) { 68 | disconnectedCallback?.call(element) 69 | } 70 | 71 | attributeChangedCallback( 72 | instance: HTMLElement, 73 | name: string, 74 | oldValue: string | null, 75 | newValue: string | null, 76 | attributeChangedCallback: (...args: unknown[]) => void 77 | ) { 78 | initializeAttrs(instance) 79 | if (name !== 'data-catalyst' && attributeChangedCallback) { 80 | attributeChangedCallback.call(instance, name, oldValue, newValue) 81 | } 82 | } 83 | } 84 | 85 | export function meta(proto: Record, name: string): Set { 86 | if (!Object.prototype.hasOwnProperty.call(proto, symbol)) { 87 | const parent = proto[symbol] as Map> | undefined 88 | const map = (proto[symbol] = new Map>()) 89 | if (parent) { 90 | for (const [key, value] of parent) { 91 | map.set(key, new Set(value)) 92 | } 93 | } 94 | } 95 | const map = proto[symbol] as Map> 96 | if (!map.has(name)) map.set(name, new Set()) 97 | return map.get(name)! 98 | } 99 | -------------------------------------------------------------------------------- /test/tag-observer.ts: -------------------------------------------------------------------------------- 1 | import {expect, fixture, html} from '@open-wc/testing' 2 | import {fake, match} from 'sinon' 3 | import {registerTag, observeElementForTags} from '../src/tag-observer.js' 4 | 5 | describe('tag observer', () => { 6 | let instance: HTMLElement 7 | beforeEach(async () => { 8 | instance = await fixture(html`
9 |
10 |
`) 11 | }) 12 | 13 | it('can register new tag observers', () => { 14 | registerTag('foo', fake(), fake()) 15 | }) 16 | 17 | it('throws an error when registering a duplicate', () => { 18 | registerTag('duplicate', fake(), fake()) 19 | expect(() => registerTag('duplicate', fake(), fake())).to.throw() 20 | }) 21 | 22 | describe('registered behaviour', () => { 23 | const testParse = fake(v => v.split('.')) 24 | const testFound = fake() 25 | registerTag('data-tagtest', testParse, testFound) 26 | beforeEach(() => { 27 | observeElementForTags(instance) 28 | }) 29 | 30 | it('uses parse to extract tagged element values', () => { 31 | expect(testParse).to.be.calledWithExactly('section.a.b.c') 32 | expect(testParse).to.be.calledWithExactly('section.d.e.f') 33 | expect(testParse).to.be.calledWithExactly('doesntexist.g.h.i') 34 | }) 35 | 36 | it('calls found with el and args based from testParse', () => { 37 | const div = instance.querySelector('div')! 38 | expect(testFound).to.be.calledWithExactly(div, instance, 'data-tagtest', 'a', 'b', 'c') 39 | expect(testFound).to.be.calledWithExactly(div, instance, 'data-tagtest', 'd', 'e', 'f') 40 | expect(testFound).to.not.be.calledWithMatch(match.any, match.any, 'data-tagtest', 'g', 'h', 'i') 41 | }) 42 | 43 | it('calls found if added to a node that has tags on itself', () => { 44 | const div = document.createElement('div') 45 | div.setAttribute('data-tagtest', 'div.j.k.l') 46 | observeElementForTags(div) 47 | expect(testParse).to.be.calledWithExactly('div.j.k.l') 48 | expect(testFound).to.be.calledWithExactly(div, div, 'data-tagtest', 'j', 'k', 'l') 49 | }) 50 | 51 | it('pierces shadowdom boundaries to find nearest controller', () => { 52 | const div = document.createElement('div') 53 | const shadow = div.attachShadow({mode: 'open'}) 54 | const span = document.createElement('span') 55 | span.setAttribute('data-tagtest', 'div.m.n.o') 56 | shadow.append(span) 57 | observeElementForTags(span) 58 | expect(testParse).to.be.calledWithExactly('div.m.n.o') 59 | expect(testFound).to.be.calledWithExactly(span, div, 'data-tagtest', 'm', 'n', 'o') 60 | }) 61 | 62 | it('queries inside shadowdom, and pierces to find nearest controller', () => { 63 | const div = document.createElement('div') 64 | const shadow = div.attachShadow({mode: 'open'}) 65 | const span = document.createElement('span') 66 | span.setAttribute('data-tagtest', 'div.p.q.r') 67 | shadow.append(span) 68 | observeElementForTags(shadow) 69 | expect(testParse).to.be.calledWithExactly('div.p.q.r') 70 | expect(testFound).to.be.calledWithExactly(span, div, 'data-tagtest', 'p', 'q', 'r') 71 | }) 72 | 73 | describe('mutations', () => { 74 | it('calls parse+found on attributes that change', async () => { 75 | instance.setAttribute('data-tagtest', 'section.s.t.u not.v.w.x') 76 | await Promise.resolve() 77 | expect(testParse).to.be.calledWithExactly('section.s.t.u') 78 | expect(testParse).to.be.calledWithExactly('not.v.w.x') 79 | expect(testFound).to.be.calledWithExactly(instance, instance, 'data-tagtest', 's', 't', 'u') 80 | expect(testFound).to.not.be.calledWithMatch(match.any, match.any, 'data-tagtest', 'v', 'w', 'x') 81 | }) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /docs/_guide/lifecycle-hooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 7 4 | title: Lifecycle Hooks 5 | subtitle: Observing the life cycle of an element 6 | --- 7 | 8 | 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. 9 | 10 | ### `connectedCallback()` 11 | 12 | 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. 13 | 14 | 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. 15 | 16 | #### Things to remember 17 | 18 | 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. 19 | 20 | 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. 21 | 22 | ### `attributeChangedCallback()` 23 | 24 | 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: 25 | 26 | ```typescript 27 | attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {} 28 | ``` 29 | 30 | #### Things to remember 31 | 32 | 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. 33 | 34 | ### `disconnectedCallback()` 35 | 36 | 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. 37 | 38 | ### `adoptedCallback()` 39 | 40 | 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. 41 | 42 | [ce-callbacks]: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks 43 | -------------------------------------------------------------------------------- /docs/_guide/lifecycle-hooks-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 10 4 | title: Lifecycle Hooks 5 | subtitle: Observing the life cycle of an element 6 | permalink: /guide-v2/lifecycle-hooks 7 | --- 8 | 9 | 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. 10 | 11 | ### `connectedCallback()` 12 | 13 | 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. 14 | 15 | 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. 16 | 17 | #### Things to remember 18 | 19 | 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. 20 | 21 | 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. 22 | 23 | ### `attributeChangedCallback()` 24 | 25 | 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: 26 | 27 | ```typescript 28 | attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {} 29 | ``` 30 | 31 | #### Things to remember 32 | 33 | 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. 34 | 35 | ### `disconnectedCallback()` 36 | 37 | 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. 38 | 39 | ### `adoptedCallback()` 40 | 41 | 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. 42 | 43 | [ce-callbacks]: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks 44 | -------------------------------------------------------------------------------- /docs/_guide/your-first-component-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 2 4 | title: Your First Component 5 | subtitle: Building an HTMLElement 6 | permalink: /guide-v2/your-first-component 7 | --- 8 | 9 | ### Catalyst's `@controller` decorator 10 | 11 | Catalyst's `@controller` decorator lets you create Custom Elements with virtually no boilerplate, by automatically calling `customElements.register`, and by adding ["Actions"]({{ site.baseurl }}/guide/actions) and ["Targets"]({{ site.baseurl }}/guide/targets) features described later. Using TypeScript (with `decorators` support enabled), simply add `@controller` to the top of your class: 12 | 13 | 18 | 19 | ```js 20 | import {controller} from '@github/catalyst' 21 | 22 | @controller 23 | class HelloWorldElement extends HTMLElement { 24 | connectedCallback() { 25 | this.innerHTML = 'Hello World!' 26 | } 27 | } 28 | ``` 29 |
30 | 31 | Catalyst will automatically convert the classes name so the HTML tag will be ``. It removes the trailing `Element` suffix and lowercases all capital letters, separating them with a dash. 32 | 33 | Catalyst controllers can end in `Element`, `Controller`, or `Component` and Catalyst will remove this suffix when generating a tag name. Adding one of these suffixes is _not_ required - just convention. All examples in this guide use `Element` suffixed names (see our [convention note on this for more]({{ site.baseurl }}/guide/conventions#suffix-your-controllers-consistently-for-symmetry)). 34 | 35 | {% capture callout %} 36 | Remember! A class name _must_ include at least two CamelCased words (not including the `Element`, `Controller` or `Component` suffix). One-word elements will raise exceptions. Example of good names: `UserListElement`, `SubTaskController`, `PagerContainerComponent` 37 | {% endcapture %}{% include callout.md %} 38 | 39 | 40 | ### What does `@controller` do? 41 | 42 | The `@controller` decorator ties together the various other decorators within Catalyst, plus a few extra conveniences such as automatically registering the element, which saves you writing some boilerplate that you'd otherwise have to write by hand. Specifically the `@controller` decorator: 43 | 44 | - Derives a tag name based on your class name, removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash. 45 | - Calls `window.customElements.define` with the newly derived tag name and your class. 46 | - Loads the `attrable` decorator, which provides the ability to define `@attr` decorators. See [attrs]({{ site.baseurl }}/guide/attrs) for more on this. 47 | - Loads the `actionable` decorator, which provides the ability to bind actions. See [actions]({{ site.baseurl }}/guide/actions) for more on this. 48 | - Loads the `targetable` decorator, which provides Target querying. See [targets]({{ site.baseurl }}/guide/targets) for more on this. 49 | 50 | You can do all of this manually; for example here's the above `HelloWorldElement`, written without the `@controller` annotation: 51 | 52 | ```js 53 | import {attrable, targetable, actionable} from '@github/catalyst' 54 | 55 | @register 56 | @actionable 57 | @attrable 58 | @targetable 59 | class HelloWorldElement extends HTMLElement { 60 | } 61 | ``` 62 | 63 | The `@controller` decorator saves on having to write this boilerplate for each element. 64 | 65 | ### What about without TypeScript Decorators? 66 | 67 | If you don't want to use TypeScript decorators, you can use `controller` as a regular function by passing it to your class: 68 | 69 | ```js 70 | import {controller} from '@github/catalyst' 71 | 72 | controller( 73 | class HelloWorldElement extends HTMLElement { 74 | //... 75 | } 76 | ) 77 | ``` 78 |
79 | 80 | -------------------------------------------------------------------------------- /test/ability.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElement} from '../src/custom-element.js' 2 | import {expect, fixture, html} from '@open-wc/testing' 3 | import {restore} from 'sinon' 4 | import {createAbility} from '../src/ability.js' 5 | 6 | describe('ability', () => { 7 | const calls: string[] = [] 8 | const fakeable = createAbility( 9 | Class => 10 | class extends Class { 11 | foo() { 12 | return 'foo!' 13 | } 14 | connectedCallback() { 15 | calls.push('fakeable connectedCallback') 16 | super.connectedCallback?.() 17 | } 18 | disconnectedCallback() { 19 | calls.push('fakeable disconnectedCallback') 20 | super.disconnectedCallback?.() 21 | } 22 | adoptedCallback() { 23 | calls.push('fakeable adoptedCallback') 24 | super.adoptedCallback?.() 25 | } 26 | attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { 27 | calls.push('fakeable attributeChangedCallback') 28 | super.attributeChangedCallback?.(name, oldValue, newValue) 29 | } 30 | } 31 | ) 32 | const otherfakeable = createAbility( 33 | Class => 34 | class extends Class { 35 | bar() { 36 | return 'bar!' 37 | } 38 | connectedCallback() { 39 | calls.push('otherfakeable connectedCallback') 40 | super.connectedCallback?.() 41 | } 42 | disconnectedCallback() { 43 | calls.push('otherfakeable disconnectedCallback') 44 | super.disconnectedCallback?.() 45 | } 46 | adoptedCallback() { 47 | calls.push('otherfakeable adoptedCallback') 48 | super.adoptedCallback?.() 49 | } 50 | attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { 51 | calls.push('otherfakeable attributeChangedCallback') 52 | super.attributeChangedCallback?.(name, oldValue, newValue) 53 | } 54 | } 55 | ) 56 | class Element extends HTMLElement { 57 | connectedCallback() {} 58 | disconnectedCallback() {} 59 | adoptedCallback() {} 60 | attributeChangedCallback() {} 61 | } 62 | 63 | afterEach(() => restore()) 64 | 65 | it('creates a function, which creates a subclass of the given class', async () => { 66 | const DElement = fakeable(Element) 67 | expect(DElement).to.have.property('prototype').instanceof(Element) 68 | }) 69 | 70 | it('retains original class name', () => { 71 | const DElement = fakeable(Element) 72 | const D2Element = otherfakeable(Element) 73 | expect(DElement).to.have.property('name', 'Element') 74 | expect(D2Element).to.have.property('name', 'Element') 75 | }) 76 | 77 | it('can be used in decorator position', async () => { 78 | @fakeable 79 | class DElement extends HTMLElement {} 80 | 81 | expect(DElement).to.have.property('prototype').instanceof(HTMLElement) 82 | }) 83 | 84 | it('can be chained with multiple abilities', async () => { 85 | const DElement = fakeable(Element) 86 | expect(Element).to.not.equal(DElement) 87 | const D2Element = otherfakeable(DElement) 88 | expect(DElement).to.not.equal(D2Element) 89 | expect(DElement).to.have.property('prototype').be.instanceof(Element) 90 | expect(D2Element).to.have.property('prototype').be.instanceof(Element) 91 | }) 92 | 93 | it('can be called multiple times, but only applies once', async () => { 94 | const MultipleFakeable = fakeable(fakeable(fakeable(fakeable(fakeable(Element))))) 95 | customElements.define('multiple-fakeable', MultipleFakeable) 96 | const instance: CustomElement = await fixture(html``) 97 | expect(calls).to.eql(['fakeable connectedCallback']) 98 | instance.connectedCallback!() 99 | expect(calls).to.eql(['fakeable connectedCallback', 'fakeable connectedCallback']) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /docs/custom.css: -------------------------------------------------------------------------------- 1 | pre { 2 | font-size: 100% !important; 3 | } 4 | code { 5 | font-size: 90% !important; 6 | } 7 | 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5 { 13 | margin: 1rem 0; 14 | width: 100%; 15 | } 16 | ul li { 17 | margin-left: 1rem; 18 | } 19 | 20 | .top-100px { 21 | top: 100px; 22 | } 23 | 24 | /* No preference or prefers light */ 25 | :root:not([data-prefers-color-scheme='dark']), 26 | html[data-prefers-color-scheme='light'] { 27 | --color-bg-canvas: rgb(255, 255, 255); 28 | --color-bg-canvas-shadow: rgba(255, 255, 255, 0); 29 | --color-bg-canvas-tertiary: #f6f8fa; 30 | --color-text-primary: #24292e; 31 | --color-markdown-code-bg: rgba(27, 31, 35, 0.05); 32 | --color-accent-fg: #0969da; 33 | } 34 | /* Prefers dark */ 35 | html[data-prefers-color-scheme='dark'] { 36 | --color-bg-canvas: rgb(13, 17, 23); 37 | --color-bg-canvas-shadow: rgba(13, 17, 23, 0); 38 | --color-bg-canvas-tertiary: #161b22; 39 | --color-text-primary: #c9d1d9; 40 | --color-markdown-code-bg: rgba(240, 246, 252, 0.05); 41 | --color-accent-fg: #58a6ff; 42 | } 43 | @media (prefers-color-scheme: dark) { 44 | :root:not([data-prefers-color-scheme='light']) { 45 | --color-bg-canvas: rgb(13, 17, 23); 46 | --color-bg-canvas-shadow: rgba(13, 17, 23, 0); 47 | --color-bg-canvas-tertiary: #161b22; 48 | --color-text-primary: #c9d1d9; 49 | --color-markdown-code-bg: rgba(240, 246, 252, 0.05); 50 | --color-accent-fg: #58a6ff; 51 | } 52 | } 53 | 54 | body { 55 | background-color: var(--color-bg-canvas); 56 | color: var(--color-text-primary); 57 | } 58 | 59 | a { 60 | color: var(--color-accent-fg); 61 | } 62 | 63 | .min-height-full { 64 | min-height: 100vh 65 | } 66 | 67 | .main-header { 68 | height: 5rem; 69 | z-index: 1; 70 | background-image: linear-gradient(to top, var(--color-bg-canvas-shadow), var(--color-bg-canvas) 25%); 71 | } 72 | 73 | :root { 74 | --logo-width: 18rem; 75 | } 76 | 77 | .logo { 78 | min-width: var(--logo-width); 79 | } 80 | 81 | .sidebar { 82 | height: calc(100vh - 5rem); 83 | top: 5rem; 84 | min-width: var(--logo-width); 85 | } 86 | 87 | @media only screen and (max-width : 768px) { 88 | .main-header { 89 | height: auto; 90 | background: none; 91 | } 92 | 93 | .sidebar { 94 | height: auto; 95 | } 96 | 97 | .logo.bg-gray { 98 | background-color: inherit !important; 99 | min-width: 9rem; 100 | } 101 | } 102 | 103 | 104 | /* Sidebar */ 105 | /* NB: `!important` is already used; so it’s required here */ 106 | .bg-gray { 107 | background-color: var(--color-bg-canvas-tertiary) !important; 108 | } 109 | 110 | /* Code Blocks & Syntax */ 111 | .markdown-body .highlight pre, 112 | .markdown-body pre { 113 | background-color: var(--color-bg-canvas-tertiary); 114 | overflow: auto; 115 | } 116 | 117 | /* Inline Code */ 118 | .markdown-body code, 119 | .markdown-body tt { 120 | background-color: var(--color-markdown-code-bg); 121 | } 122 | 123 | /* Tables */ 124 | .markdown-body table tr:nth-of-type(odd) th, 125 | .markdown-body table tr:nth-of-type(odd) td { 126 | background-color: var(--color-bg-canvas); 127 | } 128 | .markdown-body table tr:nth-of-type(even) th, 129 | .markdown-body table tr:nth-of-type(even) td { 130 | background-color: var(--color-bg-canvas-tertiary); 131 | } 132 | 133 | /* Override Primer .tooltipped default aria-label */ 134 | .code-tooltip:after { 135 | content: attr(data-title); 136 | text-align: left; 137 | font-size: 100%; 138 | } 139 | /* :after text will be announced by AT, this adds a separation between textContent and :after text */ 140 | .code-tooltip:before { 141 | content: ': '; 142 | font-size: 0; 143 | } 144 | .code-tooltip { 145 | scroll-margin-top: 150px; 146 | } 147 | 148 | 149 | /* Prev and next links */ 150 | .prev-next-links { 151 | display: flex; 152 | gap: 16px; 153 | } 154 | 155 | .prev-next-links__button { 156 | border: solid 1px; 157 | padding: 16px; 158 | border-radius: 4px; 159 | flex: 1; 160 | } 161 | -------------------------------------------------------------------------------- /src/bind.ts: -------------------------------------------------------------------------------- 1 | const controllers = new WeakSet() 2 | 3 | /* 4 | * Bind `[data-action]` elements from the DOM to their actions. 5 | * 6 | */ 7 | export function bind(controller: HTMLElement): void { 8 | controllers.add(controller) 9 | if (controller.shadowRoot) bindShadow(controller.shadowRoot) 10 | bindElements(controller) 11 | listenForBind(controller.ownerDocument) 12 | } 13 | 14 | export function bindShadow(root: ShadowRoot): void { 15 | bindElements(root) 16 | listenForBind(root) 17 | } 18 | 19 | const observers = new WeakMap() 20 | /** 21 | * Set up observer that will make sure any actions that are dynamically 22 | * injected into `el` will be bound to it's controller. 23 | * 24 | * This returns a Subscription object which you can call `unsubscribe()` on to 25 | * stop further live updates. 26 | */ 27 | export function listenForBind(el: Node = document): Subscription { 28 | if (observers.has(el)) return observers.get(el)! 29 | let closed = false 30 | const observer = new MutationObserver(mutations => { 31 | for (const mutation of mutations) { 32 | if (mutation.type === 'attributes' && mutation.target instanceof Element) { 33 | bindActions(mutation.target) 34 | } else if (mutation.type === 'childList' && mutation.addedNodes.length) { 35 | for (const node of mutation.addedNodes) { 36 | if (node instanceof Element) { 37 | bindElements(node) 38 | } 39 | } 40 | } 41 | } 42 | }) 43 | observer.observe(el, {childList: true, subtree: true, attributeFilter: ['data-action']}) 44 | const subscription = { 45 | get closed() { 46 | return closed 47 | }, 48 | unsubscribe() { 49 | closed = true 50 | observers.delete(el) 51 | observer.disconnect() 52 | } 53 | } 54 | observers.set(el, subscription) 55 | return subscription 56 | } 57 | 58 | interface Subscription { 59 | closed: boolean 60 | unsubscribe(): void 61 | } 62 | 63 | function bindElements(root: Element | ShadowRoot) { 64 | for (const el of root.querySelectorAll('[data-action]')) { 65 | bindActions(el) 66 | } 67 | // Also bind the controller to itself 68 | if (root instanceof Element && root.hasAttribute('data-action')) { 69 | bindActions(root) 70 | } 71 | } 72 | 73 | // Bind a single function to all events to avoid anonymous closure performance penalty. 74 | function handleEvent(event: Event) { 75 | const el = event.currentTarget as Element 76 | for (const binding of bindings(el)) { 77 | if (event.type === binding.type) { 78 | type EventDispatcher = HTMLElement & Record unknown> 79 | const controller = el.closest(binding.tag)! 80 | if (controllers.has(controller) && typeof controller[binding.method] === 'function') { 81 | controller[binding.method](event) 82 | } 83 | const root = el.getRootNode() 84 | if (root instanceof ShadowRoot && controllers.has(root.host) && root.host.matches(binding.tag)) { 85 | const shadowController = root.host as EventDispatcher 86 | if (typeof shadowController[binding.method] === 'function') { 87 | shadowController[binding.method](event) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | type Binding = {type: string; tag: string; method: string} 95 | function* bindings(el: Element): Iterable { 96 | for (const action of (el.getAttribute('data-action') || '').trim().split(/\s+/)) { 97 | const eventSep = action.lastIndexOf(':') 98 | const methodSep = Math.max(0, action.lastIndexOf('#')) || action.length 99 | yield { 100 | type: action.slice(0, eventSep), 101 | tag: action.slice(eventSep + 1, methodSep), 102 | method: action.slice(methodSep + 1) || 'handleEvent' 103 | } || 'handleEvent' 104 | } 105 | } 106 | 107 | function bindActions(el: Element) { 108 | for (const binding of bindings(el)) { 109 | el.addEventListener(binding.type, handleEvent) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /docs/index.js: -------------------------------------------------------------------------------- 1 | function storeColorSchemePreference() { 2 | // Get color scheme preference from URL 3 | const url = new URL(window.location.href, window.location.origin) 4 | const params = new URLSearchParams(url.search) 5 | 6 | // Return early if there’s nothing to store 7 | if (!params.has('prefers-color-scheme')) { 8 | return 9 | } 10 | 11 | const param = params.get('prefers-color-scheme').toLowerCase() 12 | if (['light', 'dark'].includes(param)) { 13 | // Store preference in Local Storage 14 | window.localStorage.setItem('prefers-color-scheme', param) 15 | } else { 16 | // Clear preference in Local Storage 17 | window.localStorage.clear('prefers-color-scheme') 18 | } 19 | 20 | // Remove color scheme preference from URL 21 | params.delete('prefers-color-scheme') 22 | url.search = params.toString() 23 | history.replaceState(null, '', url) 24 | } 25 | 26 | function applyColorSchemePreference() { 27 | // Get color scheme preference from Local Storage 28 | const preference = window.localStorage.getItem('prefers-color-scheme') 29 | 30 | // Return early if no preference exists 31 | if (!preference) { 32 | return 33 | } 34 | 35 | // Write preference to attribute 36 | document.body.parentElement.setAttribute('data-prefers-color-scheme', preference) 37 | } 38 | 39 | storeColorSchemePreference() 40 | applyColorSchemePreference() 41 | 42 | function addAnnotations() { 43 | for (const codeBlock of document.querySelectorAll('.highlighter-rouge')) { 44 | const comment = parseCommentNode(codeBlock) 45 | if (comment.annotations) annotate(codeBlock, comment.annotations) 46 | } 47 | } 48 | 49 | function parseCommentNode(el) { 50 | const stopAtEl = el.previousElementSibling 51 | let t = el.previousSibling 52 | if (!stopAtEl && !t) return 53 | let comment 54 | while (t && t !== stopAtEl) { 55 | if (t.nodeType === 8) { 56 | comment = t 57 | break 58 | } else { 59 | t = t.previousSibling 60 | } 61 | } 62 | 63 | if (!comment) return {} 64 | 65 | const [type, ...details] = comment.textContent.trim().split('\n') 66 | 67 | return { 68 | noDemo: type.match(/no_demo/), 69 | onlyDemo: type.match(/only_demo/), 70 | annotations: type.match(/annotations/) && details 71 | } 72 | } 73 | 74 | let matchIndex = 0 75 | function annotate(codeBlock, items) { 76 | const noMatch = new Set(items) 77 | const annotated = new WeakMap() 78 | for (const el of codeBlock.querySelectorAll('code > span')) { 79 | for (const item of items) { 80 | let currentNode = el 81 | const [pattern, rest] = item.split(/: /) 82 | const [title, link] = (rest || '').split(/ \| /) 83 | const parts = pattern.split(' ') 84 | let toAnnotate = [] 85 | for (const part of parts) { 86 | if (currentNode && currentNode.textContent.match(part)) { 87 | toAnnotate.push(currentNode) 88 | currentNode = currentNode.nextElementSibling 89 | } else { 90 | toAnnotate = [] 91 | break 92 | } 93 | } 94 | for (const node of toAnnotate) { 95 | noMatch.delete(item) 96 | if (title) { 97 | if (annotated.get(node)) { 98 | continue 99 | } 100 | annotated.set(node, title) 101 | const a = document.createElement('a') 102 | a.className = `${node.className} code-tooltip tooltipped tooltipped-multiline tooltipped-se bg-gray text-underline` 103 | a.id = `match-${matchIndex++}` 104 | a.href = link || `#${a.id}` 105 | a.setAttribute('data-title', title) 106 | a.textContent = node.textContent 107 | node.replaceWith(a) 108 | } else { 109 | node.classList.add('bg-gray') 110 | } 111 | } 112 | } 113 | } 114 | for (const pattern of noMatch) { 115 | // eslint-disable-next-line no-console 116 | console.error(`Code annotations: No match found for "${pattern}"`) 117 | } 118 | } 119 | 120 | addAnnotations() 121 | -------------------------------------------------------------------------------- /src/attr.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElementClass} from './custom-element.js' 2 | import {mustDasherize} from './dasherize.js' 3 | import {meta} from './core.js' 4 | 5 | const attrKey = 'attr' 6 | type attrValue = string | number | boolean 7 | 8 | /** 9 | * Attr is a decorator which tags a property as one to be initialized via 10 | * `initializeAttrs`. 11 | * 12 | * The signature is typed such that the property must be one of a String, 13 | * Number or Boolean. This matches the behavior of `initializeAttrs`. 14 | */ 15 | export function attr(proto: Record, key: K): void { 16 | meta(proto, attrKey).add(key) 17 | } 18 | 19 | /** 20 | * initializeAttrs is called with a set of class property names (if omitted, it 21 | * will look for any properties tagged with the `@attr` decorator). With this 22 | * list it defines property descriptors for each property that map to `data-*` 23 | * attributes on the HTMLElement instance. 24 | * 25 | * It works around Native Class Property semantics - which are equivalent to 26 | * calling `Object.defineProperty` on the instance upon creation, but before 27 | * `constructor()` is called. 28 | * 29 | * If a class property is assigned to the class body, it will infer the type 30 | * (using `typeof`) and define an appropriate getter/setter combo that aligns 31 | * to that type. This means class properties assigned to Numbers can only ever 32 | * be Numbers, assigned to Booleans can only ever be Booleans, and assigned to 33 | * Strings can only ever be Strings. 34 | * 35 | * This is automatically called as part of `@controller`. If a class uses the 36 | * `@controller` decorator it should not call this manually. 37 | */ 38 | const initialized = new WeakSet() 39 | export function initializeAttrs(instance: HTMLElement, names?: Iterable): void { 40 | if (initialized.has(instance)) return 41 | initialized.add(instance) 42 | const proto = Object.getPrototypeOf(instance) 43 | const prefix = proto?.constructor?.attrPrefix ?? 'data-' 44 | if (!names) names = meta(proto, attrKey) 45 | for (const key of names) { 46 | const value = (>(instance))[key] 47 | const name = mustDasherize(`${prefix}${key}`) 48 | let descriptor: PropertyDescriptor = { 49 | configurable: true, 50 | get(this: HTMLElement): string { 51 | return this.getAttribute(name) || '' 52 | }, 53 | set(this: HTMLElement, newValue: string) { 54 | this.setAttribute(name, newValue || '') 55 | } 56 | } 57 | if (typeof value === 'number') { 58 | descriptor = { 59 | configurable: true, 60 | get(this: HTMLElement): number { 61 | return Number(this.getAttribute(name) || 0) 62 | }, 63 | set(this: HTMLElement, newValue: string) { 64 | this.setAttribute(name, newValue) 65 | } 66 | } 67 | } else if (typeof value === 'boolean') { 68 | descriptor = { 69 | configurable: true, 70 | get(this: HTMLElement): boolean { 71 | return this.hasAttribute(name) 72 | }, 73 | set(this: HTMLElement, newValue: boolean) { 74 | this.toggleAttribute(name, newValue) 75 | } 76 | } 77 | } 78 | Object.defineProperty(instance, key, descriptor) 79 | if (key in instance && !instance.hasAttribute(name)) { 80 | descriptor.set!.call(instance, value) 81 | } 82 | } 83 | } 84 | 85 | export function defineObservedAttributes(classObject: CustomElementClass): void { 86 | let observed = classObject.observedAttributes || [] 87 | 88 | const prefix = classObject.attrPrefix ?? 'data-' 89 | const attrToAttributeName = (name: string) => mustDasherize(`${prefix}${name}`) 90 | 91 | Object.defineProperty(classObject, 'observedAttributes', { 92 | configurable: true, 93 | get() { 94 | return [...meta(classObject.prototype, attrKey)].map(attrToAttributeName).concat(observed) 95 | }, 96 | set(attributes: string[]) { 97 | observed = attributes 98 | } 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /docs/_guide/your-first-component.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 2 4 | title: Your First Component 5 | subtitle: Building an HTMLElement 6 | --- 7 | 8 | ### Catalyst's `@controller` decorator 9 | 10 | Catalyst's `@controller` decorator lets you create Custom Elements with virtually no boilerplate, by automatically calling `customElements.register`, and by adding ["Actions"]({{ site.baseurl }}/guide/actions) and ["Targets"]({{ site.baseurl }}/guide/targets) features described later. Using TypeScript (with `decorators` support enabled), simply add `@controller` to the top of your class: 11 | 12 | 17 | 18 | ```js 19 | import {controller} from '@github/catalyst' 20 | 21 | @controller 22 | class HelloWorldElement extends HTMLElement { 23 | connectedCallback() { 24 | this.innerHTML = 'Hello World!' 25 | } 26 | } 27 | ``` 28 |
29 | 30 | Catalyst will automatically convert the classes name; removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash. 31 | 32 | By convention Catalyst controllers end in `Element`; Catalyst will omit this when generating a tag name. The `Element` suffix is _not_ required - just convention. All examples in this guide use `Element` suffixed names. 33 | 34 | {% capture callout %} 35 | Remember! A class name _must_ include at least two CamelCased words (not including the `Element` suffix). One-word elements will raise exceptions. Example of good names: `UserListElement`, `SubTaskElement`, `PagerContainerElement` 36 | {% endcapture %}{% include callout.md %} 37 | 38 | 39 | ### What does `@controller` do? 40 | 41 | The `@controller` decorator ties together the various other decorators within Catalyst, plus a few extra conveniences such as automatically registering the element, which saves you writing some boilerplate that you'd otherwise have to write by hand. Specifically the `@controller` decorator: 42 | 43 | - Derives a tag name based on your class name, removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash. 44 | - Calls `window.customElements.define` with the newly derived tag name and your class. 45 | - Calls `defineObservedAttributes` with the class to add map any `@attr` decorators. See [attrs]({{ site.baseurl }}/guide/attrs) for more on this. 46 | - Injects the following code inside of the `connectedCallback()` function of your class: 47 | - `bind(this)`; ensures that as your element connects it picks up any `data-action` handlers. See [actions]({{ site.baseurl }}/guide/actions) for more on this. 48 | - `autoShadowRoot(this)`; ensures that your element loads any `data-shadowroot` templates. See [rendering]({{ site.baseurl }}/guide/rendering) for more on this. 49 | - `initializeAttrs(this)`; ensures that your element binds any `data-*` attributes to props. See [attrs]({{ site.baseurl }}/guide/attrs) for more on this. 50 | 51 | You can do all of this manually; for example here's the above `HelloWorldElement`, written without the `@controller` annotation: 52 | 53 | ```js 54 | import {bind, autoShadowRoot, initializeAttrs, defineObservedAttributes} from '@github/catalyst' 55 | class HelloWorldElement extends HTMLElement { 56 | connectedCallback() { 57 | autoShadowRoot(this) 58 | initializeAttrs(this) 59 | this.innerHTML = 'Hello World!' 60 | bind(this) 61 | } 62 | } 63 | defineObservedAttributes(HelloWorldElement) 64 | window.customElements.define('hello-world', HelloWorldElement) 65 | ``` 66 | 67 | The `@controller` decorator saves on having to write this boilerplate for each element. 68 | 69 | ### What about without TypeScript Decorators? 70 | 71 | If you don't want to use TypeScript decorators, you can use `controller` as a regular function by passing it to your class: 72 | 73 | ```js 74 | import {controller} from '@github/catalyst' 75 | 76 | controller( 77 | class HelloWorldElement extends HTMLElement { 78 | //... 79 | } 80 | ) 81 | ``` 82 |
83 | -------------------------------------------------------------------------------- /docs/_guide/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 14 4 | title: Testing 5 | subtitle: Tips for automated testing 6 | --- 7 | 8 | Catalyst controllers are based on Web Components, and as such need the Web Platform environment to run in, including in tests. It's possible to run these tests in "browser like" environments such as NodeJS or Deno with libraries like jsdom, but it's best to run tests directly in the browser. 9 | 10 | ### Recommended Libraries 11 | 12 | We recommend using [`@web/test-runner`](https://modern-web.dev/docs/test-runner/overview/), which provides the `web-test-runner` command line tool that can run [mocha](https://mochajs.org/) test files in a headless Chromium instance. We also recommend using [`@open-wc/testing`](https://open-wc.org/docs/testing/testing-package/) which provides a set of testing functions, including `expect` from [Chai](https://www.chaijs.com/api/bdd/). If you're using TypeScript, it may be worth also installing [`@web/dev-server-esbuild`](https://modern-web.dev/docs/dev-server/overview/) which can transpile TypeScript to JavaScript, allowing the use of TypeScript within test files themselves. 13 | 14 | With these installed and configured your `package.json` might look something like: 15 | 16 | ```json 17 | { 18 | "name": "my-catalyst-component", 19 | "scripts": { 20 | "test": "web-test-server" 21 | }, 22 | "devDependencies": { 23 | "@web/dev-server-esbuild": "^0.3.0", 24 | "@web/test-runner": "^0.13.27", 25 | "@open-wc/testing": "^3.1.2" 26 | } 27 | } 28 | ``` 29 | 30 | You can configure the `web-test-server` by writing a `web-test-runner.config.js` file, which sets up the esbuild plugin to transpile TypeScript, and configure the directory containing your test files: 31 | 32 | ```typescript 33 | import {esbuildPlugin} from '@web/dev-server-esbuild' 34 | 35 | export default { 36 | files: ['test/*'], 37 | nodeResolve: true, 38 | plugins: [esbuildPlugin({ts: true})] 39 | } 40 | ``` 41 | 42 | #### Example Test File 43 | 44 | With this set-up, the boilerplate for an Element test suite might look something like this: 45 | 46 | ```typescript 47 | // test/my-controller.ts 48 | import {expect, fixture, html} from '@open-wc/testing' 49 | import {MyController} from '../src/my-controller' 50 | 51 | describe('MyController', () => { 52 | let instance 53 | beforeEach(async () => { 54 | instance = await fixture(html` 55 |
56 |
`) 57 | }) 58 | 59 | it('is a Catalyst controller', () => { 60 | expect(instance).to.have.attribute('data-catalyst') 61 | }) 62 | 63 | it('matches snapshot', () => { 64 | expect(instance).dom.to.equalSnapshot() 65 | }) 66 | 67 | it('passes Axe tests', () => 68 | expect(instance).to.be.accessible() 69 | }) 70 | 71 | it('...') // Fill out the rest 72 | }) 73 | ``` 74 | 75 | ##### Useful Assertions 76 | 77 | The `@open-wc/testing` package exports the `expect` function from Chai, but also automatically registers a set of plugins useful for writing web components, including [chai-a11y-axe](https://www.npmjs.com/package/chai-a11y-axe) and [chai-dom](https://www.npmjs.com/package/chai-dom). Here are some handy example assertions which may be commonly written: 78 | 79 | 80 | - `expect(instance).to.be.accessible()` - Runs a suite of [Axe](https://www.npmjs.com/package/axe) accessibility tests on the element. 81 | - `expect(instance).dom.to.equalSnapshot()` - Stores a snaphsot test of the existing DOM, which can be tested against later, for regressions. 82 | - `expect(instance).shadowDom.to.equalSnapshot()` - Stores a snaphsot test of the existing ShadowDOM, which can be tested against later, for regressions. 83 | - `expect(instance).to.have.class('foo')` - Checks the element has the `foo` class (like `el.classList.contains('foo')`). 84 | - `expect(instance).to.have.attribute('foo')` - Checks the element has the `foo` attribute (like `el.hasAttribute('foo')`). 85 | - `expect(instance).to.have.attribute('foo')` - Checks the element has the `foo` attribute (like `el.hasAttribute('foo')`). 86 | - `expect(instance).to.have.descendants('.foo')` - Checks the element has elements matching the selector `.foo` attribute (like `el.querySelectorAll('foo')`). 87 | 88 | -------------------------------------------------------------------------------- /docs/_guide/testing-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | chapter: 13 4 | title: Testing 5 | subtitle: Tips for automated testing 6 | permalink: /guide-v2/testing 7 | --- 8 | 9 | Catalyst controllers are based on Web Components, and as such need the Web Platform environment to run in, including in tests. It's possible to run these tests in "browser like" environments such as NodeJS or Deno with libraries like jsdom, but it's best to run tests directly in the browser. 10 | 11 | ### Recommended Libraries 12 | 13 | We recommend using [`@web/test-runner`](https://modern-web.dev/docs/test-runner/overview/), which provides the `web-test-runner` command line tool that can run [mocha](https://mochajs.org/) test files in a headless Chromium instance. We also recommend using [`@open-wc/testing`](https://open-wc.org/docs/testing/testing-package/) which provides a set of testing functions, including `expect` from [Chai](https://www.chaijs.com/api/bdd/). If you're using TypeScript, it may be worth also installing [`@web/dev-server-esbuild`](https://modern-web.dev/docs/dev-server/overview/) which can transpile TypeScript to JavaScript, allowing the use of TypeScript within test files themselves. 14 | 15 | With these installed and configured your `package.json` might look something like: 16 | 17 | ```json 18 | { 19 | "name": "my-catalyst-component", 20 | "scripts": { 21 | "test": "web-test-server" 22 | }, 23 | "devDependencies": { 24 | "@web/dev-server-esbuild": "^0.3.0", 25 | "@web/test-runner": "^0.13.27", 26 | "@open-wc/testing": "^3.1.2" 27 | } 28 | } 29 | ``` 30 | 31 | You can configure the `web-test-server` by writing a `web-test-runner.config.js` file, which sets up the esbuild plugin to transpile TypeScript, and configure the directory containing your test files: 32 | 33 | ```typescript 34 | import {esbuildPlugin} from '@web/dev-server-esbuild' 35 | 36 | export default { 37 | files: ['test/*'], 38 | nodeResolve: true, 39 | plugins: [esbuildPlugin({ts: true})] 40 | } 41 | ``` 42 | 43 | #### Example Test File 44 | 45 | With this set-up, the boilerplate for an Element test suite might look something like this: 46 | 47 | ```typescript 48 | // test/my-controller.ts 49 | import {expect, fixture, html} from '@open-wc/testing' 50 | import {MyController} from '../src/my-controller' 51 | 52 | describe('MyController', () => { 53 | let instance 54 | beforeEach(async () => { 55 | instance = await fixture(html` 56 |
57 |
`) 58 | }) 59 | 60 | it('is a Catalyst controller', () => { 61 | expect(instance).to.have.attribute('data-catalyst') 62 | }) 63 | 64 | it('matches snapshot', () => { 65 | expect(instance).dom.to.equalSnapshot() 66 | }) 67 | 68 | it('passes Axe tests', () => 69 | expect(instance).to.be.accessible() 70 | }) 71 | 72 | it('...') // Fill out the rest 73 | }) 74 | ``` 75 | 76 | ##### Useful Assertions 77 | 78 | The `@open-wc/testing` package exports the `expect` function from Chai, but also automatically registers a set of plugins useful for writing web components, including [chai-a11y-axe](https://www.npmjs.com/package/chai-a11y-axe) and [chai-dom](https://www.npmjs.com/package/chai-dom). Here are some handy example assertions which may be commonly written: 79 | 80 | 81 | - `expect(instance).to.be.accessible()` - Runs a suite of [Axe](https://www.npmjs.com/package/axe) accessibility tests on the element. 82 | - `expect(instance).dom.to.equalSnapshot()` - Stores a snaphsot test of the existing DOM, which can be tested against later, for regressions. 83 | - `expect(instance).shadowDom.to.equalSnapshot()` - Stores a snaphsot test of the existing ShadowDOM, which can be tested against later, for regressions. 84 | - `expect(instance).to.have.class('foo')` - Checks the element has the `foo` class (like `el.classList.contains('foo')`). 85 | - `expect(instance).to.have.attribute('foo')` - Checks the element has the `foo` attribute (like `el.hasAttribute('foo')`). 86 | - `expect(instance).to.have.attribute('foo')` - Checks the element has the `foo` attribute (like `el.hasAttribute('foo')`). 87 | - `expect(instance).to.have.descendants('.foo')` - Checks the element has elements matching the selector `.foo` attribute (like `el.querySelectorAll('foo')`). 88 | 89 | -------------------------------------------------------------------------------- /src/lazy-define.ts: -------------------------------------------------------------------------------- 1 | type Strategy = (tagName: string) => Promise 2 | 3 | const dynamicElements = new Map void>>() 4 | 5 | const ready = new Promise(resolve => { 6 | if (document.readyState !== 'loading') { 7 | resolve() 8 | } else { 9 | document.addEventListener('readystatechange', () => resolve(), {once: true}) 10 | } 11 | }) 12 | 13 | const firstInteraction = new Promise(resolve => { 14 | const controller = new AbortController() 15 | controller.signal.addEventListener('abort', () => resolve()) 16 | const listenerOptions = {once: true, passive: true, signal: controller.signal} 17 | const handler = () => controller.abort() 18 | 19 | document.addEventListener('mousedown', handler, listenerOptions) 20 | // eslint-disable-next-line github/require-passive-events 21 | document.addEventListener('touchstart', handler, listenerOptions) 22 | document.addEventListener('keydown', handler, listenerOptions) 23 | document.addEventListener('pointerdown', handler, listenerOptions) 24 | }) 25 | 26 | const visible = (tagName: string): Promise => 27 | new Promise(resolve => { 28 | const observer = new IntersectionObserver( 29 | entries => { 30 | for (const entry of entries) { 31 | if (entry.isIntersecting) { 32 | resolve() 33 | observer.disconnect() 34 | return 35 | } 36 | } 37 | }, 38 | { 39 | // Currently the threshold is set to 256px from the bottom of the viewport 40 | // with a threshold of 0.1. This means the element will not load until about 41 | // 2 keyboard-down-arrow presses away from being visible in the viewport, 42 | // giving us some time to fetch it before the contents are made visible 43 | rootMargin: '0px 0px 256px 0px', 44 | threshold: 0.01 45 | } 46 | ) 47 | for (const el of document.querySelectorAll(tagName)) { 48 | observer.observe(el) 49 | } 50 | }) 51 | 52 | const strategies: Record = { 53 | ready: () => ready, 54 | firstInteraction: () => firstInteraction, 55 | visible 56 | } 57 | 58 | type ElementLike = Element | Document | ShadowRoot 59 | 60 | const timers = new WeakMap() 61 | function scan(element: ElementLike) { 62 | cancelAnimationFrame(timers.get(element) || 0) 63 | timers.set( 64 | element, 65 | requestAnimationFrame(() => { 66 | for (const tagName of dynamicElements.keys()) { 67 | const child: Element | null = 68 | element instanceof Element && element.matches(tagName) ? element : element.querySelector(tagName) 69 | if (customElements.get(tagName) || child) { 70 | const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies 71 | const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready 72 | // eslint-disable-next-line github/no-then 73 | for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb) 74 | dynamicElements.delete(tagName) 75 | timers.delete(element) 76 | } 77 | } 78 | }) 79 | ) 80 | } 81 | 82 | let elementLoader: MutationObserver 83 | 84 | export function lazyDefine(object: Record void>): void 85 | export function lazyDefine(tagName: string, callback: () => void): void 86 | export function lazyDefine(tagNameOrObj: string | Record void>, singleCallback?: () => void) { 87 | if (typeof tagNameOrObj === 'string' && singleCallback) { 88 | tagNameOrObj = {[tagNameOrObj]: singleCallback} 89 | } 90 | for (const [tagName, callback] of Object.entries(tagNameOrObj)) { 91 | if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>()) 92 | dynamicElements.get(tagName)!.add(callback) 93 | } 94 | observe(document) 95 | } 96 | 97 | export function observe(target: ElementLike): void { 98 | elementLoader ||= new MutationObserver(mutations => { 99 | if (!dynamicElements.size) return 100 | for (const mutation of mutations) { 101 | for (const node of mutation.addedNodes) { 102 | if (node instanceof Element) scan(node) 103 | } 104 | } 105 | }) 106 | 107 | scan(target) 108 | 109 | elementLoader.observe(target, {subtree: true, childList: true}) 110 | } 111 | -------------------------------------------------------------------------------- /docs/_guide/you-will-need.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 2 4 | subtitle: How to install and set up Catalyst 5 | --- 6 | 7 | Catalyst is available as an npm module `@github/catalyst`. To install into your project, use the command `npm install @github/catalyst`. 8 | 9 | ### TypeScript 10 | 11 | Catalyst has no strict dependencies, but it relies on TypeScript for decorator support, so you should also configure your project to use TypeScript. [Read the TypeScript docs on how to set up TypeScript on a new project](https://www.typescriptlang.org/docs/home.html). 12 | 13 | ### Polyfills 14 | 15 | Catalyst uses modern browser standards, and so requires evergreen browsers or may require polyfilling native functionality in older browsers. You'll need to ensure the following features are available: 16 | 17 | - [`toggleAttribute`](https://caniuse.com/#search=toggleAttribute). [`mdn-polyfills`](https://github.com/msn0/mdn-polyfills) or [`dom4`](https://github.com/WebReflection/dom4) libraries can polyfill this. 18 | - [`window.customElements`](https://caniuse.com/#search=customElements). [`@webcomponents/custom-elements`](https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements) can polyfill this. 19 | - [`MutationObserver`](https://caniuse.com/#search=MutationObserver). [`mutation-observer`](https://github.com/webmodules/mutation-observer) can polyfill this. 20 | 21 | Please note this list may increase over time. Catalyst will never ship with polyfills that add missing browser functionality, but will continue to use the latest Web Standards, and so may require more polyfills as new releases come out. 22 | 23 | ### Build considerations 24 | 25 | When using build tools, some JavaScript minifiers modify the class name that Catalyst relies on. You know you have an issue if you encounter the error `"c" is not a valid custom element name`. 26 | 27 | The preferred way to handle this is to disable renaming class names in your build tools. 28 | 29 | #### ESBuild 30 | 31 | When using ESBuild you can turn off all class and function name minification with the [`keep_names`](https://esbuild.github.io/api/#keep-names) option. Setting this to `true` in your build will opt-out all classes and all functions from minification. 32 | 33 | 34 | ```ts 35 | { keep_names: true } 36 | // Or --keep-names on the CLI 37 | ``` 38 | 39 | #### Terser 40 | 41 | When using Terser you have a bit more control, and can explicitly opt just classes, or just certain class names out of minification. For example to opt-out class names that end with `Element` you can set the following config: 42 | 43 | ```ts 44 | { keep_classnames: /Element$/ } 45 | ``` 46 | 47 | It is also possible to set `keep_classnames` to `true` (or pass `--keep-classnames` to the CLI tool), which will opt-out all class names. [You can read more about the minification options on Terser's docs](https://terser.org/docs/api-reference#minify-options) 48 | 49 | #### SWC 50 | 51 | When using SWC you can use the `keep_classnames` option just like Terser. As SWC also handles Transpilation, you should be sure to enable native class syntax by specifiying `target` to at least `es2016`. [Take a look at the SWC docs for more about compression options](https://swc.rs/docs/configuration/minification#jscminifycompress). 52 | 53 | ```json 54 | { 55 | "jsc": { 56 | "target": "es2016", 57 | "minify": { 58 | "compress": { 59 | "keep_classnames": true 60 | } 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | #### Other alternatives 67 | 68 | If your tool chain does not support opting out of minification, or if you would prefer to keep name minification on, you can instead selectively re-assign the `name` field to Catalyst controllers: 69 | 70 | ```ts 71 | @controller 72 | class UserList extends HTMLElement { 73 | static name = 'UserList' 74 | } 75 | ``` 76 | 77 | TypeScript will need the `useDefineForClassFields` set to `true` for the above to work, alternatively you can use the following syntax with `useDefineForClassFields` kept off: 78 | 79 | ```ts 80 | @controller 81 | class UserList extends HTMLElement { 82 | static get name() { return 'UserList' } 83 | } 84 | ``` 85 | 86 | You'll need to keep the class name either way. TypeScript decorators only support _class declarations_ which require a name between `class` and `extends`. For example the following will be a SyntaxError: 87 | 88 | ```ts 89 | @controller 90 | class extends HTMLElement { // You can't do this! 91 | static name = 'UserList' 92 | } 93 | ``` 94 | 95 | 96 | -------------------------------------------------------------------------------- /test/lazy-define.ts: -------------------------------------------------------------------------------- 1 | import {expect, fixture, html} from '@open-wc/testing' 2 | import {spy} from 'sinon' 3 | import {lazyDefine, observe} from '../src/lazy-define.js' 4 | 5 | const animationFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) 6 | 7 | describe('lazyDefine', () => { 8 | describe('ready strategy', () => { 9 | it('calls define for a lazy component', async () => { 10 | const onDefine = spy() 11 | lazyDefine('scan-document-test', onDefine) 12 | await fixture(html``) 13 | 14 | await animationFrame() 15 | 16 | expect(onDefine).to.be.callCount(1) 17 | }) 18 | 19 | it('initializes dynamic elements that are defined after the document is ready', async () => { 20 | const onDefine = spy() 21 | await fixture(html``) 22 | lazyDefine('later-defined-element-test', onDefine) 23 | 24 | await animationFrame() 25 | 26 | expect(onDefine).to.be.callCount(1) 27 | }) 28 | 29 | it("doesn't call the same callback twice", async () => { 30 | const onDefine = spy() 31 | lazyDefine('twice-defined-element', onDefine) 32 | lazyDefine('once-defined-element', onDefine) 33 | lazyDefine('twice-defined-element', onDefine) 34 | await fixture(html` 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | `) 43 | 44 | await animationFrame() 45 | 46 | expect(onDefine).to.be.callCount(2) 47 | }) 48 | 49 | it('takes an object as well', async () => { 50 | const onDefine1 = spy() 51 | const onDefine2 = spy() 52 | const onDefine3 = spy() 53 | lazyDefine({ 54 | 'first-object-element': onDefine1, 55 | 'second-object-element': onDefine2, 56 | 'third-object-element': onDefine3 57 | }) 58 | await fixture(html` 59 | 60 | 61 | 62 | `) 63 | 64 | await animationFrame() 65 | 66 | expect(onDefine1).to.have.callCount(1) 67 | expect(onDefine2).to.have.callCount(1) 68 | expect(onDefine3).to.have.callCount(1) 69 | }) 70 | 71 | it('lazy loads elements in shadow roots', async () => { 72 | const onDefine = spy() 73 | lazyDefine('nested-shadow-element', onDefine) 74 | 75 | const el = await fixture(html`
`) 76 | const shadowRoot = el.attachShadow({mode: 'open'}) 77 | observe(shadowRoot) 78 | // eslint-disable-next-line github/unescaped-html-literal 79 | shadowRoot.innerHTML = '
' 80 | 81 | await animationFrame() 82 | 83 | expect(onDefine).to.be.callCount(1) 84 | }) 85 | }) 86 | 87 | describe('firstInteraction strategy', () => { 88 | it('calls define for a lazy component', async () => { 89 | const onDefine = spy() 90 | lazyDefine('scan-document-test', onDefine) 91 | await fixture(html``) 92 | 93 | await animationFrame() 94 | expect(onDefine).to.be.callCount(0) 95 | 96 | document.dispatchEvent(new Event('mousedown')) 97 | 98 | await animationFrame() 99 | expect(onDefine).to.be.callCount(1) 100 | }) 101 | }) 102 | describe('visible strategy', () => { 103 | it('calls define for a lazy component', async () => { 104 | const onDefine = spy() 105 | lazyDefine('scan-document-test', onDefine) 106 | await fixture( 107 | html`
108 | ` 109 | ) 110 | await animationFrame() 111 | expect(onDefine).to.be.callCount(0) 112 | 113 | document.documentElement.scrollTo({top: 10}) 114 | 115 | await animationFrame() 116 | expect(onDefine).to.be.callCount(1) 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /test/target.ts: -------------------------------------------------------------------------------- 1 | import {expect, fixture, html} from '@open-wc/testing' 2 | import {target, targets} from '../src/target.js' 3 | import {controller} from '../src/controller.js' 4 | 5 | describe('Targetable', () => { 6 | @controller 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | class TargetTestElement extends HTMLElement { 9 | @target foo!: Element 10 | bar = 'hello' 11 | @target baz!: Element 12 | @target qux!: Element 13 | @target shadow!: Element 14 | 15 | @target bing!: Element 16 | @targets foos!: Element[] 17 | bars = 'hello' 18 | @target quxs!: Element[] 19 | @target shadows!: Element[] 20 | } 21 | 22 | let instance: HTMLElement 23 | beforeEach(async () => { 24 | instance = await fixture(html` 25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
`) 36 | }) 37 | 38 | describe('target', () => { 39 | it('returns the first element where closest tag is the controller', async () => { 40 | expect(instance).to.have.property('foo').exist.with.attribute('id', 'el4') 41 | expect(instance.querySelector('target-test')).to.have.property('foo').exist.with.attribute('id', 'el2') 42 | }) 43 | 44 | it('does not assign to non-target decorated properties', async () => { 45 | expect(instance).to.have.property('bar', 'hello') 46 | }) 47 | 48 | it('returns the first element that has the exact target name', async () => { 49 | expect(instance).to.have.property('baz').exist.with.attribute('id', 'el5') 50 | }) 51 | 52 | it('returns target when there are mutliple target values', async () => { 53 | expect(instance).to.have.property('bing').exist.with.attribute('id', 'el6') 54 | }) 55 | 56 | it('returns targets when there are mutliple target values with different controllers', async () => { 57 | expect(instance).to.have.property('qux').exist.with.attribute('id', 'el8') 58 | }) 59 | 60 | it('returns targets from the shadowRoot, if available', async () => { 61 | instance.attachShadow({mode: 'open'}) 62 | const el = document.createElement('div') 63 | el.setAttribute('data-target', 'target-test.shadow') 64 | instance.shadowRoot!.appendChild(el) 65 | expect(instance).to.have.property('shadow', el) 66 | }) 67 | 68 | it('prioritises shadowRoot targets over others', async () => { 69 | instance.attachShadow({mode: 'open'}) 70 | const shadowEl = document.createElement('div') 71 | shadowEl.setAttribute('data-target', 'target-test.foo') 72 | instance.shadowRoot!.appendChild(shadowEl) 73 | expect(instance).to.have.property('foo', shadowEl) 74 | }) 75 | }) 76 | 77 | describe('targets', () => { 78 | it('returns all elements where closest tag is the controller', async () => { 79 | expect(instance).to.have.property('foos').with.lengthOf(2) 80 | expect(instance).to.have.nested.property('foos[0]').with.attribute('id', 'el4') 81 | expect(instance).to.have.nested.property('foos[1]').with.attribute('id', 'el5') 82 | }) 83 | 84 | it('returns all elements inside a shadow root', async () => { 85 | instance.attachShadow({mode: 'open'}) 86 | const els = [document.createElement('div'), document.createElement('div'), document.createElement('div')] 87 | for (const el of els) el.setAttribute('data-targets', 'target-test.foos') 88 | instance.shadowRoot!.append(...els) 89 | 90 | expect(instance).to.have.property('foos').with.lengthOf(5) 91 | expect(instance).to.have.nested.property('foos[0]', els[0]) 92 | expect(instance).to.have.nested.property('foos[1]', els[1]) 93 | expect(instance).to.have.nested.property('foos[2]', els[2]) 94 | expect(instance).to.have.nested.property('foos[3]').with.attribute('id', 'el4') 95 | expect(instance).to.have.nested.property('foos[4]').with.attribute('id', 'el5') 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /docs/_guide/rendering.md: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | chapter: 8 4 | title: Rendering 5 | subtitle: Rendering HTML subtrees 6 | --- 7 | 8 | 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. 9 | 10 | {% capture callout %} 11 | 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/). 12 | {% endcapture %}{% include callout.md %} 13 | 14 | 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. 15 | 16 | Catalyst will automatically look for elements that match the `template[data-shadowroot]` selector, within your controller. If it finds one as a direct-child of your controller, it will use that to create a shadowRoot. 17 | 18 | Catalyst Controllers will search for a direct child of `template[data-shadowroot]` and load its contents as the `shadowRoot` of the element. [Actions]({{ site.baseurl }}/guide/actions) and [Targets]({{ site.baseurl }}/guide/targets) all work within an elements ShadowRoot. 19 | 20 | ### Example 21 | 22 | ```html 23 | 24 | 29 | 30 | ``` 31 | ```typescript 32 | import { controller, target } from "@github/catalyst" 33 | 34 | @controller 35 | class HelloWorldElement extends HTMLElement { 36 | @target nameEl: HTMLElement 37 | get name() { 38 | return this.nameEl.textContent 39 | } 40 | set name(value: string) { 41 | this.nameEl.textContent = value 42 | } 43 | } 44 | ``` 45 | 46 | Providing the `