empowered.house ↗

sections/faq.ex

FAQ

A frequently-asked-questions section with five questions as rows, one of them open with its answer.

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

<div class="h-96 rounded-xl border border-border">
  <.sections_faq animated />
</div>
default
isometric
four questions · custom copy
hover · no glow · answer as text lines
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "FAQ"
heading string "Questions we hear first"
items list [%{q: "How long does a rollout take?"}, %{a: "No. It sits on top of the ERP and writes back to" <> ..., q: "Does it replace our ERP?"}, %{...}, ...] Up to six `%{q, a}`. A question is up to about 48 characters. The answer of the open row wraps onto up to three lines; without an `a` it is drawn as two text lines.
open integer 1 Index of the open question. A negative index leaves all of them closed.
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/faq.ex
faq.ex
defmodule EHVisuals.Sections.Faq do
  @moduledoc """
  A frequently-asked-questions section: questions as rows, one of them open
  with its answer.
  """

  use EHVisuals.Composition,
    name: "sections/faq",
    title: "FAQ",
    description:
      "A frequently-asked-questions section with five questions as rows, one of them open with its answer."

  @items [
    %{q: "How long does a rollout take?"},
    %{
      q: "Does it replace our ERP?",
      a: "No. It sits on top of the ERP and writes back to it, so the ledger stays where it is."
    },
    %{q: "Who approves what an agent does?"},
    %{q: "Can we start with one process?"},
    %{q: "Where is our data stored?"}
  ]

  attr(:eyebrow, :string, default: "FAQ")
  attr(:heading, :string, default: "Questions we hear first")

  attr(:items, :list,
    default: @items,
    doc:
      "Up to six `%{q, a}`. A question is up to about 48 characters. The answer of the open row wraps onto up to three lines; without an `a` it is drawn as two text lines."
  )

  attr(:open, :integer,
    default: 1,
    doc: "Index of the open question. A negative index leaves all of them closed."
  )

  attr(:label, :string,
    default: "A list of five frequently asked questions with the second one open"
  )

  frame_attrs()

  @spec sections_faq(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_faq(assigns) do
    {rows, bottom} =
      assigns.items
      |> Enum.take(6)
      |> Enum.with_index()
      |> Enum.map_reduce(74, fn {item, index}, y ->
        open = index == assigns.open
        answer = if open, do: answer(item), else: []
        skeleton = open and answer == []
        height = if open, do: 32 + max(length(answer), 2) * 15 + 10, else: 32

        row = %{
          q: item.q,
          index: index,
          y: y,
          h: height,
          open: open,
          answer: Enum.with_index(answer),
          skeleton: skeleton,
          rule: index > 0 and not open and index - 1 != assigns.open
        }

        {row, y + height}
      end)

    assigns = assigns |> assign(:rows, rows) |> assign(:height, bottom + 16)

    ~H"""
    <.frame name="sections/faq" 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-t-strong ehv-a-fade ehv-i-2" x="24" y="58" font-size="16">{@heading}</text>
          <g :for={row <- @rows} class={"ehv-sections-faq__row ehv-a-rise ehv-i-#{row.index + 3}"}>
            <rect
              class={if row.open, do: "ehv-card-2", else: "ehv-sections-faq__hit"}
              x="12.5"
              y={row.y + 0.5}
              width="375"
              height={row.h - 1}
              rx="8"
            />
            <line
              :if={row.rule}
              class="ehv-line-faint"
              x1="24"
              x2="376"
              y1={row.y + 0.5}
              y2={row.y + 0.5}
            />
            <text
              class={[row.open && "ehv-t-strong", !row.open && "ehv-t-2"]}
              x="24"
              y={row.y + 20}
              font-size="12"
            >
              {row.q}
            </text>
            <circle :if={row.open} class="ehv-fill-lime-soft" cx="364" cy={row.y + 16} r="9" />
            <path
              class={[
                "ehv-sections-faq__icon ehv-round",
                if(row.open, do: "ehv-line-lime", else: "ehv-line-ink")
              ]}
              d={
                if row.open,
                  do: "M360 #{row.y + 16} h8",
                  else: "M360 #{row.y + 16} h8 M364 #{row.y + 12} v8"
              }
            />
            <text
              :for={{line, index} <- row.answer}
              class={"ehv-t-2 ehv-a-fade ehv-i-#{min(row.index + 5 + index, 24)}"}
              x="24"
              y={row.y + 39 + index * 15}
              font-size="11.5"
            >
              {line}
            </text>
            <rect
              :for={{width, index} <- Enum.with_index(if(row.skeleton, do: [308, 196], else: []))}
              class={"ehv-fill-soft ehv-a-grow-x ehv-i-#{min(row.index + 5 + index, 24)}"}
              x="24"
              y={row.y + 33 + index * 15}
              width={width}
              height="6"
              rx="3"
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp answer(item) do
    case Map.get(item, :a) do
      text when is_binary(text) and text != "" -> text |> wrap(54) |> Enum.take(3)
      _none -> []
    end
  end

  defp wrap(text, limit) do
    text
    |> String.split()
    |> Enum.reduce([], fn
      word, [] ->
        [word]

      word, [line | rest] ->
        if String.length(line) + 1 + String.length(word) <= limit,
          do: ["#{line} #{word}" | rest],
          else: [word, line | rest]
    end)
    |> Enum.reverse()
  end
end
assets/css/sections/faq.css
faq.css
/* sections/faq */
.ehv-sections-faq { --ehv-max-w: 440px; }

/* A closed row has no surface, only something for the pointer to land on. */
.ehv-sections-faq__hit { fill: transparent; }

.ehv--hover .ehv-sections-faq__icon { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-sections-faq__row:hover .ehv-sections-faq__icon { stroke: var(--ehv-lime); }