Learn Phoenix LiveView update guide (January 2025)

(This post is intended for existing customers of Learn Phoenix LiveView. If you haven’t bought and worked through the tutorial, it’s not relevant to you.)

As of January 2025, I’ve made some improvements to the Slax app that you build in the tutorial. If you’ve already completed the relevant lessons, you’ll need to update your Slax code to keep it consistent with the new content.

In brief:

  • The “add rooms” dropdown menu in the sidebar has been rewritten to fix some bugs and accessibility issues.
  • The “room index” LiveView (SlaxWeb.ChatLive.Index) has been rewritten to use <.link> components instead of <div>s.

In this post I’ll explain exactly what’s changed, why I changed it, and how you can update Slax. For more details, check the relevant lessons in Learn Phoenix LiveView, which have been fully updated with the new code.

Note: the earliest lesson whose code I’ve changed is Lesson 46. If you haven’t yet got that far, you don’t need to change anything.

The “Add rooms” dropdown sidebar menu

In the lesson Room index page, you added a dropdown menu to the sidebar, initially with one menu item:

In the original version of this lesson, the dropdown was rendered by the following code, which wraps the menu in a <button> and hides and shows it using Tailwind’s group-focus utility:

 <div id="rooms-list">
   <.room_link :for={room <- @rooms} room={room} active={room.id == @room.id} />
   <button class="group relative flex items-center h-8 text-sm pl-8 pr-3 hover:bg-slate-300 cursor-pointer w-full">
     <.icon name="hero-plus" class="h-4 w-4 relative top-px" />
     <span class="ml-2 leading-none">Add rooms</span>
     <div class="hidden group-focus:block cursor-default absolute top-8 right-2 bg-white border-slate-200 border py-3 rounded-lg">
       <div class="w-full text-left">
         <div class="hover:bg-sky-600">
           <div
             phx-click={JS.navigate(~p"/rooms")}
             class="cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1"
           >
             Browse rooms
           </div>
         </div>
       </div>
     </div>
   </button>
 </div>

I got this Tailwind trick from the original Lo-fi Slack UI template that I based Slax on. (The original template doesn’t have an “Add rooms” dropdown menu, but the group-focus trick is used to hide and show the “Dropdown” menu in the top left.) But after I published the lesson, I soon discovered that the trick doesn’t work in Safari. Several of you have emailed with suggestions for how to fix it, which I appreciate.

However, after trying a few different ideas, I’ve concluded that the whole thing needs a total rewrite. The group-focus trick isn’t actually a good idea: it’s an icky, weird hack to put a dropdown menu inside a <button>, and even if it worked in Safari it still breaks accessibility, because the dropdown menu is impossible to navigate with the “Tab” key.

So here’s the new version of the code, found in the updated lesson:

 <div id="rooms-list">
   <.room_link :for={room <- @rooms} room={room} active={room.id == @room.id} />

   <div class="relative">
     <button
       class="flex items-center peer h-8 text-sm pl-8 pr-3 hover:bg-slate-300 cursor-pointer w-full"
       phx-click={JS.toggle(to: "#sidebar-rooms-menu")}
     >
       <.icon name="hero-plus" class="h-4 w-4 relative top-px" />
       <span class="ml-2 leading-none">Add rooms</span>
     </button>

     <div
       id="sidebar-rooms-menu"
       class="hidden cursor-default absolute top-8 right-2 bg-white border-slate-200 border py-3 rounded-lg"
       phx-click-away={JS.hide()}
     >
       <div class="w-full text-left">
         <.link
           class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
           navigate={~p"/rooms"}
         >
           Browse rooms
         </.link>
       </div>
     </div>
   </div>
 </div>

This now shows and hides the menu with a more normal LiveView approach, using the JS module and phx-click-away. See the updated Room index page lesson for the full explanation.

Not only does this new version work in Safari, but it has the advantage of being navigable by keyboard:

