search/command-palette.ex
Command palette
A command palette with a typed query, grouped commands, the selected row and keyboard hints.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.search_command_palette animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
query |
string | "post inv" |
What has been typed, up to 30 characters. |
placeholder |
string | "Type a command or search" |
Shown when the query is empty. |
groups |
list | [%{name: "Actions", items: [%{keys: "P I", text: "Post invoices to the ERP", ...}, ...]}, ...] |
`%{name, items}`, seven items in all. An item is `%{text, keys, icon}`: text up to 36 characters, `keys` a shortcut such as `"P I"` (optional), `icon` one of `run`, `doc`, `agent`, `go`. |
selected |
integer | 0 |
Index of the selected row, counted across the groups. A negative index selects none. |
hints |
list | ["Navigate", "Run", "Close"] |
Three words for the arrow keys, the enter key and the escape key. |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/search/command-palette.ex
defmodule EHVisuals.Search.CommandPalette do
@moduledoc """
A command palette: the typed query, the commands it finds in groups, the
selected row and the keys that drive it.
"""
use EHVisuals.Composition,
name: "search/command-palette",
title: "Command palette",
description:
"A command palette with a typed query, grouped commands, the selected row and keyboard hints."
@groups [
%{
name: "Actions",
items: [
%{text: "Post invoices to the ERP", keys: "P I", icon: "run"},
%{text: "Post a credit note", keys: "P C", icon: "run"},
%{text: "Start the invoice run", keys: "R I", icon: "agent"}
]
},
%{
name: "Records",
items: [
%{text: "INV-2291 · Northwind", icon: "doc"},
%{text: "INV-2288 · Initech", icon: "doc"}
]
}
]
@max_rows 7
@row_h 32
@group_h 26
attr(:query, :string, default: "post inv", doc: "What has been typed, up to 30 characters.")
attr(:placeholder, :string,
default: "Type a command or search",
doc: "Shown when the query is empty."
)
attr(:groups, :list,
default: @groups,
doc:
"`%{name, items}`, seven items in all. An item is `%{text, keys, icon}`: text up to 36 characters, `keys` a shortcut such as `\"P I\"` (optional), `icon` one of `run`, `doc`, `agent`, `go`."
)
attr(:selected, :integer,
default: 0,
doc: "Index of the selected row, counted across the groups. A negative index selects none."
)
attr(:hints, :list,
default: ~w(Navigate Run Close),
doc: "Three words for the arrow keys, the enter key and the escape key."
)
attr(:label, :string,
default: "A command palette with the query post inv and the first of five results selected"
)
frame_attrs()
@spec search_command_palette(map()) :: Phoenix.LiveView.Rendered.t()
def search_command_palette(assigns) do
{sections, bottom} = layout(assigns.groups)
footer_y = bottom + 8
assigns =
assigns
|> assign(:sections, sections)
|> assign(:footer_y, footer_y)
|> assign(:height, footer_y + 34)
|> assign(:hint_items, hint_items(assigns.hints))
~H"""
<.frame name="search/command-palette" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 380 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="379" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<circle class="ehv-line-ink" cx="26.5" cy="23.5" r="5.5" />
<path class="ehv-line-ink ehv-round" d="M30.6 27.6 l4.4 4.4" />
<text :if={@query != ""} phx-no-format x="46" y="29" font-size="13.5">{@query}<tspan class="ehv-t-lime ehv-search-command-palette__caret">|</tspan></text>
<text :if={@query == ""} phx-no-format class="ehv-t-3" x="46" y="29" font-size="13.5"><tspan class="ehv-t-lime ehv-search-command-palette__caret">|</tspan>{@placeholder}</text>
<rect class="ehv-card-2" x="332.5" y="14.5" width="29" height="18" rx="5" />
<text class="ehv-mono" x="347" y="26.5" font-size="7.5" text-anchor="middle">ESC</text>
<line class="ehv-line-soft" x1="0.5" x2="379.5" y1="48.5" y2="48.5" />
</g>
<g :for={section <- @sections}>
<text
class={"ehv-mono ehv-a-fade ehv-i-#{min(section.stagger, 24)}"}
x="20"
y={section.y + 19}
font-size="8"
>
{String.upcase(section.name)}
</text>
<g :for={row <- section.rows} class={"ehv-a-rise ehv-i-#{min(row.index + 2, 24)}"}>
<g :if={row.index == @selected}>
<rect class="ehv-fill-surface-3" x="8" y={row.y + 1} width="364" height="30" rx="8" />
<rect class="ehv-fill-lime" x="8" y={row.y + 8} width="3" height="16" rx="1.5" />
</g>
<rect
class={if row.index == @selected, do: "ehv-well", else: "ehv-card-2"}
x="20.5"
y={row.y + 6.5}
width="19"
height="19"
rx="6"
/>
<g transform={"translate(30 #{row.y + 16})"}>
<path
class="ehv-line-ink ehv-round ehv-search-command-palette__icon"
d={icon(row.icon)}
/>
</g>
<text
class={[if(row.index == @selected, do: "ehv-t-strong", else: "ehv-t-2")]}
x="50"
y={row.y + 20.3}
font-size="12.5"
>
{row.text}
</text>
<g :if={row.index == @selected}>
<rect class="ehv-fill-lime-soft" x="340" y={row.y + 7} width="20" height="18" rx="5" />
<path class="ehv-line-lime ehv-round" d={enter(346, row.y + 7)} />
</g>
<g :for={key <- row.keys} :if={row.index != @selected}>
<rect
class="ehv-card-2"
x={key.x + 0.5}
y={row.y + 7.5}
width={key.w - 1}
height="17"
rx="5"
/>
<text
class="ehv-mono"
x={key.x + key.w / 2}
y={row.y + 19.3}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(key.name)}
</text>
</g>
</g>
</g>
<g class="ehv-a-fade ehv-i-9">
<line class="ehv-line-soft" x1="0.5" x2="379.5" y1={@footer_y + 0.5} y2={@footer_y + 0.5} />
<g :for={hint <- @hint_items}>
<g :for={cap <- hint.caps}>
<rect
class="ehv-card-2"
x={cap.x + 0.5}
y={@footer_y + 9.5}
width={cap.w - 1}
height="15"
rx="4.5"
/>
<path
:if={cap.kind == "up"}
class="ehv-line-ink ehv-round ehv-search-command-palette__icon"
d={arrow(cap.x + 8, @footer_y + 13, -1)}
/>
<path
:if={cap.kind == "down"}
class="ehv-line-ink ehv-round ehv-search-command-palette__icon"
d={arrow(cap.x + 8, @footer_y + 21, 1)}
/>
<path
:if={cap.kind == "enter"}
class="ehv-line-ink ehv-round ehv-search-command-palette__icon"
d={enter(cap.x + 4, @footer_y + 8)}
/>
<text
:if={cap.kind == "esc"}
class="ehv-mono"
x={cap.x + cap.w / 2}
y={@footer_y + 20}
font-size="6.5"
text-anchor="middle"
>
ESC
</text>
</g>
<text class="ehv-mono" x={hint.x} y={@footer_y + 20} font-size="7.5">
{String.upcase(hint.text)}
</text>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Stacks the groups under the input and numbers the rows across groups.
# Returns the sections and the y of the bottom edge of the last row.
defp layout(groups) do
{sections, {_count, bottom}} =
Enum.map_reduce(groups, {0, 50}, fn group, {count, y} ->
rows =
group
|> Map.get(:items, [])
|> Enum.take(@max_rows - count)
|> Enum.with_index(count)
|> Enum.map(fn {item, index} ->
%{
index: index,
y: y + @group_h + (index - count) * @row_h,
text: Map.get(item, :text, ""),
icon: Map.get(item, :icon, "run"),
keys: keys(Map.get(item, :keys))
}
end)
section = %{name: Map.get(group, :name, ""), y: y, stagger: count + 2, rows: rows}
height = if rows == [], do: 0, else: @group_h + length(rows) * @row_h
{section, {count + length(rows), y + height}}
end)
{Enum.reject(sections, &(&1.rows == [])), bottom}
end
# Key caps for a shortcut, laid out from the right edge of the row.
defp keys(nil), do: []
defp keys(shortcut) do
shortcut
|> String.split()
|> Enum.reverse()
|> Enum.map_reduce(360, fn name, right ->
w = 13 + String.length(name) * 5
{%{name: name, w: w, x: right - w}, right - w - 3}
end)
|> elem(0)
end
# The footer: the caps of each hint, then its word, left to right.
defp hint_items(hints) do
[["up", "down"], ["enter"], ["esc"]]
|> Enum.zip(hints)
|> Enum.map_reduce(20, fn {kinds, text}, x ->
{caps, text_x} =
Enum.map_reduce(kinds, x, fn kind, cap_x ->
w = if kind == "esc", do: 24, else: 16
{%{kind: kind, x: cap_x, w: w}, cap_x + w + 3}
end)
{%{caps: caps, text: text, x: text_x + 3},
Float.round(text_x + 3 + String.length(text) * 5.2 + 16, 1)}
end)
|> elem(0)
end
# Icons are drawn around {0, 0}, inside a 10 by 10 box.
defp icon("doc"), do: "M-3.2 -4.5 h4 l2.6 2.6 v6.4 h-6.6 z M0.8 -4.5 v2.6 h2.6"
defp icon("agent"), do: "M-4 -4 h8 v8 h-8 z M-1.2 -1.2 h2.4 v2.4 h-2.4 z"
defp icon("go"), do: "M-4 0 h8 M1 -3 l3 3 l-3 3"
defp icon(_run), do: "M-2 -3.6 l3.8 3.6 l-3.8 3.6"
# The enter arrow, in a cap whose top left corner is {x, y}.
defp enter(x, y), do: "M#{x + 9} #{y + 5} v4.5 h-7 m2.6 -2.6 l-2.6 2.6 l2.6 2.6"
# An arrow with its tip at {x, y}, pointing up (-1) or down (1).
defp arrow(x, y, dir),
do:
"M#{x} #{y} v#{-dir * 8} M#{x - 2.6} #{y - dir * 2.6} l2.6 #{dir * 2.6} l2.6 #{-dir * 2.6}"
end
assets/css/search/command-palette.css
/* search/command-palette */
.ehv-search-command-palette { --ehv-max-w: 420px; --ehv-step: 80ms; }
.ehv-search-command-palette__icon { stroke-width: 1.1; }
/* The caret is a character of the query, so it always follows the text. */
@keyframes ehv-search-command-palette-blink { 50% { opacity: 0; } }
.ehv svg .ehv-search-command-palette__caret { font-weight: 400; }
.ehv--animated .ehv-search-command-palette__caret {
animation: ehv-search-command-palette-blink 1.1s steps(1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.ehv--animated .ehv-search-command-palette__caret { animation: none; }
}