empowered.house ↗

search/command-palette.ex

Command palette

A command palette with a typed query, grouped commands, the selected row and keyboard hints.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.search_command_palette animated />
</div>
default
isometric
third row selected · custom copy
empty query · hover · no glow
light surface
isometric · fade out · still

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
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
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; }
}