Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile

# [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
ARG VARIANT="16"
ARG VARIANT="24"
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": { "VARIANT": "16" }
"args": { "VARIANT": "24" }
},

// Set *default* container specific settings.json values on container create.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ jobs:
- name: Checkout the project
uses: actions/checkout@v3

- name: Use Node.js 16.x (LTS)
- name: Use Node.js 24.x (LTS)
uses: actions/setup-node@v3
with:
node-version: 16.x
node-version: 24.x
cache: 'npm'
- run: npm ci

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout the project
uses: actions/checkout@v3
- name: Use Node.js 16.x (LTS)
- name: Use Node.js 24.x (LTS)
uses: actions/setup-node@v3
with:
node-version: 16.x
node-version: 24.x
cache: 'npm'
- run: npm ci
- name: Lint Codebase
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ jobs:
steps:
- name: Checkout the project
uses: actions/checkout@v3
- name: Use Node.js 16.x (LTS)
- name: Use Node.js 24.x (LTS)
uses: actions/setup-node@v3
with:
node-version: 16.x
node-version: 24.x
registry-url: https://registry.npmjs.org/
cache: npm
- run: npm ci
Expand Down
37 changes: 35 additions & 2 deletions docs/_guide/your-first-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ Catalyst will automatically convert the classes name; removing the trailing `Ele

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.

### Custom Element Names

If you need to use a specific element name that doesn't match your class name (for example, to support minification), you can pass the element name directly to the `@controller` decorator:

```js
import {controller} from '@github/catalyst'

@controller('hello-widget')
class SomeClass extends HTMLElement {
connectedCallback() {
this.innerHTML = 'Hello from hello-widget!'
}
}
```
<br>

This will register the element as `<hello-widget>` regardless of the class name. This is particularly useful when:
- Your production build minifies class names
- You want explicit control over the element name
- The class name doesn't follow the naming pattern required for automatic naming

{% capture callout %}
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`
{% endcapture %}{% include callout.md %}
Expand All @@ -40,8 +61,8 @@ Remember! A class name _must_ include at least two CamelCased words (not includi

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:

- Derives a tag name based on your class name, removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash.
- Calls `window.customElements.define` with the newly derived tag name and your class.
- Derives a tag name based on your class name, removing the trailing `Element` suffix and lowercasing all capital letters, separating them with a dash. You can optionally provide a custom element name as a parameter (e.g., `@controller('my-element')`).
- Calls `window.customElements.define` with the newly derived (or provided) tag name and your class.
- Calls `defineObservedAttributes` with the class to add map any `@attr` decorators. See [attrs]({{ site.baseurl }}/guide/attrs) for more on this.
- Injects the following code inside of the `connectedCallback()` function of your class:
- `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.
Expand Down Expand Up @@ -79,4 +100,16 @@ controller(
}
)
```

Or with a custom element name:

```js
import {controller} from '@github/catalyst'

controller('my-custom-name')(
class HelloWorldElement extends HTMLElement {
//...
}
)
```
<br>
13 changes: 11 additions & 2 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import type {CustomElementClass} from './custom-element.js'
* registry, as well as ensuring `bind(this)` is called on `connectedCallback`,
* wrapping the classes `connectedCallback` method if needed.
*/
export function controller(classObject: CustomElementClass): void {
new CatalystDelegate(classObject)
export function controller(classObject: CustomElementClass): void
export function controller(name: string): (classObject: CustomElementClass) => void
export function controller(
classObjectOrName: CustomElementClass | string
): void | ((classObject: CustomElementClass) => void) {
if (typeof classObjectOrName === 'string') {
return (classObject: CustomElementClass) => {
new CatalystDelegate(classObject, classObjectOrName)
}
}
new CatalystDelegate(classObjectOrName)
}
4 changes: 2 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {observe} from './lazy-define.js'
const symbol = Symbol.for('catalyst')

export class CatalystDelegate {
constructor(classObject: CustomElementClass) {
constructor(classObject: CustomElementClass, elementName?: string) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const delegate = this

Expand Down Expand Up @@ -44,7 +44,7 @@ export class CatalystDelegate {
})

defineObservedAttributes(classObject)
register(classObject)
register(classObject, elementName)
}

observedAttributes(instance: HTMLElement, observedAttributes: string[]) {
Expand Down
8 changes: 4 additions & 4 deletions src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {dasherize} from './dasherize.js'
*
* Example: HelloController => hello-controller
*/
export function register(classObject: CustomElementClass): CustomElementClass {
const name = dasherize(classObject.name).replace(/-element$/, '')
export function register(classObject: CustomElementClass, name?: string): CustomElementClass {
const tagName = name || dasherize(classObject.name).replace(/-element$/, '')
Comment on lines +11 to +12
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom element name parameter is not validated. According to the HTML specification, custom element names must contain a hyphen to avoid conflicts with standard HTML elements. Consider adding validation to ensure the provided name contains a hyphen and follows custom element naming rules, or rely on the browser's customElements.define to throw appropriate errors for invalid names.

Copilot uses AI. Check for mistakes.

try {
window.customElements.define(name, classObject)
window.customElements.define(tagName, classObject)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window[classObject.name] = customElements.get(name)
window[classObject.name] = customElements.get(tagName)
} catch (e: unknown) {
// The only reason for window.customElements.define to throw a `NotSupportedError`
// is if the element has already been defined.
Expand Down
23 changes: 23 additions & 0 deletions test/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ describe('controller', () => {
expect(instance).to.be.instanceof(ControllerRegisterElement)
})

it('registers element with custom name when provided', async () => {
@controller('happy-widget')
class SomeClass extends HTMLElement {}
instance = await fixture(html`<happy-widget />`)
expect(instance).to.be.instanceof(SomeClass)
})

it('registers element with custom name using function syntax', async () => {
controller('custom-element-name')(class AnotherClass extends HTMLElement {})
instance = await fixture(html`<custom-element-name />`)
expect(instance).to.exist
})

it('adds data-catalyst to elements with custom names', async () => {
@controller('custom-named-element')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class CustomNamedElement extends HTMLElement {}

instance = await fixture(html`<custom-named-element />`)
expect(instance.hasAttribute('data-catalyst')).to.equal(true)
expect(instance.getAttribute('data-catalyst')).to.equal('')
})
Comment on lines +17 to +38
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for edge cases when using custom element names. Consider adding tests for invalid custom element names (e.g., names without hyphens, reserved names, uppercase characters) to verify that appropriate errors are thrown or handled correctly.

Copilot uses AI. Check for mistakes.

it('adds data-catalyst to elements', async () => {
@controller
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
1 change: 1 addition & 0 deletions web-test-runner.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
files: ['test/*'],
nodeResolve: true,
concurrency: 1,
testsFinishTimeout: 30000,
plugins: [esbuildPlugin({ts: true, target: 'es2020'})],
filterBrowserLogs: log => !log.args.some(arg => typeof arg === 'string' && arg.includes('Lit is in dev mode'))
}
Loading