Note that later in the course, in the lesson on modals, we add a new link to this menu:

      <div
        id="sidebar-rooms-menu"
        class="hidden cursor-default absolute top-8 right-2 bg-white border-slate-200 border py-3 rounded-lg"
        phx-click-away={JS.hide()}
      >
        <div class="w-full text-left">
          <.link
            class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
+           phx-click={show_modal("new-room-modal")}
          >
            Create a new room
          </.link>
          <.link
            class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
            navigate={~p"/rooms"}
          >
            Browse rooms
          </.link>
        </div>
      </div>

Then in the Live action lesson, we change that link’s attributes:

          <.link
            class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
-           phx-click={show_modal("new-room-modal")}
+           navigate={~p"/rooms/#{@room}/new"}
          >
            Create a new room
          </.link>

The final version (as of January 2025) looks like this:

        <div id="rooms-list">
          <.room_link
            :for={{room, unread_count} <- @rooms}
            room={room}
            active={room.id == @room.id}
            unread_count={unread_count}
          />

          <div class="relative">
            <button
              class="flex items-center peer h-8 text-sm pl-8 pr-3 hover:bg-slate-300 cursor-pointer w-full"
              phx-click={JS.toggle(to: "#sidebar-rooms-menu")}
            >
              <.icon name="hero-plus" class="h-4 w-4 relative top-px" />
              <span class="ml-2 leading-none">Add rooms</span>
            </button>

            <div
              id="sidebar-rooms-menu"
              class="hidden cursor-default absolute top-8 right-2 bg-white border-slate-200 border py-3 rounded-lg"
              phx-click-away={JS.hide()}
            >
              <div class="w-full text-left">
                <.link
                  class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
                  navigate={~p"/rooms"}
                >
                  Browse rooms
                </.link>
                <.link
                  class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 block hover:bg-sky-600"
                  navigate={~p"/rooms/#{@room}/new"}
                >
                  Create a new room
                </.link>
              </div>
            </div>
          </div>
        </div>

Room index page

In the old version of the “Room index page” lesson, you added a page rendered by this LiveView:

 # lib/slax_web/live/chat_room_live/index.ex
 defmodule SlaxWeb.ChatRoomLive.Index do
  use SlaxWeb, :live_view

  alias Slax.Chat

  def render(assigns) do
    ~H"""
    <main class="flex-1 p-6 max-w-4xl mx-auto">
      <div class="mb-4">
        <h1 class="text-xl font-semibold"><%= @page_title %></h1>
      </div>
      <div class="bg-slate-50 border rounded">
        <div id="rooms" class="divide-y" phx-update="stream">
          <div
            :for={{id, room} <- @streams.rooms}
            class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
            id={id}
            phx-click={JS.navigate(~p"/rooms/#{room}")}
          >
            <div>
              <div class="font-medium mb-1">
                #<%= room.name %>
                <span class="mx-1 text-gray-500 font-light text-sm opacity-0 group-hover:opacity-100">
                  View room
                </span>
              </div>
              <div class="text-gray-500 text-sm">
                <%= if room.topic do %>
                  <%= room.topic %>
                <% end %>
              </div>
            </div>
          </div>
        </div>
      </div>
    </main>
    """
  end

  def mount(_params, _session, socket) do
    rooms = Chat.list_rooms()
    socket = socket |> assign(page_title: "All rooms") |> stream(:rooms, rooms)
    {:ok, socket}
  end
 end

Each room is rendered by a <div> with a phx-click attribute that opens the room:

 <div
   :for={{id, room} <- @streams.rooms}
   class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
   id={id}
   phx-click={JS.navigate(~p"/rooms/#{room}")}
 >

