A simple search component without external tooling Thursday August 31, 2023
I keep hearing that it's now possible to build self-contained web components using only native web technologies. Until now, creating a component that can be freely incorporated into another web page has required layers of inscrutable translators and compilers and bundlers between the code and the browser.
Here's the code in this page that enables the search interface above:
<notes-search
data-src="/posts/index.json"
data-placeholder="Search by title and press enter.">
</notes-search>
<script lang="javascript" src="/static/notes-search.js"></script>
It's just an element using a custom tag notes-search
and a script
tag to
load the notes-search.js
script that defines the custom
element. Click the link above to take a look at the script. That's the entire
thing, a web component in a single file that can be loaded on any web page!
The component
To build this component I used the custom element API and shadow DOM to create a little self-contained DOM that can be styled without affecting the rest of the page. It starts with a class definition:
class NotesSearch extends HTMLElement {
constructor() {
super();
const wrapper = document.createElement('div');
const form = wrapper.appendChild(document.createElement('form'));
const input = form.appendChild(document.createElement('input'));
input.setAttribute('name', 'q');
input.setAttribute('autocomplete', 'off');
input.setAttribute('placeholder', this.getAttribute('data-placeholder'));
const button = form.appendChild(document.createElement('button'));
button.textContent = 'Search';
const results = wrapper.appendChild(document.createElement('ul'));
// ...
}
When the element is constructed, we create a wrapper
div and populate it with
the basic controls. The input placeholder is fetched from an attribute
data-placeholder
. This is my least favorite part of the code since I am used
to using JSX or some other template language, but there isn't much of it.
We also set up the form submission and fetch the index data needed for the search:
// Handle form submit.
form.addEventListener(
'submit',
this._searchSubmitFormHandler(input, results),
);
// Select text on focus.
input.addEventListener('focus', e => e.target.select());
// We aren't using await because constructor isn't async.
this._fetchNotesIndex()
.then(index => {
this.index = index;
})
.catch(e => {
alert('Failed to fetch notes index.', e);
});
This is not an advanced tool at all: For illustration purposes I generated a simple JSON list of my posts and I'm using that as the search index. A better component might have a search backend to call, but this is just a little static website.
Connecting it up
You may have noticed that the constructor didn't actually add anything to the
document yet. For that, we are going to implement connectedCallback
and
utilize the shadow DOM API:
connectedCallback() {
this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = this.style;
this.shadowRoot.append(style, this.wrapper);
this.input.focus();
}
Here we call attachShadow
, which defines this.shadowRoot
, to which we
append our style element and our wrapper element. Finally, we focus the search
input.
Styling it
This value of this.style
is a static string value on the class that contains
all of the CSS for the component. Because we are only styling the shadow DOM,
we don't have to worry about style conflicts or pollution, we can use
whatever elements and class names we want!
style = `
:host {
--form-border: #ccc;
--form-bg: #fefefe;
--focus-border: blue;
--input-text: #000;
}
form {
display: inline-flex;
outline: 1px solid var(--form-border);
border-radius: 0.25rem;
padding: 0.5rem;
width: 100%;
background: var(--form-bg);
}
/* ... */
`;
There are other ways we could load our styles. For instance, if you wanted your
CSS to be in a different file (better syntax highlighting!), you could add a
<link rel="stylesheet">
to your shadow DOM instead.
Also: The :host
CSS selector is the shadow DOM equivalent of the :root
selector, for the purpose that we are using it today. Here, I'm using it to
define all the colors in one place.
Doing the thing
Our form submit handler is implemented in the method
_searchSubmitFormHandler
:
// return a submit callback for the search form
_searchSubmitFormHandler(input, results) {
return async e => {
e.preventDefault();
if (!input.value) {
results.replaceChildren();
return;
}
const value = `${input.value}`.toLowerCase();
const resultRows = this.index.notes
.filter(note => note.title.toLowerCase().indexOf(value) >= 0)
.map(note => {
const row = document.createElement('li');
const item = row.appendChild(document.createElement('a'));
item.textContent = note.title;
item.href = note.url;
return row;
});
results.replaceChildren(...resultRows);
input.select();
};
}
Given an input
element and a results
element, this method returns a handler
that performs the search and displays the results. Again, the search itself is
very primitive since it's mostly for illustration purposes. We also select
the contents of the input element after the search, as a little treat.
Fetching the index
For completeness, here is _fetchNotesIndex
, which is called in the
constructor to load the index. It's just a wrapper around fetch
that pulls
the index URL from the data-src
attribute of the custom element:
// fetch the notes index
async _fetchNotesIndex() {
const notesUrl = this.getAttribute('data-src');
try {
const request = await fetch(notesUrl);
return await request.json();
} catch (e) {
console.error('Failed to fetch notes index.', e);
return {};
}
}
The Upsides
A component like this can be dropped into a legacy web app pretty easily,
regardless of how it was made. I am going to try building more little
components to get the hang of it. I didn't even get to incorporate <template>
and <slot>
yet.
The Downsides
I am already missing a couple of niceties from my usual React-based stack. The big one is that I can't use TypeScript, but I can see how it could be possible to incorporate with a relatively small increase in complexity (as compared to say, a full React app). I probably won't bother for small components like this, but I think it would make a difference if you wanted to use this technique in a large codebase.
I'm unsure how this tech could be used to construct an entire application, but it seems like it'd be possible to do. I like the idea of making an actual web app without all those translation layers.