defmodule TarakanWeb.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 tables, forms, and inputs. 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 foundation for styling is Tailwind CSS, a utility-first CSS framework. Here are useful references: * [Tailwind CSS](https://tailwindcss.com) - the foundational framework we build on. You will use it for layout, sizing, flexbox, grid, and spacing. * [Heroicons](https://heroicons.com) - see `icon/1` for usage. * [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) - the component system used by Phoenix. Some components, such as `<.link>` and `<.form>`, are defined there. """ use Phoenix.Component use Gettext, backend: TarakanWeb.Gettext alias Phoenix.LiveView.JS @doc """ Renders flash notices. ## Examples <.flash kind={:info} flash={@flash} /> <.flash id="welcome-back" kind={:info} phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")} hidden > Welcome Back! """ attr :id, :string, 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 :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" attr :auto_dismiss, :boolean, default: true, doc: "automatically clears ordinary toast messages" attr :dismiss_after, :integer, default: nil, doc: "auto-dismiss delay in milliseconds" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" def flash(assigns) do assigns = assigns |> assign_new(:id, fn -> "flash-#{assigns.kind}" end) |> assign(:dismiss_after, assigns.dismiss_after || default_dismiss_after(assigns.kind)) ~H"""
hide("##{@id}")} phx-hook={@auto_dismiss && "AutoDismiss"} data-auto-dismiss-ms={@auto_dismiss && @dismiss_after} role="alert" class="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-[max(1rem,env(safe-area-inset-right))] left-[max(1rem,env(safe-area-inset-left))] z-50 w-auto max-w-[min(24rem,calc(100vw-2rem))] sm:left-auto" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />

{@title}

{msg}

""" end defp default_dismiss_after(:info), do: 5_000 defp default_dismiss_after(:error), do: 8_000 @doc """ Small notched badge with a continuous outline (or solid fill via `bg-ink` / `bg-signal`). Use instead of `border` + `clip-notch-sm`. """ attr :id, :string, default: nil attr :class, :any, default: nil attr :rest, :global slot :inner_block, required: true def notch_badge(assigns) do ~H""" {render_slot(@inner_block)} """ end @doc """ Renders a button with navigation support. Controls sit outside the container border scale (2px = page region, 1px rule = division inside a region). A 2px stroke on a button competes with the region boundary it sits in, so secondary/danger buttons take a 1px stroke and primary takes none at all - the fill is its edge. Reach for `variant`, never a hand-rolled class string: the point of the component is that Confirm and Dispute cannot silently drift apart. ## Examples <.button>Send! <.button phx-click="go" variant="primary">Send! <.button variant="danger" size="sm">Dispute <.button navigate={~p"/"}>Home """ attr :rest, :global, include: ~w(href navigate patch method download name value disabled form type) attr :class, :any, default: nil, doc: "extras only (margin, layout) - never competing styling" attr :variant, :string, default: nil, values: [nil, "primary", "danger", "quiet"] attr :size, :string, default: "md", values: ~w(sm md) slot :inner_block, required: true def button(%{rest: rest} = assigns) do # Primary is filled + clip-notch (solid edge). Outline buttons omit clip-notch: # border + clip-path removes the top stroke. variants = %{ "primary" => "clip-notch bg-btn text-btn-fg hover:opacity-90", "danger" => "border border-signal text-signal hover:bg-signal hover:text-btn-fg", "quiet" => "text-ink-muted hover:text-ink", nil => "border border-strong text-ink hover:bg-panel" } sizes = %{ "md" => "px-4 py-2 text-sm", "sm" => "px-3 py-1.5 text-[11px]" } assigns = assign(assigns, :button_class, [ "inline-flex items-center justify-center gap-1.5 font-display uppercase tracking-[0.12em]", "transition focus:outline-none focus:ring-2 focus:ring-phosphor", "disabled:cursor-not-allowed disabled:opacity-50", Map.fetch!(sizes, assigns.size), Map.fetch!(variants, assigns.variant), assigns.class ]) if rest[:href] || rest[:navigate] || rest[:patch] do ~H""" <.link class={@button_class} {@rest}> {render_slot(@inner_block)} """ else ~H""" """ end 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 ` """ end def input(%{type: "checkbox"} = assigns) do assigns = assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) end) ~H"""
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}
""" end def input(%{type: "select"} = assigns) do ~H"""
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}
""" end def input(%{type: "textarea"} = assigns) do ~H"""
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}
""" end # All other inputs text, datetime-local, url, password, etc. are handled here... def input(assigns) do ~H"""
<.error :for={msg <- @errors} :if={!@hide_errors} id={@id && "#{@id}-error"}>{msg}
""" end @doc """ Renders a field's errors on their own, for inputs embedded in composed controls (set `hide_errors` on the input and place this outside the frame). ## Examples <.input field={@form[:url]} hide_errors ... /> <.field_errors field={@form[:url]} /> """ attr :field, Phoenix.HTML.FormField, required: true def field_errors(assigns) do errors = if Phoenix.Component.used_input?(assigns.field), do: assigns.field.errors, else: [] assigns = assign(assigns, :messages, Enum.map(errors, &translate_error/1)) ~H""" <.error :for={msg <- @messages} id={"#{@field.id}-error"}>{msg} """ end # Helper used by inputs to generate form errors attr :id, :string, default: nil slot :inner_block, required: true defp error(assigns) do ~H"""

