lib/money/input/visualizer/render.ex

defmodule Money.Input.Visualizer.Render do
  @moduledoc false

  # Shared HTML helpers for the visualizer. Pure functions,
  # iodata in / iodata out. No templates.

  @doc "HTML-escapes a binary or iodata."
  @spec escape(iodata() | term()) :: iodata()
  def escape(iodata) when is_list(iodata), do: Enum.map(iodata, &escape/1)

  def escape(binary) when is_binary(binary) do
    binary
    |> String.replace("&", "&")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&#39;")
  end

  def escape(other), do: escape(to_string(other))

  @doc "Wraps body iodata in the full HTML page chrome."
  def page(assigns) do
    title = Keyword.fetch!(assigns, :title)
    active = Keyword.fetch!(assigns, :active)
    body = Keyword.fetch!(assigns, :body)
    base = Keyword.fetch!(assigns, :base)
    error = Keyword.get(assigns, :error)

    [
      "<!doctype html><html lang=\"en\">",
      "<head>",
      "<meta charset=\"utf-8\">",
      "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
      "<title>",
      escape(title),
      " — Money.Input.Visualizer</title>",
      # Load the component CSS first, then the visualizer's own
      # stylesheet. Both use `--mi-*` CSS variables; loading
      # order matters because the visualizer's vars and explicit
      # class overrides (e.g. `.money-input-wrapper { background:
      # var(--mi-surface-2); }`) need to win the cascade so the
      # embedded components inherit the light/dark theme.
      "<link rel=\"stylesheet\" href=\"",
      escape(base),
      "/assets/money_input.css\">",
      "<link rel=\"stylesheet\" href=\"",
      escape(base),
      "/assets/style.css\">",
      "<link rel=\"icon\" type=\"image/png\" href=\"",
      escape(base),
      "/assets/logo.png\">",
      # Apply the saved theme synchronously before paint to avoid
      # a flash of the wrong palette. The script reads from
      # localStorage (set by the toggle below) and sets a
      # `data-theme` attribute on `<html>`. When no preference is
      # stored, the CSS falls back to prefers-color-scheme.
      theme_init_script(),
      "</head><body>",
      header(active, base),
      "<main class=\"mi-main\">",
      error_block(error),
      body,
      footer(),
      "</main>",
      theme_toggle_script(),
      clipboard_script(),
      "</body></html>"
    ]
  end

  defp header(active, base) do
    tabs = [
      {"input", "Input"},
      {"parse", "Parse"},
      {"format", "Format"},
      {"locale", "Locale"}
    ]

    [
      "<header class=\"mi-header\">",
      "<div class=\"mi-header-top\">",
      "<a class=\"mi-brand\" href=\"",
      escape(base),
      "/\">",
      "<img class=\"mi-logo\" src=\"",
      escape(base),
      "/assets/logo.png\" alt=\"\" width=\"40\" height=\"40\">",
      "<div class=\"mi-brand-text\">",
      "<h1>Money.Input.Visualizer</h1>",
      "<p>locale-aware number &amp; money input — try how it behaves across locales and currencies</p>",
      "</div>",
      "</a>",
      theme_toggle(),
      "</div>",
      "<nav class=\"mi-tabs\">",
      Enum.map(tabs, fn {path, label} ->
        cls = if path == active, do: "active", else: ""

        [
          "<a href=\"",
          escape(base),
          "/",
          path,
          "\" class=\"",
          cls,
          "\">",
          label,
          "</a>"
        ]
      end),
      "</nav>",
      "</header>"
    ]
  end

  # 3-way segmented control: system / light / dark. JS in
  # `theme_toggle_script/0` wires the click handling and keeps
  # `data-active` on the wrapper in sync with the saved choice.
  # System uses an inline desktop icon, light a sun, dark a moon.
  defp theme_toggle do
    """
    <div class="mi-theme-toggle" data-mi-theme-toggle data-active="system" role="group" aria-label="Theme">
      <button type="button" data-theme-choice="system" aria-pressed="true" aria-label="Use system theme" title="System">
        <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
      </button>
      <button type="button" data-theme-choice="light" aria-pressed="false" aria-label="Use light theme" title="Light">
        <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
      </button>
      <button type="button" data-theme-choice="dark" aria-pressed="false" aria-label="Use dark theme" title="Dark">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
      </button>
      <span class="mi-theme-toggle-thumb" aria-hidden="true"></span>
    </div>
    """
  end

  defp theme_init_script do
    """
    <script>
      (function () {
        try {
          var saved = localStorage.getItem('money_input:theme');
          if (saved === 'light' || saved === 'dark') {
            document.documentElement.setAttribute('data-theme', saved);
          }
        } catch (_) { /* private mode, ignore */ }
      })();
    </script>
    """
  end

  defp theme_toggle_script do
    """
    <script>
      (function () {
        var STORAGE_KEY = 'money_input:theme';
        var toggle = document.querySelector('[data-mi-theme-toggle]');
        if (!toggle) return;

        function active() {
          try {
            var saved = localStorage.getItem(STORAGE_KEY);
            return (saved === 'light' || saved === 'dark') ? saved : 'system';
          } catch (_) {
            return 'system';
          }
        }

        function apply(choice) {
          try {
            if (choice === 'system') {
              localStorage.removeItem(STORAGE_KEY);
              document.documentElement.removeAttribute('data-theme');
            } else {
              localStorage.setItem(STORAGE_KEY, choice);
              document.documentElement.setAttribute('data-theme', choice);
            }
          } catch (_) {
            // Fall back to attribute-only when storage is blocked.
            if (choice === 'system') {
              document.documentElement.removeAttribute('data-theme');
            } else {
              document.documentElement.setAttribute('data-theme', choice);
            }
          }
          toggle.setAttribute('data-active', choice);
          toggle.querySelectorAll('button').forEach(function (btn) {
            btn.setAttribute('aria-pressed', btn.getAttribute('data-theme-choice') === choice ? 'true' : 'false');
          });
        }

        apply(active());

        toggle.addEventListener('click', function (e) {
          var btn = e.target.closest('button[data-theme-choice]');
          if (!btn) return;
          apply(btn.getAttribute('data-theme-choice'));
        });
      })();
    </script>
    """
  end

  # Click-delegated clipboard handler. Any button with
  # `data-mi-copy-target="#selector"` copies that element's text
  # content. Flips `data-copied="true"` on the button for ~1.4s so
  # the CSS can swap the clipboard icon for a checkmark, then
  # clears it. No external lib — uses navigator.clipboard.writeText.
  defp clipboard_script do
    """
    <script>
    (function () {
      document.addEventListener("click", function (event) {
        var button = event.target.closest("[data-mi-copy-target]");
        if (!button) return;
        var selector = button.getAttribute("data-mi-copy-target");
        var target = selector && document.querySelector(selector);
        if (!target) return;
        var text = target.innerText || target.textContent || "";
        var done = function () {
          button.setAttribute("data-copied", "true");
          setTimeout(function () {
            button.removeAttribute("data-copied");
          }, 1400);
        };
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(text).then(done, function () {
            /* permission denied or insecure context — swallow */
          });
        } else {
          // Older-browser fallback via a transient textarea.
          var area = document.createElement("textarea");
          area.value = text;
          area.style.position = "fixed";
          area.style.opacity = "0";
          document.body.appendChild(area);
          area.select();
          try { document.execCommand("copy"); done(); } catch (e) {}
          document.body.removeChild(area);
        }
      });
    })();
    </script>
    """
  end

  defp error_block(nil), do: ""

  defp error_block(message) do
    ["<div class=\"mi-error\">", escape(to_string(message)), "</div>"]
  end

  defp footer do
    [
      "<footer class=\"mi-footer\">",
      "<p>Headless API: <code>Money.Input.Cast</code>, ",
      "<code>Money.Input.Validator</code>, ",
      "<code>Money.Input.Currency</code>.</p>",
      "</footer>"
    ]
  end

  @doc """
  Renders a `<select>` for a locale chooser.

  Accepts an options keyword list: `:reactive` (boolean) adds a
  `data-mi-reactive` attribute that the bootstrap JS listens to,
  so changing the value re-submits the form immediately.
  """
  def locale_select(name, selected, options \\ []) do
    reactive = if Keyword.get(options, :reactive, false), do: " data-mi-reactive", else: ""

    # Always include the currently-selected locale and any extras
    # the caller requested (typically the deployment's default
    # locale, so it stays accessible after the user picks
    # something else).
    extras = [selected | Keyword.get(options, :always_include, [])]
    locales = ensure_all_in_list(locale_options(), extras)

    [
      "<select name=\"",
      escape(name),
      "\"",
      reactive,
      ">",
      Enum.map(locales, fn {value, label} ->
        sel = if to_string(value) == to_string(selected), do: " selected", else: ""

        [
          "<option value=\"",
          escape(value),
          "\"",
          sel,
          ">",
          escape(label),
          "</option>"
        ]
      end),
      "</select>"
    ]
  end

  # Prepend any locale that isn't already in the curated list.
  # Two cases this handles:
  #
  #   1. The currently-selected locale — without this, picking
  #      `en-AU` (not in the curated set) would render with no
  #      `<option selected>`, so the browser would show the
  #      first option as the apparent selection.
  #
  #   2. The deployment's default locale — passed in via
  #      `always_include`. Without it, the dropdown would *show*
  #      the default at first render (case 1 above), but the
  #      user picking another locale would remove it from the
  #      list permanently with no way to get back.
  #
  # Entries are de-duplicated, blanks filtered, label text comes
  # from CLDR's locale-display data.
  defp ensure_all_in_list(locales, extras) do
    extras
    |> Enum.reject(&(&1 in [nil, ""]))
    |> Enum.map(&to_string/1)
    |> Enum.uniq()
    |> Enum.reverse()
    |> Enum.reduce(locales, &prepend_if_missing/2)
  end

  defp prepend_if_missing(locale_id, locales) do
    if Enum.any?(locales, fn {value, _} -> to_string(value) == locale_id end) do
      locales
    else
      [{locale_id, locale_label_for(locale_id)} | locales]
    end
  end

  defp locale_label_for(locale_id) do
    case locale_display_name(locale_id) do
      {:ok, name} -> name
      _ -> locale_id
    end
  end

  defp locale_display_name(locale_id) do
    Localize.Locale.LocaleDisplay.display_name(locale_id)
  rescue
    _ -> :error
  catch
    _, _ -> :error
  end

  @doc """
  Renders a `<select>` for a currency chooser.

  Accepts an options keyword list: `:allow_blank` adds a "— none —"
  option at the top; `:reactive` makes the select submit the form
  immediately on change.
  """
  def currency_select(name, selected, options \\ []) do
    allow_blank = Keyword.get(options, :allow_blank, false)
    reactive = if Keyword.get(options, :reactive, false), do: " data-mi-reactive", else: ""
    currencies = currency_options()

    blank =
      if allow_blank do
        sel = if selected in [nil, ""], do: " selected", else: ""
        ["<option value=\"\"", sel, ">— none —</option>"]
      else
        ""
      end

    [
      "<select name=\"",
      escape(name),
      "\"",
      reactive,
      ">",
      blank,
      Enum.map(currencies, fn {code, name} ->
        sel = if to_string(code) == to_string(selected), do: " selected", else: ""

        [
          "<option value=\"",
          escape(code),
          "\"",
          sel,
          ">",
          escape(code),
          " — ",
          escape(name),
          "</option>"
        ]
      end),
      "</select>"
    ]
  end

  @doc """
  Returns the list of demo locales the visualizer offers.
  """
  def locale_options do
    [
      {"en", "English (US)"},
      {"en-GB", "English (UK)"},
      {"en-IN", "English (India)"},
      {"de", "German (Germany)"},
      {"de-CH", "German (Switzerland)"},
      {"fr", "French (France)"},
      {"fr-CH", "French (Switzerland)"},
      {"es", "Spanish (Spain)"},
      {"pt-BR", "Portuguese (Brazil)"},
      {"it", "Italian"},
      {"ja", "Japanese"},
      {"zh-Hans", "Chinese (Simplified)"},
      {"ar", "Arabic"},
      {"fa", "Persian"},
      {"he", "Hebrew"},
      {"ru", "Russian"},
      {"sv", "Swedish"},
      {"pl", "Polish"}
    ]
  end

  @doc """
  Returns the curated list of demo currencies.
  """
  def currency_options do
    [
      {"USD", "US Dollar"},
      {"EUR", "Euro"},
      {"GBP", "Pound Sterling"},
      {"JPY", "Japanese Yen"},
      {"CHF", "Swiss Franc"},
      {"CAD", "Canadian Dollar"},
      {"AUD", "Australian Dollar"},
      {"BRL", "Brazilian Real"},
      {"INR", "Indian Rupee"},
      {"CNY", "Chinese Yuan"},
      {"SAR", "Saudi Riyal"},
      {"BHD", "Bahraini Dinar"},
      {"KWD", "Kuwaiti Dinar"},
      {"SEK", "Swedish Krona"},
      {"NOK", "Norwegian Krone"},
      {"ZAR", "South African Rand"}
    ]
  end

  @doc """
  Renders a labelled form row.
  """
  def field(label, control, opts \\ []) do
    hint = Keyword.get(opts, :hint)

    [
      "<div class=\"mi-field\">",
      "<label>",
      escape(label),
      control,
      "</label>",
      if(hint, do: ["<small class=\"mi-hint\">", escape(hint), "</small>"], else: ""),
      "</div>"
    ]
  end

  @doc """
  Pretty-prints an arbitrary Elixir term.
  """
  def code(term) do
    ["<pre class=\"mi-code\">", escape(inspect(term, pretty: true, width: 60)), "</pre>"]
  end
end