empowered.house ↗

images/gallery.ex

Gallery

A grid of placeholder pictures with one of them selected, for attachments and photo evidence.

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

<div class="h-96 rounded-xl border border-border">
  <.images_gallery animated />
</div>
default
isometric
two columns · custom copy
four columns · none selected · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Site photos"
images list [%{name: "dock-4-inbound.jpg", kind: "blocks"}, %{name: "yard-north-gate.jpg", kind: "hills"}, ...] Up to twelve `%{name, kind}`. Kind is `hills`, `peak`, `sun`, `grid` or `blocks`. Names up to 26 characters.
columns integer 3 Pictures per row, two to four.
selected any 1 Index of the selected picture, from 0. `nil` selects none and drops the footer.
note string "Attached to PO-2291" The mono line next to the selected file's name. Up to 22 characters.
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/images/gallery.ex
gallery.ex
defmodule EHVisuals.Images.Gallery do
  @moduledoc """
  An image gallery: a grid of abstract placeholder pictures with one of them
  selected.
  """

  use EHVisuals.Composition,
    name: "images/gallery",
    title: "Gallery",
    description:
      "A grid of placeholder pictures with one of them selected, for attachments and photo evidence."

  @images [
    %{name: "dock-4-inbound.jpg", kind: "blocks"},
    %{name: "yard-north-gate.jpg", kind: "hills"},
    %{name: "cold-store-aisle-2.jpg", kind: "grid"},
    %{name: "loading-bay.jpg", kind: "sun"},
    %{name: "site-plan.png", kind: "peak"},
    %{name: "pallet-3.jpg", kind: "hills"}
  ]

  attr(:eyebrow, :string, default: "Site photos")

  attr(:images, :list,
    default: @images,
    doc:
      "Up to twelve `%{name, kind}`. Kind is `hills`, `peak`, `sun`, `grid` or `blocks`. Names up to 26 characters."
  )

  attr(:columns, :integer, default: 3, doc: "Pictures per row, two to four.")

  attr(:selected, :any,
    default: 1,
    doc: "Index of the selected picture, from 0. `nil` selects none and drops the footer."
  )

  attr(:note, :string,
    default: "Attached to PO-2291",
    doc: "The mono line next to the selected file's name. Up to 22 characters."
  )

  attr(:label, :string, default: "A gallery of six placeholder pictures, the second one selected")
  frame_attrs()

  @spec images_gallery(map()) :: Phoenix.LiveView.Rendered.t()
  def images_gallery(assigns) do
    columns = assigns.columns |> max(2) |> min(4)
    images = Enum.take(assigns.images, 12)
    w = Float.round((320 - (columns - 1) * 10) / columns, 1)
    h = Float.round(w * 0.74, 1)
    rows = max(ceil(length(images) / columns), 1)
    bottom = Float.round(44 + rows * (h + 10) - 10, 1)

    tiles =
      for {image, index} <- Enum.with_index(images) do
        x = Float.round(20 + rem(index, columns) * (w + 10), 1)
        y = Float.round(44 + div(index, columns) * (h + 10), 1)

        %{
          x: x,
          y: y,
          on: index == assigns.selected,
          scene: scene(image.kind, x, y, w, h),
          stagger: min(div(index, columns) + rem(index, columns) + 2, 24)
        }
      end

    chosen = is_integer(assigns.selected) && Enum.at(images, assigns.selected)

    assigns =
      assigns
      |> assign(:tiles, tiles)
      |> assign(:w, w)
      |> assign(:h, h)
      |> assign(:count, if(length(images) == 1, do: "1 file", else: "#{length(images)} files"))
      |> assign(:chosen, chosen)
      |> assign(:bottom, bottom)
      |> assign(:height, Float.round(if(chosen, do: bottom + 46, else: bottom + 20), 1))

    ~H"""
    <.frame name="images/gallery" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 360 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="20" y="30" font-size="9">{String.upcase(@eyebrow)}</text>
            <text class="ehv-mono ehv-t-4" x="340" y="30" font-size="9" text-anchor="end">
              {String.upcase(@count)}
            </text>
          </g>
          <g :for={tile <- @tiles} class={"ehv-a-rise ehv-i-#{tile.stagger}"}>
            <rect
              class={[
                "ehv-images-gallery__tile",
                if(tile.on, do: "ehv-images-gallery__tile--on", else: "ehv-well")
              ]}
              x={tile.x}
              y={tile.y}
              width={@w}
              height={@h}
              rx="8"
            />
            <circle
              :if={tile.scene.sun}
              class="ehv-fill-4 ehv-images-gallery__scene"
              cx={elem(tile.scene.sun, 0)}
              cy={elem(tile.scene.sun, 1)}
              r={elem(tile.scene.sun, 2)}
            />
            <path class="ehv-line-ink ehv-round ehv-images-gallery__scene" d={tile.scene.lines} />
            <g :if={tile.on} class="ehv-a-pop ehv-i-9">
              <circle class="ehv-images-gallery__gap" cx={tile.x + @w - 3} cy={tile.y + 3} r="10.5" />
              <circle class="ehv-fill-lime" cx={tile.x + @w - 3} cy={tile.y + 3} r="8" />
              <path
                class="ehv-images-gallery__check ehv-round"
                d={"M#{tile.x + @w - 6.8} #{tile.y + 3.2} l2.6 2.6 l4.8 -5.4"}
              />
            </g>
          </g>
          <g :if={@chosen} class="ehv-a-fade ehv-i-10">
            <line class="ehv-line-faint" x1="20" x2="340" y1={@bottom + 12.5} y2={@bottom + 12.5} />
            <text class="ehv-code" x="20" y={@bottom + 32} font-size="10.5">{@chosen.name}</text>
            <text
              class="ehv-mono ehv-t-lime"
              x="340"
              y={@bottom + 32}
              font-size="8.5"
              text-anchor="end"
            >
              {String.upcase(@note)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # A placeholder picture in the box at {x, y} of size {w, h}: hairlines for a
  # horizon and a landscape, and an optional sun as {cx, cy, r}.
  defp scene(kind, x, y, w, h) do
    px = fn f -> Float.round(x + f * w, 1) end
    py = fn f -> Float.round(y + f * h, 1) end
    r = Float.round(min(w, h) * 0.09, 1)

    case kind do
      "peak" ->
        %{
          lines:
            "M#{px.(0.1)} #{py.(0.78)} H#{px.(0.9)} " <>
              "M#{px.(0.16)} #{py.(0.78)} L#{px.(0.42)} #{py.(0.3)} L#{px.(0.55)} #{py.(0.52)} " <>
              "L#{px.(0.64)} #{py.(0.42)} L#{px.(0.86)} #{py.(0.78)}",
          sun: {px.(0.76), py.(0.26), r}
        }

      "sun" ->
        %{
          lines:
            "M#{px.(0.1)} #{py.(0.6)} H#{px.(0.9)} " <>
              "M#{px.(0.36)} #{py.(0.6)} A#{Float.round(0.14 * w, 1)} #{Float.round(0.14 * w, 1)} 0 0 1 #{px.(0.64)} #{py.(0.6)} " <>
              "M#{px.(0.4)} #{py.(0.72)} H#{px.(0.6)} M#{px.(0.45)} #{py.(0.83)} H#{px.(0.55)}",
          sun: nil
        }

      "grid" ->
        rays =
          Enum.map_join([0.1, 0.3, 0.5, 0.7, 0.9], " ", fn f ->
            "M#{px.(0.5)} #{py.(0.46)} L#{px.(f)} #{py.(0.86)}"
          end)

        rungs =
          Enum.map_join([0.3, 0.6, 1.0], " ", fn t ->
            "M#{px.(0.5 - 0.4 * t)} #{py.(0.46 + 0.4 * t)} H#{px.(0.5 + 0.4 * t)}"
          end)

        %{
          lines: "M#{px.(0.1)} #{py.(0.46)} H#{px.(0.9)} #{rays} #{rungs}",
          sun: {px.(0.74), py.(0.25), r}
        }

      "blocks" ->
        blocks =
          Enum.map_join(
            [{0.16, 0.28, 0.5}, {0.32, 0.48, 0.28}, {0.52, 0.66, 0.56}, {0.7, 0.84, 0.4}],
            " ",
            fn {left, right, top} ->
              "M#{px.(left)} #{py.(0.78)} V#{py.(top)} H#{px.(right)} V#{py.(0.78)}"
            end
          )

        %{lines: "M#{px.(0.1)} #{py.(0.78)} H#{px.(0.9)} #{blocks}", sun: nil}

      _hills ->
        %{
          lines:
            "M#{px.(0.1)} #{py.(0.78)} H#{px.(0.9)} " <>
              "M#{px.(0.12)} #{py.(0.78)} C#{px.(0.24)} #{py.(0.46)} #{px.(0.38)} #{py.(0.46)} #{px.(0.52)} #{py.(0.78)} " <>
              "M#{px.(0.41)} #{py.(0.66)} C#{px.(0.56)} #{py.(0.3)} #{px.(0.74)} #{py.(0.34)} #{px.(0.88)} #{py.(0.78)}",
          sun: {px.(0.24), py.(0.27), r}
        }
    end
  end
end
assets/css/images/gallery.css
gallery.css
/* images/gallery */
.ehv-images-gallery { --ehv-max-w: 400px; --ehv-step: 70ms; }

.ehv-images-gallery__tile--on { fill: var(--ehv-canvas); stroke: var(--ehv-lime); stroke-width: 1.5; }
.ehv-images-gallery__scene { pointer-events: none; }
.ehv-images-gallery__check { fill: none; stroke: var(--ehv-on-lime); stroke-width: 1.5; }

.ehv--hover .ehv-images-gallery__tile { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-images-gallery__tile:hover { stroke: var(--ehv-ink-2); }
.ehv--hover .ehv-images-gallery__tile--on:hover { stroke: var(--ehv-lime); }

/* The selection badge sits on the corner, cut out of the tile by a ring in the card's colour. */
.ehv-images-gallery__gap { fill: var(--ehv-surface-1); }