550 lines
15 KiB
Elixir
550 lines
15 KiB
Elixir
defmodule ComfycampWeb.CoreComponents do
|
|
@moduledoc """
|
|
Provides core UI components.
|
|
|
|
At first glance, this module may seem daunting, but its goal is to provide
|
|
core building blocks for your application, such as modals, tables, and
|
|
forms. The components consist mostly of markup and are well-documented
|
|
with doc strings and declarative assigns. You may customize and style
|
|
them in any way you want, based on your application growth and needs.
|
|
|
|
The default components use Tailwind CSS, a utility-first CSS framework.
|
|
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
|
|
how to customize them or feel free to swap in another framework altogether.
|
|
|
|
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
|
"""
|
|
use Phoenix.Component
|
|
|
|
alias Phoenix.LiveView.JS
|
|
import ComfycampWeb.Gettext
|
|
import ComfycampWeb.Icons
|
|
|
|
@doc """
|
|
Renders a modal.
|
|
|
|
## Examples
|
|
|
|
<.modal id="confirm-modal">
|
|
This is a modal.
|
|
</.modal>
|
|
|
|
JS commands may be passed to the `:on_cancel` to configure
|
|
the closing/cancel event, for example:
|
|
|
|
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
|
This is another modal.
|
|
</.modal>
|
|
|
|
"""
|
|
attr :id, :string, required: true
|
|
attr :show, :boolean, default: false
|
|
attr :on_cancel, JS, default: %JS{}
|
|
slot :inner_block, required: true
|
|
|
|
def modal(assigns) do
|
|
~H"""
|
|
<div
|
|
id={@id}
|
|
phx-mounted={@show && show_modal(@id)}
|
|
phx-remove={hide_modal(@id)}
|
|
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
|
class="modal"
|
|
>
|
|
<div id={"#{@id}-bg"} class="modal-bg" aria-hidden="true" />
|
|
<div
|
|
class="modal-body"
|
|
aria-labelledby={"#{@id}-title"}
|
|
aria-describedby={"#{@id}-description"}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="0"
|
|
>
|
|
<.focus_wrap
|
|
id={"#{@id}-container"}
|
|
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
|
phx-key="escape"
|
|
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
|
class="modal-container"
|
|
>
|
|
<div class="modal-close-button-container">
|
|
<button
|
|
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
|
type="button"
|
|
class="modal-close-button"
|
|
aria-label={gettext("close")}
|
|
>
|
|
<.icon name="hero-x-mark-solid" class="modal-close-button-icon" />
|
|
</button>
|
|
</div>
|
|
<div id={"#{@id}-content"}>
|
|
<%= render_slot(@inner_block) %>
|
|
</div>
|
|
</.focus_wrap>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a simple form.
|
|
|
|
## Examples
|
|
|
|
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
|
<.input field={@form[:email]} label="Email"/>
|
|
<.input field={@form[:username]} label="Username" />
|
|
<:actions>
|
|
<.button>Save</.button>
|
|
</:actions>
|
|
</.simple_form>
|
|
"""
|
|
attr :for, :any, required: true, doc: "the datastructure for the form"
|
|
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
|
|
|
|
attr :rest, :global,
|
|
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
|
doc: "the arbitrary HTML attributes to apply to the form tag"
|
|
|
|
slot :inner_block, required: true
|
|
slot :actions, doc: "the slot for form actions, such as a submit button"
|
|
|
|
def simple_form(assigns) do
|
|
~H"""
|
|
<.form :let={f} for={@for} as={@as} {@rest}>
|
|
<div class="simple-form">
|
|
<%= render_slot(@inner_block, f) %>
|
|
<div :for={action <- @actions} class="simple-form-action">
|
|
<%= render_slot(action, f) %>
|
|
</div>
|
|
</div>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a button.
|
|
|
|
## Examples
|
|
|
|
<.button>Send!</.button>
|
|
<.button phx-click="go" class="ml-2">Send!</.button>
|
|
"""
|
|
attr :type, :string, default: nil
|
|
attr :class, :string, default: nil
|
|
attr :rest, :global, include: ~w(disabled form name value)
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def button(assigns) do
|
|
~H"""
|
|
<button
|
|
type={@type}
|
|
class={[
|
|
"button",
|
|
@class
|
|
]}
|
|
{@rest}
|
|
>
|
|
<%= render_slot(@inner_block) %>
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders an input with label and error messages.
|
|
|
|
A `Phoenix.HTML.FormField` may be passed as argument,
|
|
which is used to retrieve the input name, id, and values.
|
|
Otherwise all attributes may be passed explicitly.
|
|
|
|
## Types
|
|
|
|
This function accepts all HTML input types, considering that:
|
|
|
|
* You may also set `type="select"` to render a `<select>` tag
|
|
|
|
* `type="checkbox"` is used exclusively to render boolean values
|
|
|
|
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
|
|
|
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
|
for more information. Unsupported types, such as hidden and radio,
|
|
are best written directly in your templates.
|
|
|
|
## Examples
|
|
|
|
<.input field={@form[:email]} type="email" />
|
|
<.input name="my-input" errors={["oh no!"]} />
|
|
"""
|
|
attr :id, :any, default: nil
|
|
attr :name, :any
|
|
attr :label, :string, default: nil
|
|
attr :value, :any
|
|
|
|
attr :type, :string,
|
|
default: "text",
|
|
values: ~w(checkbox color date datetime-local email file month number password
|
|
range search select tel text textarea time url week)
|
|
|
|
attr :field, Phoenix.HTML.FormField,
|
|
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
|
|
|
attr :errors, :list, default: []
|
|
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
|
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
|
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
|
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
|
|
|
attr :rest, :global,
|
|
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
|
multiple pattern placeholder readonly required rows size step)
|
|
|
|
slot :inner_block
|
|
|
|
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
|
assigns
|
|
|> assign(field: nil, id: assigns.id || field.id)
|
|
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
|
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
|
|> assign_new(:value, fn -> field.value end)
|
|
|> input()
|
|
end
|
|
|
|
def input(%{type: "checkbox"} = assigns) do
|
|
assigns =
|
|
assign_new(assigns, :checked, fn ->
|
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
|
end)
|
|
|
|
~H"""
|
|
<div phx-feedback-for={@name}>
|
|
<label class="checkbox-label">
|
|
<input type="hidden" name={@name} value="false" />
|
|
<input
|
|
type="checkbox"
|
|
id={@id}
|
|
name={@name}
|
|
value="true"
|
|
checked={@checked}
|
|
class="checkbox-input"
|
|
{@rest}
|
|
/>
|
|
<%= @label %>
|
|
</label>
|
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def input(%{type: "select"} = assigns) do
|
|
~H"""
|
|
<div phx-feedback-for={@name}>
|
|
<.label for={@id}><%= @label %></.label>
|
|
<select
|
|
id={@id}
|
|
name={@name}
|
|
class="select"
|
|
multiple={@multiple}
|
|
{@rest}
|
|
>
|
|
<option :if={@prompt} value=""><%= @prompt %></option>
|
|
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
|
</select>
|
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def input(%{type: "textarea"} = assigns) do
|
|
~H"""
|
|
<div phx-feedback-for={@name}>
|
|
<.label for={@id}><%= @label %></.label>
|
|
<textarea
|
|
id={@id}
|
|
name={@name}
|
|
class={[
|
|
"textarea",
|
|
@errors == [] && "",
|
|
@errors != [] && ""
|
|
]}
|
|
{@rest}
|
|
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
|
def input(assigns) do
|
|
~H"""
|
|
<div phx-feedback-for={@name}>
|
|
<.label for={@id}><%= @label %></.label>
|
|
<input
|
|
type={@type}
|
|
name={@name}
|
|
id={@id}
|
|
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
|
class={[
|
|
"input",
|
|
@errors == [] && "",
|
|
@errors != [] && ""
|
|
]}
|
|
{@rest}
|
|
/>
|
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a label.
|
|
"""
|
|
attr :for, :string, default: nil
|
|
slot :inner_block, required: true
|
|
|
|
def label(assigns) do
|
|
~H"""
|
|
<label for={@for} class="label">
|
|
<%= render_slot(@inner_block) %>
|
|
</label>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Generates a generic error message.
|
|
"""
|
|
slot :inner_block, required: true
|
|
|
|
def error(assigns) do
|
|
~H"""
|
|
<p class="error">
|
|
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
|
<%= render_slot(@inner_block) %>
|
|
</p>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a header with title.
|
|
"""
|
|
attr :class, :string, default: nil
|
|
|
|
slot :inner_block, required: true
|
|
slot :subtitle
|
|
slot :actions
|
|
|
|
def header(assigns) do
|
|
~H"""
|
|
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
|
<div>
|
|
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
|
<%= render_slot(@inner_block) %>
|
|
</h1>
|
|
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
|
<%= render_slot(@subtitle) %>
|
|
</p>
|
|
</div>
|
|
<div class="flex-none"><%= render_slot(@actions) %></div>
|
|
</header>
|
|
"""
|
|
end
|
|
|
|
@doc ~S"""
|
|
Renders a table with generic styling.
|
|
|
|
## Examples
|
|
|
|
<.table id="users" rows={@users}>
|
|
<:col :let={user} label="id"><%= user.id %></:col>
|
|
<:col :let={user} label="username"><%= user.username %></:col>
|
|
</.table>
|
|
"""
|
|
attr :id, :string, required: true
|
|
attr :rows, :list, required: true
|
|
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
|
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
|
|
|
attr :row_item, :any,
|
|
default: &Function.identity/1,
|
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
|
|
|
slot :col, required: true do
|
|
attr :label, :string
|
|
end
|
|
|
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
|
|
|
def table(assigns) do
|
|
assigns =
|
|
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
|
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
|
end
|
|
|
|
~H"""
|
|
<div class="table-container">
|
|
<table class="table">
|
|
<thead class="thead">
|
|
<tr>
|
|
<th :for={col <- @col} class="th"><%= col[:label] %></th>
|
|
<th :if={@action != []} class="th-actions">
|
|
<span class="sr-only"><%= gettext("Actions") %></span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody
|
|
id={@id}
|
|
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
|
class="tbody"
|
|
>
|
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="">
|
|
<td
|
|
:for={{col, i} <- Enum.with_index(@col)}
|
|
phx-click={@row_click && @row_click.(row)}
|
|
class={["td", @row_click && "hover:cursor-pointer"]}
|
|
>
|
|
<div class="block py-4 pr-6">
|
|
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
|
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
|
<%= render_slot(col, @row_item.(row)) %>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td :if={@action != []} class="relative w-14 p-0">
|
|
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
|
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
|
<span
|
|
:for={action <- @action}
|
|
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
|
>
|
|
<%= render_slot(action, @row_item.(row)) %>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a data list.
|
|
|
|
## Examples
|
|
|
|
<.list>
|
|
<:item title="Title"><%= @post.title %></:item>
|
|
<:item title="Views"><%= @post.views %></:item>
|
|
</.list>
|
|
"""
|
|
slot :item, required: true do
|
|
attr :title, :string, required: true
|
|
end
|
|
|
|
def list(assigns) do
|
|
~H"""
|
|
<div class="list">
|
|
<dl class="list-description">
|
|
<div :for={item <- @item} class="list-item">
|
|
<dt class="list-item-title"><%= item.title %></dt>
|
|
<dd class="list-item-description"><%= render_slot(item) %></dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders a back navigation link.
|
|
|
|
## Examples
|
|
|
|
<.back navigate={~p"/posts"}>Back to posts</.back>
|
|
"""
|
|
attr :navigate, :any, required: true
|
|
slot :inner_block, required: true
|
|
|
|
def back(assigns) do
|
|
~H"""
|
|
<div class="mt-16">
|
|
<.link
|
|
navigate={@navigate}
|
|
class="back-nav-link"
|
|
>
|
|
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
|
<%= render_slot(@inner_block) %>
|
|
</.link>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
## JS Commands
|
|
|
|
def show(js \\ %JS{}, selector) do
|
|
JS.show(js,
|
|
to: selector,
|
|
transition: "show"
|
|
)
|
|
end
|
|
|
|
def hide(js \\ %JS{}, selector) do
|
|
JS.hide(js,
|
|
to: selector,
|
|
time: 200,
|
|
transition: "hide"
|
|
)
|
|
end
|
|
|
|
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
|
js
|
|
|> JS.show(to: "##{id}")
|
|
|> JS.show(
|
|
to: "##{id}-bg",
|
|
transition: "show-modal"
|
|
)
|
|
|> show("##{id}-container")
|
|
|> JS.add_class("overflow-hidden", to: "body")
|
|
|> JS.focus_first(to: "##{id}-content")
|
|
end
|
|
|
|
def hide_modal(js \\ %JS{}, id) do
|
|
js
|
|
|> JS.hide(
|
|
to: "##{id}-bg",
|
|
transition: "hide-modal"
|
|
)
|
|
|> hide("##{id}-container")
|
|
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
|
|> JS.remove_class("overflow-hidden", to: "body")
|
|
|> JS.pop_focus()
|
|
end
|
|
|
|
@doc """
|
|
Translates an error message using gettext.
|
|
"""
|
|
def translate_error({msg, opts}) do
|
|
# When using gettext, we typically pass the strings we want
|
|
# to translate as a static argument:
|
|
#
|
|
# # Translate the number of files with plural rules
|
|
# dngettext("errors", "1 file", "%{count} files", count)
|
|
#
|
|
# However the error messages in our forms and APIs are generated
|
|
# dynamically, so we need to translate them by calling Gettext
|
|
# with our gettext backend as first argument. Translations are
|
|
# available in the errors.po file (as we use the "errors" domain).
|
|
if count = opts[:count] do
|
|
Gettext.dngettext(ComfycampWeb.Gettext, "errors", msg, msg, count, opts)
|
|
else
|
|
Gettext.dgettext(ComfycampWeb.Gettext, "errors", msg, opts)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Translates the errors for a field from a keyword list of errors.
|
|
"""
|
|
def translate_errors(errors, field) when is_list(errors) do
|
|
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
|
end
|
|
end
|