Class Decorator
How to code an interface for Web Components with TypeScript decorators that enables declaration of the selector, styles, and template.
Implementing a class decorator is going to improve the ergonomics of Web Component development quite significantly. We'll enhance the class-based implementation which custom elements rely on by cleaning up each constructor
, choosing to move the declaration of a HTML template and CSS to a new function
that decorates the class
. This, combined with abstracting template and style generation to reusable function
will have the positive effect of reducing the boilerplate for each component. For example, when you are finished with this section, ButtonComponent
will look something like this.
xxxxxxxxxx
@Component({
custom: { extends: "button" },
selector: "in-button",
style: buttonStyles,
})
export class ButtonComponent extends HTMLButtonElement {
constructor() {
super();
}
connectedCallback() {
this.classList.add("in-button");
attachStyle(this);
}
}
We're working in the packages/common
directory of the monorepo for the first time, which currently only has one source file: index.ts
. To keep this package organized, we should figure out a way to divide the code into logical chunks. Making a directory for all decorators seems prudent, then organizing the decorators by function.
To start developing the component decorator, create a new directory in packages/common/src/decorator
, then make a new file at packages/common/src/decorator/index.ts
, and another file at packages/common/src/decorator/component.ts
.
xxxxxxxxxx
mkdir -p packages/common/src/decorator
touch packages/common/src/decorator/index.ts
touch packages/common/src/decorator/component.ts
Any file named index.ts
manages exports for several files in the same directory. Other files in a directory contain library code.
Open packages/common/src/decorator/component.ts
in your IDE to begin.
Define the Interface#
First let's define the interface used by the component decorator. We'll need this interface
to keep the first argument of the decorator function
type safe. Declare a new interface
named ElementMeta
and ensure it is exported with the export
keyword.
xxxxxxxxxx
export interface ElementMeta {
selector?: string;
style?: string;
template?: string;
}
There are three properties on this interface
we should define: selector
, style
, and template
. All three properties are optional, denoted by the ?
prior to :
. Each of these properties are optional to allow flexibility for the end-user. selector
will be used to automatically call customElements.define
in the context of the decorator, but what if the end-user for some reason has to register components in some other way? The user could omit the selector and register the component however they like. There are times an engineer may want to style the component, or not. Alternatively, there may be no need for a template, perhaps only styling. Making each property optional allows for flexibility, but as we'll observe programmatically, we'll have to check each property on ElementMeta
exists and react accordingly.
Component Decorator#
TypeScript class decorators are factory functions, that is, they return a function
that constructs an Object
. The Object
we're concerned with is the class
definition of the component using this decorator. Based on the values of properties on the ElementMeta
, we want to modify the prototype
of the class
so we can use those properties to register the component with the CustomElementRegistry
and instantiate a HTML template with Shadow DOM.
The basic structure of the function
is as follows: Declare a new function
named Component
and make sure it's exportable with the export
keyword. Name the first argument of the function
meta
and type define the argument with the interface you just created: ElementMeta
. return a function
. The first argument of this function
is defined by TypeScript when you use Component
in the context of a class decorator. target
is the class definition of the class
that is being decorated. Inside this factory function
, return the class definition. target
should be type defined as any
here because so many different class
will use the decorator. It would be difficult to implement type safety here, nor should we.
xxxxxxxxxx
export function Component(meta: ElementMeta) {
return (target: any) => {
return target;
};
}
What happens before the target
is returned is the most interesting part of the class decorator. Before we start modifying the class definition, let's put a fail-safe mechanism in for users who haven't specified an ElementMeta
in the first argument of Component
.
xxxxxxxxxx
export function Component(meta: ElementMeta) {
if (!meta) {
console.error("Component must include ElementMeta to compile");
return;
}
The above conditional checks if meta
is undefined
and when truthy, displays a console error for developers in Dev Tools, then aborts the rest of the algorithm that depends on ElementMeta
.
We have two objectives for the class decorator: register the component using the selector
property, then add metadata on the class
definition to later append a HTML template to Shadow DOM using the style
and template
properties on the ElementMeta
.
Let's start with registering the component via the decorator function. In the factory function, reference the selector
and pass it and the class definition, here named target
, to customElements.define
.
xxxxxxxxxx
export function Component(meta: ElementMeta) {
if (!meta) {
console.error("Component must include ElementMeta to compile");
return;
}
return (target: any) => {
customElements.define(meta.selector, target);
return target;
};
}
This one line allows the end-user to omit a call from customElements.define
in their implementation, as you'll later observe when refactoring TextInputComponent
, ButtonComponent
, and CardComponent
, but the user has the option to ignore selector
entirely in the ElementMeta
, so be sure to wrap the call to define
in a conditional.
xxxxxxxxxx
if (meta.selector) {
customElements.define(meta.selector, target);
}
That was easy enough, although we're not yet using the class decorator for its main function: modifying the prototype
of the class
it decorates. This just happens to be a convenient place to also make the call to customElements.define
. We can't use this space to call attachShadow
and create a HTML template, then append it to the shadowRoot
like we've done in previous chapters, because the class decorator doesn't have access to the instance of the class, only the definition.
Class decorators are a place where you can modify the prototype
of a class
, so that's exactly what we're going to do. We're going to store the ElementMeta
passed through the Component function
arguments on the prototype
of the class
, so we can later access the template
and style
in the constructor
of the component class
.
Set a new property called elementMeta
on the prototype
to the meta
that was passed into the Component function
.
xxxxxxxxxx
target.prototype.elementMeta = meta;
If we want to avoid the style
and template
properties from ever being undefined
, here is a good place for some conditionals that set both of these properties to empty string
in the case they are undefined
.
xxxxxxxxxx
if (!meta.style) {
meta.style = "";
}
if (!meta.template) {
meta.template = "";
}
When you are finished with the Component
decorator, the function
should look like this:
xxxxxxxxxx
export function Component(meta: ElementMeta) {
if (!meta) {
console.error("Component must include ElementMeta to compile");
return;
}
return (target: any) => {
if (!meta.style) {
meta.style = "";
}
if (!meta.template) {
meta.template = "";
}
target.prototype.elementMeta = meta;
if (meta.selector) {
customElements.define(meta.selector, target);
}
return target;
};
}
Build the @in/common
package#
Development of the Component
decorator is finished, but you still need to provide the decorator and possibly the interface
for use outside of the @in/common package
. In previous chapters you developed components in a sibling directory at packages/component
. To access the Component
decorator from the files in that package, you need to properly export
from the @in/common
package.
To finalize development of the decorator, add the proper exports topackages/common/src/decorator/index.ts
and packages/common/index.ts
.
In packages/common/src/decorator/index.ts
add the following exports.
xxxxxxxxxx
export { Component } from "./component";
export type { ElementMeta } from "./component";
Export the same from packages/common/index.ts
.
xxxxxxxxxx
export { Component } from "./src/decorator";
export type { ElementMeta } from "./src/decorator";
index.ts
is the entry point of the @in/common
package. When the @in/common
package is built, other packages in the monorepo will be able to reference anything exported from the index.ts
.
Check the build successfully runs, which means your TypeScript is valid. This is your first time running the build
command because previously you didn't really need to. Storybook was handling the development environment for all the components so far. In future chapters you'll need to build the @in/ui
package as well, but for now you'll need to make sure the @in/common
package is built using the following command.
xxxxxxxxxx
lerna exec --scope @in/common -- yarn build
If you get an error like zsh: command not found: lerna
make sure lerna is installed globally and try the build command again.
xxxxxxxxxx
npm install --global lerna
If you see lerna success
output in the Terminal, you are ready to proceed. Otherwise, check your TypeScript is correct.
Abstracting attachShadow#
Now that we have ElementMeta
stored on the prototype
of any class
using the Component
decorator, we can write a reusable function
that could be used in the constructor
of the same class
to instantiate Shadow DOM much like we already did in the components developed in Part One. By abstracting this logic to a reusable function
we can reduce several lines of code in each component implementation down to one line.
Basically we want to take something like this...
xxxxxxxxxx
const shadowRoot = this.attachShadow(options);
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
background: var(--color-white);
border-radius: var(--radius-md);
box-shadow: var(--shadow);
overflow: hidden;
max-width: 320px;
}
</style>
<div class="container"></div>
`;
shadowRoot.appendChild(template.content.cloneNode(true));
and reduce it to one line.
xxxxxxxxxx
attachShadow(this, options);
The first argument of attachShadow
is the instance of the class
which in the constructor
you can reference as this
. The second argument is the Object
that configures the call to element.attachShadow
. According to MDN, this Object
can accept three fields: mode
and delegatesFocus
. The type definition exported from TypeScript's lib.dom.d.ts called ShadowRootInit
reveals a third field: slotAssignment
. You've set the fields on this Object
in prior chapters for TextInputComponent
, ButtonComponent
, and CardComponent
.
To start development of this new function
, make a new directory named template in packages/common/src
and create a new file in that directory named index.ts
. Create another file in the directory named shadow.ts
.
xxxxxxxxxx
mkdir packages/common/src/template
touch packages/common/src/template/index.ts
touch packages/common/src/template/shadow.ts
In packages/common/src/template/shadow.ts
, create a new function
named attachShadow
and export
it from the file. Declare two arguments for attachShadow
: context
and options
, making options
optional with ?
and type defining context
as any
for now and options
as ShadowRootInit
, a type definition exported from lib.dom.d.ts.
xxxxxxxxxx
export function attachShadow(context: any, options?: ShadowRootInit) {}
Follow up in packages/common/src/template/index.ts
and ensure attachShadow
is exported for the main index.ts
.
xxxxxxxxxx
export { attachShadow } from "./shadow";
Finally in packages/common/index.ts
export the attachShadow function
.
xxxxxxxxxx
export { attachShadow } from "./src/template";
Jumping back to packages/common/src/template/shadow.ts
, fill in the algorithm for attachShadow
, referencing the constructor
in CardComponent
at packages/component/src/card/Card.ts
as a guide. Make a const
named shadowRoot
, type defined as ShadowRoot
, equal to context.attachShadow
. Since we've opted to make options
optional, we need to at the very least give context.attachShadow
the minimum ShadowRootInit
, which requires mode
.
xxxxxxxxxx
const shadowRoot: ShadowRoot = context.attachShadow(
options || { mode: "open" }
);
On the next line, make a const
named template
equal to document.createElement('template')
. This line creates a new HTML template.
xxxxxxxxxx
const template = document.createElement("template");
Set the content of the HTML template using the ElementMeta
stored on the prototype
of whatever class
will use this attachShadow function
. Pass in context.elementMeta.style
to a <style>
tag and context.elementMeta.template
afterward.
xxxxxxxxxx
template.innerHTML = `<style>${context.elementMeta.style}</style>${context.elementMeta.template}`;
Finally, append a clone of the HTML template to the ShadowRoot
.
xxxxxxxxxx
shadowRoot.appendChild(template.content.cloneNode(true));
When you are finished, the attachShadow function
should look like this:
xxxxxxxxxx
export function attachShadow(context: any, options?: ShadowRootInit) {
const shadowRoot: ShadowRoot = context.attachShadow(
options || { mode: "open" }
);
const template = document.createElement("template");
template.innerHTML = `<style>${context.elementMeta.style}</style>${context.elementMeta.template}`;
shadowRoot.appendChild(template.content.cloneNode(true));
}
With Component
and attachShadow
now supporting autonomous and form-associated custom elements, you can now refactor CardComponent
and TextInputComponent
to use the new decorator pattern. Build the @in/common
package again so files inside the @in/ui
package can pickup the latest changes.
xxxxxxxxxx
lerna exec --scope @in/common -- yarn build
Refactor CardComponent and TextInputComponent#
Open packages/component/src/card/Card.ts
and observe the current state of the CardComponent class
. Before you replace the content in the constructor
, import Component
and attachShadow
from @in/common
.
This lesson preview is part of the Fullstack Web Components course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.
Get unlimited access to Fullstack Web Components, plus 70+ \newline books, guides and courses with the \newline Pro subscription.
