In previous episode we added a very simple command palette to the file manager. Over this and next few episodes we'll be improving on it. The first feature to add - match highlighting.
Why we need highlighting
It might seed like just an aesthetic issue, but it isn't. If user search for go
, and matches are:
- Go to First File
- Go to Last File
- Go to Next File
- Go to Previous File
- Page Down
It might be very baffling why that last one is there ("pa*Ge dO*wn"). Especially if for any reason the unexpected match takes priority over expected matches. Any such confusion can break the user out of the flow state.
src/CommandPalette.svelte
CommandPalette
stops being responsible for filtering commands, all the responsibility will move to matcher.js
import matcher from "./matcher.js"
$: matchingCommands = matcher(commands, pattern)
src/matcher.js
This is a fairly simple implementation, even if it uses a lot of RegExp
trickery.
- first we turn pattern into all lower case and strip everything that's not letter or number
- every letter in the pattern we turn into regular expression, for example
x
becomes/(.*?)(x)(.*)/i
- that is first parenthesis will match everything left of "x", second will match "x" (case insensitive), third everything to the right of "x" - if there are multiple "x"s, we only match the first one. That's what the question mark is for, to stop as soon as possible, by default regular expressions keep going as far as possible. - then we loop over all commands calling
checkMatch
- if it matches, we add it to results together with the match, otherwise we don't add it to the result
function matcher(commands, pattern) {
let rxs = pattern
.toLowerCase()
.replace(/[^a-z0-9]/, "")
.split("")
.map(l => new RegExp(`(.*?)(${l})(.*)`, "i"))
let result = []
for (let command of commands) {
let match = checkMatch(rxs, command.name)
if (match) {
result.push({...command, match: match})
}
}
return result
}
export default matcher
In checkMatch
, we slice the name one letter at a time. For example if we match "Page Down" against "go", the first iteration will be:
-
"Page Down"
becomes["Pa", "g", "e Down"]
-
["Pa", false]
is added to result, so it won't be highlighted -
["g", true]
is added to result, so it will be highlighted - only
"e Down"
goes to next iteration
Then in second iteration:
-
"e Down"
becomes["e D", "o", "wn"]
-
["e D", false]
is added to result, so it won't be highlighted -
["o", true]
is added to result, so it will be highlighted - only
"wn"
remains after the loop, and whatever's left is added to the result non-highlighted as["wn", false]
Here's the code:
function checkMatch(rxs, name) {
if (!name) {
return
}
let result = []
for (let rx of rxs) {
let m = rx.exec(name)
if (m) {
result.push([m[1], false])
result.push([m[2], true])
name = m[3]
} else {
return null
}
}
result.push([name, false])
return result
}
This would be somewhat more concide in a language with more powerful regular expressions like Ruby or even Perl, but it's not too bad.
src/CommandPaletteEntry.svelte
And finally we need to add support for displaying highlighted results to CommandPaletteEntry
.
<script>
import { getContext } from "svelte"
let { eventBus } = getContext("app")
export let name
export let match = undefined
export let key
export let action
function handleClick() {
eventBus.emit("app", "closePalette")
eventBus.emit(...action)
}
function keyName(key) {
if (key === " ") {
return "Space"
} else {
return key
}
}
</script>
<li on:click={handleClick}>
<span class="name">
{#if match}
{#each match as [part, highlight]}
{#if highlight}
<em>{part}</em>
{:else}
{part}
{/if}
{/each}
{:else}
{name}
{/if}
</span>
{#if key}
<span class="key">{keyName(key)}</span>
{/if}
</li>
<style>
li {
display: flex;
padding: 0px 8px;
}
li:first-child {
background-color: #66b;
}
.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%;
}
.name em {
color: #ff2;
font-weight: bold;
font-style: normal;
}
</style>
There's one extra optional property match
. If it's there, we loop over it treating it as array of [part, highlight]
. Highlighted parts are wrapped in <em>
which is then formatted below to be highlighted in same style as selected files.
This highlighting is not quite as visible as I hoped, so at some point I'll need to adjust the styling.
Result
Here's the results:
This was a nice small feature. In the next episode we'll teach our app how to deal with modifier keys like Control, Command, Shift, and so on, so keyboard shortcuts can be more than one key.
As usual, all the code for the episode is here.
Top comments (0)