Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

How to create a simple UI library using decorators

2.67/5 (3 votes)
27 Nov 2021CPOL8 min read 9.5K   15  
In this article, I would like to share some of the learnings I got in the process of building an UI library using typescript decorators
Lately I've been looking for a light-weight library that helps to develop UI components using native browser technologies like Web Components, Shadow DOM etc. I see most of them surviving out there like Angular, React are not completely based on native technologies and not only that they have quite some learning curve. Also, there is always this fear that, am I doing this in the right way?. I found libraries like LitElement compelling but finally I ended up creating something simple, light-weight that focuses only on solving the component building business and nothing else. I created a small library called tiny that utilizes decorators and a simple base class to create components using native technologies. I'm pleased by the outcome. Though it doesn't support fancy data bindings and other cool features (for now) it turned out to be good!. In this article, I would like to share some of the learnings I got in the process of building that library and hopefully someone can use them to bui

Concept

The below code reveals the concept I had in my mind. It represents a simple web component that displays different emojis in different sizes based on inputs.

TypeScript
@element('my-smiley, `<span></span>`)
class SmileyElement extends BaseElement {
 
    @input()    
    type: string = 'happy';
 
    @input()    
    size = 'small' | 'medium' | 'large' = 'medium';
 
    @query('span')
    spanEl;
 
    onChanges(changes) {
        // TODO: perform DOM operations
    }
}

First of all, I love decorators! I hope you do too. I want to leverage them as much as possible to do the reusable business. You can see in the above snippet that we've applied a few decorators like element, input and query.

The element decorator transforms the class into a web component. The input decorator as the name is used to mark the properties as inputs. The query decorator is used to automatically query and return the child element on accessing the applied property. We can have more fun with decorators, how about one to auto bind events to a function? Yes, we can! Let's keep things simple for now. Please check out the tiny github repo to refer to more decorators.

The other important thing to notice is, the SmileyElement extends from a base class BaseElement. To make the decorators work we got to do some plumbing and not only that we have other works too.. like rendering the passed template, helper methods to work with DOM etc. Most importantly, to register a class as a web component it should extend from HTMLElement or one of the built-in elements and the BaseElement extends from HTMLElement.

The base class also provides some life-cycle hooks for the component to intercept and act. As you see there is an onChanges method that'll be invoked every time there is a change in inputs and it is the place you need to perform the DOM operations. Since we don't have those cool data bindings we need to do the DOM updates manually. Don't worry, the base class provides a bunch of helper methods to make that process easier, effective and with absolute control.

Alright, let's set up the project and see how we can build those decorators first and then the base class.

Setting-up the Project

Pick your favourite editor (WebStorm is mine) and create a new project with the name "base-element". We need TypeScript and Webpack to get the development going. First, initialize the "package.json" by running the below command from the terminal.

Shell
npm init

You'll be asked with a string of questions like project name, license etc. Type the details as you wish and once the package.json file is created run the below command to install the development dependencies.

Shell
npm i typescript ts-loader webpack webpack-cli webpack-dev-server --save-dev

To configure the typescript you need to create a "tsconfig.json" file. Create it and paste the below stuff into it. The important thing to note down is the experimentalDecorators flag which should be set to true to make the decorators work. Also, we should include the "es2018", "dom" and "dom.iterable" packages in the lib property.

TypeScript
{
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "dist",
    "skipLibCheck": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "forceConsistentCasingInFileNames": true,
    "downlevelIteration": true,
    "module": "commonjs",
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noUnusedLocals": false,
    "experimentalDecorators": true,
    "noImplicitOverride": false,
    "noImplicitAny": false,
    "pretty": true,
    "sourceMap": true,
    "strict": true,
    "strictNullChecks": false,
    "target": "es2020",
    "incremental": true,
    "newLine": "LF",
    "lib": ["es2018", "dom", "dom.iterable"]
  },
  "files": ["dev.ts"],
  "include": [
    "lib/**/*.ts"
  ]
}

To test our SmileyElement we need an "index.html" file and of course a web server to launch it. Create the "index.html" file and fill it with the below contents. Don't miss the script reference to "app.js" which is important.

