>

Command palettes are generally cool, at least I think so. Not sure if it's the original, but the first one I recall seeing it in use was Macintosh's search bar feature.

Alfred then became a pretty popular app as to fill the gap, and lately we're seeing more apps also implementing command palettes like Slack, Linear, GitHub and probably thousands more that I'm not aware of.

I'm proud to say that this little website of mine has also implmented one for search purposes. Mainly to help me find things in the future since I generally only remember the reference points, and not the content itself. As long as I know roughly what I want to find, search should be able to narrow the candidates for me.

Enough of the reasonings for my self satisfaction and let's take a look at how it's built.

What are we looking at

Expectations are important so I'm setting it beforehand.

This blog post is merely going to share how to build a command palette UI, but won't be providing any sort of data.

If you're using Hugo as well, there are custom outputs that can generate the site data into formats like JSON, which then can be consumed by the UI code. That's how this website is made. How the search should work is up to you so feel free to customize and tweak it to your liking.

search.png
What the search palette looks like at the time of writing.

Setup walkover

There's nothing more annoying than to ask someone to read another person's random code out of the blue. So here's a rough walk through of the setup to give you a better idea of what's going on.

Structure of the code

For the sake of my sanity, and also showcasing a working version, the code being referenced will be the one that's currently being used on this website.

There are 2 components in the search file.

  • A nice search input like button that opens up the command palette
  • The actual command palette itself

You can see the full version on GitHub. The footer is separated as a partial file but since it's rather irrelevant for this post, we can ignore it.

*Latest version might look different

Alpine.js integration

Since this sample is based on Alpine, make sure to have it installed. I recommend to use the CDN distributed version initially for simplicity.

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
Source is available here on GitHub

This code includes the Focus plugin for Alpine and Alpine itself. I'll explain why we want the plugin later.

FlexSearch

The search functionality will be supported via FlexSearch. Similar to Alpine, I recommend to use the CDN distributed version for simplicity.

<script defer src="https://cdn.jsdelivr.net/gh/nextapps-de/[email protected]/dist/flexsearch.bundle.js"></script>
Source is available here on GitHub

Data

When searching something, you obviously need to provide the content to be searched for. Hugo by default generates HTML, AMP and RSS pages out of the box. However, it also has a way to generate custom data as well via custom outputs.

Since this website is using FlexSearch which is a JavaScript library, providing data as JSON makes it easier to feed it into FlexSearch.

outputFormats:
  SearchIndex:
    mediaType: application/json
    baseName: searchindex
    isPlainText: true
    isHTML: false
    notAlternative: true

outputs:
  home:
    - HTML
    - RSS
    - SearchIndex
configuration for settings up JSON custom output in Hugo

This config declares a new output format called SearchIndex, that's data type is JSON and the filename for Hugo to reference is searchindex. Hugo will expect there to be a template file at /layouts/_default/list.searchindex.json which will be used to generate the JSON data content.

In order for Hugo to actually generate the data file, make sure to add SearchIndex to the outputs list. Once generated, the data can be seen at /searchindex.json. Feel free to take a look at the JSON data of this website to get an idea of what I'm talking about.

Here's an example of how the template will look like.

{{- $.Scratch.Add "searchindex" slice -}}

{{- range (where .Site.RegularPages "Type" "in" .Site.Params.mainSections) -}}
  {{- $.Scratch.Add "searchindex" (dict "id" .Params.uuid "type" .Type "title" .Title "href" .RelPermalink "summary" (.Summary | plainify) "content" .Plain) -}}
{{- end -}}

{{- $.Scratch.Get "searchindex" | jsonify -}}
Template is available on GitHub.

You can see more details regarding Hugo's custom outputs here.

Implementation

Now that the foundation is set, let's take a look at the implmentation itself.

Data initialization

<div
  x-data="{ searchbar: false, searchterm: '', searchresults: [], index: createIndex() }"
>
  ...
</div>
Source is available here on GitHub

The data being used for searching are initialized at the top DOM object of the file.

  • searchbar decides if the palette can be shown or not
  • searchterm is the term that's being searched
  • searchresults is a list that contains the results that matches searchterm
  • index is the Document object initialized from FlexSearch

