feat: Add keyboard navigation to member linking dropdown
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
4b4ec63613
commit
e5d4e84bd2
4 changed files with 255 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
58
notes.md
Normal 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.
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue