empowered.house ↗

sections/components.ex

Components

A showcase grid of interface building blocks: a button, an input, a toggle, a badge, a checkbox and a select.

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

<div class="h-96 rounded-xl border border-border">
  <.sections_components animated />
</div>
default
isometric
lime toggle · custom copy
three blocks · hover · no glow
light surface · lime badge
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Components"
heading string "One kit for every internal tool"
items list [%{text: "Approve", kind: "button"}, %{text: "PO-2291", kind: "input"}, ...] Up to six `%{kind, text}`, three to a row. Kind is `button`, `input`, `toggle`, `badge`, `checkbox` or `select`. Text is up to about 11 characters, 7 for a toggle.
accent integer 0 Index of the building block drawn in lime. A negative index marks none.
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/sections/components.ex
components.ex
defmodule EHVisuals.Sections.Components do
  @moduledoc """
  A showcase grid of interface building blocks: a button, an input, a toggle,
  a badge, a checkbox and a select, one of them in lime.
  """

  use EHVisuals.Composition,
    name: "sections/components",
    title: "Components",
    description:
      "A showcase grid of interface building blocks: a button, an input, a toggle, a badge, a checkbox and a select."

  @items [
    %{kind: "button", text: "Approve"},
    %{kind: "input", text: "PO-2291"},
    %{kind: "toggle", text: "Sync"},
    %{kind: "badge", text: "Posted"},
    %{kind: "checkbox", text: "Matched"},
    %{kind: "select", text: "Finance"}
  ]

  @kinds ~w(button input toggle badge checkbox select)

  attr(:eyebrow, :string, default: "Components")
  attr(:heading, :string, default: "One kit for every internal tool")

  attr(:items, :list,
    default: @items,
    doc:
      "Up to six `%{kind, text}`, three to a row. Kind is `button`, `input`, `toggle`, `badge`, `checkbox` or `select`. Text is up to about 11 characters, 7 for a toggle."
  )

  attr(:accent, :integer,
    default: 0,
    doc: "Index of the building block drawn in lime. A negative index marks none."
  )

  attr(:label, :string,
    default: "A grid of six interface building blocks with the button drawn in lime"
  )

  frame_attrs()

  @spec sections_components(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_components(assigns) do
    tiles =
      assigns.items
      |> Enum.take(6)
      |> Enum.with_index()
      |> Enum.map(fn {item, index} ->
        %{
          kind: if(item.kind in @kinds, do: item.kind, else: "button"),
          text: item.text,
          index: index,
          x: 24 + rem(index, 3) * 120,
          y: 76 + div(index, 3) * 88
        }
      end)

    rows = max(div(length(tiles) + 2, 3), 1)

    assigns =
      assigns
      |> assign(:tiles, tiles)
      |> assign(:height, 76 + rows * 80 + (rows - 1) * 8 + 24)

    ~H"""
    <.frame name="sections/components" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 400 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height={@height - 1} rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="24" y="34" font-size="8.5">
            {String.upcase(@eyebrow)}
          </text>
          <text class="ehv-t-strong ehv-a-fade ehv-i-2" x="24" y="58" font-size="16">{@heading}</text>
          <g
            :for={tile <- @tiles}
            class={"ehv-sections-components__item ehv-a-rise ehv-i-#{tile.index + 3}"}
          >
            <rect
              class="ehv-card-2 ehv-sections-components__tile"
              x={tile.x + 0.5}
              y={tile.y + 0.5}
              width="111"
              height="79"
              rx="10"
            />
            <text class="ehv-mono" x={tile.x + 10} y={tile.y + 18} font-size="7.5">
              {String.upcase(tile.kind)}
            </text>
            <.control
              kind={tile.kind}
              text={tile.text}
              x={tile.x}
              cx={tile.x + 56}
              cy={tile.y + 49}
              on={tile.index == @accent}
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  attr(:kind, :string, required: true)
  attr(:text, :string, required: true)
  attr(:x, :any, required: true)
  attr(:cx, :any, required: true)
  attr(:cy, :any, required: true)
  attr(:on, :boolean, required: true)

  # One building block, centred on {cx, cy} inside a tile that starts at x.
  defp control(%{kind: "button"} = assigns) do
    assigns = assign(assigns, :w, clamp(28 + String.length(assigns.text) * 5.6, 48, 92))

    ~H"""
    <rect
      class={if @on, do: "ehv-fill-lime", else: "ehv-card-3"}
      x={@cx - @w / 2}
      y={@cy - 13}
      width={@w}
      height="26"
      rx="6"
    />
    <text
      class={["ehv-t-strong", @on && "ehv-t-on-lime"]}
      x={@cx}
      y={@cy + 4}
      font-size="10.5"
      text-anchor="middle"
    >
      {@text}
    </text>
    """
  end

  defp control(%{kind: "input"} = assigns) do
    assigns =
      assign(assigns, :caret, Float.round(assigns.x + 22 + String.length(assigns.text) * 5.9, 1))

    ~H"""
    <rect
      class={["ehv-well", @on && "ehv-sections-components__field--on"]}
      x={@x + 10.5}
      y={@cy - 12.5}
      width="91"
      height="25"
      rx="6"
    />
    <text x={@x + 19} y={@cy + 4} font-size="10.5">{@text}</text>
    <line
      class={[
        "ehv-sections-components__caret",
        if(@on, do: "ehv-line-lime", else: "ehv-line-ink")
      ]}
      x1={@caret}
      x2={@caret}
      y1={@cy - 6}
      y2={@cy + 6}
    />
    """
  end

  defp control(%{kind: "toggle"} = assigns) do
    assigns =
      assign(assigns, :left, Float.round(assigns.cx - (47 + text_w(assigns.text)) / 2, 1))

    ~H"""
    <rect
      class={if @on, do: "ehv-fill-lime", else: "ehv-fill-strong"}
      x={@left}
      y={@cy - 11}
      width="40"
      height="22"
      rx="11"
    />
    <circle
      class={
        if @on,
          do: "ehv-sections-components__knob ehv-sections-components__knob--on",
          else: "ehv-sections-components__knob ehv-fill-ink"
      }
      cx={@left + 29}
      cy={@cy}
      r="8"
    />
    <text class="ehv-t-2" x={@left + 47} y={@cy + 4} font-size="10.5">{@text}</text>
    """
  end

  defp control(%{kind: "badge"} = assigns) do
    w = Float.round(27 + String.length(assigns.text) * 5.5, 1)
    assigns = assigns |> assign(:w, w) |> assign(:left, Float.round(assigns.cx - w / 2, 1))

    ~H"""
    <rect
      class={if @on, do: "ehv-fill-lime-soft", else: "ehv-card-3"}
      x={@left}
      y={@cy - 10}
      width={@w}
      height="20"
      rx="6"
    />
    <circle class={if @on, do: "ehv-fill-lime", else: "ehv-fill-3"} cx={@left + 11} cy={@cy} r="2.5" />
    <text
      class={["ehv-mono", if(@on, do: "ehv-t-lime", else: "ehv-t-2")]}
      x={@left + 19}
      y={@cy + 3}
      font-size="8"
    >
      {String.upcase(@text)}
    </text>
    """
  end

  defp control(%{kind: "checkbox"} = assigns) do
    assigns =
      assign(assigns, :left, Float.round(assigns.cx - (24 + text_w(assigns.text)) / 2, 1))

    ~H"""
    <rect
      class={if @on, do: "ehv-fill-lime", else: "ehv-fill-2"}
      x={@left}
      y={@cy - 8}
      width="16"
      height="16"
      rx="4.5"
    />
    <path
      class={[
        "ehv-sections-components__tick ehv-round",
        @on && "ehv-sections-components__tick--on"
      ]}
      d={"M#{@left + 4} #{@cy} l2.8 2.8 l5 -5.6"}
    />
    <text x={@left + 24} y={@cy + 4} font-size="10.5">{@text}</text>
    """
  end

  defp control(assigns) do
    ~H"""
    <rect
      class={["ehv-well", @on && "ehv-sections-components__field--on"]}
      x={@x + 10.5}
      y={@cy - 12.5}
      width="91"
      height="25"
      rx="6"
    />
    <text x={@x + 19} y={@cy + 4} font-size="10.5">{@text}</text>
    <path
      class={["ehv-round", if(@on, do: "ehv-line-lime", else: "ehv-line-ink")]}
      d={"M#{@x + 87} #{@cy - 2} l3.5 4 l3.5 -4"}
    />
    """
  end

  defp text_w(text), do: String.length(text) * 5.5

  defp clamp(value, low, high), do: Float.round(min(max(value, low), high) * 1.0, 1)
end
assets/css/sections/components.css
components.css
/* sections/components */
.ehv-sections-components { --ehv-max-w: 440px; }

.ehv .ehv-sections-components__field--on { stroke: var(--ehv-lime); }
.ehv-sections-components__knob { transform-box: fill-box; }
.ehv-sections-components__knob--on { fill: var(--ehv-on-lime); }
.ehv-sections-components__tick { fill: none; stroke: var(--ehv-canvas); stroke-width: 1.5; }
.ehv-sections-components__tick--on { stroke: var(--ehv-on-lime); }

/* The caret in the input is the one live thing. */
@keyframes ehv-sections-components-blink { 50% { opacity: 0; } }
.ehv--animated .ehv-sections-components__caret {
  animation: ehv-sections-components-blink 1.1s steps(1) infinite;
}

.ehv--hover .ehv-sections-components__tile { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-sections-components__item:hover .ehv-sections-components__tile {
  stroke: var(--ehv-lime);
}