empowered.house ↗

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.

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

<div class="h-96 rounded-xl border border-border">
  <.files_upload animated />
</div>
default
isometric
two files · one failed
all done · hover · no glow
light surface
isometric · fade out · still

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