HTML
<html lang="en">
<head>
 <title>Simplifying Creating Web Components Using TypeScript Decorators</title>
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
 <link href="https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Pacifico&display=swap" rel="stylesheet">
 <style>
   body {
       margin: 0 auto;
       display: flex;
       text-align: center;
       justify-content: center;
       flex-direction: column;
       background-color: #f9f9f9;
       font-family: 'Comfortaa', cursive;
   }
 
   h1 {
       font-family: 'Pacifico', cursive;
   }
 
   pre {
       font-family: 'courier';
       color: gray;
       margin: 2rem 0;
   }
 
   footer {
       color: gray;
       font-size: 0.6rem;
   }
 </style>
</head>
<body>
<h1>My life of emotions</h1>
<main>
 <div class="app-container"></div>
<pre>
<my-smiley type="happy"></my-smiley>
</pre>
   <footer>
     Demo of building UI components using native browser technologies leveraging typescript decorators.<br> Please look into <a target="_blank" href="https://github.com/vjai/tiny">tiny</a> library for real-world development of UI components.
   </footer>
</main>
<script src="app.js"></script>
</body>
</html>

Create the webpack file (webpack.config.js) to run the development server so we can test our SmileyComponent once it's ready.

TypeScript
const path = require('path');
 
module.exports = {
 mode: 'development',
 entry: { app: './dev.ts' },
 module: {
   rules: [
     {
       test: /\.js[x]?$/,
       exclude: /(node_modules)/,
       use: {
         loader: 'babel-loader'
       }
     },
     {
       test: /\.ts$/,
       exclude: /(node_modules)/,
       use: {
         loader: 'ts-loader'
       }
     }
   ]
 },
 resolve: {
   modules: [path.resolve(__dirname), 'node_modules'],
   extensions: ['.ts', '.js', '.jsx', '.json']
 },
 devServer: {
   static: {
     directory: path.resolve(__dirname)
   }
 },
 devtool: 'source-map'
};

Finally add the below commands in the "scripts" section of your "package.json" to run and build the project.

Shell
"scripts": {
 "start": "webpack-dev-server --config webpack.config.js",
 "build": "tsc --build tsconfig.json"
}

Phew... our project setup is done. Let's go and create the decorators.

Create the Decorators

Create a new folder called "lib" to keep all our decorators, base class and other component building stuff.

Decorators help to add custom metadata to a class, method or property. This is how a typical class decorator looks like,

TypeScript
export function decoratorName(...options): ClassDecorator {
  return (target: any) => {
  };
}

Basically it's a function that returns a function, yeah sort of closure! We are gonna use decorators here to specify metadata information about a component like selector name, template, inputs, query accessors and more.

Alright, first let's create a class called ElementMetadata to store the component information like selector name, template, inputs and other things. Create a new file "element.metadata.ts" under the "lib" folder and drop the below class.

TypeScript
export class ElementMetadata {
 name: string = null;
 tpl: string = null;
 accessors = new Map<string, { selector: string; }>();
 inputs = new Set<{property: string; attribute: boolean; dataType: AttributeValueDataType; }>();
}

The accessors property stores the details of the properties used for querying and inputs to store the details of the input properties.

Create a file with the name "decorators.ts" under the "lib" folder. Let's start creating the element decorator.

The element decorator basically accepts the selector name and an optional html template and stores it in the metadata. Along with that, it also registers it as a web component by calling the native customElements.define method.

TypeScript
/**
 * Registers a class into web component.
 * @param name selector name.
 * @param [tpl] html template string.
 */
export function element(
 name: string,
 tpl?: string
): ClassDecorator {
 return (target: any) => {
   if (window.customElements.get(name)) {
     throw new Error(`Already an element is registered with the name ${name}`);
   }
 
   window.customElements.define(name, target);
   setMeta(target, Object.assign(getMeta(target), { name, tpl }));
 };
}
 
function getMeta(target: Function) {
 return target[ELEMENT_META_KEY] || new ElementMetadata();
}
 
function setMeta(target: Function, meta: ElementMetadata) {
 target[ELEMENT_META_KEY] = meta;
}

The metadata is stored as a static property in the component class. We could also use reflect-metdata library to store the metadata but we are not doing this here to avoid a production dependency and also to keep our bundle size minimum. The ELEMENT_META_KEY is a constant and you can either create a separate constant file to keep it or drop it in the "element.metadata.ts" file itself.

