empowered.house ↗

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.

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

<div class="h-96 rounded-xl border border-border">
  <.devices_tablet animated />
</div>
default
isometric
custom list · third item open
hover · no glow
light surface
isometric · fade out · still

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