phoenix_live_view
phoenix_live_view copied to clipboard
feat: toggle_classes (updated 2021/01/08)
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.
I was also going to write the JS.toggle_class but saw this PR
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).
Any updates on this one?
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 :-)
@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 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. :)
sounds good. apologies.
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!
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.
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!
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.

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
a year has passed
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.
❤️❤️❤️🐥🔥