files/upload.ex
File upload
A file upload area with a dashed drop zone and a few files with progress bars, one of them finished.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.files_upload animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Drop invoices here" |
Up to 36 characters. |
hint |
string | "PDF, XLSX or CSV · up to 20 MB" |
The line under the title. Up to 44 characters. |
files |
list | [%{name: "Northwind-4471.pdf", size: "248 KB", progress: 100}, %{name: "Invoices-Nov.xlsx", ...}, ...] |
Up to four `%{name, size, progress}`. Progress is 0 to 100; at 100 the file is finished. Add `state: "failed"` for an upload that stopped. Keep names under 26 characters. |
summary |
any | nil |
The line at the bottom. Left `nil`, it counts the finished files. |
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/files/upload.ex
defmodule EHVisuals.Files.Upload do
@moduledoc """
A file upload: a dashed drop zone and the files on their way, each with a
progress bar. A finished file gets a check, a failed one turns coral.
"""
use EHVisuals.Composition,
name: "files/upload",
title: "File upload",
description:
"A file upload area with a dashed drop zone and a few files with progress bars, one of them finished."
@files [
%{name: "Northwind-4471.pdf", size: "248 KB", progress: 100},
%{name: "Invoices-Nov.xlsx", size: "1.1 MB", progress: 64},
%{name: "Delivery-note-881.pdf", size: "96 KB", progress: 22}
]
attr(:title, :string, default: "Drop invoices here", doc: "Up to 36 characters.")
attr(:hint, :string,
default: "PDF, XLSX or CSV · up to 20 MB",
doc: "The line under the title. Up to 44 characters."
)
attr(:files, :list,
default: @files,
doc:
"Up to four `%{name, size, progress}`. Progress is 0 to 100; at 100 the file is finished. Add `state: \"failed\"` for an upload that stopped. Keep names under 26 characters."
)
attr(:summary, :any,
default: nil,
doc: "The line at the bottom. Left `nil`, it counts the finished files."
)
attr(:label, :string,
default: "A file upload area with three files, one uploaded and two in progress"
)
frame_attrs()
@spec files_upload(map()) :: Phoenix.LiveView.Rendered.t()
def files_upload(assigns) do
rows =
for {file, index} <- assigns.files |> Enum.take(4) |> Enum.with_index() do
progress = file |> Map.get(:progress, 0) |> max(0) |> min(100)
y = 116 + index * 46
state =
cond do
Map.get(file, :state) == "failed" -> "failed"
progress >= 100 -> "done"
true -> "active"
end
right =
case state do
"done" -> ""
"failed" -> "Failed"
"active" -> "#{progress}%"
end
right_w = if state == "done", do: 18, else: String.length(right) * 5.5
%{
index: index,
y: y,
name: file.name,
size: file.size,
state: state,
right: right,
size_x: Float.round(312.0 - right_w, 1),
bar_w: Float.round(max(266 * progress / 100, 4.0), 1),
page: page_d(20.5, y + 5.5, 23, 29, 7)
}
end
done = Enum.count(rows, &(&1.state == "done"))
footer_y = 116 + length(rows) * 46 + 2
assigns =
assigns
|> assign(:rows, rows)
|> assign(:footer_y, footer_y)
|> assign(:footer, assigns.summary || "#{done} of #{length(rows)} uploaded")
|> assign(:height, footer_y + 32)
~H"""
<.frame name="files/upload" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 340 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<rect class="ehv-files-upload__zone" x="16.5" y="16.5" width="307" height="86" rx="10" />
<path
class="ehv-line-ink ehv-round ehv-files-upload__arrow"
d="M170 49 v-17 m-6 6 l6 -6 l6 6"
/>
<path
class="ehv-line-ink ehv-round"
d="M157 45 v5 a2.5 2.5 0 0 0 2.5 2.5 h21 a2.5 2.5 0 0 0 2.5 -2.5 v-5"
/>
<text x="170" y="74" font-size="12.5" text-anchor="middle">{@title}</text>
<text class="ehv-mono" x="170" y="90" font-size="7.5" text-anchor="middle">
{String.upcase(@hint)}
</text>
</g>
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 2}"}>
<path class="ehv-card-3 ehv-round" d={row.page} />
<text class={[row.state == "failed" && "ehv-t-2"]} x="54" y={row.y + 17} font-size="12">
{row.name}
</text>
<text class="ehv-mono" x={row.size_x} y={row.y + 16.5} font-size="8" text-anchor="end">
{String.upcase(row.size)}
</text>
<g :if={row.state == "done"}>
<circle class="ehv-fill-lime-soft" cx="311" cy={row.y + 13} r="8.5" />
<path
class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{row.index + 8}"}
d={"M307 #{row.y + 13} l2.8 2.8 l5 -5.6"}
pathLength="1"
/>
</g>
<text
:if={row.state != "done"}
class={[
"ehv-mono",
if(row.state == "failed", do: "ehv-t-coral", else: "ehv-t-ink ehv-a-pulse")
]}
x="320"
y={row.y + 16.5}
font-size="8"
text-anchor="end"
>
{String.upcase(row.right)}
</text>
<rect class="ehv-fill-soft" x="54" y={row.y + 27} width="266" height="4" rx="2" />
<rect
class={["ehv-a-grow-x", "ehv-i-#{row.index + 4}", "ehv-files-upload__bar--#{row.state}"]}
x="54"
y={row.y + 27}
width={row.bar_w}
height="4"
rx="2"
/>
</g>
<g class="ehv-a-fade ehv-i-7">
<line
class="ehv-line-faint"
x1="0.5"
x2="339.5"
y1={@footer_y + 0.5}
y2={@footer_y + 0.5}
/>
<text class="ehv-mono" x="20" y={@footer_y + 19.5} font-size="8">
{String.upcase(@footer)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
# A page with a folded corner.
defp page_d(x, y, w, h, fold) do
"M#{x} #{y} h#{w - fold} l#{fold} #{fold} v#{h - fold} h-#{w} z " <>
"M#{x + w - fold} #{y} v#{fold} h#{fold}"
end
end
assets/css/files/upload.css
/* files/upload */
.ehv-files-upload { --ehv-max-w: 370px; }
.ehv .ehv-files-upload__zone {
fill: var(--ehv-canvas);
stroke: var(--ehv-border-strong);
stroke-width: 1;
stroke-dasharray: 5 5;
}
/* Lime is the finished file. A running upload is ink, a failed one coral. */
.ehv .ehv-files-upload__bar--done { fill: var(--ehv-lime-fill); }
.ehv .ehv-files-upload__bar--active { fill: var(--ehv-ink-2); }
.ehv .ehv-files-upload__bar--failed { fill: var(--ehv-coral); }
/* On hover the drop zone answers: the border turns lime, the arrow rises. */
.ehv-files-upload__arrow { transform-box: fill-box; }
.ehv--hover .ehv-files-upload__zone { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-files-upload__arrow { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-files-upload__zone { stroke: var(--ehv-lime); }
.ehv--hover:hover .ehv-files-upload__arrow { transform: translateY(-3px); }