empowered.house ↗

search/results.ex

Search results

A search results panel with filter chips, a result count and the matched words underlined in lime.

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

<div class="h-96 rounded-xl border border-border">
  <.search_results animated />
</div>
default
isometric
one word · second chip · custom copy
four results · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
query string "northwind invoice" The search. Its words are the ones underlined in the results.
filters list [%{count: 24, name: "All"}, %{count: 11, name: "Invoices"}, ...] Up to four `%{name, count}` chips. Keep the names short: the row holds about 34 characters.
active integer 0 Index of the chip that is switched on.
summary string "24 results · 0.18 s"
results list [%{id: "INV-2291", title: "Invoice for Northwind, March", text: "Posted to the ERP on 31 March by the posting age" <> ..., kind: "Invoice"}, %{...}, ...] Up to four `%{kind, id, title, text}`. Titles up to 40 characters, text up to 56.
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/results.ex
results.ex
defmodule EHVisuals.Search.Results do
  @moduledoc """
  A search results panel: the query, filter chips, the result count, and in
  every result the words that matched, underlined in lime.

  The matches are found in Elixir: each title and each line is split on the
  words of the query, so the underlines follow whatever copy is passed in.
  """

  use EHVisuals.Composition,
    name: "search/results",
    title: "Search results",
    description:
      "A search results panel with filter chips, a result count and the matched words underlined in lime."

  @filters [
    %{name: "All", count: 24},
    %{name: "Invoices", count: 11},
    %{name: "Orders", count: 9},
    %{name: "Wiki", count: 4}
  ]

  @results [
    %{
      kind: "Invoice",
      id: "INV-2291",
      title: "Invoice for Northwind, March",
      text: "Posted to the ERP on 31 March by the posting agent"
    },
    %{
      kind: "Order",
      id: "PO-2291",
      title: "Northwind purchase order",
      text: "Waiting for the invoice from the supplier"
    },
    %{
      kind: "Wiki",
      id: "Finance",
      title: "How we match an invoice",
      text: "Northwind and Globex send one invoice per order"
    }
  ]

  @rows_y 112
  @row_h 58

  attr(:query, :string,
    default: "northwind invoice",
    doc: "The search. Its words are the ones underlined in the results."
  )

  attr(:filters, :list,
    default: @filters,
    doc:
      "Up to four `%{name, count}` chips. Keep the names short: the row holds about 34 characters."
  )

  attr(:active, :integer, default: 0, doc: "Index of the chip that is switched on.")
  attr(:summary, :string, default: "24 results · 0.18 s")

  attr(:results, :list,
    default: @results,
    doc: "Up to four `%{kind, id, title, text}`. Titles up to 40 characters, text up to 56."
  )

  attr(:label, :string,
    default: "Search results for northwind invoice with the matched words underlined"
  )

  frame_attrs()

  @spec search_results(map()) :: Phoenix.LiveView.Rendered.t()
  def search_results(assigns) do
    pattern = pattern(assigns.query)

    rows =
      for {result, index} <- assigns.results |> Enum.take(4) |> Enum.with_index() do
        %{
          index: index,
          y: @rows_y + index * @row_h,
          record:
            [Map.get(result, :kind), Map.get(result, :id)]
            |> Enum.reject(&is_nil/1)
            |> Enum.join(" · "),
          title: segments(Map.get(result, :title, ""), pattern),
          text: segments(Map.get(result, :text, ""), pattern)
        }
      end

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:chips, chips(assigns.filters))
      |> assign(:height, @rows_y + length(rows) * @row_h + 6)

    ~H"""
    <.frame name="search/results" 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">
            <rect class="ehv-well" x="16.5" y="16.5" width="347" height="32" rx="9" />
            <circle class="ehv-line-ink" cx="33.5" cy="31.5" r="5" />
            <path class="ehv-line-ink ehv-round" d="M37.2 35.2 l4 4" />
            <text x="51" y="37" font-size="12.5">{@query}</text>
            <path class="ehv-line ehv-round" d="M344 28 l7 7 M351 28 l-7 7" />
          </g>
          <g :for={chip <- @chips} class={"ehv-a-fade ehv-i-#{chip.index + 2}"}>
            <rect
              class={[
                "ehv-search-results__chip",
                chip.index == @active && "ehv-search-results__chip--on"
              ]}
              x={chip.x + 0.5}
              y="60.5"
              width={chip.w - 1}
              height="21"
              rx="7"
            />
            <text class={[chip.index != @active && "ehv-t-3"]} x={chip.x + 9} y="75" font-size="10.5">
              {chip.name}
            </text>
            <text
              class={["ehv-mono", chip.index == @active && "ehv-t-2"]}
              x={chip.x + chip.w - 9}
              y="74.6"
              font-size="7.5"
              text-anchor="end"
            >
              {chip.count}
            </text>
          </g>
          <text class="ehv-mono ehv-a-fade ehv-i-4" x="16" y="102" font-size="8">
            {String.upcase(@summary)}
          </text>
          <g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 5}"}>
            <line class="ehv-line-faint" x1="16" x2="364" y1={row.y + 0.5} y2={row.y + 0.5} />
            <rect class="ehv-search-results__row" x="6" y={row.y + 4} width="368" height="50" rx="8" />
            <text class="ehv-mono" x="16" y={row.y + 18.5} font-size="7.5">
              {String.upcase(row.record)}
            </text>
            <text phx-no-format x="16" y={row.y + 35} font-size="12.5"><tspan :for={part <- row.title} class={[part.hit && "ehv-search-results__hit"]}><tspan class={[part.hit && "ehv-search-results__word"]}>{part.text}</tspan></tspan></text>
            <text phx-no-format class="ehv-t-3" x="16" y={row.y + 50} font-size="11"><tspan :for={part <- row.text} class={[part.hit && "ehv-search-results__hit"]}><tspan class={[part.hit && "ehv-search-results__word"]}>{part.text}</tspan></tspan></text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # A pattern that finds the words of the query, or nil when there are none.
  defp pattern(query) do
    words =
      query
      |> String.split(~r/[^\p{L}\p{N}-]+/u, trim: true)
      |> Enum.filter(&(String.length(&1) > 1))
      |> Enum.sort_by(&(-String.length(&1)))

    if words != [] do
      Regex.compile!("(" <> Enum.map_join(words, "|", &Regex.escape/1) <> ")", "iu")
    end
  end

  # The text cut into parts, each marked as a match or not.
  defp segments(text, nil), do: [%{text: text, hit: false}]

  defp segments(text, pattern) do
    pattern
    |> Regex.split(text, include_captures: true, trim: true)
    |> Enum.map(&%{text: &1, hit: Regex.match?(anchored(pattern), &1)})
  end

  defp anchored(pattern), do: Regex.compile!("^" <> Regex.source(pattern) <> "$", "iu")

  # The chips left to right, each as wide as its name and count.
  defp chips(filters) do
    filters
    |> Enum.take(4)
    |> Enum.with_index()
    |> Enum.map_reduce(16, fn {filter, index}, x ->
      name = Map.get(filter, :name, "")
      count = filter |> Map.get(:count, "") |> to_string()

      w =
        Float.round(
          18 + String.length(name) * 5.7 +
            if(count == "", do: 0, else: 7 + String.length(count) * 5.2),
          1
        )

      {%{index: index, x: x, w: w, name: name, count: count}, x + w + 6}
    end)
    |> elem(0)
  end
end
assets/css/search/results.css
results.css
/* search/results */
.ehv-search-results { --ehv-max-w: 420px; --ehv-step: 90ms; }

.ehv-search-results__chip { fill: none; stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-search-results__chip--on { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); }

/* A match: SVG paints an underline with the fill of the element that asks
   for it, so the outer span is lime and the word inside it keeps the ink. */
.ehv svg .ehv-search-results__hit {
  fill: var(--ehv-lime);
  text-decoration: underline;
  text-decoration-thickness: 1.25px;
  text-underline-offset: 2.5px;
}
.ehv svg .ehv-search-results__word { fill: var(--ehv-ink); }

/* A result has no surface of its own until it is hovered. */
.ehv-search-results__row { fill: var(--ehv-surface-2); opacity: 0; }
.ehv--hover .ehv-search-results__row { transition: opacity 200ms var(--ehv-ease); }
.ehv--hover .ehv-search-results__row:hover { opacity: 1; }