However, this breaks accessibility because the room links aren’t navigable by keyboard. So I’ve changed the room’s wrapping <div>s to <.link>s:

     ~H"""
     <main class="flex-1 p-6 max-w-4xl mx-auto">
       <div class="mb-4">
         <h1 class="text-xl font-semibold"><%= @page_title %></h1>
       </div>
       <div class="bg-slate-50 border rounded">
         <div id="rooms" class="divide-y" phx-update="stream">
-          <div
+          <.link
             :for={{id, room} <- @streams.rooms}
             class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
             id={id}
-            phx-click={JS.navigate(~p"/rooms/#{room}")}
+            navigate={~p"/rooms/#{room}"}
           >
             <div>
               <div class="font-medium mb-1">
                 #<%= room.name %>
-                <span class="mx-1 text-gray-500 font-light text-sm opacity-0 group-hover:opacity-100">
+                <span class="mx-1 text-gray-500 font-light text-sm hidden group-hover:inline group-focus:inline">
                   View room
                 </span>
               </div>
               <div class="text-gray-500 text-sm">
                 <%= if room.topic do %>
                   <%= room.topic %>
                 <% end %>
               </div>
             </div>
-          </div>
+          </.link>
         </div>
       </div>
     </main>
     """

I’ve also added the class group-focus:inline to the “View room” text in addition to the existing group-hover:inline. This is another accessibility improvement, as it makes the text visible when the parent <.link> has keyboard focus, not just on mouse hover.

Later, in the stream_configure lesson, we update this HEEx again:

     ~H"""
     <main class="flex-1 p-6 max-w-4xl mx-auto">
       <div class="mb-4">
         <h1 class="text-xl font-semibold"><%= @page_title %></h1>
       </div>
       <div class="bg-slate-50 border rounded">
         <div id="rooms" class="divide-y" phx-update="stream">
           <.link
             :for={{id, room} <- @streams.rooms}
             class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
             id={id}
             navigate={~p"/rooms/#{room}"}
           >
             <div>
               <div class="font-medium mb-1">
                 #<%= room.name %>
                 <span class="mx-1 text-gray-500 font-light text-sm hidden group-hover:inline group-focus:inline">
                   View room
                 </span>
               </div>
               <div class="text-gray-500 text-sm">
+                <% joined? = Chat.joined?(room, @current_user) %>
+                <%= if joined? do %>
+                  <span class="text-green-600 font-bold">✓ Joined</span>
+                <% end %>
+                <%= if joined? && room.topic do %>
+                  <span class="mx-1">·</span>
+                <% end %>
                 <%= if room.topic do %>
                   <%= room.topic %>
                 <% end %>
               </div>
             </div>
           </.link>
         </div>
       </div>
     </main>
     """

Then in the toggle membership lesson we add a “Join/Leave” button:

     ~H"""
     <main class="flex-1 p-6 max-w-4xl mx-auto">
       <div class="mb-4">
         <h1 class="text-xl font-semibold"><%= @page_title %></h1>
       </div>
       <div class="bg-slate-50 border rounded">
         <div id="rooms" class="divide-y" phx-update="stream">
           <.link
             :for={{id, room} <- @streams.rooms}
             class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
             id={id}
             navigate={~p"/rooms/#{room}"}
           >
             <div>
               <div class="font-medium mb-1">
                 #<%= room.name %>
                 <span class="mx-1 text-gray-500 font-light text-sm hidden group-hover:inline group-focus:inline">
                   View room
                 </span>
               </div>
               <div class="text-gray-500 text-sm">
                 <%= if room.topic do %>
                   <%= room.topic %>
                 <% end %>
               </div>
             </div>
+
+            <button
+              class="opacity-0 group-hover:opacity-100 group-focus:opacity-100 focus:opacity-100 bg-white hover:bg-gray-100 border border-gray-400 text-gray-700 px-3 py-1.5 w-24 rounded-sm font-bold"
+              phx-click="toggle-room-membership"
+              phx-value-id={room.id}
+            >
+              <%= if joined? do %>
+                Leave
+              <% else %>
+                Join
+              <% end %>
+            </button>
           </.link>
         </div>
       </div>
     </main>
     """

