feat: Add keyboard navigation to member linking dropdown
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Moritz 2025-11-27 16:01:42 +01:00
parent 4b4ec63613
commit e5d4e84bd2
4 changed files with 255 additions and 12 deletions

View file

@ -24,9 +24,32 @@ import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
// Hooks for LiveView components
let Hooks = {}
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
Hooks.ComboBox = {
mounted() {
this.handleKeyDown = (e) => {
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && isDropdownOpen) {
e.preventDefault()
}
}
this.el.addEventListener("keydown", this.handleKeyDown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
}
}
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
params: {_csrf_token: csrfToken},
hooks: Hooks
})
// Listen for custom events from LiveView

View file

@ -1328,9 +1328,10 @@ Implemented user-member linking functionality in User Edit/Create views with fuz
**Key Features:**
- Autocomplete dropdown with PostgreSQL Trigram fuzzy search
- Keyboard navigation (Arrow keys, Enter, Escape)
- Link/unlink members to user accounts
- Email synchronization between linked entities
- WCAG 2.1 AA compliant (ARIA labels)
- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility)
- Bilingual UI (English/German)
### Technical Decisions
@ -1350,7 +1351,45 @@ window.addEventListener("phx:set-input-value", (e) => {
```
**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case.
**3. Fuzzy Search Implementation**
**3. Keyboard Navigation: Hybrid Approach**
Implemented keyboard accessibility with **mostly Server-Side + minimal Client-Side**:
```elixir
# Server-Side: Navigation and Selection (~45 lines)
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
# Focus management on server
new_index = min(current + 1, max_index)
{:noreply, assign(socket, focused_member_index: new_index)}
end
```
```javascript
// Client-Side: Only preventDefault for Enter in forms (~13 lines)
Hooks.ComboBox = {
mounted() {
this.el.addEventListener("keydown", (e) => {
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && isDropdownOpen) {
e.preventDefault() // Prevent form submission
}
})
}
}
```
**Rationale:**
- Server-Side handles all navigation logic → simpler, testable, follows LiveView best practices
- Client-Side only prevents browser default behavior (form submit on Enter)
- Latency (~20-50ms) is imperceptible for keyboard events without DB queries
- Follows CODE_GUIDELINES "Minimal JavaScript Philosophy"
**Alternative Considered:** Full Client-Side with JavaScript Hook (~80 lines)
- ❌ More complex code
- ❌ State synchronization between client/server
- ✅ Zero latency (but not noticeable in practice)
- **Decision:** Server-Side approach is simpler and sufficient
**4. Fuzzy Search Implementation**
Combined PostgreSQL Full-Text Search + Trigram for optimal results:
```sql
-- FTS for exact word matching
@ -1393,11 +1432,13 @@ end
- ✅ Direct DOM manipulation (autocomplete, input values)
- ✅ Browser APIs (clipboard, geolocation)
- ✅ Third-party libraries
- ✅ Preventing browser default behaviors (form submit, scroll)
**When NOT to use JavaScript:**
- ❌ Form submissions
- ❌ Simple show/hide logic
- ❌ Server-side data fetching
- ❌ Keyboard navigation logic (can be done server-side efficiently)
**Pattern:**
```elixir
@ -1407,6 +1448,12 @@ socket |> push_event("event-name", %{key: value})
window.addEventListener("phx:event-name", (e) => { /* handle */ })
```
**Keyboard Events Pattern:**
For keyboard navigation in forms, use hybrid approach:
- Server handles navigation logic via `phx-window-keydown`
- Minimal hook only for `preventDefault()` to avoid form submit conflicts
- Result: ~13 lines JS vs ~80 lines for full client-side solution
#### 3. PostgreSQL Trigram Search
Requires `pg_trgm` extension with GIN indexes:
```sql
@ -1418,7 +1465,34 @@ Supports:
- Partial matching: "Mit" finds "Mitglied"
- Substring: "exam" finds "example.com"
#### 4. Test-Driven Development for Bug Fixes
#### 4. Server-Side Keyboard Navigation Performance
**Challenge:** Concern that server-side keyboard events would feel laggy.
**Reality Check:**
- LiveView roundtrip: ~20-50ms on decent connection
- Human perception threshold: ~100ms
- Result: **Feels instant** in practice
**Why it works:**
```elixir
# Event handler only updates index (no DB queries)
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
new_index = min(socket.assigns.focused_member_index + 1, max_index)
{:noreply, assign(socket, focused_member_index: new_index)}
end
```
- No database queries
- No complex computations
- Just state updates → extremely fast
**When to use Client-Side instead:**
- Complex animations (Canvas, WebGL)
- Real-time gaming
- Continuous interactions (drag & drop, drawing)
**Lesson:** Don't prematurely optimize for latency. Server-side is simpler and often sufficient.
#### 5. Test-Driven Development for Bug Fixes
Effective workflow:
1. Write test that reproduces bug (should fail)
2. Implement minimal fix
@ -1435,7 +1509,8 @@ Effective workflow:
- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management
**Frontend:**
- `assets/js/app.js` - Input value hook (6 lines)
- `assets/js/app.js` - Input value hook (6 lines) + ComboBox hook (13 lines)
- `lib/mv_web/live/user_live/form.ex` - Keyboard event handlers, focus management
- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN)
**Tests (NEW):**
@ -1472,14 +1547,14 @@ This project demonstrates a modern Phoenix application built with:
**Next Steps:**
- Implement roles & permissions
- Add payment tracking
- Improve accessibility (WCAG 2.1 AA)
- ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented
- Member self-service portal
- Email communication features
---
**Document Version:** 1.1
**Last Updated:** 2025-11-13
**Document Version:** 1.2
**Last Updated:** 2025-11-27
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)

View file

@ -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,67 @@ 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 ->
case socket.assigns.focused_member_index do
nil ->
{:noreply, socket}
index ->
member = Enum.at(socket.assigns.available_members, index)
if member do
handle_event("select_member", %{"id" => member.id}, socket)
else
{:noreply, socket}
end
end
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 +437,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 +482,17 @@ 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
@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 =

58
notes.md Normal file
View file

@ -0,0 +1,58 @@
# User-Member Association - Test Status
## Test Files Created/Modified
### 1. test/membership/member_available_for_linking_test.exs (NEU)
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
**Grund**: Die `:available_for_linking` Action existiert noch nicht
Tests:
- ✗ returns only unlinked members and limits to 10
- ✗ limits results to 10 members even when more exist
- ✗ email match: returns only member with matching email when exists
- ✗ email match: returns all unlinked members when no email match
- ✗ search query: filters by first_name, last_name, and email
- ✗ email match takes precedence over search query
### 2. test/accounts/user_member_linking_test.exs (NEU)
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
Tests:
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
**Grund**: Member-Linking UI ist noch nicht implementiert
Neue Tests:
- ✗ shows linked member with unlink button when user has member
- ✗ shows member search field when user has no member
- ✗ selecting member and saving links member to user
- ✗ unlinking member and saving removes member from user
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
Neuer Test:
- ✗ displays linked member name in user list
## Zusammenfassung
**Tests gesamt**: 13
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
## Nächste Schritte
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
5. Füge Gettext-Übersetzungen hinzu
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.