lib/live_phone.ex

defmodule LivePhone do
  @external_resource "./README.md"
  @moduledoc """
  #{File.read!(@external_resource)}
  """

  use Phoenix.LiveComponent
  use Phoenix.HTML

  alias Phoenix.LiveView.Socket
  alias LivePhone.{Country, Util}

  @impl true
  def mount(socket) do
    {:ok,
     socket
     |> assign_new(:preferred, fn -> ["US", "GB"] end)
     |> assign_new(:tabindex, fn -> 0 end)
     |> assign_new(:apply_format?, fn -> false end)
     |> assign_new(:value, fn -> "" end)
     |> assign_new(:opened?, fn -> false end)
     |> assign_new(:valid?, fn -> false end)}
  end

  @impl true
  def update(assigns, socket) do
    current_country =
      assigns[:country] || socket.assigns[:country] || hd(assigns[:preferred] || ["US"])

    masks =
      if assigns[:apply_format?] do
        current_country
        |> get_masks()
        |> Enum.join(",")
      end

    socket =
      socket
      |> assign(assigns)
      |> assign_country(current_country)
      |> assign(:masks, masks)

    {:ok, set_value(socket, socket.assigns.value)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div
      class={"live_phone #{if @valid?, do: "live_phone-valid"}"}
      id={"live_phone-#{@id}"}
      phx-hook="LivePhone"
    >
      <.country_selector
        tabindex={@tabindex}
        target={@myself}
        opened?={@opened?}
        country={@country}
        wrapper={"live_phone-#{@id}"}
      />

      <input
        type="tel"
        class="live_phone-input"
        value={assigns[:value]}
        tabindex={assigns[:tabindex]}
        placeholder={assigns[:placeholder] || get_placeholder(assigns[:country])}
        data-masks={@masks}
        phx-target={@myself}
        phx-keyup="typing"
        phx-blur="close"
      />

      <%= hidden_input(
        assigns[:form],
        assigns[:field],
        name: assigns[:name] || input_name(assigns[:form], assigns[:field]),
        value: assigns[:formatted_value]
      ) %>

      <%= if @opened? do %>
        <.country_list country={@country} preferred={@preferred} id={@id} target={@myself} />
      <% end %>
    </div>
    """
  end

  defguardp is_empty(value) when is_nil(value) or value == ""

  @spec set_value(Socket.t(), String.t()) :: Socket.t()
  def set_value(socket, value) do
    value =
      case value do
        empty when is_empty(empty) ->
          case socket.assigns do
            %{form: form, field: field} when not is_nil(form) and not is_nil(field) ->
              input_value(form, field)

            %{value: assigns_value} when not is_nil(assigns_value) ->
              value

            _ ->
              value
          end

        found_value ->
          found_value
      end || ""

    {_, formatted_value} = Util.normalize(value, socket.assigns[:country])
    value = apply_mask(value, socket.assigns[:country])
    valid? = Util.valid?(formatted_value)

    push? = socket.assigns[:formatted_value] != formatted_value

    socket
    |> assign(:valid?, valid?)
    |> assign(:value, value)
    |> assign(:formatted_value, formatted_value)
    |> then(fn socket ->
      if push? do
        push_event(socket, "change", %{
          id: "live_phone-#{socket.assigns.id}",
          value: formatted_value
        })
      else
        socket
      end
    end)
  end

  defp apply_mask(value, _country) when is_empty(value), do: value

  defp apply_mask(value, country) do
    case ExPhoneNumber.parse(value, country) do
      {:ok, phone_number} ->
        metadata = ExPhoneNumber.Metadata.get_for_region_code(country)

        national_significant_number =
          ExPhoneNumber.Model.PhoneNumber.get_national_significant_number(phone_number)

        ExPhoneNumber.Formatting.format_nsn(national_significant_number, metadata, :international)

      _ ->
        ""
    end
  end

  @impl true
  def handle_event("typing", %{"value" => value}, socket) do
    {:noreply, set_value(socket, value)}
  end

  def handle_event("select_country", %{"country" => country}, socket) do
    valid? = Util.valid?(socket.assigns[:formatted_value])

    placeholder =
      if socket.assigns[:country] == country do
        socket.assigns[:placeholder]
      else
        get_placeholder(country)
      end

    {:noreply,
     socket
     |> assign_country(country)
     |> assign(:valid?, valid?)
     |> assign(:opened?, false)
     |> assign(:placeholder, placeholder)
     |> push_event("focus", %{id: "live_phone-#{socket.assigns.id}"})}
  end

  def handle_event("toggle", _, socket) do
    {:noreply, assign(socket, :opened?, socket.assigns.opened? != true)}
  end

  def handle_event("close", _, socket) do
    {:noreply, assign(socket, :opened?, false)}
  end

  @spec get_placeholder(String.t()) :: String.t()
  defp get_placeholder(country) do
    country
    |> ExPhoneNumber.Metadata.get_for_region_code()
    |> case do
      %{country_code: country_code, fixed_line: %{example_number: number}} ->
        number
        |> String.replace(~r/\d/, "5")
        |> ExPhoneNumber.parse(country)
        |> case do
          {:ok, result} ->
            result
            |> ExPhoneNumber.format(:international)
            |> String.replace(~r/^(\+|00)#{country_code}/, "")
            |> String.trim()

          _ ->
            ""
        end
    end
  end

  @spec get_masks(String.t()) :: [String.t()]
  defp get_masks(country) do
    metadata = ExPhoneNumber.Metadata.get_for_region_code(country)

    # Iterate through all metadata to find phone number descriptions
    # with example numbers only, and return those example numbers
    metadata
    |> Map.from_struct()
    |> Enum.map(fn
      {_, %ExPhoneNumber.Metadata.PhoneNumberDescription{} = desc} -> desc.example_number
      _other -> nil
    end)
    |> Enum.filter(& &1)

    # Parse all example numbers with the country and only keep valid ones
    |> Enum.map(&ExPhoneNumber.parse(&1, country))
    |> Enum.map(fn
      {:ok, parsed} -> parsed
      _other -> nil
    end)
    |> Enum.filter(& &1)

    # Format all parsed numbers with the international format
    # but removing the leading country_code. Transform all digits to X
    # to be used for a mask
    |> Enum.map(&ExPhoneNumber.format(&1, :international))
    |> Enum.map(&String.replace(&1, ~r/^(\+|00)#{metadata.country_code}/, ""))
    |> Enum.map(&String.replace(&1, ~r/\d/, "X"))
    |> Enum.map(&String.trim/1)

    # And make sure we only have unique ones
    |> Enum.uniq()
  end

  @spec assign_country(Socket.t(), Country.t() | String.t()) :: Socket.t()
  defp assign_country(socket, %Country{code: country}), do: assign_country(socket, country)
  defp assign_country(socket, country), do: assign(socket, :country, country)

  defp country_selector(assigns) do
    region_code =
      case ExPhoneNumber.Metadata.get_for_region_code(assigns[:country]) do
        nil -> ""
        code -> "+#{code.country_code}"
      end

    assigns = assign(assigns, :region_code, region_code)

    ~H"""
    <div
      class="live_phone-country"
      tabindex={@tabindex}
      phx-target={@target}
      phx-click="toggle"
      aria-owns={@wrapper}
      aria-expanded={to_string(@opened?)}
      role="combobox"
    >
      <span class="live_phone-country-flag"><%= Util.emoji_for_country(@country) %></span>
      <span class="live_phone-country-code"><%= @region_code %></span>
    </div>
    """
  end

  defp country_list(assigns) do
    assigns =
      if assigns[:country] do
        assign(assigns, :preferred, [assigns[:country] | assigns[:preferred]])
      else
        assigns
      end

    assigns = assign_new(assigns, :countries, fn -> Country.list(assigns[:preferred]) end)

    assigns =
      assign_new(assigns, :last_preferred, fn ->
        assigns[:countries]
        |> Enum.filter(& &1.preferred)
        |> List.last()
      end)

    ~H"""
    <ul class="live_phone-country-list" id={"live_phone-country-list-#{@id}"} role="listbox">
      <%= for country <- @countries do %>
        <.country_list_item country={country} current_country={@country} target={@target} />

        <%= if country == @last_preferred do %>
          <li aria-disabled="true" class="live_phone-country-separator" role="separator"></li>
        <% end %>
      <% end %>
    </ul>
    """
  end

  defp country_list_item(assigns) do
    selected? = assigns[:country].code == assigns[:current_country]
    assigns = assign(assigns, :selected?, selected?)

    class = ["live_phone-country-item"]
    class = if assigns[:selected?], do: ["selected" | class], else: class
    class = if assigns[:country].preferred, do: ["preferred" | class], else: class

    assigns = assign(assigns, :class, class)

    ~H"""
    <li
      aria-selected={to_string(@selected?)}
      class={@class}
      phx-click="select_country"
      phx-target={@target}
      phx-value-country={@country.code}
      role="option"
    >
      <span class="live_phone-country-item-flag"><%= @country.flag_emoji %></span>
      <span class="live_phone-country-item-name"><%= @country.name %></span>
      <span class="live_phone-country-item-code">+<%= @country.region_code %></span>
    </li>
    """
  end
end