Unfortunately, as I explain in the lesson, this doesn’t work properly with the parent <.link>. So we change the <.link> back to a <div>, but this time with new attributes so it’s still navigable by keyboard:

  def render(assigns) do
     ~H"""
     <main class="flex-1 p-6 max-w-4xl mx-auto">
       <div class="mb-4">
         <h1 class="text-xl font-semibold"><%= @page_title %></h1>
       </div>
       <div class="bg-slate-50 border rounded">
         <div id="rooms" class="divide-y" phx-update="stream">
-          <.link
+          <div
             :for={{id, {room, joined?}} <- @streams.rooms}
             class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
             id={id}
-            navigate={~p"/rooms/#{room}"}
+            phx-click={open_room(room)}
+            phx-keydown={open_room(room)}
+            phx-key="Enter"
+            tabindex="0"
           >
             <div>
               <div class="font-medium mb-1">
                 #<%= room.name %>
                 <span class="mx-1 text-gray-500 font-light text-sm hidden group-hover:inline group-focus:inline">
                   View room
                 </span>
               </div>
               <div class="text-gray-500 text-sm">
                 <%= if room.topic do %>
                   <%= room.topic %>
                 <% end %>
               </div>
             </div>

             <button
               class="opacity-0 group-hover:opacity-100 group-focus:opacity-100 focus:opacity-100 bg-white hover:bg-gray-100 border border-gray-400 text-gray-700 px-3 py-1.5 w-24 rounded-sm font-bold"
               phx-click="toggle-room-membership"
               phx-value-id={room.id}
             >
               <%= if joined? do %>
                 Leave
               <% else %>
                 Join
               <% end %>
             </button>
-          </.link>
+          </div>
         </div>
       </div>
     </main>
     """
   end
+
+  defp open_room(room) do
+    JS.navigate(~p"/rooms/#{room}")
+  end

Of course we could have just started with a <div> with a tabindex instead of using a <.link> in the original lesson. I’m doing things in this roundabout way for educational purposes; I don’t want to introduce and explain more complicated solutions before they become necessary.

The final, full render/1 function looks like this:

   def render(assigns) do
     ~H"""
     <main class="flex-1 p-6 max-w-4xl mx-auto">
       <div class="mb-4">
         <h1 class="text-xl font-semibold"><%= @page_title %></h1>
       </div>
       <div class="bg-slate-50 border rounded">
         <div id="rooms" class="divide-y" phx-update="stream">
           <div
             :for={{id, {room, joined?}} <- @streams.rooms}
             class="cursor-pointer p-4 flex justify-between items-center group first:rounded-t last:rounded-b"
             id={id}
             phx-click={open_room(room)}
             phx-keydown={open_room(room)}
             phx-key="Enter"
             tabindex="0"
           >
             <div>
               <div class="font-medium mb-1">
                 #<%= room.name %>
                 <span class="mx-1 text-gray-500 font-light text-sm hidden group-hover:inline group-focus:inline">
                   View room
                 </span>
               </div>
               <div class="text-gray-500 text-sm">
                 <%= if room.topic do %>
                   <%= room.topic %>
                 <% end %>
               </div>
             </div>

             <button
               class="opacity-0 group-hover:opacity-100 group-focus:opacity-100 focus:opacity-100 bg-white hover:bg-gray-100 border border-gray-400 text-gray-700 px-3 py-1.5 w-24 rounded-sm font-bold"
               phx-click="toggle-room-membership"
               phx-value-id={room.id}
             >
               <%= if joined? do %>
                 Leave
               <% else %>
                 Join
               <% end %>
             </button>
           </div>
         </div>
       </div>
     </main>
     """
   end

   defp open_room(room) do
     JS.navigate(~p"/rooms/#{room}")
   end

If you have any questions or issues with the above, don’t hesitate to email me.

Learn LiveView in 15 lessons

Sign up to my newsletter to get instant access to the LiveView and OTP crash course, the FREE tutorial that’ll teach you Phoenix LiveView and OTP in 15 easy lessons.

Your privacy is my top priority. Your email address will never be shared with third parties.