search/results.ex
Search results
A search results panel with filter chips, a result count and the matched words underlined in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.search_results animated />
</div>
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
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
/* 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; }