lib/petal_components/form.ex

defmodule PetalComponents.Form do
  use Phoenix.Component
  import Phoenix.HTML.Form

  @moduledoc """
  Everything related to forms: inputs, labels etc
  """

  # prop form, :any
  # prop field, :any
  # prop label, :string
  # prop class, :css_class
  # slot default
  def form_label(assigns) do
    assigns = assigns
      |> assign_new(:classes, fn -> label_classes(assigns) end)
      |> assign_new(:form, fn -> nil end)
      |> assign_new(:has_error, fn -> false end)
      |> assign_new(:field, fn -> nil end)
      |> assign_new(:inner_block, fn -> nil end)
      |> assign_new(:label, fn ->
        if assigns[:field] do
          humanize(assigns[:field])
        else
          nil
        end
      end)
      |> assign_new(:label_opts, fn ->
        Map.drop(assigns, [
          :classes,
          :form,
          :field,
          :inner_block,
          :label,
          :__changed__
        ])
      end)

    ~H"""
    <%= if @form && @field do %>
      <%= label @form, @field, [class: @classes] ++ Map.to_list(@label_opts) do %>
        <%= if @inner_block do %>
          <%= render_slot(@inner_block) %>
        <% else %>
          <%= @label %>
        <% end %>
      <% end %>
    <% else %>
      <label class={@classes} {@label_opts}>
        <%= if @inner_block do %>
          <%= render_slot(@inner_block) %>
        <% else %>
          <%= @label %>
        <% end %>
      </label>
    <% end %>
    """
  end

  # prop form, :any, required: true
  # prop field, :any, required: true
  # prop type, :string, required: true, options: ["text_input", "email_input", "number_input", "password_input", "search_input", "telephone_input", "url_input", "time_input", "time_select", "datetime_local_input", "datetime_select", "color_input", "file_input", "range_input", "textarea", "select", "checkbox", "radio_gro"]
  # prop label, :string

  @doc "Use this when you want to include the label and some margin."
  def form_field(assigns) do
    assigns = assigns
    |> assign_new(:input_opts, fn ->
      Map.drop(assigns, [:form, :field, :label, :field_type])
    end)
    |> assign_new(:label, fn ->
      if assigns[:field] do
        humanize(assigns[:field])
      else
        nil
      end
    end)

    ~H"""
    <div class="mb-6">
      <%= case @type do %>
        <% "checkbox" -> %>
          <label class="inline-flex items-center block gap-3">
            <.checkbox form={@form} field={@field} {@input_opts} />
            <div class={label_classes(%{form: @form, field: @field, type: "checkbox"})}><%= @label %></div>
          </label>
        <% "radio_group" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.radio_group form={@form} field={@field} {@input_opts} />
        <% "text_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.text_input form={@form} field={@field} {@input_opts} />
        <% "email_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.email_input form={@form} field={@field} {@input_opts} />
        <% "number_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.number_input form={@form} field={@field} {@input_opts} />
        <% "password_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.password_input form={@form} field={@field} {@input_opts} />
        <% "search_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.search_input form={@form} field={@field} {@input_opts} />
        <% "telephone_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.telephone_input form={@form} field={@field} {@input_opts} />
        <% "url_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.url_input form={@form} field={@field} {@input_opts} />
        <% "time_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.time_input form={@form} field={@field} {@input_opts} />
        <% "time_select" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.time_select form={@form} field={@field} {@input_opts} />
        <% "datetime_select" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.datetime_select form={@form} field={@field} {@input_opts} />
        <% "datetime_local_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.datetime_local_input form={@form} field={@field} {@input_opts} />
        <% "color_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.color_input form={@form} field={@field} {@input_opts} />
        <% "file_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.file_input form={@form} field={@field} {@input_opts} />
        <% "range_input" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.range_input form={@form} field={@field} {@input_opts} />
        <% "textarea" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.textarea form={@form} field={@field} {@input_opts} />
        <% "select" -> %>
          <.form_label form={@form} field={@field} label={@label} />
          <.select form={@form} field={@field} {@input_opts} />
      <% end %>

      <.form_field_error class="mt-1" form={@form} field={@field} />
    </div>
    """
  end

  def text_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= text_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def email_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= email_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def number_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= number_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def password_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= password_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def search_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= search_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def telephone_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= telephone_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def url_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= url_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def time_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= time_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def time_select(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= time_select @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def datetime_local_input(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= datetime_local_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def datetime_select(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= datetime_select @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def color_input(assigns) do
    assigns = assign_defaults(assigns, color_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= color_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def file_input(assigns) do
    assigns = assign_defaults(assigns, file_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= file_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def range_input(assigns) do
    assigns = assign_defaults(assigns, range_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= range_input @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def textarea(assigns) do
    assigns = assign_defaults(assigns, text_input_classes(field_has_errors?(assigns)))

    ~H"""
    <%= textarea @form, @field, [class: @classes] ++ Keyword.merge([rows: "4"], @input_attributes) %>
    """
  end

  def select(assigns) do
    assigns = assign_defaults(assigns, select_classes(field_has_errors?(assigns)))

    ~H"""
    <%= select @form, @field, @options, [class: @classes] ++ @input_attributes %>
    """
  end

  def checkbox(assigns) do
    assigns = assign_defaults(assigns, checkbox_classes(field_has_errors?(assigns)))

    ~H"""
    <%= checkbox @form, @field, [class: @classes] ++ @input_attributes %>
    """
  end

  def radio(assigns) do
    assigns = assign_defaults(assigns, radio_classes(field_has_errors?(assigns)))

    ~H"""
    <%= radio_button @form, @field, @value, [class: @classes] ++ @input_attributes %>
    """
  end

  def radio_group(assigns) do
    assigns = assign_defaults(assigns, radio_classes(field_has_errors?(assigns)))

    ~H"""
    <div class="flex flex-col gap-1">
      <%= for {label, value} <- @options do %>
        <label class="inline-flex items-center block gap-3">
          <.radio form={@form} field={@field} value={value} {@input_attributes} />
          <div class={label_classes(%{form: @form, field: @field, type: "radio"})}><%= label %></div>
        </label>
      <% end %>
    </div>
    """
  end

  # <.form_field_error form={f} field={:name} />
  def form_field_error(assigns) do
    assigns = assign_new(assigns, :class, fn -> "" end)
    translate_error = translator_from_config() || (&translate_error/1)

    ~H"""
    <div class={@class}>
      <%= for error <- Keyword.get_values(@form.errors, @field) do %>
        <div class="text-xs italic text-red-500" phx-feedback-for={input_name(@form, @field)}>
          <%= translate_error.(error) %>
        </div>
      <% end %>
    </div>
    """
  end

  defp translate_error({msg, opts}) do
    # Because the error messages we show in our forms and APIs
    # are defined inside Ecto, we need to translate them dynamically.
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      try do
        String.replace(acc, "%{#{key}}", to_string(value))
      rescue
        e ->
          IO.warn(
            """
            the fallback message translator for the form_field_error function cannot handle the given value.

            Hint: you can set up the `error_translator_function` to route all errors to your application helpers:

              config :petal_components, :error_translator_function, {MyAppWeb.ErrorHelpers, :translate_error}

            Given value: #{inspect(value)}

            Exception: #{Exception.message(e)}
            """,
            __STACKTRACE__
          )

          "invalid value"
      end
    end)
  end

  defp translator_from_config do
    case Application.get_env(:petal_components, :error_translator_function) do
      {module, function} -> &apply(module, function, [&1])
      nil -> nil
    end
  end

  defp assign_defaults(assigns, base_classes) do
    assigns
    |> assign_new(:type, fn -> "text" end)
    |> assign_new(:input_attributes, fn ->
      Map.drop(assigns, [
        :label,
        :form,
        :field,
        :type,
        :options,
        :inner_block,
        :__slot__,
        :__changed__
      ])
      |> Map.to_list()
    end)
    |> assign_new(:classes, fn ->
      [
        base_classes,
        assigns[:class] || ""
      ]
      |> Enum.join(" ")
    end)
  end

  defp label_classes(assigns) do
    type_classes =
      if Enum.member?(["checkbox", "radio"], assigns[:type]) do
        ""
      else
        "mb-2 font-medium"
      end

    error_classes = if field_has_errors?(assigns) do
      "text-red-900 dark:text-red-200"
    else
      "text-gray-900 dark:text-gray-200"
    end

    "#{type_classes} #{error_classes} text-sm block"
  end

  defp text_input_classes(has_error) do
    "#{get_error_classes(has_error)} sm:text-sm block shadow-sm w-full rounded-md dark:bg-gray-800 dark:text-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500"
  end

  defp select_classes(has_error) do
    "#{get_error_classes(has_error)} block w-full pl-3 pr-10 py-2 text-base focus:outline-none sm:text-sm rounded-md"
  end

  def file_input_classes(has_error) do
    get_error_classes(has_error)
  end

  def color_input_classes(has_error) do
    get_error_classes(has_error)
  end

  def range_input_classes(has_error) do
    "#{get_error_classes(has_error)} w-full"
  end

  defp checkbox_classes(has_error) do
    error_classes = if has_error, do: "border-red-500 text-red-900 dark:text-red-200", else: "border-gray-300 text-primary-700"
    "#{error_classes} rounded w-5 h-5 ease-linear transition-all duration-150"
  end

  defp radio_classes(has_error) do
    error_classes = if has_error, do: "border-red-500", else: "border-gray-300"
    "#{error_classes} h-4 w-4 cursor-pointer text-primary-600 focus:ring-primary-500"
  end

  defp get_error_classes(true), do: "border-red-500 focus:border-red-500 text-red-900 placeholder-red-700 bg-red-50"
  defp get_error_classes(false), do: "border-gray-300 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:focus:border-primary-500"

  defp field_has_errors?(%{form: form, field: field}) do
    length(Keyword.get_values(form.errors, field)) > 0
  end

  defp field_has_errors?(_), do: false
end