images/gallery.ex
Gallery
A grid of placeholder pictures with one of them selected, for attachments and photo evidence.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.images_gallery animated />
</div>
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
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
/* 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); }