<.icon name="hero-exclamation-circle" class="size-5" /> {render_slot(@inner_block)}

""" end @doc """ Renders the standard breadcrumb trail used at the top of interior pages. Crumbs with `navigate` render as links; crumbs without render as static text. The final crumb is the current page and reads brighter than the trail. ## Examples <.breadcrumbs id="finding-breadcrumb"> <:crumb navigate={~p"/"}>registry <:crumb>finding """ attr :id, :string, default: nil slot :crumb, required: true do attr :navigate, :string end def breadcrumbs(assigns) do assigns = assign(assigns, :last_index, length(assigns.crumb) - 1) ~H""" """ end @doc """ Renders a handle as a link to its public profile. The `@` is part of the link text so the whole mention is clickable. ## Examples <.handle_link handle={@scan.submitted_by.handle} /> <.handle_link handle={comment.account.handle} class="font-semibold" /> """ attr :handle, :string, required: true attr :class, :any, default: nil def handle_link(assigns) do ~H""" <.link navigate={"/" <> @handle} class={["transition hover:text-signal", @class]}>@{@handle} """ end @doc """ Renders an up/down vote control for a votable subject (a canonical finding or a comment). The score is the plain net of votes; the caller's own vote is highlighted. Emits `phx-click="vote"` with the subject type, id, and value. ## Examples <.vote_control subject_type="canonical_finding" subject_id={@canonical.id} summary={@finding_votes} can_vote={@can_vote} /> """ attr :subject_type, :string, required: true attr :subject_id, :integer, required: true attr :summary, :map, default: %{score: 0, my_vote: 0} attr :can_vote, :boolean, default: false attr :class, :any, default: nil def vote_control(assigns) do ~H"""
0 && "text-quote", @summary.score < 0 && "text-signal", @summary.score == 0 && "text-ink-muted" ]}> {@summary.score}
""" end @doc """ Renders one discussion comment and its replies, recursively. Nesting indents up to a fixed depth, then stops so deep chains don't run off the page. Removed comments render as a placeholder that keeps the thread intact. """ attr :comment, :map, required: true attr :reply_to, :string, default: nil attr :can_reply, :boolean, default: false attr :can_moderate, :boolean, default: false attr :can_vote, :boolean, default: false attr :votes, :map, default: %{} def comment_thread(assigns) do ~H"""
<.vote_control :if={is_nil(@comment.removed_at)} subject_type="comment" subject_id={@comment.id} summary={Map.get(@votes, @comment.id, %{score: 0, my_vote: 0})} can_vote={@can_vote} class="mr-1" /> <.handle_link handle={@comment.account.handle} class="font-semibold text-ink-muted" /> {comment_time(@comment.inserted_at)} removed

{@comment.body}

Removed by moderation: {@comment.removed_reason}- original: “{@comment.body}”

<.comment_thread :for={reply <- @comment.replies} comment={reply} reply_to={@reply_to} can_reply={@can_reply} can_moderate={@can_moderate} can_vote={@can_vote} votes={@votes} />
""" end defp comment_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M") @doc """ Renders a header with title. """ slot :inner_block, required: true slot :subtitle slot :actions def header(assigns) do ~H"""

{render_slot(@inner_block)}

{render_slot(@subtitle)}

{render_slot(@actions)}
""" end @doc """ Renders a [Heroicon](https://heroicons.com). Heroicons come in three styles - outline, solid, and mini. By default, the outline style is used, but solid and mini may be applied by using the `-solid` and `-mini` suffix. You can customize the size and colors of the icons by setting width, height, and background color classes. Icons are extracted from the `deps/heroicons` directory and bundled within your compiled app.css by the plugin in `assets/vendor/heroicons.js`. ## Examples <.icon name="hero-x-mark" /> <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> """ attr :name, :string, required: true attr :class, :any, default: "size-4" def icon(%{name: "hero-" <> _} = assigns) do ~H""" """ end ## JS Commands def show(js \\ %JS{}, selector) do JS.show(js, to: selector, time: 300, transition: {"transition-all 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 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 @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(TarakanWeb.Gettext, "errors", msg, msg, count, opts) else Gettext.dgettext(TarakanWeb.Gettext, "errors", msg, opts) end end end