feat: Add keyboard navigation to member linking dropdown
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
4b4ec63613
commit
3da0ebcb3f
4 changed files with 255 additions and 12 deletions
|
|
@ -162,9 +162,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
type="text"
|
||||
id="member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-change="search_members"
|
||||
phx-debounce="300"
|
||||
phx-window-keydown="member_dropdown_keydown"
|
||||
value={@member_search_query}
|
||||
placeholder={gettext("Search for a member to link...")}
|
||||
class="w-full input"
|
||||
|
|
@ -175,6 +177,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
|
|
@ -186,15 +193,22 @@ defmodule MvWeb.UserLive.Form do
|
|||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for member <- @available_members do %>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected="false"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class="px-4 py-3 hover:bg-base-200 cursor-pointer border-b border-base-300 last:border-b-0"
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
|
|
@ -263,6 +277,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> load_initial_members()
|
||||
|> assign_form()}
|
||||
end
|
||||
|
|
@ -353,7 +368,55 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
def handle_event("hide_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: false)}
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
index when index < max_index -> index + 1
|
||||
_ -> current
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
0 -> 0
|
||||
index -> index - 1
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
select_focused_member(socket)
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
|
|
@ -362,6 +425,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(:member_search_query, query)
|
||||
|> load_available_members(query)
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -406,6 +470,29 @@ defmodule MvWeb.UserLive.Form do
|
|||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
# Helper to ignore keyboard events when dropdown is closed
|
||||
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp return_if_dropdown_closed(socket, func) do
|
||||
if socket.assigns.show_member_dropdown do
|
||||
func.()
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Select the currently focused member from the dropdown
|
||||
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp select_focused_member(socket) do
|
||||
with index when not is_nil(index) <- socket.assigns.focused_member_index,
|
||||
member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do
|
||||
handle_event("select_member", %{"id" => member.id}, socket)
|
||||
else
|
||||
_ -> {:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||
form =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue