Zack Hobson

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.