TypeScript
export const ELEMENT_META_KEY = '__ELEMENT_INFO__'

Let's see the other two decorators. They are quite simple though, all they do is accept the passed parameters and store it in the metadata.

TypeScript
/**
 * Marks the applied property as an input.
 * @param [attribute] True to bind the property with the attribute.
 * @param [dataType] The data type of the attribute.
 */
export function input(attribute = false, dataType = AttributeValueDataType.STRING): PropertyDecorator {
 return (target: object, property: string | symbol) => {
   const metadata = getMeta(target.constructor),
     { inputs } = metadata;
 
   if (inputs.has(property)) {
     throw new Error(
       `Input decorator is already applied for the property ${
         property as string
       }`
     );
   }
 
   inputs.add({ property, attribute, dataType });
   setMeta(target.constructor, metadata);
 };
}
 
/**
 * Marks the applied property as a CSS selector.
 * @param selector CSS selector.
 */
export function query(selector: string): PropertyDecorator {
 return (target: object, property: string | symbol) => {
   const metadata = getMeta(target.constructor),
     { accessors } = metadata;
 
   if (accessors.has(property)) {
     throw new Error(
       `Already a CSS selector is assigned for the property ${
         property as string
       }`
     );
   }
 
   accessors.set(property, { selector });
   setMeta(target.constructor, metadata);
 };
}

The input decorator takes a couple of properties: attribute and dataType. The attribute property says whether the input property value should be read from the DOM element attribute initially and should be kept in sync. The dataType tells the type of the property which we need to parse the value correctly when we read from the attribute.

The query decorator takes a single parameter which is nothing but the selector. Our decorators are ready and it's time to write the base class.

The Base Class

To convert a class into a webcomponent we have to not only register it by calling the customElements.define method and also it has to extend from the native HTMLElement or any other built-in html element. We are gonna extend our base class from HTMLElement.

Create a file with the name "base-element.ts" and drop the below class into it.

TypeScript
class BaseElement extends HTMLElement {
} 

We've to do quite a bit of work here. First, we've to read the template and render the component. Second, we've to override the getters and setters of those properties to which we applied the decorators so the component can detect whenever there is any change in the inputs to refresh the UI or to query and return the child element whenever they access any of the properties decorated with the query decorator.

To summarize, the following are the main things we need to do.

  1. Read the template from metadata and render it.
  2. Override the getters and setters of the decorated properties.
  3. Everytime there is a change in the inputs queue that change and trigger a timer to invoke the onChanges method in the next tick.
  4. Create helper methods to perform DOM operations like add/remove css classes, add/remove styles etc.

Alright, let's first see how we can read the template from the metadata and render it.

TypeScript
/**
 * Base class for all custom web components.
 */
class BaseElement extends HTMLElement {
 
  /**
   * The component metadata.
   */
  private readonly _metadata: ElementMetadata = null;
 
  /**
   * True when the component is rendered.
   */
  private _rendered: boolean = false;
 
  protected constructor() {
    super();
    // Read the metadata from the constructor.
    this._metadata = this.constructor[ELEMENT_META_KEY];
  }
 
  /**
   * Native life-cycle hook.
   */
  protected connectedCallback() {
    // Call the render if the component is not rendered.
    if (!this._rendered) {
      this.render();
      this._rendered = true;
    }
  }
 
  /**
   * Reads the template from metadata and renders the template.
   */
  protected render() {
    if (!this._metadata.tpl) {
      return;
    }
 
    const template = document.createElement('template');
    template.innerHTML = this._metadata.tpl;
    this.appendChild(template.content.cloneNode(true));
  }
}

The important thing to note down is we've hooked into the native connectedCallback lifecycle handler of the web component to render the template. We've also created a flag _rendered to make sure to render only once.

Next we need to override the getters and setters of the decorated properties. Let's see how we can make the query decorator work first. The query decorator turns the applied property into a CSS selector means every time you access the property it automatically queries and returns the matched DOM element. To make this happen we need to override the getter of the property to query and return the DOM element matching the CSS selector.

To override the getter/setter of a property in any object you can use the Object.defineProperty method.

