Animate closing a flash message with liveview

I thought I wanted to do something simple, namely fade-out the flash message when closing it in a liveview page.

Turns out it’s not that trivial, or I haven’t found the trivial way yet.

So my current approach is to use alpinejs like this:

  <div
    x-show="open"
    x-data="{open: <%= !!live_flash(@flash, :info) %>}"
    x-cloak
    x-transition:enter="transition ease-out duration-100 transform"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="transition ease-in duration-75 transform"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    class="relative bg-indigo-600"
    role="alert"
  >

The problem is that `phx-click=“lv:clear-flash” is not really what I want to do, because then you don’t get any animation.

I’ve seen this nice blog from @stryrckt and I think I should be able to get it working in that way, although I don’t know how to do the phx-click="lv:clear-flash" from javascript.

The bigger question though is shouldn’t it be easier to add these simple class of animations in a liveview page?

2 Likes

This is what I use:

<%= for {type, timeout} <- [error: 0, success: 10, info: 15, warning: 0] do %>
      <p class="flash__message flash__message--<%= type %>"
        role="alert"
        phx-click="lv:clear-flash"
        phx-value-key="<%= type %>"
        x-data="{timeout: <%= timeout %>, ...timeout_element()}"
        x-init="() => init()"
        x-show="show"
        x-transition:leave="transition ease-in duration-300"
        x-transition:leave-start="opacity-100 transform scale-100"
        x-transition:leave-end="opacity-0 transform scale-90"><%=
        live_flash(@flash, type)
      %></p>
    <% end %>
export default function () {
  return {
    show: true,
    init() {
      if (this.timeout > 0) {
        setTimeout(() => {
          this.show = false
        }, this.timeout * 1000)
      }
    }
  }
}
6 Likes

If I read that correctly, this doesn’t animate on closing the flash message with a click? Only on the timeout?
When you click, it just is removed from the page.

You could just as well have a click handler set show to false instead of a timeout.

yes, that’s what I’m doing now, but it’s not as nice I would like because the content of the alert is not animated out because that is just removed.

Why would it need to be removed though? LiveView does not support animations, so if you remove it (server side) it’ll be removed from the dom instantly. Just hide it client side only.

Are there any downsides to not removing it server side?

Are there any plans to add some support for animations in the future of LiveView?

Afaik there are plans, but they’re not yet being worked on. Generally the downside is that you keep a few KB of text around on the server and on the client. So unless you’re sending huge amounts of flash messages the downsides are likely insignificant.

If you really want to you could try and see if you can hook into the animation ending of alpine and trigger a remove on the server after the animation for hiding is finished.

An other downside is that you need some custom logic when you have multiple messages after you have hidden something. At least x-data="{open: <%= !!live_flash(@flash, :info) %>}" doesn’t work because that doesn’t change now.

How would that be a downside? Each notification has it’s own livecycle, so each handles its own closing.

A downside of only hiding it client-side and never closing it server-side is that you need custom logic client-side to make the next flash appear correctly again.
I mean a down-side in extra complexity added.

I’m not sure why the client side would be vastly different for either way, but I’m also only using the above for flash messages between page transitions.

I’ve been able to create a solution.
Using transitionend event that I connect in a mounted hook, I detect when the animation is done, and then I send an event lv:clear-flashwith the correct key.

This probably needs some other hooks to detect disconnects to make it more robust, but for now this works nicely.

Full solution for anyone who wants to use it

let Hooks = {}
Hooks.Flash = {
  mounted() {
    this.el.addEventListener('transitionend', (evt) => {
      evt.stopPropagation();
      const result = /flash-([\w]*)/.exec(evt.target.id);
      if (result) {
        const [_, key] = result
        this.pushEvent("lv:clear-flash", {key: key})
      }
    })
  }
};
  <%= for {type, color} <- [error: "red", info: "indigo"] do %>
    <div
      id="flash-<%= type %>"
      x-show="open"
      x-data="{open: <%= !!live_flash(@flash, type) %>}"
      x-cloak
      x-transition:enter="transition ease-out duration-100 transform"
      x-transition:enter-start="opacity-0"
      x-transition:enter-end="opacity-100"
      x-transition:leave="transition ease-in duration-300 transform"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      role="alert"
      phx-hook="Flash"
    >
           <span>
              <%= live_flash(@flash, :info) %>
           </span>
          <button @click="open = false" type="button" aria-label="Dismiss">
            x
          </button>
    </div>
  <% end %>
3 Likes

Here is a mashup of your two solutions:

app.js:

Hooks.Flash = {
  mounted() {
    this.el.addEventListener('transitionend', (evt) => {
      evt.stopPropagation()
      const result = /flash-([\w]*)/.exec(evt.target.id)
      const [_, key] = result
      this.closeFlash(key)
    })
    this.el.addEventListener('flash-opened', (evt) => {
      evt.stopPropagation()
      if (evt.detail.key && evt.detail.timeout > 0) {
        setTimeout(() => this.closeFlash(evt.detail.key), evt.detail.timeout)
      }
    })
  },
  closeFlash(key) {
    if (key) {
      this.pushEvent("lv:clear-flash", {
        key: key
      })
    }
  }
}

live.html.leex:

<main role="main"
      class="container">
  <%= if Phoenix.LiveView.connected?(@socket) do %>
  <%= for {type, {color, timeout}} <- [error: {"red", 0}, info: {"indigo", 10000}] do %>
  <div id="flash-<%= type %>"
       class="alert bg-<%= color %>-100 border border-<%= color %>-300 text-<%= color %>-800 px-4 py-3 rounded relative flex justify-between items-center"
       x-data="{open: <%= !!live_flash(@flash, type) %>}"
       x-show="open"
       x-init="() => {
         if (open) {
           $dispatch('flash-opened', {key: '<%= type %>', timeout: <%= timeout %>})
         }
       }"
       x-cloak
       x-transition:enter="transition ease-out duration-100 transform"
       x-transition:enter-start="opacity-0"
       x-transition:enter-end="opacity-100"
       x-transition:leave="transition ease-in duration-300 transform"
       x-transition:leave-start="opacity-100"
       x-transition:leave-end="opacity-0"
       role="alert"
       phx-hook="Flash">
    <div>
      <%= live_flash(@flash, :info) %>
    </div>
    <button class="focus:outline-none"
            @click="open = false"
            type="button"
            aria-label="Dismiss">
      <svg class="fill-current"
           width="16"
           height="16"
           viewBox="0 0 32 32">
        <path d="M6.869 6.869c0.625-0.625 1.638-0.625 2.263 0l6.869 6.869 6.869-6.869c0.625-0.625 1.638-0.625 2.263 0s0.625 1.638 0 2.263l-6.869 6.869 6.869 6.869c0.625 0.625 0.625 1.638 0 2.263s-1.638 0.625-2.263 0l-6.869-6.869-6.869 6.869c-0.625 0.625-1.638 0.625-2.263 0s-0.625-1.638 0-2.263l6.869-6.869-6.869-6.869c-0.625-0.625-0.625-1.638 0-2.263z"></path>
      </svg>
    </button>
  </div>
  <% end %>
  <% end %>

  <%= @inner_content %>
</main>
6 Likes

In my testing, I found that listening on transitionend in the hook didn’t work, as the event doesn’t get fired as expected. However listening on transitioncancel does work.

Also, in the live.html.leex code above, <%= live_flash(@flash, :info) %> should be <%= live_flash(@flash, type) %>

I’ve tested with end and that worked on Firefox. Haven’t tested with chrome yet

I noticed some strange things with both: transitionend and transitioncancel that are hard to control, so I made something that is a bit more robust I believe. I use $dispatch to dispatch a custom event on close and listen to that in the hooks, and there I start a timeout to send lv:clear-flash

The relevant code:

 <button @click="$dispatch('close-flash', {key: '<%= type %>', timeout: 300}); open = false" type="button">
Hooks.Flash = {
  mounted() {
    this.el.addEventListener('close-flash', (evt) => {
      const timeout = evt.detail.timeout || 500;
      setTimeout(() => {
        this.pushEvent("lv:clear-flash", {key: evt.detail.key})
      }, timeout);
    })
  }
};
2 Likes