You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
594 lines
19 KiB
594 lines
19 KiB
defmodule ChirpWeb.Components do |
|
@moduledoc """ |
|
Provides core UI components. |
|
|
|
The components in this module use Tailwind CSS, a utility-first CSS framework. |
|
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to |
|
customize the generated components in this module. |
|
|
|
Icons are provided by [heroicons](https://heroicons.com), using the |
|
[heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. |
|
""" |
|
use Phoenix.Component |
|
|
|
import ChirpWeb.Gettext, warn: false |
|
|
|
alias Phoenix.LiveView.JS |
|
|
|
@doc """ |
|
Renders a modal. |
|
|
|
## Examples |
|
|
|
<.modal id="confirm-modal"> |
|
Are you sure? |
|
<:confirm>OK</:confirm> |
|
<:cancel>Cancel</:cancel> |
|
<.modal> |
|
|
|
JS commands may be passed to the `:on_cancel` and `on_confirm` attributes |
|
for the caller to reactor to each button press, for example: |
|
|
|
<.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> |
|
Are you sure you? |
|
<:confirm>OK</:confirm> |
|
<:cancel>Cancel</:confirm> |
|
<.modal> |
|
""" |
|
attr :id, :string, required: true |
|
attr :show, :boolean, default: false |
|
attr :on_cancel, JS, default: %JS{} |
|
attr :on_confirm, JS, default: %JS{} |
|
|
|
slot :inner_block, required: true |
|
slot :title |
|
slot :subtitle |
|
slot :confirm |
|
slot :cancel |
|
|
|
def modal(assigns) do |
|
~H""" |
|
<div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden"> |
|
<div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" /> |
|
<div |
|
class="fixed inset-0 overflow-y-auto" |
|
aria-labelledby={"#{@id}-title"} |
|
aria-describedby={"#{@id}-description"} |
|
role="dialog" |
|
aria-modal="true" |
|
tabindex="0" |
|
> |
|
<div class="flex min-h-full items-center justify-center"> |
|
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8"> |
|
<.focus_wrap |
|
id={"#{@id}-container"} |
|
phx-mounted={@show && show_modal(@id)} |
|
phx-window-keydown={hide_modal(@on_cancel, @id)} |
|
phx-key="escape" |
|
phx-click-away={hide_modal(@on_cancel, @id)} |
|
class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition" |
|
> |
|
<div class="absolute top-6 right-5"> |
|
<button |
|
phx-click={hide_modal(@on_cancel, @id)} |
|
type="button" |
|
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40" |
|
aria-label="Close" |
|
> |
|
<Heroicons.x_mark solid class="h-5 w-5 stroke-current" /> |
|
</button> |
|
</div> |
|
<div id={"#{@id}-content"}> |
|
<header :if={@title != []}> |
|
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800"> |
|
<%= render_slot(@title) %> |
|
</h1> |
|
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> |
|
<%= render_slot(@subtitle) %> |
|
</p> |
|
</header> |
|
<%= render_slot(@inner_block) %> |
|
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5"> |
|
<.button |
|
:for={confirm <- @confirm} |
|
id={"#{@id}-confirm"} |
|
phx-click={@on_confirm} |
|
phx-disable-with |
|
class="py-2 px-3" |
|
> |
|
<%= render_slot(confirm) %> |
|
</.button> |
|
<.link |
|
:for={cancel <- @cancel} |
|
phx-click={hide_modal(@on_cancel, @id)} |
|
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" |
|
> |
|
<%= render_slot(cancel) %> |
|
</.link> |
|
</div> |
|
</div> |
|
</.focus_wrap> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
""" |
|
end |
|
|
|
@doc """ |
|
Renders flash notices. |
|
|
|
## Examples |
|
|
|
<.flash kind={:info} flash={@flash} /> |
|
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> |
|
""" |
|
attr :id, :string, default: "flash", doc: "the optional id of flash container" |
|
attr :flash, :map, default: %{}, doc: "the map of flash messages to display" |
|
attr :title, :string, default: nil |
|
attr :rest, :global |
|
attr :kind, :atom, doc: "one of :info, :error used for styling and flash lookup" |
|
attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount" |
|
attr :close, :boolean, default: true, doc: "whether the flash can be closed" |
|
|
|
slot :inner_block, doc: "the optional inner block that renders the flash message" |
|
|
|
def flash(assigns) do |
|
~H""" |
|
<div |
|
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} |
|
id={@id} |
|
phx-mounted={@autoshow && show("##{@id}")} |
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("#flash")} |
|
role="alert" |
|
class={[ |
|
"fixed hidden top-2 right-2 w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1", |
|
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", |
|
@kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900" |
|
]} |
|
{@rest} |
|
> |
|
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6"> |
|
<Heroicons.information_circle :if={@kind == :info} mini class="h-4 w-4" /> |
|
<Heroicons.exclamation_circle :if={@kind == :error} mini class="h-4 w-4" /> |
|
<%= @title %> |
|
</p> |
|
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p> |
|
<button :if={@close} type="button" class="group absolute top-2 right-1 p-2" aria-label="Close"> |
|
<Heroicons.x_mark solid class="h-5 w-5 stroke-current opacity-40 group-hover:opacity-70" /> |
|
</button> |
|
</div> |
|
""" |
|
end |
|
|
|
@doc """ |
|
Renders a simple form. |
|
|
|
## Examples |
|
|
|
<.simple_form :let={f} for={:user} phx-change="validate" phx-submit="save"> |
|
<.input field={{f, :email}} label="Email"/> |
|
<.input field={{f, :username}} label="Username" /> |
|
<:actions> |
|
<.button>Save</.button> |
|
<:actions> |
|
</.simple_form> |
|
""" |
|
attr :for, :any, default: nil, doc: "the datastructure for the form" |
|
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" |
|
attr :rest, :global, 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="space-y-8 bg-white mt-10"> |
|
<%= render_slot(@inner_block, f) %> |
|
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> |
|
<%= 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, doc: "the arbitrary HTML attributes to apply to the button tag" |
|
|
|
slot :inner_block, required: true |
|
|
|
def button(assigns) do |
|
~H""" |
|
<button |
|
type={@type} |
|
class={[ |
|
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3", |
|
"text-sm font-semibold leading-6 text-white active:text-white/80", |
|
@class |
|
]} |
|
{@rest} |
|
> |
|
<%= render_slot(@inner_block) %> |
|
</button> |
|
""" |
|
end |
|
|
|
@doc """ |
|
Renders an input with label and error messages. |
|
|
|
A `%Phoenix.HTML.Form{}` and field name may be passed to the input |
|
to build input names and error messages, or all the attributes and |
|
errors may be passed explicitly. |
|
|
|
## Examples |
|
|
|
<.input field={{f, :email}} type="email" /> |
|
<.input name="my-input" errors={["oh no!"]} /> |
|
""" |
|
attr :id, :any |
|
attr :name, :any |
|
attr :label, :string, default: nil |
|
|
|
attr :type, :string, |
|
default: "text", |
|
doc: ~s|one of "text", "textarea", "number" "email", "date", "time", "datetime", "select"| |
|
|
|
attr :value, :any |
|
attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}" |
|
attr :errors, :list |
|
attr :rest, :global, doc: "the arbitrary HTML attributes for the input tag" |
|
|
|
slot :inner_block |
|
slot :option, doc: "the slot for select input options" |
|
|
|
def input(%{field: {f, field}} = assigns) do |
|
assigns |
|
|> assign(field: nil) |
|
|> assign_new(:name, fn -> Phoenix.HTML.Form.input_name(f, field) end) |
|
|> assign_new(:id, fn -> Phoenix.HTML.Form.input_id(f, field) end) |
|
|> assign_new(:value, fn -> Phoenix.HTML.Form.input_value(f, field) end) |
|
|> assign_new(:errors, fn -> translate_errors(f.errors || [], field) end) |
|
|> input() |
|
end |
|
|
|
def input(%{type: "checkbox"} = assigns) do |
|
~H""" |
|
<label phx-feedback-for={@name} class="flex items-center gap-4 text-sm leading-6 text-zinc-600"> |
|
<input |
|
type="checkbox" |
|
id={@id || @name} |
|
name={@name} |
|
class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900" |
|
/> |
|
<%= @label %> |
|
</label> |
|
""" |
|
end |
|
|
|
def input(%{type: "select"} = assigns) do |
|
~H""" |
|
<div phx-feedback-for={@name}> |
|
<.label for={@id}><%= @label %></.label> |
|
<select |
|
id={@id} |
|
name={@name} |
|
autocomplete={@name} |
|
class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm" |
|
{@rest} |
|
> |
|
<option :for={opt <- @option} {assigns_to_attributes(opt)}><%= render_slot(opt) %></option> |
|
</select> |
|
<.error :for={msg <- @errors} message={msg} /> |
|
</div> |
|
""" |
|
end |
|
|
|
def input(%{type: "textarea"} = assigns) do |
|
~H""" |
|
<div phx-feedback-for={@name}> |
|
<.label for={@id}><%= @label %></.label> |
|
<textarea |
|
id={@id || @name} |
|
name={@name} |
|
class={[ |
|
input_border(@errors), |
|
"mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[calc(theme(spacing.2)-1px)] px-[calc(theme(spacing.3)-1px)]", |
|
"text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6", |
|
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5" |
|
]} |
|
{@rest} |
|
><%= @value %></textarea> |
|
<.error :for={msg <- @errors} message={msg} /> |
|
</div> |
|
""" |
|
end |
|
|
|
def input(assigns) do |
|
~H""" |
|
<div phx-feedback-for={@name}> |
|
<.label for={@id}><%= @label %></.label> |
|
<input |
|
type={@type} |
|
name={@name} |
|
id={@id || @name} |
|
value={@value} |
|
class={[ |
|
input_border(@errors), |
|
"mt-2 block w-full rounded-lg border-zinc-300 py-[calc(theme(spacing.2)-1px)] px-[calc(theme(spacing.3)-1px)]", |
|
"text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6", |
|
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5" |
|
]} |
|
{@rest} |
|
/> |
|
<.error :for={msg <- @errors} message={msg} /> |
|
</div> |
|
""" |
|
end |
|
|
|
defp input_border([] = _errors), |
|
do: "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5" |
|
|
|
defp input_border([_ | _] = _errors), |
|
do: "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10" |
|
|
|
@doc """ |
|
Renders a label. |
|
""" |
|
attr :for, :string, default: nil |
|
slot :inner_block, required: true |
|
|
|
def label(assigns) do |
|
~H""" |
|
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800"> |
|
<%= render_slot(@inner_block) %> |
|
</label> |
|
""" |
|
end |
|
|
|
@doc """ |
|
Generates a generic error message. |
|
""" |
|
attr :message, :string, required: true |
|
|
|
def error(assigns) do |
|
~H""" |
|
<p class="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-rose-600"> |
|
<Heroicons.exclamation_circle mini class="mt-0.5 h-5 w-5 flex-none fill-rose-500" /> |
|
<%= @message %> |
|
</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 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 :row_click, JS, default: nil |
|
attr :rows, :list, required: true |
|
|
|
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 |
|
~H""" |
|
<div id={@id} class="overflow-y-auto px-4 sm:overflow-visible sm:px-0"> |
|
<table class="mt-11 w-[40rem] sm:w-full"> |
|
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500"> |
|
<tr> |
|
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th> |
|
<th class="relative p-0 pb-4"><span class="sr-only">Actions</span></th> |
|
</tr> |
|
</thead> |
|
<tbody class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"> |
|
<tr |
|
:for={row <- @rows} |
|
id={"#{@id}-#{Phoenix.Param.to_param(row)}"} |
|
class="group hover:bg-zinc-50" |
|
> |
|
<td |
|
:for={{col, i} <- Enum.with_index(@col)} |
|
phx-click={@row_click && @row_click.(row)} |
|
class={["relative p-0", @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) %> |
|
</span> |
|
</div> |
|
</td> |
|
<td :if={@action != []} class="relative p-0 w-14"> |
|
<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) %> |
|
</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="mt-14"> |
|
<dl class="-my-4 divide-y divide-zinc-100"> |
|
<div :for={item <- @item} class="flex gap-4 py-4 sm:gap-8"> |
|
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt> |
|
<dd class="text-sm leading-6 text-zinc-700"><%= 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="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" |
|
> |
|
<Heroicons.arrow_left solid class="w-3 h-3 stroke-current inline" /> |
|
<%= render_slot(@inner_block) %> |
|
</.link> |
|
</div> |
|
""" |
|
end |
|
|
|
## JS Commands |
|
|
|
def show(js \\ %JS{}, selector) do |
|
JS.show(js, |
|
to: selector, |
|
transition: |
|
{"transition-all transform ease-out duration-300", |
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", |
|
"opacity-100 translate-y-0 sm:scale-100"} |
|
) |
|
end |
|
|
|
def hide(js \\ %JS{}, selector) do |
|
JS.hide(js, |
|
to: selector, |
|
time: 200, |
|
transition: |
|
{"transition-all transform ease-in duration-200", |
|
"opacity-100 translate-y-0 sm:scale-100", |
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} |
|
) |
|
end |
|
|
|
def show_modal(js \\ %JS{}, id) when is_binary(id) do |
|
js |
|
|> JS.show(to: "##{id}") |
|
|> JS.show( |
|
to: "##{id}-bg", |
|
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} |
|
) |
|
|> show("##{id}-container") |
|
|> JS.focus_first(to: "##{id}-content") |
|
end |
|
|
|
def hide_modal(js \\ %JS{}, id) do |
|
js |
|
|> JS.hide( |
|
to: "##{id}-bg", |
|
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} |
|
) |
|
|> hide("##{id}-container") |
|
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) |
|
|> 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 "is invalid" in the "errors" domain |
|
# dgettext("errors", "is invalid") |
|
# |
|
# # Translate the number of files with plural rules |
|
# dngettext("errors", "1 file", "%{count} files", count) |
|
# |
|
# Because the error messages we show in our forms and APIs |
|
# are defined inside Ecto, we need to translate them dynamically. |
|
# This requires us to call the Gettext module passing our gettext |
|
# backend as first argument. |
|
# |
|
# Note we use the "errors" domain, which means translations |
|
# should be written to the errors.po file. The :count option is |
|
# set by Ecto and indicates we should also apply plural rules. |
|
if count = opts[:count] do |
|
Gettext.dngettext(ChirpWeb.Gettext, "errors", msg, msg, count, opts) |
|
else |
|
Gettext.dgettext(ChirpWeb.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
|
|
|