TypeScript
Object.defineProperty(obj, propName, {
  get() {
    // Override
  },
  set(value) {
    // Override
  },
});

Here is our updated code.

TypeScript
export type UIElement = string | BaseElement | HTMLElement;
 
class BaseElement extends HTMLElement {
 
  ...
 
  /**
   * True when the component is initialized (applied the decorators and refreshed with the initial inputs state).
   */
  private _initialized: boolean = false;
 
  /**
   * Overrides the getter of the properties decorated with `query` decorator to return the dom elements
   * on accessing the properties.
   */
  private _applyAccessors() {
    [...this._metadata.accessors].forEach(
      ([prop, { selector }]) => {
        Object.defineProperty(this, prop, {
          get() {
            return this.$(selector);
          }
        });
      }
    );
  }
 
  private _element(el: UIElement): UIElement {
    if (arguments.length === 0 || el === 'self') {
      return this;
    }
 
    if (el instanceof HTMLElement) {
      return el;
    }
 
    return this.$(el as string);
  }
   
  protected connectedCallback() {
    if (!this._rendered) {
      this.render();
      this._rendered = true;
    }
 
    if (!this._initialized) {
      this._applyAccessors();
      this._initialized = true;
    }
  }
 
  /**
   * Returns the DOM element for the passed selector.
   * @param selector CSS selector.
   * @param [element] Optional parent element. If not passed the element is queried inside the current component.
   */
  $<T extends HTMLElement>(selector: string, element: UIElement = this): T {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return <any>this;
    }
 
    if (el === this) {
      return this.querySelector(selector);
    }
 
    if (el instanceof BaseElement) {
      return el.$(selector);
    }
 
    return el.querySelector(selector) as T;
  }
}

What we are doing in the _applyAccessors method is basically iterating over each of the applied properties and overriding the getter to query and return the child DOM element matching the passed selector in the decorator. The $ method returns the child element of the component or from the passed parent (element) matching the selector.

Let's see how we can make the input decorator work. This is a little complicated. Every time an input property changes we have to take the change and push to an internal queue and trigger the timer to update the UI in the next tick using setTimeout. Not only, if the attribute flag is true then we have to read the initial value from the DOM attribute and parse it correctly using the dataType.

TypeScript
function isVoid(val) {
  return val === null || val === undefined;
}
 
export type ElementChanges = Map<string, { oldValue: any; newValue: any }>;
 
 
class BaseElement extends HTMLElement {
 
  ...
 
  /**
   * Changes of inputs.
   */
  private _changes = new Map<string, { oldValue: any; newValue: any }>();
 
  /**
   * The current state of properties.
   */
  private _props = new Map<string, any>();
 
  /**
   * Timer to refresh the UI from the changes map.
   */
  private _updateTimer: any = null;
 
  /**
   * Overrides the getter and setter of the properties decorated with `input` decorator.
   * The getter is overridden to return the current state from the `_props` property and the setter is
   * overridden to track the change and push to the `changes` map eventually triggering the update timer to
   * refresh the UI in the next tick.
   */
  private _applyInputs() {
    [...this._metadata.inputs].forEach(({ property, attribute, dataType }) => {
      let value;
 
      // If attribute is passed as `true` then read the initial value of the property from
      // DOM attribute parse it based on the data type and store it in the `_props`.
      if (attribute) {
        let attrValue: any = this.getAttr(property);
 
        if (attrValue !== null) {
          if (
            dataType === AttributeValueDataType.NUMBER &&
            !isNaN(parseFloat(attrValue))
          ) {
            attrValue = parseFloat(attrValue);
          } else if (dataType === AttributeValueDataType.BOOLEAN) {
            attrValue = attrValue === 'true' || attrValue === '';
          }
 
          value = attrValue;
        } else {
          value = this[property];
        }
 
        if (!isVoid(value) && value !== attrValue) {
          this.setAttr({ [property]: value });
        }
      } else {
        value = this[property];
      }
 
      this._pushChange(property, value);
      this._props.set(property, value);
 
      const target = this;
 
      // Override the getter and setter.
      // On setting a new value push the change and trigger the timer.
      Object.defineProperty(this, property, {
        get() {
          return target._props.get(property);
        },
        set(value) {
          if (attribute) {
            if (value) {
              target.setAttr({
                [property]: !isVoid(value) ? value.toString() : value
              });
            } else {
              target.removeAttr(property);
            }
          }
 
          target._pushChange(property, value);
          target._props.set(property, value);
          target._initialized && target._triggerUpdate();
        }
      });
    });
  }
 
