(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:
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.
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>
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.