lib/phoenix_live_view/js.ex

defmodule Phoenix.LiveView.JS do
  @moduledoc ~S'''
  Provides commands for executing JavaScript utility operations on the client.

  JS commands support a variety of utility operations for common client-side
  needs, such as adding or removing css classes, showing or hiding content, and transitioning
  in and out with animations. While these operations can be accomplished via
  client-side hooks, JS commands are DOM patch aware, so operations applied
  by the JS APIs will stick to elements across patches from the server.

  In addition to purely client-side utilities, the JS command incluces a
  rich `push` API, for extending the default `phx-` binding pushes with
  options to customize targets, loading states, and additional payload values.

  ## Enhanced Push Events

  The `push/3` command allows you to extend the built-in pushed event handling
  when a `phx-` event is pushed to the server. For example, you may wish to
  target a specific component, specify additional payload values to include
  with the event, apply loading states to external elements, etc. For example,
  given this basic `phx-click` event:

      <div phx-click="inc">+</div>

  Imagine you need to target your current component, and apply a loading state
  to the parent container while the client awaits the server acknowledgement:

      alias Phoenix.LiveView.JS

      <div phx-click={JS.push("inc", loading: ".thermo", target: @myself)}>+</div>

  Push commands also compose with all other utilities. For example, to add
  a class when pushing:

      <div phx-click={
        JS.push("inc", loading: ".thermo", target: @myself)
        |> JS.add_class(".warmer", to: ".thermo")
      }>+</div>

  ## Client Utility Commands

  The following utilities are included:

    * `add_class` - Add classes to elements, with optional transitions
    * `remove_class` - Remove classes from elements, with optional transitions
    * `show` - Show elements, with optional transitions
    * `hide` - Hide elements, with optional transitions
    * `toggle` - Shows or hides elements based on visiblity, with optional transitions
    * `transition` - Apply a temporary transition to elements for animations
    * `dispatch` - Dispatch a DOM event to elements

  For example, the following modal component can be shown or hidden on the
  client without a trip to the server:

      alias Phoenix.LiveView.JS

      def hide_modal(js \\ %JS{}) do
        js
        |> JS.hide(transition: "fade-out", to: "#modal")
        |> JS.hide(transition: "fade-out-scale", to: "#modal-content")
      end

      def modal(assigns) do
        ~H"""
        <div id="modal" class="phx-modal" phx-remove={hide_modal()}>
          <div
            id="modal-content"
            class="phx-modal-content"
            phx-click-away={hide_modal()}
            phx-window-keydown={hide_modal()}
            phx-key="escape"
          >
            <button class="phx-modal-close" phx-click={hide_modal()}>✖</button>
            <p><%= @text %></p>
          </div>
        </div>
        """
      end
  '''
  alias Phoenix.LiveView.JS

  defstruct ops: []

  @default_transition_time 200

  defimpl Phoenix.HTML.Safe, for: Phoenix.LiveView.JS do
    def to_iodata(%Phoenix.LiveView.JS{} = cmd) do
      Phoenix.HTML.Engine.html_escape(Phoenix.json_library().encode!(cmd.ops))
    end
  end

  @doc """
  Pushes an event to the server.

    * `event` - The string event name to push.

  ## Options
    * `:target` - The selector or component ID to push to
    * `:loading` - The selector to apply the phx loading classes to
    * `:page_loading` - Boolean to trigger the phx:page-loading-start and
      phx:page-loading-stop events for this push. Defaults to `false`
    * `:value` - The map of values to send to the server

  ## Examples

      <button phx-click={JS.push("clicked")}>click me!</button>
      <button phx-click={JS.push("clicked", value: %{id: @id})}>click me!</button>
      <button phx-click={JS.push("clicked", page_loading: true)}>click me!</button>
  """
  def push(event) when is_binary(event) do
    push(%JS{}, event, [])
  end

  def push(event, opts) when is_binary(event) and is_list(opts) do
    push(%JS{}, event, opts)
  end

  def push(%JS{} = cmd, event) when is_binary(event) do
    push(cmd, event, [])
  end

  def push(%JS{} = cmd, event, opts) when is_binary(event) and is_list(opts) do
    opts =
      opts
      |> validate_keys(:push, [:target, :loading, :page_loading, :value])
      |> put_target()
      |> put_value()

    put_op(cmd, "push", Enum.into(opts, %{event: event}))
  end

  @doc """
  Dispatches an event to the DOM.

    * `event` - The string event name to dispatch.

  ## Options

    * `:to` - The optional DOM selector to dispatch the event to.
      Defaults to the interacted element.
    * `:detail` - The optional detail map to dispatch along
      with the client event. The details will be available in the
      `event.detail` attribute for event listeners.

  ## Examples

      window.addEventListener("click", e => console.log("clicked!", e.detail))

      <button phx-click={JS.dispatch("click", to: ".nav")}>Click me!</button>
  """
  def dispatch(cmd \\ %JS{}, event, opts) do
    opts = validate_keys(opts, :dispatch, [:to, :detail])
    args = %{event: event, to: opts[:to]}

    args =
      case Keyword.fetch(opts, :detail) do
        {:ok, detail} -> Map.put(args, :detail, detail)
        :error -> args
      end

    put_op(cmd, "dispatch", args)
  end

  @doc """
  Toggles elements.

  ## Options

    * `:to` - The optional DOM selector to toggle.
      Defaults to the interacted element.
    * `:in` - The string of classes to apply when toggling in, or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`
    * `:out` - The string of classes to apply when toggling out, or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-100", "opacity-0"}`
    * `:time` - The time to apply the transition `:in` and `:out` classes.
      Defaults #{@default_transition_time}
    * `:display` - The optional display value to set when toggling in. Defaults `"block"`.

  ## Examples

      <div id="item">My Item</div>

      <button phx-click={JS.toggle(to: "#item")}>
        toggle item!
      </button>

      <button phx-click={JS.show(to: "#item", in: "fade-in-scale", out: "fade-out-scale")}>
        toggle fancy!
      </button>
  """
  def toggle(opts \\ [])
  def toggle(%JS{} = cmd), do: toggle(cmd, [])
  def toggle(opts) when is_list(opts), do: toggle(%JS{}, opts)

  def toggle(cmd, opts) when is_list(opts) do
    opts = validate_keys(opts, :toggle, [:to, :in, :out, :display, :time])
    in_classes = transition_class_names(opts[:in])
    out_classes = transition_class_names(opts[:out])
    time = opts[:time] || @default_transition_time

    put_op(cmd, "toggle", %{
      to: opts[:to],
      display: opts[:display],
      ins: in_classes,
      outs: out_classes,
      time: time
    })
  end

  @doc """
  Shows elements.

  ## Options

    * `:to` - The optional DOM selector to show.
      Defaults to the interacted element.
    * `:transition` - The string of classes to apply before showing or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`
    * `:time` - The time to apply the transition from `:transition`.
      Defaults #{@default_transition_time}
    * `:display` - The optional display value to set when showing. Defaults `"block"`.

  ## Examples

      <div id="item">My Item</div>

      <button phx-click={JS.show(to: "#item")}>
        show!
      </button>

      <button phx-click={JS.show(to: "#item", transition: "fade-in-scale")}>
        show fancy!
      </button>
  """
  def show(opts \\ [])
  def show(%JS{} = cmd), do: show(cmd, [])
  def show(opts) when is_list(opts), do: show(%JS{}, opts)

  def show(cmd, opts) when is_list(opts) do
    opts = validate_keys(opts, :show, [:to, :transition, :display, :time])
    transition = transition_class_names(opts[:transition])
    time = opts[:time] || @default_transition_time

    put_op(cmd, "show", %{
      to: opts[:to],
      display: opts[:display],
      transition: transition,
      time: time
    })
  end

  @doc """
  Hides elements.

  ## Options

    * `:to` - The optional DOM selector to hide.
      Defaults to the interacted element.
    * `:transition` - The string of classes to apply before hiding or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`
    * `:time` - The time to apply the transition from `:transition`.
      Defaults #{@default_transition_time}

  ## Examples

      <div id="item">My Item</div>

      <button phx-click={JS.hide(to: "#item")}>
        hide!
      </button>

      <button phx-click={JS.hide(to: "#item", transition: "fade-out-scale")}>
        hide fancy!
      </button>
  """
  def hide(opts \\ [])
  def hide(%JS{} = cmd), do: hide(cmd, [])
  def hide(opts) when is_list(opts), do: hide(%JS{}, opts)

  def hide(cmd, opts) when is_list(opts) do
    opts = validate_keys(opts, :hide, [:to, :transition, :time])
    transition = transition_class_names(opts[:transition])
    time = opts[:time] || @default_transition_time

    put_op(cmd, "hide", %{
      to: opts[:to],
      transition: transition,
      time: time
    })
  end

  @doc """
  Adds classes to elements.

    * `names` - The string of classes to add.

  ## Options

    * `:to` - The optional DOM selector to add classes to.
      Defaults to the interacted element.
    * `:transition` - The string of classes to apply before adding classes or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`
    * `:time` - The time to apply the transition from `:transition`.
      Defaults #{@default_transition_time}

  ## Examples

      <div id="item">My Item</div>
      <button phx-click={JS.add_class("highlight underline", to: "#item")}>
        highlight!
      </button>
  """
  def add_class(names) when is_binary(names), do: add_class(%JS{}, names, [])

  def add_class(%JS{} = js, names) when is_binary(names) do
    add_class(js, names, [])
  end

  def add_class(names, opts) when is_binary(names) and is_list(opts) do
    add_class(%JS{}, names, opts)
  end

  def add_class(%JS{} = js, names, opts) when is_binary(names) and is_list(opts) do
    opts = validate_keys(opts, :add_class, [:to, :transition, :time])
    time = opts[:time] || @default_transition_time

    put_op(js, "add_class", %{
      to: opts[:to],
      names: class_names(names),
      transition: transition_class_names(opts[:transition]),
      time: time
    })
  end

  @doc """
  Removes classes from elements.

    * `names` - The string of classes to remove.

  ## Options

    * `:to` - The optional DOM selector to remove classes from.
      Defaults to the interacted element.
    * `:transition` - The string of classes to apply before removing classes or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`
    * `:time` - The time to apply the transition from `:transition`.
      Defaults #{@default_transition_time}

  ## Examples

      <div id="item">My Item</div>
      <button phx-click={JS.remove_class("highlight underline", to: "#item")}>
        remove highlight!
      </button>
  """
  def remove_class(names) when is_binary(names), do: remove_class(%JS{}, names, [])

  def remove_class(%JS{} = js, names) when is_binary(names) do
    remove_class(js, names, [])
  end

  def remove_class(names, opts) when is_binary(names) and is_list(opts) do
    remove_class(%JS{}, names, opts)
  end

  def remove_class(%JS{} = js, names, opts) when is_binary(names) and is_list(opts) do
    opts = validate_keys(opts, :remove_class, [:to, :transition, :time])
    time = opts[:time] || @default_transition_time

    put_op(js, "remove_class", %{
      to: opts[:to],
      names: class_names(names),
      transition: transition_class_names(opts[:transition]),
      time: time
    })
  end

  @doc """
  Transitions elements.

    * `transition` - The string of classes to apply before removing classes or
      a 3-tuple containing the transition class, the class to apply
      to start the transition, and the ending transition class, such as:
      `{"ease-out duration-300", "opacity-0", "opacity-100"}`

  Transitions are useful for temporarily adding an animation class
  to element(s), such as for highlighting content changes.

  ## Options

    * `:to` - The optional DOM selector to remove classes from.
      Defaults to the interacted element.
    * `:time` - The time to apply the transition from `:transition`.
      Defaults #{@default_transition_time}

  ## Examples

      <div id="item">My Item</div>
      <button phx-click={JS.transition("shake", to: "#item")}>Shake!</button>
  """
  def transition(transition) when is_binary(transition) or is_tuple(transition) do
    transition(%JS{}, transition, [])
  end

  def transition(transition, opts) when (is_binary(transition) or is_tuple(transition)) and is_list(opts) do
    transition(%JS{}, transition, opts)
  end

  def transition(%JS{} = cmd, transition) when is_binary(transition) or is_tuple(transition) do
    transition(cmd, transition, [])
  end

  def transition(%JS{} = cmd, transition, opts) when (is_binary(transition) or is_tuple(transition)) and is_list(opts) do
    opts = validate_keys(opts, :transition, [:to, :time])
    time = opts[:time] || @default_transition_time

    put_op(cmd, "transition", %{
      time: time,
      to: opts[:to],
      transition: transition_class_names(transition)
    })
  end

  defp put_op(%JS{ops: ops} = cmd, kind, %{} = args) do
    %JS{cmd | ops: ops ++ [[kind, args]]}
  end

  defp class_names(nil), do: []

  defp class_names(names) do
    String.split(names, " ")
  end

  defp transition_class_names(nil), do: [[], [], []]

  defp transition_class_names(transition) when is_binary(transition),
    do: [class_names(transition), [], []]

  defp transition_class_names({transition, tstart, tend})
       when is_binary(tstart) and is_binary(transition) and is_binary(tend) do
    [class_names(transition), class_names(tstart), class_names(tend)]
  end

  defp validate_keys(opts, kind, allowed_keys) do
    for key <- Keyword.keys(opts) do
      if key not in allowed_keys do
        raise ArgumentError, """
        invalid option for #{kind}
        Expected keys to be one of #{inspect(allowed_keys)}, got: #{inspect(key)}
        """
      end
    end

    opts
  end

  defp put_value(opts) do
    case Keyword.fetch(opts, :value) do
      {:ok, val} when is_map(val) -> Keyword.put(opts, :value, val)
      {:ok, val} -> raise ArgumentError, "push :value expected to be a map, got: #{inspect(val)}"
      :error -> opts
    end
  end

  defp put_target(opts) do
    case Keyword.fetch(opts, :target) do
      {:ok, %Phoenix.LiveComponent.CID{cid: cid}} -> Keyword.put(opts, :target, cid)
      {:ok, selector} -> Keyword.put(opts, :target, selector)
      :error -> opts
    end
  end
end