  /**
   * Checks if there is really a change if yes then push it to the `_changes` map.
   * @param prop
   * @param value
   */
  private _pushChange(prop: string, value: any) {
    if (!this._changes.has(prop)) {
      this._changes.set(prop, { oldValue: this[prop], newValue: value });
      return;
    }
 
    const { oldValue, newValue } = this._changes.get(prop);
    if (oldValue === newValue && this._initialized) {
      this._changes.delete(prop);
      return;
    }
 
    this._changes.set(prop, { oldValue, newValue: value });
  }
 
  /**
   * Kicks the UI update timer.
   */
  private _triggerUpdate() {
    if (this._updateTimer) {
      return;
    }
 
    this._updateTimer = setTimeout(() => this.refresh(), 0);
  }
 
  protected connectedCallback() {
    if (!this._rendered) {
      this.render();
      this._rendered = true;
    }
 
    if (!this._initialized) {
      this._applyAccessors();
      this._applyInputs();
      this._initialized = true;
    }
 
    this.refresh();
  }
   
  /**
   * Invoked whenever there is a change in inputs.
   * @param changes
   */
  protected onChanges(changes) {}
 
  protected refresh() {
    this.onChanges(this._changes);
    this._changes.clear();
    this._updateTimer && window.clearTimeout(this._updateTimer);
    this._updateTimer = null;
  }
}

The _changes property is used to store the queue of changes to the inputs. The _props stores the latest value of those properties. That's all the work we've to do to make the decorators work.

Let's add some methods that help to manipulate the DOM.

TypeScript
export interface KeyValue {
 [key: string]: any;
}
 
import { isVoid } from "./util";
import { KeyValue, UIElement } from "./base-element";
 
class BaseElement extends HTMLElement {
 
  ...
 
  /**
   * Adds single or multiple css classes.
   * @param classes
   * @param [element]
   */
  addClass(
    classes: string | Array<string>,
    element: UIElement = this
  ): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    el.classList.add(...(Array.isArray(classes) ? classes : [classes]));
    return this;
  }
 
  /**
   * Removes single or multiple css classes.
   * @param classes
   * @param [element]
   */
  removeClass(
    classes: string | Array<string>,
    element: UIElement = this
  ): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    el.classList.remove(...(Array.isArray(classes) ? classes : [classes]));
    return this;
  }
 
  /**
   * Applies passed styles.
   * @param styles
   * @param [element]
   */
  addStyle(styles: KeyValue, element: UIElement = this): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    Object.entries(styles).forEach(([k, v]) => {
      if (k.startsWith('--')) {
        el.style.setProperty(k, v);
      } else if (v === null) {
        this.removeStyles(k, el);
      } else {
        el.style[k] = v;
      }
    });
    return this;
  }
 
  /**
   * Removes passed styles.
   * @param styles
   * @param [element]
   */
  removeStyles(
    styles: string | Array<string>,
    element: UIElement = this
  ): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    (Array.isArray(styles) ? styles : [styles]).forEach(
      style => (el.style[style] = null)
    );
    return this;
  }
 
  /**
   * Returns passed attribute's value.
   * @param name
   * @param [element]
   */
  getAttr(name: string, element: UIElement = this): string {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return '';
    }
 
    return el.getAttribute(name);
  }
 
  /**
   * Sets the attributes.
   * @param obj
   * @param [element]
   */
  setAttr(obj: KeyValue, element: UIElement = this): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    Object.entries(obj).forEach(([key, value]) =>
      isVoid(value) ? this.removeAttr(key) : el.setAttribute(key, value)
    );
    return this;
  }
 
  /**
   * Removes the passed attributes.
   * @param attrs
   * @param [element]
   */
  removeAttr(
    attrs: string | Array<string>,
    element: UIElement = this
  ): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    (Array.isArray(attrs) ? attrs : [attrs]).forEach(attr =>
      el.removeAttribute(attr)
    );
 
    return this;
  }
 
  /**
   * Updates the inner html.
   * @param html
   * @param [element]
   */
  updateHtml(html: string, element: UIElement = this): BaseElement {
    const el = this._element(element) as HTMLElement;
 
    if (!el) {
      return this;
    }
 
    el.innerHTML = !isVoid(html) ? html : '';
    return this;
  }
}

