JavaScript in the browser has evolved. Developers who want to take advantage of the latest features have the option of going framework-less with less hassle. Options normally reserved to front-end frameworks, such as a component-based approach, is now feasible in plain old JavaScript.
In this take, I’ll showcase all the latest JavaScript features, using a UI that features author data with a grid and a search filter. To keep it simple, once a technique gets introduced, I’ll move on to the next technique so as not to belabor the point. For this reason, the UI will have an Add option, and a dropdown search filter. The author model will have three fields: name, email, and an optional topic. Form validation will be included mostly to show this framework-less technique without being thorough.
The once plucky language has grown up with many modern features such as Proxies, import/export, the optional chain operator, and web components. This fits perfectly within the Jamstack, because the app renders on the client via HTML and vanilla JavaScript.
I’ll leave out the API to stay focused on the app, but I’ll point to where this integration can occur within the app.
Getting Started
The app is a typical JavaScript app with two dependencies: an http-server and Bootstrap. The code will only run in the browser, so there’s no back end other than one to host static assets. The code is up on GitHub for you to play with.
Assuming you have the latest Node LTS installed on the machine:
mkdir framework-less-web-components
cd framework-less-web-components
npm init
This should end up with a single package.json
file where to put dependencies.
To install the two dependencies:
npm i http-server bootstrap@next --save-exact
- http-server: an HTTP server to host static assets in the Jamstack
- Bootstrap: a sleek, powerful set of CSS styles to ease web development
If you feel http-server
isn’t a dependency, but a requirement for this app to run, there’s the option to install it globally via npm i -g http-server
. Either way, this dependency isn’t shipped to the client, but only serves static assets to the client.
Open the package.json
file and set the entry point via "start": "http-server"
under scripts
. Go ahead and fire up the app via npm start
, which will make http://localhost:8080/
available to the browser. Any index.html
file put in the root folder gets automatically hosted by the HTTP server. All you do is a refresh on the page to get the latest bits.
The folder structure looks like this:
┳
┣━┓ components
┃ ┣━━ App.js
┃ ┣━━ AuthorForm.js
┃ ┣━━ AuthorGrid.js
┃ ┗━━ ObservableElement.js
┣━┓ model
┃ ┣━━ actions.js
┃ ┗━━ observable.js
┣━━ index.html
┣━━ index.js
┗━━ package.json
This is what each folder is meant for:
components
: HTML web components with an App.js
and custom elements that inherit from ObservableElement.js
model
: app state and mutations that listen for UI state changes
index.html
: main static asset file that can be hosted anywhere
To create the folders and files in each folder, run the following:
mkdir components model
touch components/App.js components/AuthorForm.js components/AuthorGrid.js components/ObservableElement.js model/actions.js model/observable.js index.html index.js
Integrate Web Components
In a nutshell, web components are custom HTML elements. They define the custom element that can be put in the markup, and declare a callback method that renders the component.
Here’s a quick rundown of a custom web component:
class HelloWorldComponent extends HTMLElement {
connectedCallback() { // callback method
this.innerHTML = 'Hello, World!'
}
}
// Define the custom element
window.customElements.define('hello-world', HelloWorldComponent)
// The markup can use this custom web component via:
// <hello-world></hello-world>
If you feel you need a more gentle introduction to web components, check out the MDN article. At first, they may feel magical, but a good grasp of the callback method makes this perfectly clear.
The main index.html
static page declares the HTML web components. I’ll use Bootstrap to style HTML elements and bring in the index.js
asset that becomes the app’s main entry point and gateway into JavaScript.
Bust open the index.html
file and put this in place:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Framework-less Components</title>
</head>
<body>
<template id="html-app">
<div class="container">
<h1>Authors</h1>
<author-form></author-form>
<author-grid></author-grid>
<footer class="fixed-bottom small">
<p class="text-center mb-0">
Hit Enter to add an author entry
</p>
<p class="text-center small">
Created with ❤ By C R
</p>
</footer>
</div>
</template>
<template id="author-form">
<form>
<div class="row mt-4">
<div class="col">
<input type="text" class="form-control" placeholder="Name" aria-label="Name">
</div>
<div class="col">
<input type="email" class="form-control" placeholder="Email" aria-label="Email">
</div>
<div class="col">
<select class="form-select" aria-label="Topic">
<option>Topic</option>
<option>JavaScript</option>
<option>HTMLElement</option>
<option>ES7+</option>
</select>
</div>
<div class="col">
<select class="form-select search" aria-label="Search">
<option>Search by</option>
<option>All</option>
<option>JavaScript</option>
<option>HTMLElement</option>
<option>ES7+</option>
</select>
</div>
</div>
</form>
</template>
<template id="author-grid">
<table class="table mt-4">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Topic</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</template>
<template id="author-row">
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</template>
<nav class="navbar navbar-expand-lg navbar-light bg-dark">
<div class="container-fluid">
<a class="navbar-brand text-light" href="/">
Framework-less Components with Observables
</a>
</div>
</nav>
<html-app></html-app>
<script type="module" src="index.js"></script>
</body>
</html>
Pay close attention to the script
tag with a type
attribute set to module
. This is what unlocks import/export in vanilla JavaScript in the browser. The template
tag with an id
defines the HTML elements that enable web components. I’ve broken up the app into three main components: html-app
, author-form
, and author-grid
. Because nothing’s defined in JavaScript yet, the app will render the navigation bar without any of the custom HTML tags.
To start off easy, place this in ObservableElement.js
. It’s the parent element to all the author components:
export default class ObservableElement extends HTMLElement {
}
Then, define the html-app
component in App.js
:
export default class App extends HTMLElement {
connectedCallback() {
this.template = document
.getElementById('html-app')
window.requestAnimationFrame(() => {
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
})
}
}
Note the use of export default
to declare JavaScript classes. This is the capability I enabled via the module
type when I referenced the main script file. To use web components, inherit from HTMLElement
and define the connectedCallback
class method. The browser takes care of the rest. I’m using requestAnimationFrame
to render the main template before the next repaint in the browser.
This is a common technique you’ll see with web components. First, grab the template via an element ID. Then, clone the template via cloneNode
. Lastly, appendChild
the new content
into the DOM. If you run into any problems where web components don’t render, be sure to check that the cloned content got appended to the DOM first.
Next, define the AuthorGrid.js
web component. This one will follow a similar pattern and manipulate the DOM a bit:
import ObservableElement from './ObservableElement.js'
export default class AuthorGrid extends ObservableElement {
connectedCallback() {
this.template = document
.getElementById('author-grid')
this.rowTemplate = document
.getElementById('author-row')
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
this.table = this.querySelector('table')
this.updateContent()
}
updateContent() {
this.table.style.display =
(this.authors?.length ?? 0) === 0
? 'none'
: ''
this.table
.querySelectorAll('tbody tr')
.forEach(r => r.remove())
}
}
I defined the main this.table
element with a querySelector
. Because this is a class, it’s possible to keep a nice reference to the target element by using this
. The updateContent
method mostly nukes the main table when there are no authors to show in the grid. The optional chaining operator (?.
) and null coalescing takes care of setting the display
style to none.
Take a look at the import
statement, because it brings in the dependency with a fully qualified extension in the file name. If you’re used to Node development, this is where it differs from the browser implementation, which follows the standard, where this does require a file extension like .js
. Learn from me and be sure to put the file extension while working in the browser.
Next, the AuthorForm.js
component has two main parts: render the HTML and wire up element events to the form.
To render the form, open AuthorForm.js
:
import ObservableElement from './ObservableElement.js'
export default class AuthorForm extends ObservableElement {
connectedCallback() {
this.template = document
.getElementById('author-form')
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
this.form = this.querySelector('form')
this.form.querySelector('input').focus()
}
resetForm(inputs) {
inputs.forEach(i => {
i.value = ''
i.classList.remove('is-valid')
})
inputs[0].focus()
}
}
The focus
guides the user to start typing on the first input element available in the form. Be sure to place any DOM selectors after the appendChild
, as otherwise this technique won’t work. The resetForm
isn’t used right now but will reset the state of the form when the user presses Enter.
Wire up events via addEventListener
by appending this code inside the connectedCallback
method. This can be added to the very end of the connectedCallback
method:
this.form
.addEventListener('keypress', e => {
if (e.key === 'Enter') {
const inputs = this.form.querySelectorAll('input')
const select = this.form.querySelector('select')
console.log('Pressed Enter: ' +
inputs[0].value + '|' +
inputs[1].value + '|' +
(select.value === 'Topic' ? '' : select.value))
this.resetForm(inputs)
}
})
this.form
.addEventListener('change', e => {
if (e.target.matches('select.search')
&& e.target.value !== 'Search by') {
console.log('Filter by: ' + e.target.value)
}
})
These are typical event listeners that get attached to the this.form
element in the DOM. The change
event uses event delegation to listen for all change events in the form but targets only the select.search
element. This is an effective way to delegate a single event to as many target elements in the parent element. With this in place, typing anything in the form and hitting Enter resets the form back to zero state.
To get these web components to render on the client, open index.js
and put this in:
import AuthorForm from './components/AuthorForm.js'
import AuthorGrid from './components/AuthorGrid.js'
import App from './components/App.js'
window.customElements.define('author-form', AuthorForm)
window.customElements.define('author-grid', AuthorGrid)
window.customElements.define('html-app', App)
Feel free to refresh the page in the browser now and play with the UI. Open up your developer tools and look at the console messages as you click and type in the form. Pressing the Tab key should help you navigate between input elements in the HTML document.
Continue reading
Build a Web App with Modern JavaScript and Web Components
on SitePoint.