Toggle classes with Phoenix.LiveView.JS

Just playing around with Phoenix.LiveView.JS and wondering if there is a way to toggle classes on and off on an element? There is ‘add_class’ and ‘remove_class’ but ‘toggle’ looks like it is just used for showing and hiding. But maybe there is a way but just can’t see it.

7 Likes

I came here to ask this just now trying to make an indicator transform 180 degrees and not disappear.

It looks like its not in master yet.

I’m using dispatch for now:

js = JS.dispatch(js, "icon:rotate-180", to: "#accordion")

window.addEventListener("icon:rotate-180", e => {
    e.target.classList.toggle("transform-rotate-180")
});
5 Likes

I have an updated solution that supports more complexity inspired by some code I found in LiveBeats



# app.js
const execJS = (selector, attr) => {
  # this executes rendered JS commands on an element
  document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr)))
}

window.addEventListener('phx:collapse', e => {
  const id = `#${e.target.id}`

  if (e.target.getAttribute('data-open') === 'true') {
    execJS(id, 'js-hide')
  } else {
    execJS(id, 'js-show')
  }
})
~H"""
<div
  id="my-accordian"
  data-open="false"
  phx-click={JS.dispatch("phx:collapse", to: "#my-accordian")}
  js-show={show()}
  js-hide={hide()}
>
...
</div>
"""
2 Likes

Is there a reason to not merge the PR? Seems like a really popular request and also something built in to JS/DOM with classList.toggle? Maybe it adds too much complexity? Is the PR bad?

4 Likes

Not sure, I just again came in search of this functionality again two days back so I checked the PR to see why it hadn’t gone forward – just appears abandoned.

The updated solution from begleynk worked like a charm for me though.

Crossposting suggested solution for future searchers:

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

It adds the class if it does not exist on the element and removes it if it does.

7 Likes

More like forgotten by the LV core team I would say. AFAICS they didn’t provide any feedback apart from “we’re aware of this one, just wait”. A useful feature indeed that should be part of the core JS module IMO.

how does it work though? Superficially, it looks like it first removes the class and then adds it, so shouldn’t it always end up adding it? Aren’t the remove_class and add_class commands executed in order?

1 Like

Technically, yes, but the psuedo class :not selector in the command:

Only adds the class to the #outer-menu element if it does not have .expanded in its class list

never used LV.JS, so most likely stupid question: why not just use LV to toggle the class?

If you’re asking why not use the LV server state to render the element with / without a certain class, it’s because there are some UI interactions that don’t require a round-trip to the server which (can) introduce a couple hundred ms delay. Things like user profile menus opening/closing should be instantaneous (from a UX perspective) and don’t require the LV to keep track of its open/closed state

I hope that helps and answers your question

1 Like

I think it implies a kind of “DOM transaction”, probably the changes are applied to a shadow DOM, while the selectors are run against the document DOM? This lets you batch changes up and do tricks like as listed but might also catch you out if you don’t expect it.

See this fiddle, where clicking remove then add (what it “looks” like the LV.JS is doing) is effectively a no-op, which isn’t observed in LV.

1 Like

That doesn’t explain why it works. The first operation removes the class if the element has it, the second one adds it if the element doesn’t have it. But after the first operation the element is guaranteed not to have the class, so the second operation should always be triggered, resulting in a no-op as @soup has pointed out.

My guess is that the magic is happening here, but I wasn’t able to figure out how exactly.

The important thing to remember is that the JS commands are not executed at the time they’re invoked, they’re added to the JS struct as a sort of “batch” command – You can see here that add_class doesn’t actually add the class, but puts an “op” into the ops field of the JS struct as a command

So, when add_class is invoked, even though it comes after remove_class, the classes haven’t actually been removed from the element. That’s why the :not selector works

So at the end of the toggle_class fn above, the JS struct has two ops, 1 for removing classes, and 1 for adding classes, with the end-result being a toggle

I’m aware of that. But that’s only where the commands are stored in the page waiting to be executed by the javascript front end. The js front-end executes them here in sequence, so without special care the end result would be a noop (remove the class, the add the class). Something is happening at the JS level that prevents it, not in Elixir.

I see what you’re getting at.

So – Only thing I can think is that the class names are collected before any of the JS commands are executed.

I’m digging through the live_view.js to see the order of execution but haven’t gotten it 100% yet

A simple way to solve this by native html:

<button 
  onclick="document.getElementById('element-ie').classList.toggle('toggle-class')"
>
  click
</click>
1 Like

That wont survive dom patching, probably.

1 Like

I tested this and it actually works with LV

P.S. provided you use the phx-update=“ignore” attribute.

Gonna bump this as I’m running into this again. It’s easy to work around but it seems a bit odd that we can’t toggle_class.

With Tailwind being the out-of-the-box CSS solution, it essentially makes toggle, show, and hide useless since these set style="display: block". You can control the display type used on show (block, inline-block, flex, grid, …) but that doesn’t help when doing responsive design.

For example, I have a nav where the items are next to each other on desktop, but are stacked in the mobile burger menu.

<ul class="hidden md:flex">
  ...
</ul>

I were to go to mobile menu and show then hide the menu, then resize into a desktop view, the menu will remain hidden because it now has style="display: none" on it. I realize this is edge-casey but of course, this is exactly what clients do when they are demoing the app you’ve given them. It’s also not that edge-casey at all. For example, when working my laptop, I’ll have hexdocs open half-screen with the menu hidden. Then when I want to dig into something and explore, I’ll full-screen hexdocs and open the menu again. This isn’t a problem with hexdocs since its menu stays the same in both views, but that’s not always the case.

Of course there is an easy way around this with hooks, using different buttons for show and hide, or any of the clever ways above, but toggle_class would be useful in many different scenarios. Also, JS commands just feel so good!

3 Likes

Here’s the approach that works for me. It rotates a chevron icon by 180 degrees and then back on the next tap.

First in the template:

<div
  id="dropdown-icon"
  phx-click="toggle-dropdown" 
  class={[
    "p-4 transition-transform ease-linear duration-200",
    (@show_dropdown? && "rotate-180") || "rotate-0"
  ]}
>
  <.icon name="hero-chevron-down-mini" class="h-5 w-5" />
</div>

Then, in the liveview:

  def handle_event("toggle-dropdown", _params, socket) do
    {:noreply, update(socket, :show_dropdown?, fn val -> not val end)}
  end