data/filters.ex
Filters
A filter builder whose rules land one after another, with a result count that narrows with each rule.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.data_filters animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Filter orders" |
|
total |
integer | 2410 |
Records before any rule. |
unit |
string | "orders" |
What is counted, up to 10 characters. |
rules |
list | [%{count: 1204, value: "Waiting", op: "is", field: "Status"}, %{...}, ...] |
Up to five `%{field, op, value, count}`. `count` is what is left after the rule. Field, operator and value together up to 26 characters. |
add |
string | "Add rule" |
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/data/filters.ex
defmodule EHVisuals.Data.Filters do
@moduledoc """
A filter builder: every rule is a field, an operator and a value, and the
bar under it shows how many records are left after it.
"""
use EHVisuals.Composition,
name: "data/filters",
title: "Filters",
description:
"A filter builder whose rules land one after another, with a result count that narrows with each rule."
@rules [
%{field: "Status", op: "is", value: "Waiting", count: 1204},
%{field: "Total", op: "over", value: "€5,000", count: 318},
%{field: "Customer", op: "is not", value: "Initech", count: 128}
]
attr(:title, :string, default: "Filter orders")
attr(:total, :integer, default: 2410, doc: "Records before any rule.")
attr(:unit, :string, default: "orders", doc: "What is counted, up to 10 characters.")
attr(:rules, :list,
default: @rules,
doc:
"Up to five `%{field, op, value, count}`. `count` is what is left after the rule. Field, operator and value together up to 26 characters."
)
attr(:add, :string, default: "Add rule")
attr(:label, :string,
default: "A filter builder with three rules that narrow 2,410 orders down to 128"
)
frame_attrs()
@spec data_filters(map()) :: Phoenix.LiveView.Rendered.t()
def data_filters(assigns) do
list = Enum.take(assigns.rules, 5)
count = length(list)
total = max(assigns.total, 1)
rows =
for {rule, index} <- Enum.with_index(list) do
field_w = Float.round(16 + String.length(rule.field) * 6.0, 1)
op_w = Float.round(String.length(rule.op) * 5.7, 1)
value_w = Float.round(16 + String.length(rule.value) * 6.0, 1)
op_x = 62 + field_w + 8
%{
index: index,
field: rule.field,
op: rule.op,
value: rule.value,
count: delimit(rule.count),
word: if(index == 0, do: "where", else: "and"),
y: 82 + index * 44,
field_w: field_w,
op_x: op_x,
value_x: Float.round(op_x + op_w + 8, 1),
value_w: value_w,
bar_w: Float.round(max(298 * ratio(rule.count, total), 4), 1),
last: index == count - 1,
step: min(index * 3 + 3, 22)
}
end
left = if count > 0, do: List.last(list).count, else: assigns.total
add_y = 82 + count * 44
assigns =
assigns
|> assign(:rows, rows)
|> assign(:left, delimit(left))
|> assign(:of, "of #{delimit(assigns.total)} #{assigns.unit}")
|> assign(:add_y, add_y)
|> assign(:add_w, Float.round(30 + String.length(assigns.add) * 5.2, 1))
|> assign(:add_step, min(count * 3 + 3, 24))
|> assign(:height, add_y + 24 + 20)
~H"""
<.frame name="data/filters" 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">
<path class="ehv-line-ink ehv-round" d="M20.5 22.5 h13 M23 27.5 h8 M25.5 32.5 h3" />
<text class="ehv-t-strong" x="42" y="32" font-size="14">{@title}</text>
<text class="ehv-mono" x="20" y="52" font-size="8">{String.upcase(@of)}</text>
</g>
<text
class={"ehv-t-display ehv-a-fade ehv-i-#{@add_step}"}
x="360"
y="46"
font-size="28"
text-anchor="end"
>
{@left}
</text>
<line class="ehv-line-faint" x1="20" x2="360" y1="66.5" y2="66.5" />
<g :for={row <- @rows}>
<g class={"ehv-a-rise ehv-i-#{row.step}"}>
<text class="ehv-mono" x="20" y={row.y + 15.5} font-size="7.5">
{String.upcase(row.word)}
</text>
<rect
class="ehv-card-3"
x="62.5"
y={row.y + 0.5}
width={row.field_w}
height="23"
rx="6"
/>
<text x="70.5" y={row.y + 16} font-size="11.5">{row.field}</text>
<text class="ehv-t-3" x={row.op_x} y={row.y + 16} font-size="11">{row.op}</text>
<rect
class="ehv-well"
x={row.value_x}
y={row.y + 0.5}
width={row.value_w}
height="23"
rx="6"
/>
<text class="ehv-t-strong" x={row.value_x + 8} y={row.y + 16} font-size="11.5">
{row.value}
</text>
<text
class={["ehv-mono", if(row.last, do: "ehv-t-lime", else: "ehv-t-2")]}
x="360"
y={row.y + 15.5}
font-size="9"
text-anchor="end"
>
{row.count}
</text>
</g>
<line
class={"ehv-line-faint ehv-a-fade ehv-i-#{row.step}"}
x1="62"
x2="360"
y1={row.y + 32.5}
y2={row.y + 32.5}
/>
<rect
class={[
if(row.last, do: "ehv-fill-lime", else: "ehv-fill-3"),
"ehv-a-grow-x ehv-i-#{row.step + 1}"
]}
x="62"
y={row.y + 31}
width={row.bar_w}
height="3"
rx="1.5"
/>
</g>
<g class={"ehv-a-fade ehv-i-#{@add_step}"}>
<rect
class="ehv-line ehv-dash ehv-data-filters__add"
x="62.5"
y={@add_y + 0.5}
width={@add_w}
height="23"
rx="6"
/>
<path class="ehv-line-ink ehv-round" d={"M71 #{@add_y + 12} h8 m-4 -4 v8"} />
<text class="ehv-mono ehv-t-2" x="86" y={@add_y + 15.5} font-size="7.5">
{String.upcase(@add)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
defp ratio(count, total) when is_number(count), do: min(count / total, 1.0)
defp ratio(_count, _total), do: 1.0
# 2410 becomes "2,410". Anything that is not an integer is shown as given.
defp delimit(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|> String.reverse()
end
defp delimit(other), do: to_string(other)
end
assets/css/data/filters.css
/* data/filters */
.ehv-data-filters { --ehv-max-w: 410px; --ehv-step: 110ms; }
.ehv--hover .ehv-data-filters__add { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-data-filters__add { stroke: var(--ehv-lime); }