sloanelybutsurely.com/lib/web/components/core_components.ex
2025-04-12 13:56:20 -04:00

254 lines
6.4 KiB
Elixir

defmodule Web.CoreComponents do
@moduledoc """
Provides core UI components.
"""
use Phoenix.Component
alias Phoenix.HTML.FormField
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string, default: "text", values: ~w[text password textarea]
attr :field, FormField
attr :errors, :list, default: []
attr :rest, :global, include: ~w[disabled form pattern placeholder readonly required]
def input(%{field: %FormField{} = field} = assigns) do
errors =
if Phoenix.Component.used_input?(field) do
field.errors
else
[]
end
assigns
|> assign(
field: nil,
id: assigns.id || field.id,
errors: Enum.map(errors, &translate_error/1)
)
|> assign_new(:name, fn -> field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<textarea id={@id} name={@name}>{Phoenix.HTML.Form.normalize_value(@type, @value)}</textarea>
<.error :for={error <- @errors}>{error}</.error>
</div>
"""
end
def input(assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<input
id={@id}
type={@type}
name={@name}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
{@rest}
/>
<.error :for={error <- @errors}>{error}</.error>
</div>
"""
end
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for}>
{render_slot(@inner_block)}
</label>
"""
end
slot :inner_block, required: true
def error(assigns) do
~H"""
<p>
<.icon name="hero-exclamation-circle-mini" class="h-5 w-5 flex-none" />
{render_slot(@inner_block)}
</p>
"""
end
attr :name, :string, required: true
attr :class, :string, default: nil
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
attr :type, :string, default: "button", values: ~w[button submit]
attr :rest, :global
slot :inner_block, required: true
def button(assigns) do
~H"""
<button type={@type} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
attr :format, :string, required: true
attr :value, :any, default: nil
attr :formatter, :atom, default: :default
attr :timezone, :string, default: "America/New_York"
attr :global, :global
def timex(%{value: nil} = assigns) do
~H"""
<time datetime="">--</time>
"""
end
def timex(%{value: value, timezone: timezone} = assigns) do
assigns =
assign_new(assigns, :local_value, fn ->
case value do
%DateTime{} = datetime ->
datetime
%NaiveDateTime{} = naive ->
naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.shift_zone!(timezone)
end
end)
~H"""
<time
datetime={Timex.format!(@local_value, "{ISO:Extended}")}
title={Timex.format!(@local_value, "{Mshort} {D}, {YYYY}, {h12}:{m} {AM} {Zabbr}")}
{@global}
>
{Timex.format!(@local_value, @format, timex_formatter(@formatter))}
</time>
"""
end
defp timex_formatter(formatter) do
Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}"))
end
attr :id, :string, required: true
attr :posts, :any, required: true
slot :inner_block, required: true
def post_list(assigns) do
~H"""
<ol id={@id} phx-update={if is_struct(@posts, Phoenix.LiveView.LiveStream), do: "phx-update"}>
<li
:for={{dom_id, item} <- normalize_posts(@posts)}
id={dom_id}
class="flex flex-row justify-between"
>
<span>{render_slot(@inner_block, item)}</span>
<span>
<%= if item.deleted_at do %>
deleted
<% else %>
<%= if item.published_at do %>
<%= case item.kind do %>
<% :blog -> %>
<.timex value={item.published_at} format="{YYYY}-{0M}-{0D}" />
<% :status -> %>
<.timex value={item.published_at} format="{relative}" formatter={:relative} />
<% end %>
<% else %>
draft
<% end %>
<% end %>
</span>
</li>
</ol>
"""
end
defp normalize_posts(%Phoenix.LiveView.LiveStream{} = stream), do: stream
defp normalize_posts(posts) when is_list(posts),
do: Enum.with_index(posts, &{"#{&1.kind}-#{&2}", &1})
@doc """
Renders markdown content as HTML.
## Examples
<.markdown content={@post.body} />
"""
attr :content, :string, required: true
attr :class, :string, default: ""
def markdown(assigns) do
~H"""
<div class={["markdown-content", @class]}>
{Phoenix.HTML.raw(Web.Markdown.to_html(@content))}
</div>
"""
end
@doc """
Renders pagination controls for navigating through a paginated list.
## Examples
<.pagination meta={@meta} path={~p"/blog"} schema={Schema.Post} />
"""
attr :meta, :map, required: true, doc: "the pagination metadata from Flop"
attr :path, :string, required: true, doc: "the base path for pagination links"
attr :schema, :atom, required: true, doc: "the schema module for Flop.Phoenix.build_path"
attr :class, :string, default: "mt-4", doc: "additional CSS classes"
def pagination(assigns) do
~H"""
<div class={["flex justify-between", @class]}>
<%= if @meta.has_previous_page? do %>
<.link
navigate={Flop.Phoenix.build_path(@path, Flop.to_previous_cursor(@meta), for: @schema)}
class="text-gray-500 hover:text-gray-800"
>
Newer posts
</.link>
<% else %>
<div></div>
<% end %>
<%= if @meta.has_next_page? do %>
<.link
navigate={Flop.Phoenix.build_path(@path, Flop.to_next_cursor(@meta), for: @schema)}
class="text-gray-500 hover:text-gray-800"
>
Older posts
</.link>
<% else %>
<div></div>
<% end %>
</div>
"""
end
end