empowered.house ↗

sections/comments.ex

Comments

A comments thread with avatars, names, comment lines, an indented reply and vote counts.

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

<div class="h-96 rounded-xl border border-border">
  <.sections_comments animated />
</div>
default
isometric
three comments · custom copy
hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Discussion"
heading string "Month-end close checklist"
comments list [%{name: "Marta Nowak", time: "2h ago", votes: 12}, %{name: "Jan Kowalski", ...}, ...] Up to five `%{name, time, votes}`. Add `reply: true` to indent a comment under the one before it.
voted integer 0 Index of the comment whose vote is marked in lime. A negative index marks none.
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/sections/comments.ex
comments.ex
defmodule EHVisuals.Sections.Comments do
  @moduledoc """
  A comments thread: avatars, names, comment lines, a reply indented under
  one comment, and vote counts.
  """

  use EHVisuals.Composition,
    name: "sections/comments",
    title: "Comments",
    description:
      "A comments thread with avatars, names, comment lines, an indented reply and vote counts."

  @comments [
    %{name: "Marta Nowak", time: "2h ago", votes: 12},
    %{name: "Jan Kowalski", time: "1h ago", votes: 4, reply: true},
    %{name: "Ewa Lis", time: "40m ago", votes: 7},
    %{name: "Piotr Zielinski", time: "12m ago", votes: 2}
  ]

  # Widths of the two text lines of a comment, cycled by position.
  @bodies [{236, 152}, {204, 92}, {244, 176}, {188, 64}]

  attr(:eyebrow, :string, default: "Discussion")
  attr(:heading, :string, default: "Month-end close checklist")

  attr(:comments, :list,
    default: @comments,
    doc:
      "Up to five `%{name, time, votes}`. Add `reply: true` to indent a comment under the one before it."
  )

  attr(:voted, :integer,
    default: 0,
    doc: "Index of the comment whose vote is marked in lime. A negative index marks none."
  )

  attr(:label, :string,
    default: "A thread of four comments with vote counts, one of them a reply to the first"
  )

  frame_attrs()

  @spec sections_comments(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_comments(assigns) do
    rows =
      assigns.comments
      |> Enum.take(5)
      |> Enum.with_index()
      |> Enum.map(fn {comment, index} ->
        reply = index > 0 and Map.get(comment, :reply, false)
        indent = if reply, do: 34, else: 0
        {first, second} = Enum.at(@bodies, rem(index, length(@bodies)))

        %{
          comment: comment,
          index: index,
          reply: reply,
          y: 86 + index * 50,
          avatar_x: 36 + indent,
          text_x: 58 + indent,
          widths: [min(first, 262 - indent), min(second, 262 - indent)]
        }
      end)

    count = length(rows)

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:count, if(count == 1, do: "1 comment", else: "#{count} comments"))
      |> assign(:height, 86 + max(count - 1, 0) * 50 + 35 + 22)

    ~H"""
    <.frame name="sections/comments" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 400 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height={@height - 1} rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="24" y="34" font-size="8.5">
            {String.upcase(@eyebrow)}
          </text>
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="376" y="34" font-size="8.5" text-anchor="end">
            {String.upcase(@count)}
          </text>
          <text class="ehv-t-strong ehv-a-fade ehv-i-2" x="24" y="58" font-size="16">{@heading}</text>
          <line class="ehv-line-faint" x1="24" x2="376" y1="72.5" y2="72.5" />
          <g
            :for={row <- @rows}
            class={"ehv-sections-comments__row ehv-a-rise ehv-i-#{row.index + 3}"}
          >
            <path
              :if={row.reply}
              class={"ehv-line ehv-a-draw ehv-i-#{row.index + 4}"}
              d={"M36 #{row.y - 22} V#{row.y + 6} Q36 #{row.y + 12} 42 #{row.y + 12} H54"}
              pathLength="1"
            />
            <circle class="ehv-card-3" cx={row.avatar_x} cy={row.y + 12} r="12" />
            <text
              class="ehv-mono ehv-t-2"
              x={row.avatar_x}
              y={row.y + 15}
              font-size="8"
              text-anchor="middle"
            >
              {initials(row.comment.name)}
            </text>
            <text x={row.text_x} y={row.y + 9} font-size="12">
              <tspan>{row.comment.name}</tspan>
              <tspan class="ehv-mono" dx="5" font-size="7.5">{String.upcase(row.comment.time)}</tspan>
            </text>
            <rect
              :for={{width, line} <- Enum.with_index(row.widths)}
              class={"ehv-fill-soft ehv-a-grow-x ehv-i-#{row.index + 4 + line}"}
              x={row.text_x}
              y={row.y + 18 + line * 11}
              width={width}
              height="6"
              rx="3"
            />
            <rect
              class={
                if row.index == @voted,
                  do: "ehv-fill-lime-soft",
                  else: "ehv-line-soft"
              }
              x="336.5"
              y={row.y + 1.5}
              width="39"
              height="21"
              rx="6"
            />
            <path
              class={[
                "ehv-sections-comments__chevron ehv-round",
                if(row.index == @voted, do: "ehv-line-lime", else: "ehv-line-ink")
              ]}
              d={"M344 #{row.y + 14} l3.5 -4 l3.5 4"}
            />
            <text
              class={["ehv-mono", if(row.index == @voted, do: "ehv-t-lime", else: "ehv-t-2")]}
              x="369"
              y={row.y + 15}
              font-size="8.5"
              text-anchor="end"
            >
              {row.comment.votes}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end
end
assets/css/sections/comments.css
comments.css
/* sections/comments */
.ehv-sections-comments { --ehv-max-w: 440px; }

.ehv-sections-comments__chevron { transform-box: fill-box; }
.ehv--hover .ehv-sections-comments__chevron { transition: transform 200ms var(--ehv-ease); }
.ehv--hover .ehv-sections-comments__row:hover .ehv-sections-comments__chevron { transform: translateY(-2px); }