Recreating a Highlight component with liveview

I’m trying to recreate a highlight component that was implemented in React (Highlight - https://www.youtube.com/watch?v=Q2CJNB6yvwY). But I’m struggling a bit.

This is what I have so far:

  attr(:trigger, :any)
  attr(:duration, :integer)
  attr(:rest, :global)

  slot(:inner_block)

  def highlight(assigns) do
    assigns =
      assign_new(assigns, :highlight, fn ->
        if changed?(assigns, :trigger) do
          "on"
        else
          "off"
        end
      end)

    ~H"""
    <div {@rest} data-highlight={@highlight} phx-mounted={JS.remove_attribute("data-highlight")}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end

This is the behaviour I want: everytime trigger changes, I want to set data-highlight=on and after duration I want to set it to data-highlight=off.

The above code obviously doesn’t work because:

  1. JS.remove_attribute triggers immediately.
  2. once the attribute is removed, it never is shown again

I guess what I want to do isn’t possible without hooks, but still going to ask some questions:

  1. changed? returns true on the initial render as well. Is there a way to detect between initial render and “real” changes?
  2. am I correct in thinking that I can’t reproduce the example code without using hooks or am I missing something?

You could also use JS.dispatch and an event handler in your javascript for that as well.

on what binding would the JS.dispatch go here? Can I dispatch when an attribute changes?

The same setup as you already have would likely work. Could ignore the first event per node if the initial mount is not meant to trigger anything.

phx-mounted only dispatches on the initial mount. So that wouldn’t work.

And it also has the same downside as hooks, in that I now need to track every highlight component uniquely, in case I want to show multiple highlight components.

Not super efficient, but you could derive an id from the trigger. Updating the id on the node will force phx-mounted to run again.

Yeah, I’m think I’m better of with hooks then. Thanks.

Hopefully in the future liveview will have some other primitives to implement this without hooks :slight_smile:

Yeah, phx-update exists, but doesn’t accept a js command it seems.

No it doesn’t, unfortunately

Ok, I’ve tried to do this with hooks, but I have some very weird behaviour:

Hooks.Highlight = {
  updated() {
		if (this.handler) {
			clearTimeout(this.handler);
		}
		if (this.el.dataset.highlight === "on") {
			const handler = setTimeout(() => {
				this.el.setAttribute("data-highlight", "off");
			}, 1000);
			this.handler = handler;
			
		}
 }
}
  attr(:id, :any, required: true)
  attr(:trigger, :any)
  attr(:duration, :integer)
  slot(:inner_block)
  attr(:rest, :global)

  def highlight(assigns) do
    assigns =
      assign_new(assigns, :highlight, fn ->
        if changed?(assigns, :trigger) do
          "on"
        else
          "off"
        end
      end)

    ~H"""
    <div id={@id} data-id={@id} phx-hook="Highlight" {@rest} data-highlight={@highlight}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end
defmodule Storybook.CoreComponents.Highlight do
  use PhoenixStorybook.Story, :example
  import Elixir.DomainModellingWeb.CoreComponents

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, value: 10, value2: 20, value3: 30)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.highlight
      id="1"
      trigger={@value}
      duration={500}
      class="data-[highlight=on]:bg-primary-500 bg-neutral-900 text-white"
    >
      <div class="p-4">
        <button phx-click="click">Value 1: <%= @value %></button>
      </div>
    </.highlight>
    <.highlight
      id="2"
      trigger={@value2}
      duration={500}
      class="data-[highlight=on]:bg-primary-500 bg-neutral-900 text-white"
    >
      <div class="p-4">
        <button phx-click="click2">Value 2: <%= @value2 %></button>
      </div>
    </.highlight>
    <.highlight
      id="3"
      trigger={@value3}
      duration={500}
      class="data-[highlight=on]:bg-primary-500 bg-neutral-900 text-white"
    >
      <div class="p-4">
        <button phx-click="click3">Value 3: <%= @value3 %></button>
      </div>
    </.highlight>
    """
  end

  @impl true
  def handle_event("click", _, socket) do
    {:noreply,
     socket
     |> update(:value, fn value ->
       value + 1
     end)}
  end

  @impl true
  def handle_event("click2", _, socket) do
    {:noreply,
     socket
     |> update(:value2, fn value ->
       value + 1
     end)}
  end

  @impl true
  def handle_event("click3", _, socket) do
    {:noreply,
     socket
     |> update(:value3, fn value ->
       value + 1
     end)}
    |> dbg
  end

end

The strange behaviour I get: every time I click, all previous highlight components that have been clicked also run the updated hook.
The items that haven’t been previously updated, don’t get in the updated hook.
What’s even stranger is that for these items that get in the update hook, the data-highlight changes to “on”.
On the elixir side, nothing happens in the def highlight though.

Here you can see the behaviour: Screen Recording 2023-09-13 at 17.25.39.mov - Google Drive

That seems like a bug, but I guess there’s a bigger change that I’m doing something wrong.

I’ve created a minimal reproduction here: show the bug · tcoopman/liveview-hooks-bug@e144228 · GitHub

If no one sees an issue with my code, I’ll submit it as a bug.

1 Like

Hmm, does the server need to know about the highlight click at all?

If not, it’d be simpler to add a client side only click event handler on the mounted lifecycle hook.

<div id={@id} data-id={@id} phx-hook="Highlight" {@rest} data-highlight="off">

Hooks.Highlight = {
  mounted() {
    this.el.addEventListener("click", e => {
      if (this.el.dataset.highlight === "off") {
        this.el.setAttribute("data-highlight", "on")
        const handler = setTimeout(() => {
          this.el.setAttribute("data-highlight", "off")
        }, 1000)
        this.handler = handler
     }
   })
 }
}

~ updated to add ~

If it’s a server rather than client/user driven highlighting interaction, you could then use push_event and JS.transition as described in Handling server-pushed events:

For example, imagine the following template where you want to highlight an existing element from the server to draw the user’s attention:

<div id={"item-#{item.id}"} class="item">
  <%= item.title %>
</div>

Next, the server can issue a highlight using the standard push_event:

def handle_info({:item_updated, item}, socket) do
  {:noreply, push_event(socket, "highlight", %{id: "item-#{item.id}"})}
end

Finally, a window event listener can listen for the event and conditionally execute the highlight command if the element matches:

let liveSocket = new LiveSocket(...)
window.addEventListener(`phx:highlight`, (e) => {
  let el = document.getElementById(e.detail.id)
  if(el) {
    // logic for highlighting
  }
})

If you desire, you can also integrate this functionality with Phoenix’ JS commands, executing JS commands for the given element whenever highlight is triggered. First, update the element to embed the JS command into a data attribute:

<div id={"item-#{item.id}"} class="item" data-highlight={JS.transition("highlight")}>
  <%= item.title %>
</div>

Now, in the event listener, use LiveSocket.execJS to trigger all JS commands in the new attribute:

let liveSocket = new LiveSocket(...)
window.addEventListener(`phx:highlight`, (e) => {
  document.querySelectorAll(`[data-highlight]`).forEach(el => {
    if(el.id == e.detail.id){
      liveSocket.execJS(el, el.getAttribute("data-highlight"))
    }
  })
})

Yes, the idea is that you highlight something when data (from the server) updates.

The issue now is that the highlight component isn’t a standalone thing anymore that just works. You need to add extra event handlers in the liveview that use it.

With hooks this is better (if it would work), the only “leaking” is that it now needs a unique id.

I’ve created a bug report: updated hook triggered for multiple elements instead of only the updated element. · Issue #2805 · phoenixframework/phoenix_live_view · GitHub

Hmm, after taking another look, I noticed that the highlight assign gets stuck at "on" after the initial click and never reaches the else block that resets it to "off" because it’s not re-rendered after it gets clicked – if you add IO.puts to that else block, you can confirm that it’s never reached. So from the server’s point of view, the assigns never changed after incrementing the trigger so it never re-renders it. My theory is that the updated hook gets called for previously clicked elements because their data-highlight is set to off on the client whereas it is set to on the server.

To test that, I added a click_counter assign that gets updated in every handle_event callback and passed that assign into the .highlight component. This ensures that subsequent clicks will reset the data-highlight to off for non-clicked components on every click. While this did fix the unwanted highlighting of previously clicked components, it did feel unnecessarily complicated and a bit forced having to manually ensure the client and server were in sync.

Here’s the screen recording with no more unwanted highlighting as well as the commit with the fix described above.

1 Like

So I tried to change my code and change an attribute that the server is not tracking at all:

Hooks.Highlight = {
  updated() {
		console.log("why 3 though?", this.el.dataset)
		if (this.handler) {
			console.log("clearing", this.handler);
			clearTimeout(this.handler);
		}
		this.el.setAttribute("data-highlight-client", "on");
			const handler = setTimeout(() => {
				console.log("off");
				this.el.setAttribute("data-highlight-client", "off");
			}, 1000);
			this.handler = handler;
			
 }
}

Notice the data-highlight-client and I’ve removed all change tracking from the highlight component:

  def highlight(assigns) do
    ~H"""
    <div id={@id} data-id={@id} phx-hook="Highlight" {@rest}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end

So it seems that your theory is at least partially incorrect. Even if you change parts of the element client side that the server should not care about, it still results in the same bug.

Thanks for investigating though, but it feels weird that this doesn’t work, because isn’t this what hooks are about?