phoenix_live_view icon indicating copy to clipboard operation
phoenix_live_view copied to clipboard

feat: toggle_classes (updated 2021/01/08)

Open nbw opened this issue 3 years ago • 10 comments

I've been implementing a live view app and came across an instance where I wanted to toggle a class, not simply add or remove it.

I could simply store the state in a live view component or on the html tag itself (to decide if I want to remove or add), but it seemed like maybe a toggle would be nice to have.

~~I haven't added any function @doc comments-- yet. If toggle_classes isn't the direction the live view team wants to go with, then feel free to close this PR. Otherwise, if it looks good then I'll also add the documentation too (something similar to add_classes or remove_classes)~~ 👍

Example

Here's a simple use case where toggle adds or removes a class "dark" which could be used for darkmode:

https://user-images.githubusercontent.com/3220620/139871078-3f4f5f6c-a53b-4ef8-944c-a5a8b291af70.mov

In order to get it to work I had to build phoenix live view assets locally and do some shenanigans with my mix.exs to use a local dependency, so I won't be sharing the app code, but here's the live view:

defmodule PhxTesterWeb.TestLive do
  use PhxTesterWeb, :live_view
  alias Phoenix.LiveView.JS

  def mount(_params, _, socket) do
    {:ok, socket}
  end

  def toggle_mode(js \\ %JS{}) do
    js
    |> JS.toggle_class("dark", to: "body")
  end

  def test_button(assigns) do
    ~H"""
    <div>
      <button class="modal-button" phx-click={toggle_mode()}>toggle</button>
    </div>
    """
  end
end

and .heex file

The button below will toggle dark mode.
<%= test_button(%{}) %>

Code

I had issues getting jest to pass and left a comment in the code: https://github.com/phoenixframework/phoenix_live_view/pull/1721#discussion_r741164658

Unrelated

Running mix format changes the code decently. Perhaps it's worth running it once or setting up format rules in a config file.

nbw avatar Nov 02 '21 15:11 nbw

I was also going to write the JS.toggle_class but saw this PR

AlexR2D2 avatar Dec 21 '21 12:12 AlexR2D2

Hi Phoenix team. Since this task has received a number of 👍 (12 when writing this), I went ahead and added documentation.

Also I rebased master (upstream, or whatever you want to call it). ​

nbw avatar Jan 08 '22 05:01 nbw

Any updates on this one?

nicolasdabreo avatar Jan 28 '22 19:01 nicolasdabreo

Our designer just asked me about something where this would be a perfect fit, so +1 for getting this merged so we can skip our Alpine solution :-)

joerichsen avatar Feb 11 '22 13:02 joerichsen

@chrismccord apologies for honking you, but is there someone on the phoenix team who'd review and make a judgement call on this one.

nbw avatar Feb 23 '22 10:02 nbw

@nbw we are aware that are open issues, but we are few. If we haven't looked at them, it is because we are working on other stuff. Pinging does not help, please wait. :)

josevalim avatar Feb 23 '22 11:02 josevalim

sounds good. apologies.

nbw avatar Feb 23 '22 11:02 nbw

No worries, sometimes I do ping on the PRs I send too! But we aware of this one as it is quite active. We will get to it, thank you!

josevalim avatar Feb 23 '22 12:02 josevalim

Came across this issue looking for a toggle function, but I came up with a workaround that may be helpful for others.

This method both removes an expanded class if it is already applied, but also adds it if it is missing, effectively working as a toggle.

def toggle_expanded(js \\ %JS{}) do
  js
  |> JS.remove_class(
    "expanded",
    to: "#outer-menu.expanded"
  )
  |> JS.add_class(
    "expanded",
    to: "#outer-menu:not(.expanded)"
  )
end

Not sure if there are some gotchas that I'm not seeing yet, but this workaround seems to be working for now in my testing.

begleynk avatar Mar 30 '22 13:03 begleynk

Came across this issue looking for a toggle function, but I came up with a workaround that may be helpful for others.

This method both removes an expanded class if it is already applied, but also adds it if it is missing, effectively working as a toggle.

def toggle_expanded(js \\ %JS{}) do
  js
  |> JS.remove_class(
    "expanded",
    to: "#outer-menu.expanded"
  )
  |> JS.add_class(
    "expanded",
    to: "#outer-menu:not(.expanded)"
  )
end

Not sure if there are some gotchas that I'm not seeing yet, but this workaround seems to be working for now in my testing.

solid solid answer there @begleynk ! thank you!

megalithic avatar May 23 '22 19:05 megalithic

Wow, the github search filter is less than ideal, and so I was just working on this as well and about to open a draft PR 😅 😬

This is definitely a valuable and needed addition to the functionality.

Screen Shot 2022-12-01 at 2 14 28 PM

ryanwinchester avatar Dec 01 '22 18:12 ryanwinchester

Thanks @begleynk for the inspiration. Here is a more generic version, which allows you to pass multiple classes.

toggle_class("rotate-180 bg-green", to: "#icon")

  @spec toggle_class(js :: map(), classes :: String.t(), opts :: keyword()) :: map()
  def toggle_class(js \\ %JS{}, classes, opts) when is_binary(classes) do
    if not Keyword.has_key?(opts, :to) do
      raise ArgumentError, "Missing option `:to`"
    end

    case String.split(classes) do
      [class] ->
        opts_remove_class = Keyword.update!(opts, :to, fn selector -> "#{selector}.#{class}" end)
        opts_add_class = Keyword.update!(opts, :to, fn selector -> "#{selector}:not(.#{class})" end)

        js
        |> JS.remove_class(class, opts_remove_class)
        |> JS.add_class(class, opts_add_class)

      classes ->
        Enum.reduce(classes, js, fn class, js ->
          toggle_class(js, class, opts)
        end)
    end
  end

rubas avatar Feb 22 '23 09:02 rubas

a year has passed

shizhaojingszj avatar Dec 22 '23 10:12 shizhaojingszj

About the oft-shared workaround to send two add_class commands and select them differently with :not(): it's been pointed out that this shouldn't really work because after one go, they will end up both firing at the same time no matter what order you do them in.

So it makes me wonder, why does it work? It seems like it shouldn't?

I've played around with trying this same trick with set_attribute and it does not work over there. So this behavior kind of seems like a bug to me.

That being said, I think LiveView.JS probably needs some more advanced primitives for toggling stuff if it really wants to be a comprehensive solution.

srcrip avatar Dec 26 '23 17:12 srcrip

❤️❤️❤️🐥🔥

chrismccord avatar Jan 02 '24 19:01 chrismccord