browser/tabs.ex
Browser tabs
A browser window with a strip of tabs, one active, and an address bar and page that follow the active tab.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.browser_tabs animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
tabs |
list | [%{title: "Orders", url: "erp.yourcompany.com/orders", note: "128 open orders this week"}, %{title: "Invoice 4471", ...}, ...] |
Two to four `%{title, url, note}`. Long titles are cut to fit the tab. The note is the line under the page heading and may be left out. |
active |
integer | 1 |
Index of the active tab, starting at 0. |
rows |
integer | 3 |
Skeleton rows of the page, 1 to 4. |
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/browser/tabs.ex
defmodule EHVisuals.Browser.Tabs do
@moduledoc """
A browser window with a strip of tabs. One tab is active and the address
bar and the page follow it.
"""
use EHVisuals.Composition,
name: "browser/tabs",
title: "Browser tabs",
description:
"A browser window with a strip of tabs, one active, and an address bar and page that follow the active tab."
@tabs [
%{title: "Orders", url: "erp.yourcompany.com/orders", note: "128 open orders this week"},
%{
title: "Invoice 4471",
url: "erp.yourcompany.com/invoices/4471",
note: "Northwind · due 30 Nov"
},
%{title: "Stock levels", url: "wms.yourcompany.com/stock", note: "Counted this morning"}
]
@bone_widths [124, 152, 104, 138]
attr(:tabs, :list,
default: @tabs,
doc:
"Two to four `%{title, url, note}`. Long titles are cut to fit the tab. The note is the line under the page heading and may be left out."
)
attr(:active, :integer, default: 1, doc: "Index of the active tab, starting at 0.")
attr(:rows, :integer, default: 3, doc: "Skeleton rows of the page, 1 to 4.")
attr(:label, :string,
default:
"A browser window with three tabs, the second one active and shown in the address bar"
)
frame_attrs()
@spec browser_tabs(map()) :: Phoenix.LiveView.Rendered.t()
def browser_tabs(assigns) do
tabs = Enum.take(assigns.tabs, 4)
count = length(tabs)
active = assigns.active |> max(0) |> min(count - 1)
tab_w = Float.round(min(124.0, 374 / count), 1)
row_count = assigns.rows |> max(1) |> min(4)
items =
for {tab, index} <- Enum.with_index(tabs) do
%{
index: index,
x: Float.round(12 + index * tab_w, 1),
title: clip(tab.title, trunc((tab_w - if(index == active, do: 46, else: 36)) / 5.3)),
active: index == active,
divider: index > 0 and index != active and index - 1 != active
}
end
current = Enum.at(tabs, active)
active_x = Float.round(12 + active * tab_w, 1)
rows =
for index <- 0..(row_count - 1) do
%{index: index, y: 156 + index * 26, w: Enum.at(@bone_widths, index)}
end
assigns =
assigns
|> assign(:items, items)
|> assign(:tab_w, tab_w)
|> assign(:active_x, active_x)
|> assign(:active_d, tab_d(active_x, tab_w))
|> assign(:plus_x, Float.round(12 + count * tab_w + 15, 1))
|> assign(:current, current)
|> assign(:note, Map.get(current, :note))
|> assign(:position, "Tab #{active + 1} of #{count}")
|> assign(:bones, rows)
|> assign(:height, 156 + row_count * 26 + 14)
~H"""
<.frame name="browser/tabs" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 420 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="419" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<path
class="ehv-fill-canvas"
d="M1 34 V12 A11 11 0 0 1 12 1 H408 A11 11 0 0 1 419 12 V34 Z"
/>
<line class="ehv-line-soft" x1="0.5" x2={@active_x} y1="34.5" y2="34.5" />
<line class="ehv-line-soft" x1={@active_x + @tab_w} x2="419.5" y1="34.5" y2="34.5" />
<path class="ehv-browser-tabs__active" d={@active_d} />
<path
class="ehv-line-ink ehv-round"
d={"M#{@plus_x - 4} 21 h8 M#{@plus_x} 17 v8"}
/>
</g>
<g
:for={tab <- @items}
class={["ehv-browser-tabs__tab", "ehv-a-slide", "ehv-i-#{tab.index + 2}"]}
>
<rect class="ehv-browser-tabs__hit" x={tab.x} y="7" width={@tab_w} height="27" />
<line
:if={tab.divider}
class="ehv-line-soft"
x1={tab.x}
x2={tab.x}
y1="14"
y2="28"
/>
<rect
:if={tab.active}
class="ehv-fill-lime"
x={tab.x + 12}
y="17"
width="8"
height="8"
rx="2.5"
/>
<rect
:if={!tab.active}
class="ehv-node-soft"
x={tab.x + 12.5}
y="17.5"
width="7"
height="7"
rx="2"
/>
<text
class={[
"ehv-browser-tabs__title",
tab.active && "ehv-t-strong",
!tab.active && "ehv-t-3"
]}
x={tab.x + 27}
y="24.5"
font-size="10.5"
>
{tab.title}
</text>
<path
:if={tab.active}
class="ehv-line ehv-round"
d={"M#{tab.x + @tab_w - 17} 18.5 l5 5 m0 -5 l-5 5"}
/>
</g>
<g class="ehv-a-fade ehv-i-4">
<path class="ehv-line-ink ehv-round" d="M21 47.5 l-4 4 l4 4" />
<path class="ehv-line ehv-round" d="M35 47.5 l4 4 l-4 4" />
<path class="ehv-line-ink ehv-round" d="M60.5 51.5 a4.5 4.5 0 1 1 -1.6 -3.45" />
<path class="ehv-line-ink ehv-round" d="M59.3 44.9 v3.3 h-3.3" />
<rect class="ehv-well" x="74" y="40.5" width="332" height="22" rx="11" />
<rect class="ehv-line-ink" x="85" y="50" width="7" height="6" rx="1.4" />
<path class="ehv-line-ink ehv-round" d="M86.5 50 v-1.6 a2 2 0 0 1 4 0 v1.6" />
<text class="ehv-mono ehv-t-2" x="100" y="54.5" font-size="8.5">{@current.url}</text>
<line class="ehv-line-soft" x1="0.5" x2="419.5" y1="68.5" y2="68.5" />
</g>
<g class="ehv-a-rise ehv-i-5">
<text class="ehv-mono" x="28" y="95" font-size="8">{String.upcase(@position)}</text>
<text class="ehv-t-strong" x="28" y="119" font-size="17">{@current.title}</text>
<text :if={@note} class="ehv-t-3" x="28" y="138" font-size="11.5">{@note}</text>
</g>
<g :for={row <- @bones} class={"ehv-a-rise ehv-i-#{row.index + 6}"}>
<line class="ehv-line-faint" x1="28" x2="392" y1={row.y + 0.5} y2={row.y + 0.5} />
<rect class="ehv-fill-soft" x="28" y={row.y + 10.5} width={row.w} height="6" rx="3" />
<rect class="ehv-fill-soft" x="222" y={row.y + 10.5} width="58" height="6" rx="3" />
<rect class="ehv-fill-soft" x="350" y={row.y + 8.5} width="42" height="10" rx="5" />
</g>
</g>
</svg>
</.frame>
"""
end
# The active tab: open at the bottom, so it runs into the toolbar.
defp tab_d(x, w) do
"M#{x} 34.5 V14 A7 7 0 0 1 #{x + 7} 7 H#{Float.round(x + w - 7, 1)} " <>
"A7 7 0 0 1 #{Float.round(x + w, 1)} 14 V34.5"
end
defp clip(text, max_chars) do
if String.length(text) > max_chars do
String.trim_trailing(String.slice(text, 0, max(max_chars - 1, 1))) <> "…"
else
text
end
end
end
assets/css/browser/tabs.css
/* browser/tabs */
.ehv-browser-tabs { --ehv-max-w: 460px; }
/* The active tab has the colour of the window, so it reads as one piece with
the toolbar under it. */
.ehv .ehv-browser-tabs__active { fill: var(--ehv-surface-1); stroke: var(--ehv-border-soft); stroke-width: 1; }
/* A hit area, so a tab reacts to the pointer over its whole width. */
.ehv .ehv-browser-tabs__hit { fill: transparent; }
.ehv--hover .ehv-browser-tabs__title { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-browser-tabs__tab:hover .ehv-browser-tabs__title { fill: var(--ehv-ink); }