One of the best UI innovations in the last decade has been the Command Palette - from Sublime Text it's been spreading like wildfire to all software.
So obviously we want it in our app too.
There's exising command palette components for pretty much every framework, but we're going to build our own.
What command palette needs?
There are quite a few parts:
- a shortcut to start the command palette
- modal dialog that should disable most interactions with other parts of the app while it's open
- a list of commands that can be executed
- learnable shortcuts displayed with each command
- fuzzy search for matching commands
- a way to select first command with Enter, or to navigate to other suggestions with mouse or arrow keys
- Escape to leave the command palette
Fuzzy Search
In principle we could get away with a simple subscring search. If user searches for abc
, we take it to mean any command that contains a
, anything, b
, anything, c
(/a.*b.*c/i
). And display them all alphametically or something
This isn't optimal, for example if you have a text editor, and you search ssm
, then it will match commands like:
- Set Syntax As*m*
- Set Syntax Markdown
And you generally want the latter to take priority.
And if you type cop
, you probably want the first one:
- Open Copilot
- Docker Containers: Prune
There are some scoring heuristics such as prioritizing first letters of world (first example), fewest breaks (second example), and so on.
Many programs also remember which commands you use more often or more recently, and prioritize those, so even if they did a poor job at first, they get better soon.
For now we're going to do none of that, and just use a simple substring search. It wouldn't even make sense until we have a lot more commands in the palette.
Let's Get Started!
First, I want to say I'm already regretting the color scheme I setup in previous two episodes, but let's roll with it. I was supposed to be cute "retro" thing, but it turns out command palette has a lot of visual subtlety to get right, and this isn't it.
I'll fix it in some future episode. And if the whole series ends up looking like pretty close to default VSCode? Nothing wrong with that.
It will also be command palette with very limited functionality for now, to keep this episode to reasonable size:
- you can type a command, then press Enter to execute top match
- you can press Ecape to close the command palette
- you can click on any specific command to execute it
Most command palettes also allow you to navigate by arrow keys, do highlighting, and have a lot more fancy stuff. We'll get there eventually.
Opening palette
As I'm still trying to get away with not using modifier keys, let's use F5 for it. This means we need to add it to src/Keyboard.svelte
and src/Footer.svelte
.
The keyboard component, which runs normal app shortcuts, also needs to be disabled while command palette is open. It will also need to be disabled for other modal dialogs.
Footer just gets this one line added:
<button on:click={() => eventBus.emit("app", "openPalette")}>F5 Palette</button>
Keyboard gets new entry for F5, as well as active
flag to turn itself off.
<script>
export let active
import { getContext } from "svelte"
let { eventBus } = getContext("app")
function handleKey({key}) {
if (!active) {
return
}
if (key.match(/^[1234]$/)) {
eventBus.emit("app", "changeBox", `box-${key}`)
}
if (key.match(/^[a-zA-Z]$/)) {
eventBus.emit("activeBox", "letter", key)
}
if (key === "Backspace") {
eventBus.emit("activeBox", "backspace", key)
}
if (key === "F1") {
eventBus.emit("activeBox", "cut")
}
if (key === "F2") {
eventBus.emit("activeBox", "copy")
}
if (key === "F3") {
eventBus.emit("activeBox", "paste")
}
if (key === "F5") {
eventBus.emit("app", "openPalette")
}
if (key === "F10") {
eventBus.emit("activeBox", "quit")
}
}
</script>
<svelte:window on:keydown={handleKey} />
src/Command.svelte
This is a simple component, that shows just one of the matching commands.
<script>
import { getContext } from "svelte"
let { eventBus } = getContext("app")
export let name
export let keys
export let action
function handleClick() {
eventBus.emit("app", "closePalette")
eventBus.emit(...action)
}
</script>
<li on:click={handleClick}>
<span class="name"> {name}</span>
{#each keys as key}
<span class="key">{key}</span>
{/each}
</li>
<style>
li {
display: flex;
padding: 0px 8px;
}
li:first-child {
background-color: hsl(180,100%,20%);
}
.name {
flex: 1;
}
.key {
display: inline-block;
background-color: hsl(180,100%,30%);
padding: 2px;
border: 1px solid hsl(180,100%,20%);
border-radius: 20%;
}
</style>
The command shows its shortcut keys on the right - it's as array as we could be having something like ["Cmd", "Shift", "P"]
, even if right now we only use single keys.
If any command is clicked, two events need to happen:
- palette needs to be closed
- chosen command needs to be executed
src/CommandPalette.svelte
The command palette has a bit more logic to it, even in our very simple version.
First template and styling. We have input for the pattern, we display list of matching commands (which will be all commands if search is empty), and we need on:keypress
handler to handle Escape and Enter keys.
It's also important that input is focused when the palette is opened, we use use:focus
for this, with focus
being a one line function we'll get to.
We can destructure all fields of command
and pass them as individual props with {...command}
instead of writing <Command name={command.name} keys={command.keys} action={command.action} />
<div class="palette">
<input use:focus bind:value={pattern} placeholder="Search for command" on:keypress={handleKey}>
<ul>
{#each matchingCommands as command}
<Command {...command} />
{/each}
</ul>
</div>
<style>
.palette {
font-size: 24px;
font-weight: bold;
position: fixed;
left: 0;
top: 0;
right: 0;
margin: auto;
max-width: 50vw;
background-color: hsl(180,100%,25%);
color: #333;
box-shadow: 0px 0px 16px hsl(180,100%,10%);
}
input {
background-color: inherit;
font-size: inherit;
font-weight: inherit;
box-sizing: border-box;
width: 100%;
margin: 0;
}
input::placeholder {
color: #333;
font-weight: normal;
}
ul {
list-style: none;
padding: 0;
}
</style>
In the script section we have a lot of things to do. First we need the list of commands.
List of commands here, list of commands in the Keyboard component, and list of commands in ApplicationMenu component are highly overlapping set, but they're not identical. For now let's accept duplication, but this will need to change at some point.
let commands = [
{name: "Cut", keys: ["F1"], action: ["activeBox", "cut"]},
{name: "Copy", keys: ["F2"], action: ["activeBox", "copy"]},
{name: "Paste", keys: ["F3"], action: ["activeBox", "paste"]},
{name: "Quit", keys: ["F10"], action: ["app", "quit"]},
{name: "Box 1", keys: ["1"], action: ["app", "changeBox", "box-1"]},
{name: "Box 2", keys: ["2"], action: ["app", "changeBox", "box-2"]},
{name: "Box 3", keys: ["3"], action: ["app", "changeBox", "box-3"]},
{name: "Box 4", keys: ["4"], action: ["app", "changeBox", "box-4"]},
]
For matching function, we strip all special characters, ignore case, and then treat search for o2
as search for: "anything, letter o, anything, number 2, anything".
function checkMatch(pattern, name) {
let parts = pattern.toLowerCase().replace(/[^a-z0-9]/, "")
let rx = new RegExp(parts.split("").join(".*"))
name = name.toLowerCase().replace(/[^a-z0-9]/, "")
return rx.test(name)
}
And here's all of it connected together. focus
is called when the palette is opened, matchingCommands
reactively calls our function if pattern
changes, and handleKey
is called when any key is pressed, dealing with Escape
and Enter
, but letting all other keys be handled by the <input>
itself.
If you try to press Enter
when there are no matching commands, it will also close the palette.
import Command from "./Command.svelte"
import { getContext } from "svelte"
let { eventBus } = getContext("app")
let pattern = ""
$: matchingCommands = commands.filter(({name}) => checkMatch(pattern, name))
function handleKey(event) {
let {key} = event;
if (key === "Enter") {
event.preventDefault()
eventBus.emit("app", "closePalette")
if (matchingCommands[0]) {
eventBus.emit(...matchingCommands[0].action)
}
}
if (key === "Escape") {
event.preventDefault()
eventBus.emit("app", "closePalette")
}
}
function focus(el) {
el.focus()
}
src/App.svelte
And finally, to enable it we need to do a few things in the main component.
I'm skipping the styling section, as it didn't change:
<script>
import { writable } from "svelte/store"
import { setContext } from "svelte"
import Box from "./Box.svelte"
import Footer from "./Footer.svelte"
import Keyboard from "./Keyboard.svelte"
import AppMenu from "./AppMenu.svelte"
import CommandPalette from "./CommandPalette.svelte"
import EventBus from "./EventBus.js"
let activeBox = writable("box-1")
let clipboard = writable("")
let eventBus = new EventBus()
let commandPaletteActive = false
setContext("app", {activeBox, clipboard, eventBus})
function quit() {
window.close()
}
function changeBox(id) {
activeBox.set(id)
}
function emitToActiveBox(...args) {
eventBus.emit($activeBox, ...args)
}
function openPalette() {
commandPaletteActive = true
}
function closePalette() {
commandPaletteActive = false
}
eventBus.handle("app", {quit, changeBox, openPalette, closePalette})
eventBus.handle("activeBox", {"*": emitToActiveBox})
</script>
<div class="app">
<Box id="box-1" />
<Box id="box-2" />
<Box id="box-3" />
<Box id="box-4" />
<Footer />
</div>
<Keyboard active={!commandPaletteActive} />
<AppMenu />
{#if commandPaletteActive}
<CommandPalette />
{/if}
So we have extra flag commandPaletteActive
, which controls boths the CommandPalette
and Keyboard
, so keyboard is inactive when the palette is open. There are two simple events openPalette
and closePalett
which just flip this flag. And that's all it took.
Result
Here's the results:
And that's a good time to stop our side quest with the retro looking four box app. Over the next few episodes, we'll be taking the lessons learned and enhancing the file manager we've been working on.
As usual, all the code for the episode is here.
Top comments (0)