From e422e5f4ef4c53cdf3846ab3ff4d39955529602d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 26 Feb 2026 11:17:21 +0100 Subject: [PATCH] feat: consistent and accessible modal on delete --- CODE_GUIDELINES.md | 49 ++++++++--- DESIGN_DUIDELINES.md | 9 +- .../live/custom_field_live/index_component.ex | 14 +++- lib/mv_web/live/group_live/show.ex | 14 +++- lib/mv_web/live/member_live/form.ex | 73 +++++++++++++--- lib/mv_web/live/member_live/show.ex | 72 +++++++++++++--- .../show/membership_fees_component.ex | 58 ++++++++++--- lib/mv_web/live/role_live/show.ex | 83 ++++++++++++++----- lib/mv_web/live/user_live/form.ex | 79 +++++++++++++++--- lib/mv_web/live/user_live/show.ex | 75 ++++++++++++++--- 10 files changed, 424 insertions(+), 102 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index d68d0b5..bbc5ee4 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -3011,24 +3011,53 @@ end - [ ] Skip links are available - [ ] Tables have proper structure (th, scope, caption) - [ ] ARIA labels used for icon-only buttons +- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) -### 8.11 DaisyUI Accessibility +### 8.11 Modals and Dialogs -DaisyUI components are designed with accessibility in mind, but ensure: +Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `` so focus and semantics are correct (WCAG 2.4.3, 2.1.2). + +**Structure and semantics:** + +- Use `` with DaisyUI classes `modal modal-open` when the modal is visible. +- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose. +- Give the title (e.g. `

`) a unique `id` (e.g. `id="delete-role-modal-title"`). + +**Focus management (WCAG 2.4.3):** + +- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element: + - If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group). + - If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard. +- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse. + +**Layout and consistency:** + +- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action). +- Place Cancel (or neutral) first, primary/danger action second. +- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons. + +**Closing:** + +- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). +- Optionally support Escape to close via `phx-window-keydown` on the LiveView/LiveComponent. + +**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button). + +### 8.12 DaisyUI Accessibility + +DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure: ```heex - - + + diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index fc3acac..b497254 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -331,14 +331,17 @@ No “silent success”. ### 10.2 Destructive actions: one standard confirmation pattern - **MUST:** All destructive actions use the same confirm style and wording conventions. -- Choose one approach and standardize: - - `JS.confirm("…")` everywhere (simple, consistent) - - or a modal component everywhere (more flexible, more work) +- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible. **Recommended copy style:** - Title/confirm text is clear and specific (what will be deleted, consequences). - Buttons: `Cancel` (neutral) + `Delete` (danger). +### 10.3 Dialogs and modals (mandatory) +- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element). +- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse. +- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout). + --- ## 11) Detail pages (consistent structure) diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a9f921d..b15e49d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -99,10 +99,18 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do - <%!-- Delete Confirmation Modal --%> - + <%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> + <% end %> + + <%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> + <%= if assigns[:show_delete_modal] do %> + + + + <% end %> """ @@ -346,7 +374,8 @@ defmodule MvWeb.MemberLive.Show do {:ok, socket |> assign(:active_tab, :contact) - |> assign(:vereinfacht_receipts, nil)} + |> assign(:vereinfacht_receipts, nil) + |> assign_new(:show_delete_modal, fn -> false end)} end @impl true @@ -398,13 +427,26 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + @impl true + def handle_event("open_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, true)} + end + + @impl true + def handle_event("cancel_delete_modal", _params, socket) do + {:noreply, assign(socket, :show_delete_modal, false)} + end + @impl true def handle_event("delete", %{"id" => id}, socket) do member = socket.assigns.member actor = current_actor(socket) if to_string(id) != to_string(member.id) do - {:noreply, put_flash(socket, :error, gettext("Member not found"))} + {:noreply, + socket + |> put_flash(:error, gettext("Member not found")) + |> assign(:show_delete_modal, false)} else case Ash.destroy(member, actor: actor) do :ok -> @@ -415,16 +457,20 @@ defmodule MvWeb.MemberLive.Show do {:error, %Ash.Error.Forbidden{}} -> {:noreply, - put_flash( - socket, + socket + |> put_flash( :error, gettext("You do not have permission to delete this member") - )} + ) + |> assign(:show_delete_modal, false)} {:error, error} -> require Logger Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") - {:noreply, put_flash(socket, :error, format_error(error))} + {:noreply, + socket + |> put_flash(:error, format_error(error)) + |> assign(:show_delete_modal, false)} end end end diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 09a9ee1..1db11e3 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -288,11 +288,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %> - <%!-- Edit Cycle Amount Modal --%> + <%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @editing_cycle do %> - + <% end %> - <%!-- Delete Cycle Confirmation Modal --%> + <%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_cycle do %> - + <% end %> - <%!-- Delete All Cycles Confirmation Modal --%> + <%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @deleting_all_cycles do %> - + <% end %> - <%!-- Create Cycle Modal --%> + <%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%> <%= if @creating_cycle do %> - +