empowered.house ↗

data/filters.ex

Filters

A filter builder whose rules land one after another, with a result count that narrows with each rule.

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

<div class="h-96 rounded-xl border border-border">
  <.data_filters animated />
</div>
default
isometric
two rules · custom copy
one rule · hover · no glow
light surface
isometric · fade out · still

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