Is it possible to trigger JS commands outside of phx-* bindings?

I’m trying to call JS.add_class and JS.dispatch on a mouseover event. But since LiveView doesn’t have a binding for that event, I’m not sure how to use the built-in JS commands. Of course I can build my own hook and manually call element.classList.add("bg-gray-100");, but as that’s outside of LiveView’s knowledge, it could be cleared out by any other DOM patches.

Note: I know about CSS hover state and Tailwind Groups, but I’m modifying a separate item so those don’t help me.

Yes.

You can stick the “JS commands” in an attribute then execute them separately via execJS.

Eg, given the element:

<div
  data-commands={JS.add_class("bg-red-500")}
  data-on-event="mouseover">hover</div>

and something like:

const attachCustomEvent = (el) => {
  // data-on-event could be mouseover, mouseout, etc
  el.addEventListener(el.dataset.onEvent, ({ target }) => {
    const commands = target.dataset.commands
    if(commands){
      liveSocket.execJS(target, commands)
    }
  })
}

You can attach to existing dom elements via:

// attach to all existing in document.
document.querySelectorAll("[data-on-event]").forEach(attachCustomEvent)

but you also need to hook new elements as they’re added:


let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  dom: {
    // not 100% sure this is the best way, but its *a* way. It might even technically be
    // a private method, just popped up in autocomplete, if its private use a hook as described below.
    onNodeAdded(node) {
      // Attach handler to new elements, this probably has a nicer way?
      // Note this is called for text nodes too, so we want to explicitly check
      // for HTMLElements.
      if(node instanceof HTMLElement && node.dataset.onEvent){
        attachCustomEvent(node)
      }
      return node
    },
    // onBeforeElUpdated(from, to) {
    //   // possibly also want to attach here sometimes.
    // }
  }
})

You can simplify this somewhat by having a “global [sic] view hook” that you
attach to your root element, and just use the updated callback in there to
run the querySelectorAll against the subtree, ask if that doesn’t make
sense. Probably that is more maintainable in the long run.

Never done the above in prod, so not 100% sure on how quirky onNodeAdded is,
buyer beware.

This does rely on you serialising out a %JS{} struct, as the “across the
wire” rep is technically an opaque type.

You could add more complications to this, like data-exec-on-mouseover={JS} data-exec-on-mouseout={JS} and parse the event name from the data prop etc. You also don’t have to use data-* attributes if you don’t want to but probably don’t call them phx-mouseover.

5 Likes

Perfect, thank you for the really detailed guide! The LiveView docs cover execJS but I completely missed it. I haven’t tried it yet, but I think I don’t have to worry about the serialization since JS implements Phoenix.HTML.safe.

Thank you!

I haven’t tried it yet, but I think I don’t have to worry about the serialization since JS implements Phoenix.HTML.safe.

Sorry, that was poorly phrased.

What I meant was, you could do, this, where you manually construct the “js command”:

<div data-commands="[["add_class",{"names":["bg-red-500"]}]]">`

or even

execJS(el, "[["add_class", ...]]")

But you definitely shouldn’t, instead do data-commands={JS.x() |> JS.y()}, but this means you are also limited to what you can serialise out of %JS{}. The fact that we can see the JS representation is incidental.

Also ctrl-f “alpine” on the JS interop guide for details on preserving changes outside of execJS if you need other commands (eg if you adjust a style attr from a custom event, etc and need to copy it forward on DOM patches).

Ah, got it. That makes sense, I think that’s fair- if someone’s manually writing what LiveView generates, that feels a bit off in the use-at-your-own risk territory anyway.