empowered.house ↗

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.

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

<div class="h-96 rounded-xl border border-border">
  <.browser_tabs animated />
</div>
default
isometric
first tab active
four tabs · custom copy · hover
light surface
isometric · fade out · still · no glow

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
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
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); }