You can see there is an onChanges protected method that takes the changes parameter that is not implemented and this method should be overridden by the derived classes to perform the DOM operations using the helper methods.

Our base class is pretty much ready, as a final touch let's expose two additional life-cycle hooks that can be used by derived classes to add custom logic whenever the element is connected to or disconnected from DOM.

TypeScript
/**
* Native life-cycle hook.
*/
protected connectedCallback() {
 ...
 
 // Call our custom life-cycle hook method.
 this.onConnected();
 
 // Refresh the UI with the initial input property values.
 this.refresh();
}
 
/**
* Native life-cycle hook.
*/
protected disconnectedCallback() {
 this.onDisconnected();
}
 
/**
* Custom life-cycle hook meant to be overridden by derived class if needed.
*/
protected onConnected() {}
 
/**
* Custom life-cycle hook meant to be overridden by derived class if needed.
*/
protected onDisconnected() {}

Complete the Smiley Element

We've done all the work to create custom web components. Below is the complete code of our SmileyElement we saw earlier that displays different emojis based on the passed input.

TypeScript
import { BaseElement, element, ElementChanges, input, query } from './lib';
 
enum sizeRemMap {
 'small' = 1,
 'medium' = 2,
 'large' = 3,
}
 
enum smileyMap {
 'happy'= '😀',
 'lol' = '😂',
 'angel' = '😇',
 'hero' = '😎',
 'sad' = '😞',
 'cry' = '😢',
 'romantic' = '😍',
 'sleep' = '😴',
 'nerd' = 'ðŸ¤"'
}
 
@element('my-smiley', `<span></span>`)
class SmileyElement extends BaseElement {
 
 @input(true)
 type: string = 'happy';
 
 @input(true)
 size: 'small' | 'medium' | 'large' = 'medium';
 
 @query('span')
 spanEl;
 
 onChanges(changes: ElementChanges) {
   if (changes.has('type')) {
     this.updateHtml(smileyMap[this.type || 'happy'], this.spanEl);
   }
 
   if (changes.has('size')) {
     this.addStyle({ 'font-size': `${sizeRemMap[this.size]}rem`}, this.spanEl);
   }
 }
}

We've passed the attribute parameter as true in the input decorators to pass those values as attributes in HTML. You can see the onChanges method that we are checking if the input parameters like type or size is changed and updating the DOM accordingly.

Let's create a small app component to render multiple smileys.

TypeScript
@element('my-app', `
 <my-smiley type="happy"></my-smiley>
 <my-smiley type="lol"></my-smiley>
 <my-smiley type="angel"></my-smiley>
 <my-smiley type="hero"></my-smiley>
 <my-smiley type="sad"></my-smiley>
 <my-smiley type="cry"></my-smiley>
 <my-smiley type="romantic"></my-smiley>
 <my-smiley type="sleep"></my-smiley>
 <my-smiley type="nerd"></my-smiley>
`)
class App extends BaseElement {
}

Finally to render the App component we need to wire-up a handler to the DOMContentLoaded event of the document.

TypeScript
document.addEventListener('DOMContentLoaded', () => {
 const app = document.createElement('my-app');
 document.querySelector('.app-container').appendChild(app);
});

Finally, we've done the development. Let's launch the "index.html" file and see everything works by running the below command.

Shell
npm start

If everything went well you should see the below screen,

What's next?

This is a small attempt to create our very own UI library to build components using pure native concepts leveraging decorators. For real-world usage please look into the tiny project that provides more decorators and tons of helper methods to work with DOM. Please give the repo a star and feel free to fork it. You can also try something new like how about building simple data binding in templates so we don't have to do manual DOM operations. Go on and give it a try! It's a lot of fun to build something of your own and use it in real apps.

Source Code: https://github.com/vjai/base-element

Tiny: https://github.com/vjai/tiny

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)