Stencil to Web Component to NPM

Publishing a Web Component Using Stencil (And Using It Anywhere)



·

Over the past couple of weeks we have explored creating custom components within Ionic/Angular, and creating custom web components using Stencil. Although both behave very similarly, the key difference between creating an Angular component, and a Stencil component, is that Stencil generates generic web components that can be used anywhere. Angular components can only be used within Angular (unless you are using Angular Elements.

Although you can use a web component generated with Stencil anywhere you like – in a Stencil application, an Angular application, a Vue application, or even without a framework – so far we have only covered creating a custom Stencil component where Stencil was kind of being used as the “framework” for the application. It is incorrect to consider Stencil itself to be a framework, the end result requires no “Stencil library” to be included in the application, but the Stencil App Starter does create a development environment where we are working within the Stencil syntax/structure. We create our Stencil components, run a build, and it generates our application for us.

In this tutorial, we will be covering how to export and publish a web component using Stencil such that it can be used anywhere. We will publish this web component on npm, and then we will walk through how to use that web component in an Ionic/Angular application. We will be using the Google Maps web component we created in the previous tutorial as an example.

By the end of this tutorial, we will have come full circle – we initially created a Google Maps component using Angular (which could only be used inside of Angular), and then we created the same component as a web component using Stencil (which we were just using in a Stencil environment), and now we will be publishing that web component and using it inside of Angular (but we could use it anywhere we like).

Use the Stencil Component Starter

We have been using the Stencil App Starter to create applications with Stencil, but there is also a Stencil Component Starter available which can be more easily used to publish standalone web components.

To generate a new application using this starter template, you would just run the following command:

git clone https://github.com/ionic-team/stencil-component-starter.git capacitor-google-maps

You would just change capacitor-google-maps to whatever it is that your component will be called. You should then make this new project your working directory and run:

npm install

Since the Google Maps web component that we created in the previous tutorial relies on Capacitor, we will need to install a few more dependencies and we will also need to install the types for Google Maps.

IMPORTANT: The following dependencies are only required for the Google Maps / Capacitor web component that we are using as an example, you do not need to install these otherwise.

npm install @capacitor/core --save
npm install @types/googlemaps --save-dev
npm install tslib --save-dev

Set up the Web Component

The component starter gives us a fantastic template to work with, but unfortunately, since we are just cloning an example repository we will have to modify a few things ourselves. The starter comes with an example MyComponent component, we will need to modify this throughout the application to reflect our component. This is just a simple matter of doing a bit of search and replace, I will use CapacitorGoogleMaps as an example. We will basically be changing any instance of my-component to capacitor-google-maps and any instance of MyComponent to CapacitorGoogleMaps.

Rename the following files and folders:

src/components/my-component -> src/components/capacitor-google-maps
src/components/my-component/my-component.tsx -> src/components/capacitor-google-maps/capacitor-google-maps.tsx
src/components/my-component/my-component.css -> src/components/capacitor-google-maps/capacitor-google-maps.css

Modify the names in src/components.d.ts to reflect the following:

/**
 * This is an autogenerated file created by the Stencil build process.
 * It contains typing information for all components that exist in this project
 * and imports for stencil collections that might be configured in your stencil.config.js file
 */
declare global {
  namespace JSX {
    interface Element {}
    export interface IntrinsicElements {}
  }
  namespace JSXElements {}

  interface HTMLStencilElement extends HTMLElement {
    componentOnReady(): Promise<this>;
    componentOnReady(done: (ele?: this) => void): void;
  }

  interface HTMLAttributes {}
}


import {
  CapacitorGoogleMaps as CapacitorGoogleMaps
} from './components/capacitor-google-maps/capacitor-google-maps';

declare global {
  interface HTMLCapacitorGoogleMapsElement extends CapacitorGoogleMaps, HTMLStencilElement {
  }
  var HTMLCapacitorGoogleMapsElement: {
    prototype: HTMLCapacitorGoogleMapsElement;
    new (): HTMLCapacitorGoogleMapsElement;
  };
  interface HTMLElementTagNameMap {
    "capacitor-google-maps": HTMLCapacitorGoogleMapsElement;
  }
  interface ElementTagNameMap {
    "capacitor-google-maps": HTMLCapacitorGoogleMapsElement;
  }
  namespace JSX {
    interface IntrinsicElements {
      "capacitor-google-maps": JSXElements.CapacitorGoogleMapsAttributes;
    }
  }
  namespace JSXElements {
    export interface CapacitorGoogleMapsAttributes extends HTMLAttributes {
      apikey?: string;
    }
  }
}

declare global { namespace JSX { interface StencilJSX {} } }

Modify stencil.config.js to reflect the following:

exports.config = {
  namespace: 'capacitorgooglemaps',
  generateDistribution: true,
  serviceWorker: false
};

exports.devServer = {
  root: 'www',
  watchGlob: '**/**'
}

Modify the following sections of package.json:

...snip...
  "name": "capacitor-google-maps",
  "version": "0.0.1",
  "description": "Stencil Component Starter",
  "main": "dist/capacitorgooglemaps.js",
  "types": "dist/types/index.d.ts",
  "collection": "dist/collection/collection-manifest.json",
  "files": [
    "dist/"
  ],
  "browser": "dist/capacitorgooglemaps.js",
  "scripts": {
    "build": "stencil build",
    "dev": "sd concurrent \"stencil build --dev --watch\" \"stencil-dev-server\" ",
    "serve": "stencil-dev-server",
    "start": "npm run dev",
    "test": "jest --no-cache",
    "test.watch": "jest --watch --no-cache"
  },
...snip...

Create the Web Component

With the changes above out of the way, now we just need to add the functionality for the web component. As I mentioned, we will be reusing the code from this tutorial so I won’t be explaining any of it here.

Modify src/components/capacitor-google-maps/capacitor-google-maps.tsx to reflect the following:

import { Component, Prop, Method } from '@stencil/core';
import { Plugins } from '@capacitor/core';

const { Geolocation, Network } = Plugins;

@Component({
  tag: 'capacitor-google-maps',
  styleUrl: 'capacitor-google-maps.css'
})
export class CapacitorGoogleMaps {

  @Prop() apikey: string;

  public map: any;
  public markers: any[] = [];
  private mapsLoaded: boolean = false;
  private networkHandler = null;

  render() {
    return <div id='google-map-container'></div>
  }

  componentDidLoad() {

    this.init().then(() => {
      console.log("Google Maps ready.")
    }, (err) => { 
      console.log(err);
    });

  }

  init(): Promise<any> {

    return new Promise((resolve, reject) => {

      this.loadSDK().then(() => {

        this.initMap().then(() => {
          resolve(true);
        }, (err) => {
          reject(err);
        });

      }, (err) => {

        reject(err);

      });

    });

  }

  loadSDK(): Promise<any> {

    console.log("Loading Google Maps SDK");

    return new Promise((resolve, reject) => {

      if(!this.mapsLoaded){

        Network.getStatus().then((status) => {

          if(status.connected){

            this.injectSDK().then(() => {
              resolve(true);
            }, (err) => {
              reject(err);
            });

          } else {

            if(this.networkHandler == null){

              this.networkHandler = Network.addListener('networkStatusChange', (status) => {

                if(status.connected){

                  this.networkHandler.remove();

                  this.init().then(() => {
                    console.log("Google Maps ready.")
                  }, (err) => { 
                    console.log(err);
                  });

                }

              });

            }

            reject('Not online');
          }

        }, (err) => {

          console.log(err);

          // NOTE: navigator.onLine temporarily required until Network plugin has web implementation
          if(navigator.onLine){

            this.injectSDK().then(() => {
              resolve(true);
            }, (err) => {
              reject(err);
            });

          } else {
            reject('Not online');
          }

        });

      } else {
        reject('SDK already loaded');
      }

    });


  }

  injectSDK(): Promise<any> {

    return new Promise((resolve, reject) => {

      window['mapInit'] = () => {
        this.mapsLoaded = true;
        resolve(true);
      }

      let script = document.createElement('script');
      script.id = 'googleMaps';

      if(this.apikey){
        script.src = 'https://maps.googleapis.com/maps/api/js?key=' + this.apikey + '&callback=mapInit';
        document.body.appendChild(script);
      } else {
        reject('API Key not supplied');
      }

    });

  }

  initMap(): Promise<any> {

    return new Promise((resolve, reject) => {

      Geolocation.getCurrentPosition().then((position) => {

        let latLng = new google.maps.LatLng(position.coords.latitude, position.coords.longitude);

        let mapOptions = {
          center: latLng,
          zoom: 15
        };

        this.map = new google.maps.Map(document.getElementById('google-map-container'), mapOptions);
        resolve(true);

      }, () => {

        reject('Could not initialise map');

      });

    });

  }

  @Method()
  addMarker(lat: number, lng: number): void {

    let latLng = new google.maps.LatLng(lat, lng);

    let marker = new google.maps.Marker({
      map: this.map,
      animation: google.maps.Animation.DROP,
      position: latLng
    });

    this.markers.push(marker);

  }

  @Method()
  getCenter(){
    return this.map.getCenter();
  }

}

Modify src/components/capacitor-google-maps/capacitor-google-maps.css to reflect the following:

capacitor-google-maps {
    width: 100%;
    height: 100%;
}

#google-map-container {
    width: 100%;
    height: 100%;
}

