6
votes

Problem

LiveView collapses my opened element.

Details

I have an element that starts off as being collapsed on page load:

<a class="collapse_trigger">...</a>
<div class="is-collapsible">
# content updated by liveview
</div>

If a user clicks on the collapsible, the collapsible has a class .is-active.

<a class="collapse_trigger">...</a>
<div class="is-collapsible is-active">
# content
</div>

But liveview removes that class. Any idea how I can make sure that liveview ignores the parent element <div class="is-collapsible is-active"> but takes care of the children? My first thought was phx-update="ignore". But now I'm thinking I need to put the logic of the collapsible into the backend. :/

Additional info

I use bulma-collapsible with one css change:

// the following is necessary because liveview does not work well with the bulma-collapsible. Otherwise elements would stay open but can be closed by clicking them twice.
.is-collapsible{
  height: 0;
  &.is-active{
    height: auto;
  }
}
3
any reason you are putting logic on frontend? just use a phx-click event and an assign to handle the collapse.Daniel
It is frontend logic that has nothing to do with the backend ;-) that's why.Joe Eifert
this makes no sense, liveview was made to make interactive applications without the need to write javascript, what you are trying to do is a big mess that will not be maintanable.Daniel
We are making an application for farmers. sensor data is shown and updated using liveview. But I have to consider that farmers are going offline every once in a while and having bad reception. If they cannot open a collapsible at that point,.. well.. try explaining that to a farmer.Joe Eifert
what you mentioned is more than enough to understand why you would use frontend handle, I thought that you are another hipster trying to do things "differently"Daniel

3 Answers

1
votes

Front-end changes only

In order to use front-end only options I advise the following.

  • We would need to store state for that collapsible element.
  • We would need to restore collapsible state of that element on every socket channel update

For simplicity I will use plain javascript.

We need to modify button, and write function to store state (I used simple localStorage)

<a class="collapse_trigger" onclick="memoizeCollapsibleState()">...</a>

<script type="text/javascript">
  function memoizeCollapsibleState() {
    if (!localStorage.getItem('collapsibleState')) {
      localStorage.setItem('collapsibleState', true)
    } else {
      localStorage.removeItem('collapsibleState')
    }
  }
</script>

afterwards we need to write function that will restore that state from localstorage

function restoreCollapsibleState() {
  var collapsibleEl = document.getElementById('collapseExample');

  if (localStorage.getItem('collapsibleState')) {
    collapsibleEl.classList.add('is-active')
  }
}

And final moment we need to bind that function to phoenix_live_view socket update, we need to do it right after window has been loaded.

window.onload = init;

function init() {
  liveSocket.getSocket().channels[0].onMessage = function (e, t, n) {
    setTimeout(restoreCollapsibleState, 10)
    return t
  } 
}

The purpose of setTimeout function is that socket updates is async operation, and we need to add some delay in order to have collapsible state restored. 10ms seems to be ok, but we can change it to any other debounce function, I just used it for simplicity, consider this is as proof of concept

Back-end changes only

I just made an example with default live phoenix structure
mix phx.new my_app --live

Added bootstrap to it (but I think bulma follows mostly the same rules) And modified phoenix live template to the following

<div class="collapse <%= if (@results && String.trim(@query) != ""), do: "show", else: "" %>" id="collapseExample">
  <div class="card card-body">
    <%= for {app, _vsn} <- @results do %>
      <p value="<%= app %>"><%= app %></p>
    <% end %>
  </div>
</div>

so if there are any results and query is not empty thus will not be ever collapsed.

For your case I think it will be slightly the same

<a class="collapse_trigger">...</a>
<div class="is-collapsible <% if (@results && String.trim(@query) != "") do: "is-active", else: "" %>">
# content
</div>
0
votes

Based on the answer from @zhisme, I created a working solution. The only requirement is that the collapsibles have id's.

function initCollapsibles() {
  const bulmaCollapsibleInstances = bulmaCollapsible.attach('.is-collapsible');
  bulmaCollapsibleInstances.forEach(bulmaCollapsibleInstance => {
    const key = 'collapsible_' + bulmaCollapsibleInstance.element.id;
    // some id's from other pages might leak over if we don't clean the localstorage on page load
    localStorage.removeItem(key)

    bulmaCollapsibleInstance.on('before:expand', (e) => {
      localStorage.setItem(key, true)
    })

    bulmaCollapsibleInstance.on('after:collapse', (e) => {
      localStorage.removeItem(key)
    })
  });
}

function restoreCollapsiblesState() {
  const collapsibles = Array.prototype.slice.call(document.querySelectorAll('.is-collapsible'), 0);
  collapsibles.forEach(el => {
    if (localStorage.getItem('collapsible_' + el.id)) {
      // I'd love to call the collapsible open method, but haven't figured out how to get that instance without creating a new one or storing it to the window globally.
      el.classList.add('is-active')
    }
  });
}

document.addEventListener('phx:update', restoreCollapsiblesState);
document.addEventListener('DOMContentLoaded', initCollapsibles);
-1
votes

A quite well-known, if not elegant solution is that you addEventListener on that element on mount() and put it back on update(). Here is the idea:

Utils.onDetailsTagState = {
  mount(storeEl) {
    let detailsTag = storeEl.el.closest("details")
    if (detailsTag) {
      storeEl.expand = detailsTag.open
      detailsTag.addEventListener("toggle", event => {
        storeEl.expand = event.target.open
      })
    }
  },
  update(storeEl) {
    let detailsTag = storeEl.el.closest("details")
    if (detailsTag) { detailsTag.open = storeEl.expand }
  }
}

Hooks.callaspable = {
  mounted() { Utils.onDetailsTagState.mount(this) },
  updated() { Utils.onDetailsTagState.update(this) },
}