Once upon a time, in order to create web components, one had to resort to a front end framework like angular or react (yes yes, I’ve heard the “it’s only a lib” before from all the angularists in the crowd) .
Web component is one awesome piece of technology (or rather, a combination of 4 technologies). Now that browser support is rising, publishing a web component to github for global consumption seems like a good contribution to the community.
In this article, we will build upon the example shown in the former article in the web components series: the modal window web component. You can read the article, or go directly to the repository on github.
We will structure a project in a scalable way, and modularize the components so that consumers would be able to consume only one component without the need to import the whole library.
Table of Contents
Clone the repository
- Make sure you have git installed;
git clone https://github.com/YonatanKra/web-components-ui-elements.git
git checkout before_componentization
yarn
ornpm i
You now have the repository with the modal window from the previous article. For the full result, just checkout master
.
Create the main file that imports everything
Like most modern libraries, we would like to create one file where people could import all of our components in one go (a.k.a. a barrel file). In addition, we’d like to add the ability to import specific components for performance sensitive applications (a.k.a. lazy load).
We can do that by splitting our code to separate modules both in folder structure as well as in the webpack code splitting configuration.
The components
folder:
Inside our src
folder create a components
folder. Inside create a ce-modal-window
folder and copy the ce-modal-window
files into it. In addition, create an index.js
file there.
In the index.js
file we will import CEModalWindow
and define the custom element. In CEModalWindow
we will remove the custom element definition.
This is how the files should look in the ce-modal-window
folder:
const templateString = ` | |
<style> | |
.overlay { | |
opacity: 1; | |
visibility: visible; | |
position: fixed; | |
width: 100%; | |
height: 100%; | |
top: 0; | |
left: 0; | |
background: rgba(0, 0, 0, 0.42); | |
-webkit-transition: opacity 0.5s; | |
transition: opacity 0.5s; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.overlay-hidden { | |
opacity: 0; | |
visibility: hidden; | |
-webkit-transition: opacity 0.5s, visibility 0s 0.5s; | |
transition: opacity 0.5s, visibility 0s 0.5s; | |
} | |
</style> | |
<div class="overlay overlay-hidden"> | |
<div class="overlay-content"></div> | |
</div> | |
`; | |
const template = document.createElement('template'); | |
template.innerHTML = templateString; | |
export class CEModalWindow extends HTMLElement{ | |
constructor() { | |
super(); | |
const shadowRoot = this.attachShadow({mode: 'open'}); | |
shadowRoot.appendChild(template.content.cloneNode(true)); | |
this._overlay = shadowRoot.querySelector('.overlay'); | |
this._content = shadowRoot.querySelector('.overlay-content'); | |
} | |
open(config) { | |
const supportedStyles = ['width', 'height']; | |
if (!config) { | |
return; | |
} | |
this._overlay.classList.remove('overlay-hidden'); | |
this._content.innerHTML = config.content; | |
supportedStyles.forEach((style) => { | |
CEModalWindow.setStyle(this._content, style, config[style]); | |
}); | |
} | |
close() { | |
this._overlay.classList.add('overlay-hidden'); | |
} | |
static setStyle(element, style, value) { | |
const pxStyles = ['width', 'height']; | |
if (value) { | |
if (pxStyles.indexOf(style) > -1) { | |
value += 'px'; | |
} | |
element.style[style] = value; | |
} | |
} | |
} |
import { CEModalWindow } from './ce-modal-window'; | |
if (!customElements.get('ce-modal-window')) { | |
window.customElements.define('ce-modal-window', CEModalWindow); | |
} |
Finally, let’s change our tests a bit to make them more robust by defining the custom element inside our spec file:
import { CEModalWindow } from './ce-modal-window'; | |
window.customElements.define('ce-tested-modal-window', CEModalWindow); | |
describe('app integration tests', () => { | |
let element, shadowRoot; | |
beforeEach(() => { | |
element = document.createElement('ce-tested-modal-window'); | |
shadowRoot = element.shadowRoot; | |
document.body.append(element); | |
}); | |
// check that the exposed API works | |
describe('init', () => { | |
it('should add a div with the overlay and overlay-hidden classes under the shadow root', () => { | |
expect(shadowRoot.querySelector('.overlay.overlay-hidden')).toBeTruthy(); | |
}); | |
}); | |
describe('open', () => { | |
it('should remove the hidden class from overlay', () => { | |
const overlay = shadowRoot.querySelector('.overlay.overlay-hidden'); | |
element.open({}); | |
expect(overlay.classList.contains('overlay-hidden')).toBeFalsy(); | |
}); | |
it('should add class transparent to the overlay if config.hideOverlay is true', () => { | |
const overlay = shadowRoot.querySelector('.overlay.overlay-hidden'); | |
element.open({ | |
hideOverlay: true | |
}); | |
expect(overlay.classList.contains('transparent')).toBeFalsy(); | |
}); | |
it('should insert the content string as HTML to the content element', () => { | |
const randId = Math.random().toString(2); | |
const content = shadowRoot.querySelector('.overlay-content'); | |
const htmlBefore = content.innerHTML; | |
const config = { | |
content: `<div id="${randId}">Hello CE!</div>` | |
}; | |
element.open(config); | |
expect(htmlBefore).toEqual(""); | |
expect(content.innerHTML).toEqual(config.content); | |
}); | |
it('should set width and height according to config values', () => { | |
const content = shadowRoot.querySelector('.overlay-content'); | |
const config = { | |
height: Math.round(Math.random() * 100 + 50), | |
width: Math.round(Math.random() * 100 + 50) | |
}; | |
element.open(config); | |
const overlayBoundingBox = content.getBoundingClientRect(); | |
expect(overlayBoundingBox.width).toEqual(config.width); | |
expect(overlayBoundingBox.height).toEqual(config.height); | |
}); | |
}); | |
describe('close', () => { | |
it('should add the overlay-hidden class', () => { | |
const overlay = shadowRoot.querySelector('.overlay'); | |
element.open({}); // we already know it removes the class | |
element.close(); | |
expect(overlay.classList.contains('overlay-hidden')); | |
}); | |
}); | |
afterEach(() => { | |
document.body.removeChild(element); | |
}) | |
}); |
All the tests pass. We can move on.
The main file
Let’s create src/main.spec.js
:
import './main'; | |
import { CEModalWindow } from "./components/ce-modal-window/ce-modal-window"; | |
describe('ui-elements integration tests', () => { | |
describe('ce-modal-window', () => { | |
it(`should be defined`, () => { | |
const ceModalWindowClass = window.customElements.get('ce-modal-window'); | |
expect(ceModalWindowClass).toBe(CEModalWindow); | |
}); | |
}); | |
}); |
And now implement src/main.js
that makes this test pass:
import * from './components/ce-modal-window'; |
The main file would just import our components and make sure they are defined correctly. We can add more integration tests here but for now it is enough.
Anyone who requires our main.js
file will get the custom elements we import to main.js
.
The demo folder
For order’s sake, we will give the demo its own folder.
Move the src/index.js
to a demo folder and import main.js
instead of using the standalone components:
import '../main'; | |
const modalWindow = document.createElement('ce-modal-window'); | |
modalWindow.addEventListener('click', () => { | |
modalWindow.close(); | |
}); | |
document.body.appendChild(modalWindow); | |
const button = document.createElement('button'); | |
button.innerText = 'Open modal'; | |
button.addEventListener('click', () => { | |
modalWindow.open({ | |
content: '<h1>Hello Modal</h1>', | |
height: 50, | |
width: 100 | |
}); | |
}); | |
document.body.appendChild(button); |
Here’s the commit for this step.
That’s so very cool. Our tests still pass, so our app is supposed to work as expected ( npm run serve
or npm run build
and then run the resulting dist\index.html
).
Now we need to tell our build process to split our files so they can be consumed separately.
File splitting
File splitting in webpack is a breeze. I’ve written an article about it when webpack 3.0 was all the rage. Webpack 4 makes the whole splitting so much easier…
In our case, we’d like to do the following:
- Expose the main file so one could import the whole library in one go
- Expose each component in its own file
- Create the demo folder
Webpack has us covered here. We just go to the config/webpack.common.js
file and change a few things:
- Our entry statement would now include our main module, the new demo folder and our components (currently we have only one — but we’re going to scale soon).
- The HTML webpack plugin now adds the HTML file inside the demo folder
- I’ve added a new plugin — the Clean webpack plugin, which conveniently removes the dist folder on every build
Here’s the webpack.common.js
code:
const path = require('path'); | |
const webpack = require('webpack'); | |
const HtmlWebpackPlugin = require('html-webpack-plugin'); | |
const CleanWebpackPlugin = require('clean-webpack-plugin'); | |
module.exports = { | |
entry: { | |
'demo/index' : './src/demo/index.js', | |
index: './src/main.js', | |
'lib/ce-modal-window/index': './src/components/ce-modal-window/index.js' | |
}, | |
plugins: [ | |
new CleanWebpackPlugin(), | |
new HtmlWebpackPlugin({ | |
title: 'TDD Challenge', | |
meta: { | |
viewport: 'width=device-width, initial-scale=1' | |
} | |
}) | |
], | |
module: { | |
rules: [ | |
// use the html loader | |
{ | |
test: /\.html$/, | |
exclude: /node_modules/, | |
use: {loader: 'html-loader'} | |
}, | |
// use the css loaders (first load the css, then inject the style) | |
{ | |
test: /\.css$/, | |
use: [ | |
'style-loader', | |
'css-loader' | |
] | |
}, | |
{ | |
test: /\.(png|gif|jpg|jpeg|svg|xml|json)$/, | |
use: [ 'url-loader' ] | |
} | |
] | |
}, | |
optimization: { | |
splitChunks: { | |
chunks: 'all', | |
cacheGroups: { | |
vendors: { | |
test: /[\\/]node_modules[\\/]/, | |
priority: -10 | |
}, | |
} | |
} | |
} | |
}; |
If you build ( npm run build
) a dist folder will be created and you will see it, inside our folder structure.
This way, anyone using our package will be able to do import 'web-components-ui-elements’
to get our main index.js
file or import 'web-components-ui-elements/components/ce-modal-window'
to just get the modal window component without loading the whole library.
You can see the full commit here.
NPM configurations (a.k.a. package.json)
The package.json should point to the main file. It’s already doing that (in the main
attribute). We should add a description and change the name of our module.
There are lots and lots of configurations to a package.json
. You can read more about them here and modify as you like. Here’s what I came up with:
{ | |
"name": "web-components-ui-elements", | |
"version": "1.0.0", | |
"description": "UI elements library based on web components", | |
"main": "index.js", | |
"scripts": { | |
"test": "karma start config/karma.conf.js", | |
"build": "webpack --config config/webpack.prod.js", | |
"build:watch": "webpack --config config/webpack.prod.js --watch", | |
"serve": "webpack-dev-server --config config/webpack.dev.js" | |
}, | |
"author": "Yonatan Kra", | |
"license": "MIT", | |
"devDependencies": { | |
"clean-webpack-plugin": "^0.1.19", | |
"copy-webpack-plugin": "^4.5.3", | |
"css-loader": "^1.0.0", | |
"file-loader": "^2.0.0", | |
"html-loader": "^0.5.5", | |
"html-webpack-plugin": "^3.2.0", | |
"jasmine": "^3.2.0", | |
"karma": "^3.0.0", | |
"karma-webpack": "^3.0.5", | |
"style-loader": "^0.23.1", | |
"url-loader": "^1.1.2", | |
"webpack": "^4.20.2", | |
"webpack-cli": "^3.1.2", | |
"webpack-dev-server": "^3.1.9", | |
"webpack-karma-jasmine": "^3.0.4" | |
}, | |
"dependencies": { | |
"webpack-merge": "^4.1.4" | |
}, | |
"repository": { | |
"type": "git", | |
"url": "https://github.com/yonatankra/web-components-ui-elements.git" | |
} | |
} |
Documentation
Our users can’t read our mind. Let’s add documentation:
- Edit the README.MD file (if it doesn’t already exist, create it).
- Check out the github markdown page to see how to format your documentation.
- Let’s document our work:
A web components UI library.
npm install web-components-ui-elements
You can require the whole library:
import * from web-components-ui-elements;
And use in the DOM like this:
<ce-modal-window id="modal-window"></ce-modal-window>
And then use the API:
const modal = document.querySelector('#modal-window');
modal.open({
content: '<h1>Hello Modal!</h1>'
});
// close the modal when clicking on it
function closeModal() {
modal.close();
modal.removeEventListener('click', closeModal);
}
modal.addEventListener('click', closeModal);
If you want, you can just create the element on your own and add it to the DOM:
const modalWindow = document.createElement('ce-modal-window');
modalWindow.addEventListener('click', () => {
modalWindow.close();
});
document.body.appendChild(modalWindow);
const button = document.createElement('button');
button.innerText = 'Open modal';
button.addEventListener('click', () => {
modalWindow.open({
content: '<h1>Hello Modal</h1>',
height: 50,
width: 100
});
});
document.body.appendChild(button);
ce-modal-window
Accepts a config object and opens the modal.
Closes the modal
{
content: '', // <string> HTML snippet to show inside the modal
hideOverlay: false, // <boolean> show or hide the opack overlay behind the modal
height: 150, // <number> height of the modal
width: 150, // <number> width of the modal
}
- Clone
npm i
npm run build
to get the buildnpm run test
to testnpm run serve
to run a development environment
4. Let’s install something that auto-creates a table of contents (TOC) for us:
`npm i -D doctoc`
5. Add a precommit hook to build the TOC inside the package.json:
`”precommit”: “doctoc ./README.md”,`
Here’s the commit for these changes: The Commit.
NPM Publish
Before we continue, please add "private": true
to the package.json. Remove it when your library is truly ready to publish.
Done? Cool. This one is easy:
npm login
and follow along the instructions
npm publish
And……….. We are done!
Summary
Congratulations! You’ve just published a web component library to NPM.
You can maintain it and add more features to it.
Keep in mind that a CI/CD process should be setup, so we can manage an army of contributors to this new awesomeness.
Hope you had fun and learned something here 🙂