devices/tablet.ex
Tablet
A tablet in outline showing an app page: a list on the left and the selected item in a detail pane.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.devices_tablet animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
heading |
string | "Invoices" |
|
items |
list | [%{id: "INV-4472", name: "Acme Tools", status: "New", amount: "€6,120"}, %{...}, ...] |
Up to five `%{id, name, amount, status}`. Keep names under 13 characters. |
selected |
integer | 1 |
Index of the item shown in the detail pane. |
fields |
list | [%{label: "Due", value: "30 Nov"}, %{label: "Terms", value: "Net 30"}, ...] |
Up to four `%{label, value}` rows of the detail pane. |
action |
string | "Post to ERP" |
The button under the detail. Up to 20 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/devices/tablet.ex
defmodule EHVisuals.Devices.Tablet do
@moduledoc """
A tablet drawn in outline with an app page on its screen: a list on the
left and the selected item in a detail pane on the right.
"""
use EHVisuals.Composition,
name: "devices/tablet",
title: "Tablet",
description:
"A tablet in outline showing an app page: a list on the left and the selected item in a detail pane."
@items [
%{id: "INV-4472", name: "Acme Tools", amount: "€6,120", status: "New"},
%{id: "INV-4471", name: "Northwind", amount: "€18,400", status: "Matched"},
%{id: "INV-4470", name: "Globex", amount: "€2,980", status: "Posted"},
%{id: "INV-4469", name: "Initech", amount: "€11,250", status: "Posted"},
%{id: "INV-4468", name: "Northwind", amount: "€940", status: "Posted"}
]
@fields [
%{label: "Due", value: "30 Nov"},
%{label: "Terms", value: "Net 30"},
%{label: "Matched to", value: "PO-2291"},
%{label: "Checked by", value: "Intake agent"}
]
attr(:heading, :string, default: "Invoices")
attr(:items, :list,
default: @items,
doc: "Up to five `%{id, name, amount, status}`. Keep names under 13 characters."
)
attr(:selected, :integer, default: 1, doc: "Index of the item shown in the detail pane.")
attr(:fields, :list,
default: @fields,
doc: "Up to four `%{label, value}` rows of the detail pane."
)
attr(:action, :string,
default: "Post to ERP",
doc: "The button under the detail. Up to 20 characters."
)
attr(:label, :string,
default: "A tablet showing a list of invoices with one opened in a detail pane"
)
frame_attrs()
@spec devices_tablet(map()) :: Phoenix.LiveView.Rendered.t()
def devices_tablet(assigns) do
items = Enum.take(assigns.items, 5)
selected = assigns.selected |> max(0) |> min(length(items) - 1)
current = Enum.at(items, selected)
fields = assigns.fields |> Enum.take(4) |> Enum.with_index()
assigns =
assigns
|> assign(:rows, Enum.with_index(items))
|> assign(:picked, selected)
|> assign(:current, current)
|> assign(:count, "#{length(items)} open")
|> assign(:details, fields)
|> assign(:bones_y, 117 + length(fields) * 26 + 14)
|> assign(:bones, bones(117 + length(fields) * 26 + 14))
|> assign(:pill_w, Float.round(16 + String.length(current.status) * 5.6, 1))
|> assign(:action_w, Float.round(26 + String.length(assigns.action) * 6.1, 1))
~H"""
<.frame name="devices/tablet" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 440 312">
<g class="ehv-h-lift">
<g class="ehv-a-rise">
<rect class="ehv-card" x="0.5" y="0.5" width="439" height="311" rx="20" />
<rect class="ehv-well" x="12" y="12" width="416" height="288" rx="10" />
<circle class="ehv-fill-4" cx="6.3" cy="156" r="1.3" />
</g>
<g class="ehv-a-fade ehv-i-2">
<text class="ehv-t-strong" x="26" y="38" font-size="12.5">{@heading}</text>
<text class="ehv-mono" x="148" y="37.5" font-size="7.5" text-anchor="end">
{String.upcase(@count)}
</text>
<line class="ehv-line-faint" x1="12.5" x2="160" y1="50.5" y2="50.5" />
<line class="ehv-line-soft" x1="160.5" x2="160.5" y1="12.5" y2="299.5" />
</g>
<g
:for={{item, index} <- @rows}
class={["ehv-devices-tablet__row", "ehv-a-slide", "ehv-i-#{index + 3}"]}
>
<rect
class={
if index == @picked,
do: "ehv-fill-surface-3",
else: "ehv-devices-tablet__row-bg"
}
x="18"
y={55 + index * 44}
width="136"
height="38"
rx="8"
/>
<rect
:if={index == @picked}
class="ehv-fill-lime"
x="18"
y={65 + index * 44}
width="2.5"
height="18"
rx="1.25"
/>
<text
class={if index == @picked, do: "ehv-t-strong", else: "ehv-t-2"}
x="30"
y={72 + index * 44}
font-size="11.5"
>
{item.name}
</text>
<text class="ehv-mono" x="30" y={85 + index * 44} font-size="7">
{String.upcase(item.id)}
</text>
<text
class={["ehv-mono", index == @picked && "ehv-t-ink"]}
x="146"
y={72 + index * 44}
font-size="7.5"
text-anchor="end"
>
{item.amount}
</text>
</g>
<g class="ehv-a-fade ehv-i-5">
<text class="ehv-mono" x="178" y="40" font-size="8">{String.upcase(@current.id)}</text>
<rect class="ehv-card-3" x={412 - @pill_w} y="27.5" width={@pill_w} height="18" rx="6" />
<text
class="ehv-mono ehv-t-2"
x={412 - @pill_w / 2}
y="39.5"
font-size="7.5"
text-anchor="middle"
>
{String.upcase(@current.status)}
</text>
<text class="ehv-t-strong" x="178" y="70" font-size="17">{@current.name}</text>
<text class="ehv-t-display" x="178" y="102" font-size="24">{@current.amount}</text>
</g>
<g :for={{field, index} <- @details} class={"ehv-a-rise ehv-i-#{index + 6}"}>
<line
class="ehv-line-faint"
x1="178"
x2="412"
y1={117.5 + index * 26}
y2={117.5 + index * 26}
/>
<text class="ehv-mono" x="178" y={134 + index * 26} font-size="7.5">
{String.upcase(field.label)}
</text>
<text x="412" y={134.5 + index * 26} font-size="11.5" text-anchor="end">
{field.value}
</text>
</g>
<g class="ehv-a-fade ehv-i-10">
<line
class="ehv-line-faint"
x1="178"
x2="412"
y1={@bones_y - 13.5}
y2={@bones_y - 13.5}
/>
<rect
:for={bone <- @bones}
class="ehv-fill-soft"
x="178"
y={bone.y}
width={bone.w}
height="5"
rx="2.5"
/>
</g>
<g class="ehv-a-rise ehv-i-11">
<rect class="ehv-fill-lime" x="178" y="261" width={@action_w} height="27" rx="7" />
<text
class="ehv-t-strong ehv-t-on-lime"
x={178 + @action_w / 2}
y="278.5"
font-size="11"
text-anchor="middle"
>
{@action}
</text>
<rect class="ehv-line" x={178 + @action_w + 8.5} y="261.5" width="64" height="26" rx="7" />
<rect
class="ehv-fill-soft"
x={178 + @action_w + 24}
y="272"
width="33"
height="5"
rx="2.5"
/>
</g>
</g>
</svg>
</.frame>
"""
end
# Skeleton lines that fill the room between the fields and the button.
defp bones(from) do
widths = [204, 226, 188, 214, 148, 196, 120]
count = max(min(div(246 - from, 11) + 1, 7), 1)
for index <- 0..(count - 1) do
width = if index == count - 1, do: 148, else: Enum.at(widths, index)
%{y: from + index * 11, w: width}
end
end
end
assets/css/devices/tablet.css
/* devices/tablet */
.ehv-devices-tablet { --ehv-max-w: 480px; }
/* A list row lights up under the pointer. */
.ehv .ehv-devices-tablet__row-bg { fill: transparent; }
.ehv--hover .ehv-devices-tablet__row-bg { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-devices-tablet__row:hover .ehv-devices-tablet__row-bg { fill: var(--ehv-surface-2); }