The index object is a rather unique one compared the other rather self explanatory variables. It basically holds and indexes the list of contents, and will be the one used during searching as well.

  // Initiate and register all contents from "/searchindex.json"
  const createIndex = () => {
    const index = new FlexSearch.Document({
      tokenize: "forward",
      cache: 100,
      document: {
        id: "id",
        store: ["href", "title", "summary", "type"],
        index: ["title", "summary", "content"],
      },
    })

    // built by template /layouts/_default/list.searchindex.json
    fetch("/searchindex.json")
      .then(resp => resp.json())
      .then(contentList => {
        contentList.forEach(content => {
          // console.log(content)
          index.add(content)
        })
        console.log("Indexed contents")
      })
      .catch(err => console.error(err))

    return index
  }
Initializing FlexSearch document and index contents

You should see something like the code above at the end of the file. It's basically telling FlexSearch to,

  • Which method to use for indexing – e.g. forward, there's a direct memory usage tradeoff here
  • The capacity of cached entries
  • Document structure – which field to use as ID, which fields to store and which to use as indices

And then retrieve the generated content data from /searchindex.json and insert each of them into the index.

You can see more details of FlexSearch's Document object herev0.7.31.

Activating the command palette

There are a 2 ways to show the command palette.

  1. Clicking on the search input like button
  2. Type Ctrl k or Cmd k if you're on macOS or Windows

The hidden HTML class is used as the way to hide the command palette, and the searchbar variable is the one determining if the hidden class will be assigned or not.

<div
  :class="searchbar ? '' : 'hidden'"
  x-cloak
>
  ...
</div
Source is available here on GitHub

So when the button is clicked, searchbar is set to true.

<button @click="searchbar = true">
  ...
</button>
Source is available here on GitHub

The other way using Ctrl k or Cmd k is done like this.

<div
  @keydown.ctrl.k.prevent.document="searchbar = true"
  @keydown.meta.k.prevent.document="searchbar = true"
>
  ...
</div>
Source is available here on GitHub

Which is basically the same as

document.addEventListener("keydown", (e) => {
  e.preventDefault()

  if ((e.ctrlKey && e.key === "k") || (e.metaKey && e.key === "k"))
    this.searchbar = true;
})
Trapping focus

When the command palette appears, it's best to keep all interactions against the command palette until you close it. This is where the Focus plugin comes in.

x-trap can be used for restraining the focus within the specified DOM object based on a JS expression. In this base, searchbar will be the expression for activating x-trap.

<div x-trap.inert.noscroll.noreturn="searchbar">
  ...
</div>
Source is available here on GitHub

.inert, .noscroll, .noreturn are all modifiers of x-trap. You can learn more about them in the documentation.

The main reason for using x-trap here is to utilize $focus. It makes traversing UI components easier to implement.

<ul
  @keydown.tab.document="$focus.wrap().next()"
>
  ...
</ul>
Source is available here on GitHub

Which is equivalent to something like the following

document.addEventListener("keydown", (e) => {
  e.preventDefault()

  if (e.key !== "Tab") return;

  const focusables = document.querySelectorAll("a")
  const currentItemIdx = focusables.indexOf(document.activeElement)
  const nextItemIdx = currentItemIdx + 1 < focusables.length ?
        currentItemIdx + 1 : 0

  focusables[nextItemIdx].focus()
})
Rough raw JS implementation of $focus.wrap().next()

Closing the command palette

If you can open something, you should be able to close it as well. Assuming you have been following, the closing of the command palette should be rather straightforward.

<div
  @keydown.escape.document="searchbar = false"
>
  ...
</div>
Source is available here on GitHub

Yes, it's just assigning searchbar to false, and the hidden HTML class will be added back to the DOM object, which will result in hiding it completely.

Food for thought

That was a brief run through of how to implement a command palette with Alpine and FlexSearch. The functioning version of the code is also accessible here on GitHub with the TailwindCSS classes to make it look like the screenshots.

A couple things worth thinking of if you're considering going with this approach.

How data is provided

This example is based on having a static JSON data that can be read by FlexSearch when the page loads. This might not be the case for you, depending on where your data is coming from.

Obviously if you have some kind of remote data source based on the kind of search infrastructure like Elasticsearch or Typesense, retrieving that data will need to be more involved.

The provided solution in this article will work a lot better for static content than dynamic content. For example, company blogs, or documentation websites where the content is generally static.

Scalability and resource consumption

However, even with just static contents, it's unclear how performant this solution can be. The cache option for FlexSearch initialization can be pretty influential when it comes to resource consumption of the user's laptop.

Also, since I've not done any real load testing, it's unclear how much resources FlexSearch might require if there's a somewhat large data set.

Having said all that, it's very unlikely that you'll have millions of contents for company blogs or documentation websites so my rough educated guess is that this solution should work mostly fine.