If you want to test the component, which is a good idea before you publish it, you will also need to modify src/index.html to reflect the following:

<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0">
  <title>Stencil Component Starter</title>
  <script src="/build/capacitorgooglemaps.js"></script>

</head>
<body>

    <div style="width:500px; height: 500px">
        <capacitor-google-maps apikey="YOUR_API_KEY"></capacitor-google-maps>
    </div>

</body>
</html>

Notice that we have changed the script tag to include our component. You could now run the application with:

npm run dev

to see if everything is working as you would expect. Keep in mind that running this particular example requires that you get an API Key from Google.

Publish the Web Component

Now we need to publish our component to npm. This is a rather simple step, but it does require that you have an npm account set up.

All you need to do is run:

npm run build

to create a build of your application, and then you can run:

npm publish

to submit it to npm. Once it has been published, you should find it available at:

https://www.npmjs.com/package/YOUR-PACKAGE-NAME

IMPORTANT: If you are following along with this example you will need to change the name of your package in package.json, since I’ve already got dibs on capacitor-google-maps. You can check out my published package here.

Use the Web Component

With our package published to NPM, we can very easily include our web component anywhere simply by adding this script tag:

<script src='https://unpkg.com/[email protected]/dist/capacitorgooglemaps.js'></script>

If you want to use it in conjunction with another framework, there are typically more steps involved to enable the use of custom elements. However, this can usually be solved with a quick Google search. If you want to use this web component in an Ionic/Angular application you will need to set up the CUSTOM_ELEMENTS_SCHEMA.

To do that, just import CUSTOM_ELEMENTS_SCHEMA in your app.module.ts file:

import { ErrorHandler, NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

and then add a schemas property above your imports property:

  schemas: [ CUSTOM_ELEMENTS_SCHEMA ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp)
  ],

Using this web component throughout your Ionic/Angular application should now be as simple as dropping this wherever you like:

<capacitor-google-maps apikey="YOUR_API_KEY"></capacitor-google-maps>

For example, in src/pages/home/home.html:

<ion-header>
  <ion-navbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content>

    <capacitor-google-maps apikey="YOUR_API_KEY"></capacitor-google-maps>

</ion-content>

For other integration methods, check out the Stencil Component Starter repository.

Summary

The cool thing about building and distributing web components this way is that it makes them super portable. We can now use this component just about anywhere we like, and although there is not yet universal support for web components in browsers, Stencil automatically includes polyfills for browsers that don’t support web components. That means you can confidently use web components that were built with Stencil in all of the browsers Stencil supports, which includes: Chrome (and all chrome based browsers), Safari, Firefox, Edge, and IE11.

What to watch next...