fix: color contrast dark mode and keyboard moadals

This commit is contained in:
carla 2026-02-26 15:24:29 +01:00
parent 5516c7fe62
commit c71c7d6ed6
14 changed files with 1067 additions and 740 deletions

View file

@ -3016,6 +3016,8 @@ end
- [ ] Tables have proper structure (th, scope, caption) - [ ] Tables have proper structure (th, scope, caption)
- [ ] ARIA labels used for icon-only buttons - [ ] ARIA labels used for icon-only buttons
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape) - [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
### 8.11 Modals and Dialogs ### 8.11 Modals and Dialogs
@ -3043,7 +3045,8 @@ Use a consistent, keyboard-accessible pattern for all confirmation and form moda
**Closing:** **Closing:**
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`). - 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. - **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
**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). **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).

View file

@ -538,9 +538,14 @@
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */ /* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
[data-theme="dark"] { [data-theme="dark"] {
--color-primary-content: oklch(0.97 0.02 277);
--color-error: oklch(55% 0.253 17.585); --color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0); --color-error-content: oklch(98% 0 0);
--color-primary: oklch(72% 0.17 45);
--color-primary-content: oklch(0.18 0.02 47);
--color-secondary: oklch(48% 0.233 277.117);
--color-secondary-content: oklch(98% 0 0);
} }
/* This file is for your main application CSS */ /* This file is for your main application CSS */

View file

@ -94,6 +94,32 @@ Hooks.TableRowKeydown = {
} }
} }
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {
this.handleEvent("focus_restore", ({id}) => {
const el = document.getElementById(id)
if (el) el.focus()
})
}
}
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
}
}
this.el.addEventListener('keydown', this.handleKeydown)
},
destroyed() {
this.el.removeEventListener('keydown', this.handleKeydown)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { mounted() {

View file

@ -477,7 +477,7 @@ defmodule MvWeb.CoreComponents do
tabindex="0" tabindex="0"
role="button" role="button"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={@open} aria-expanded={if @open, do: "true", else: "false"}
aria-controls={@id} aria-controls={@id}
aria-label={@button_label} aria-label={@button_label}
class={["btn"] ++ @button_focus_classes ++ [@button_class]} class={["btn"] ++ @button_focus_classes ++ [@button_class]}

View file

@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
)} )}
</p> </p>
<.button <.button
id="delete-custom-field-trigger"
type="button" type="button"
variant="danger" variant="danger"
phx-click="request_delete" phx-click="request_delete"

View file

@ -106,6 +106,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="delete-custom-field-modal-title" aria-labelledby="delete-custom-field-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold"> <h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
@ -226,6 +227,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
id -> id ->
send(self(), {:custom_field_delete_modal_open, true})
custom_field = custom_field =
Ash.get!(Mv.Membership.CustomField, id, Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count], load: [:assigned_members_count],
@ -290,6 +293,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
actor: actor actor: actor
) )
send(self(), {:custom_field_delete_modal_open, true})
{:noreply, {:noreply,
socket socket
|> assign(:custom_field_to_delete, custom_field) |> assign(:custom_field_to_delete, custom_field)
@ -310,6 +315,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
if socket.assigns.slug_confirmation == custom_field.slug do if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field, actor: actor) do case Ash.destroy(custom_field, actor: actor) do
:ok -> :ok ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_deleted, custom_field}) send(self(), {:custom_field_deleted, custom_field})
{:noreply, {:noreply,
@ -320,6 +326,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> stream_delete(:custom_fields, custom_field)} |> stream_delete(:custom_fields, custom_field)}
{:error, error} -> {:error, error} ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_delete_error, error}) send(self(), {:custom_field_delete_error, error})
{:noreply, {:noreply,
@ -329,6 +336,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:slug_confirmation, "")} |> assign(:slug_confirmation, "")}
end end
else else
send(self(), {:custom_field_delete_modal_open, false})
send(self(), :custom_field_slug_mismatch) send(self(), :custom_field_slug_mismatch)
{:noreply, {:noreply,
@ -341,10 +349,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, send(self(), {:custom_field_delete_modal_open, false})
socket {:noreply, close_delete_modal_and_restore_focus(socket)}
|> assign(:show_delete_modal, false) end
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")} def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
send(self(), {:custom_field_delete_modal_open, false})
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
end end
end end

View file

@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
socket socket
|> assign(:page_title, gettext("Datafields")) |> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil)} |> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
end end
@impl true
def handle_event("window_keydown", %{"key" => key}, socket)
when key in ["Escape", "Esc"] do
if socket.assigns[:custom_field_delete_modal_open] do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_delete_modal: false,
custom_field_to_delete: nil,
slug_confirmation: ""
)
{:noreply,
socket
|> assign(:custom_field_delete_modal_open, false)
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -35,8 +58,14 @@ defmodule MvWeb.DatafieldsLive do
</:subtitle> </:subtitle>
</.header> </.header>
<%!-- Overview: both sections with form_section wrappers --%> <%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
<div :if={@active_editing_section == nil} class="mt-6 space-y-6"> <div
:if={@active_editing_section == nil}
id="datafields-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.form_section title={gettext("Personal Data")}> <.form_section title={gettext("Personal Data")}>
<.live_component <.live_component
module={MvWeb.MemberFieldLive.IndexComponent} module={MvWeb.MemberFieldLive.IndexComponent}
@ -63,7 +92,13 @@ defmodule MvWeb.DatafieldsLive do
/> />
</div> </div>
<div :if={@active_editing_section == :custom_fields} class="mt-6"> <div
:if={@active_editing_section == :custom_fields}
id="datafields-focus-root"
class="mt-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.live_component <.live_component
module={MvWeb.CustomFieldLive.IndexComponent} module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component" id="custom-fields-component"
@ -74,6 +109,11 @@ defmodule MvWeb.DatafieldsLive do
""" """
end end
@impl true
def handle_info({:custom_field_delete_modal_open, open}, socket) do
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
end
@impl true @impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent, send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
</:actions> </:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div
id="group-show-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Group Information --%> <%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6"> <div class="max-w-2xl space-y-6 mb-6">
<div> <div>
@ -353,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<.button <.button
id="delete-group-trigger"
variant="danger" variant="danger"
type="button" type="button"
phx-click="open_delete_modal" phx-click="open_delete_modal"
@ -373,6 +379,7 @@ defmodule MvWeb.GroupLive.Show do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="delete-group-modal-title" aria-labelledby="delete-group-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4"> <h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
@ -453,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, {:noreply, close_delete_modal_and_restore_focus(socket)}
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")}
end end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("update_name_confirmation", %{"name" => name}, socket) do def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)} {:noreply, assign(socket, :name_confirmation, name)}
@ -939,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")
|> push_event("focus_restore", %{id: "delete-group-trigger"})
end
defp perform_group_deletion(socket, group, actor) do defp perform_group_deletion(socket, group, actor) do
case Membership.destroy_group(group, actor: actor) do case Membership.destroy_group(group, actor: actor) do
:ok -> :ok ->

View file

@ -38,300 +38,311 @@ defmodule MvWeb.MemberLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<div
id="member-form-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.header> <.header>
<:leading> <:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral"> <.button navigate={return_path(@return_to, @member)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
<%= if @member do %> <%= if @member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} {MvWeb.Helpers.MemberHelpers.display_name(@member)}
<% else %> <% else %>
{gettext("New Member")} {gettext("New Member")}
<% end %> <% end %>
<:actions> <:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")} {gettext("Save")}
</.button> </.button>
</:actions> </:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%> <%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
<div <div
role="tablist" role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit" class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected="true"
aria-controls="member-tabpanel-contact"
class="tab tab-active flex items-center gap-2"
> >
<.icon name="hero-identification" class="size-4 shrink-0" /> <button
{gettext("Contact Data")} id="member-tab-contact"
</button> role="tab"
</div> type="button"
tabindex="0"
aria-selected="true"
aria-controls="member-tabpanel-contact"
class="tab tab-active flex items-center gap-2"
>
<.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")}
</button>
</div>
<%!-- Contact Data Tab Content (same structure as member show) --%> <%!-- Contact Data Tab Content (same structure as member show) --%>
<div <div
id="member-tabpanel-contact" id="member-tabpanel-contact"
role="tabpanel" role="tabpanel"
aria-labelledby="member-tab-contact" aria-labelledby="member-tab-contact"
> >
<%!-- Personal Data and Custom Fields Row --%> <%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%> <%!-- Personal Data Section --%>
<div> <div>
<.form_section title={gettext("Personal Data")}> <.form_section title={gettext("Personal Data")}>
<div class="space-y-4"> <div class="space-y-4">
<%!-- Name Row --%> <%!-- Name Row --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-48"> <div class="w-48">
<.input <.input
field={@form[:first_name]} field={@form[:first_name]}
label={gettext("First Name")} label={gettext("First Name")}
required={@member_field_required_map[:first_name]} required={@member_field_required_map[:first_name]}
/> />
</div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div> </div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%> <%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-48"> <div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} /> <.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div> </div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%> <%!-- Street and Nr. below --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64"> <div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div> </div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%> <%!-- Membership Dates Row --%>
<div class="w-64"> <div class="flex gap-4">
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <div class="w-36">
</div> <.input
field={@form[:join_date]}
<%!-- Membership Dates Row --%> label={gettext("Join Date")}
<div class="flex gap-4"> type="date"
<div class="w-36"> required={@member_field_required_map[:join_date]}
<.input />
field={@form[:join_date]} </div>
label={gettext("Join Date")} <div class="w-36">
type="date" <.input
required={@member_field_required_map[:join_date]} field={@form[:exit_date]}
/> label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div> </div>
<div class="w-36">
<%!-- Notes --%>
<div>
<.input <.input
field={@form[:exit_date]} field={@form[:notes]}
label={gettext("Exit Date")} label={gettext("Notes")}
type="date" type="textarea"
required={@member_field_required_map[:exit_date]} required={@member_field_required_map[:notes]}
/> />
</div> </div>
</div> </div>
</.form_section>
</div>
<%!-- Notes --%> <%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={
if cf.value_type == :boolean, do: "flex items-end", else: ""
}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div> <div>
<.input <label class="label">
field={@form[:notes]} <span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
label={gettext("Notes")} </label>
type="textarea" <select
required={@member_field_required_map[:notes]} class="select select-bordered w-full"
/> name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div> </div>
</div> </div>
</.form_section> </.form_section>
</div> </div>
<%!-- Custom Fields Section --%> <%!-- Bottom Action Buttons --%>
<%= if Enum.any?(@custom_fields) do %> <div class="flex justify-end gap-4 mt-6">
<div> <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
<.form_section title={gettext("Custom Fields")}> {gettext("Cancel")}
<div class="grid grid-cols-2 gap-4"> </.button>
<%!-- Render in sorted order by finding the form for each sorted custom field --%> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
<%= for cf <- @sorted_custom_fields do %> {gettext("Save Member")}
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> </.button>
<%= if f_cfv[:custom_field_id].value == cf.id do %> </div>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%> <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<div class="max-w-xl"> <%= if @member && can?(@current_user, :destroy, @member) do %>
<.form_section title={gettext("Membership Fee")}> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<div class="space-y-4"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
<div> {gettext("Danger zone")}
<label class="label"> </h2>
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
</label> <p class="text-base-content/70 mb-4">
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext( {gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval." "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)} )}
</p> </p>
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<%= if @member && can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
variant="danger"
type="button"
phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @member && assigns[:show_delete_modal] do %>
<dialog
id="delete-member-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-form-modal-title"
>
<div class="modal-box">
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button <.button
type="button" id="delete-member-form-trigger"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger" variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})} type="button"
aria-label={gettext("Delete member")} phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
> >
{gettext("Delete")} <.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button> </.button>
</div> </div>
</div> </section>
</dialog> <% end %>
<% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @member && assigns[:show_delete_modal] do %>
<dialog
id="delete-member-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})}
aria-label={gettext("Delete member")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</div> </div>
</div> </div>
</.form> </.form>
@ -461,9 +472,25 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def handle_event("cancel_delete_modal", _params, socket) do def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)} {:noreply, close_delete_modal_and_restore_focus(socket)}
end end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
@ -511,6 +538,12 @@ defmodule MvWeb.MemberLive.Form do
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-form-trigger"})
end
defp handle_save_success(socket, member) do defp handle_save_success(socket, member) do
notify_parent({:saved, member}) notify_parent({:saved, member})

View file

@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
</:actions> </:actions>
</.header> </.header>
<div class="mt-6 space-y-6"> <div
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> id="member-show-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
<div <div
id="member-tablist"
role="tablist" role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit" class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
phx-hook="TabListKeydown"
phx-keydown="tab_keydown"
> >
<button <button
id="member-tab-contact" id="member-tab-contact"
role="tab" role="tab"
type="button" type="button"
tabindex="0" tabindex={if @active_tab == :contact, do: "0", else: "-1"}
aria-selected={@active_tab == :contact} aria-selected={if @active_tab == :contact, do: "true", else: "false"}
aria-controls="member-tabpanel-contact" aria-controls="member-tabpanel-contact"
class={[ class={[
"tab flex items-center gap-2", "tab flex items-center gap-2",
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
id="member-tab-membership_fees" id="member-tab-membership_fees"
role="tab" role="tab"
type="button" type="button"
tabindex="0" tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
aria-selected={@active_tab == :membership_fees} aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
aria-controls="member-tabpanel-membership_fees" aria-controls="member-tabpanel-membership_fees"
class={[ class={[
"tab flex items-center gap-2", "tab flex items-center gap-2",
@ -315,6 +323,7 @@ defmodule MvWeb.MemberLive.Show do
)} )}
</p> </p>
<.button <.button
id="delete-member-trigger"
variant="danger" variant="danger"
phx-click="open_delete_modal" phx-click="open_delete_modal"
data-testid="member-delete" data-testid="member-delete"
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Show do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="delete-member-modal-title" aria-labelledby="delete-member-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-member-modal-title" class="text-lg font-bold"> <h3 id="delete-member-modal-title" class="text-lg font-bold">
@ -434,6 +444,21 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
@impl true
def handle_event("tab_keydown", %{"key" => key}, socket)
when key in ["ArrowLeft", "ArrowRight"] do
new_tab =
case {key, socket.assigns.active_tab} do
{"ArrowRight", :contact} -> :membership_fees
{"ArrowLeft", :membership_fees} -> :contact
_ -> socket.assigns.active_tab
end
{:noreply, assign(socket, :active_tab, new_tab)}
end
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("open_delete_modal", _params, socket) do def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)} {:noreply, assign(socket, :show_delete_modal, true)}
@ -441,9 +466,26 @@ defmodule MvWeb.MemberLive.Show do
@impl true @impl true
def handle_event("cancel_delete_modal", _params, socket) do def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)} {:noreply, close_delete_modal_and_restore_focus(socket)}
end end
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
@ -493,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :vereinfacht_receipts, response)} {:noreply, assign(socket, :vereinfacht_receipts, response)}
end end
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-trigger"})
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true @impl true
def handle_info({:put_flash, type, message}, socket) do def handle_info({:put_flash, type, message}, socket) do

View file

@ -231,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="paid" phx-value-status="paid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :paid)} class={cycle_status_btn_class(cycle.status, :paid)}
aria-pressed={cycle.status == :paid} aria-pressed={if cycle.status == :paid, do: "true", else: "false"}
title={gettext("Mark as paid")} title={gettext("Mark as paid")}
> >
<.icon name="hero-check-circle" class="size-4" /> <.icon name="hero-check-circle" class="size-4" />
@ -244,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="suspended" phx-value-status="suspended"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :suspended)} class={cycle_status_btn_class(cycle.status, :suspended)}
aria-pressed={cycle.status == :suspended} aria-pressed={if cycle.status == :suspended, do: "true", else: "false"}
title={gettext("Mark as suspended")} title={gettext("Mark as suspended")}
> >
<.icon name="hero-pause-circle" class="size-4" /> <.icon name="hero-pause-circle" class="size-4" />
@ -257,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="unpaid" phx-value-status="unpaid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :unpaid)} class={cycle_status_btn_class(cycle.status, :unpaid)}
aria-pressed={cycle.status == :unpaid} aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"}
title={gettext("Mark as unpaid")} title={gettext("Mark as unpaid")}
> >
<.icon name="hero-x-circle" class="size-4" /> <.icon name="hero-x-circle" class="size-4" />
@ -301,6 +301,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="edit-cycle-amount-modal-title" aria-labelledby="edit-cycle-amount-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold"> <h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
@ -347,6 +348,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="delete-cycle-modal-title" aria-labelledby="delete-cycle-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3> <h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
@ -388,6 +390,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="delete-all-cycles-modal-title" aria-labelledby="delete-all-cycles-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error"> <h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
@ -450,6 +453,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="modal modal-open" class="modal modal-open"
role="dialog" role="dialog"
aria-labelledby="create-cycle-modal-title" aria-labelledby="create-cycle-modal-title"
phx-keydown="dialog_keydown"
> >
<div class="modal-box"> <div class="modal-box">
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3> <h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
@ -917,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, nil)} |> assign(:create_cycle_error, nil)}
end end
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
socket =
cond do
socket.assigns[:editing_cycle] ->
assign(socket, :editing_cycle, nil)
socket.assigns[:deleting_cycle] ->
assign(socket, :deleting_cycle, nil)
socket.assigns[:deleting_all_cycles] ->
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
socket.assigns[:creating_cycle] ->
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
true ->
socket
end
{:noreply, socket}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
date = date =
case Date.from_iso8601(date_str) do case Date.from_iso8601(date_str) do

View file

@ -101,9 +101,25 @@ defmodule MvWeb.RoleLive.Show do
@impl true @impl true
def handle_event("cancel_delete_modal", _params, socket) do def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)} {:noreply, close_delete_modal_and_restore_focus(socket)}
end end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp handle_delete_role(role, socket) do defp handle_delete_role(role, socket) do
if role.is_system_role do if role.is_system_role do
{:noreply, {:noreply,
@ -167,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor) recalculate_user_count(role, actor)
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-role-trigger"})
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -198,94 +220,102 @@ defmodule MvWeb.RoleLive.Show do
</:actions> </:actions>
</.header> </.header>
<.list> <div
<:item title={gettext("Name")}>{@role.name}</:item> id="role-show-focus-root"
<:item title={gettext("Description")}> phx-hook="FocusRestore"
<%= if @role.description do %> phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
{@role.description} >
<% else %> <.list>
<span class="text-base-content/70 italic">{gettext("No description")}</span> <:item title={gettext("Name")}>{@role.name}</:item>
<% end %> <:item title={gettext("Description")}>
</:item> <%= if @role.description do %>
<:item title={gettext("Permission Set")}> {@role.description}
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}> <% else %>
{@role.permission_set_name} <span class="text-base-content/70 italic">{gettext("No description")}</span>
</.badge> <% end %>
</:item> </:item>
<:item title={gettext("System Role")}> <:item title={gettext("Permission Set")}>
<.badge :if={@role.is_system_role} variant="warning"> <.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
{gettext("Yes")} {@role.permission_set_name}
</.badge> </.badge>
<.badge :if={!@role.is_system_role} variant="neutral"> </:item>
{gettext("No")} <:item title={gettext("System Role")}>
</.badge> <.badge :if={@role.is_system_role} variant="warning">
</:item> {gettext("Yes")}
</.list> </.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
</h2> </h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
{gettext( {gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first." "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)} )}
</p> </p>
<.button
variant="danger"
phx-click="open_delete_modal"
data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-role-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-role-modal-title"
>
<div class="modal-box">
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)}
</p>
<div class="modal-action">
<.button <.button
type="button" id="delete-role-trigger"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-role-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger" variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})} phx-click="open_delete_modal"
aria-label={gettext("Delete role")} data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
> >
{gettext("Delete")} <.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button> </.button>
</div> </div>
</div> </section>
</dialog> <% end %>
<% end %>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-role-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-role-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-role-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
aria-label={gettext("Delete role")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -65,315 +65,323 @@ defmodule MvWeb.UserLive.Form do
</:actions> </:actions>
</.header> </.header>
<div
id="user-form-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<%= if @user && @can_assign_role do %> <%= if @user && @can_assign_role do %>
<div class="mt-4"> <div class="mt-4">
<.input
field={@form[:role_id]}
type="select"
label={gettext("Role")}
options={Enum.map(@roles, &{&1.name, &1.id})}
prompt={gettext("Select role...")}
/>
</div>
<% end %>
<!-- Password Section -->
<div class="mt-6">
<label class="flex items-center space-x-2">
<input
type="checkbox"
name="set_password"
phx-click="toggle_password_section"
checked={@show_password_fields}
class="checkbox checkbox-sm"
/>
<span class="text-sm font-medium">
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
</span>
</label>
<%= if @show_password_fields do %>
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<p class="text-sm font-semibold text-red-800">
{gettext("SSO / OIDC user")}
</p>
<p class="mt-1 text-sm text-red-700">
{gettext(
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
)}
</p>
</div>
<% end %>
<.input <.input
field={@form[:password]} field={@form[:role_id]}
label={gettext("Password")} type="select"
type="password" label={gettext("Role")}
required options={Enum.map(@roles, &{&1.name, &1.id})}
autocomplete="new-password" prompt={gettext("Select role...")}
/> />
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input
field={@form[:password_confirmation]}
label={gettext("Confirm Password")}
type="password"
required
autocomplete="new-password"
/>
<% end %>
<div class="text-sm text-gray-600">
<p><strong>{gettext("Password requirements")}:</strong></p>
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<li>{gettext("At least 8 characters")}</li>
<li>{gettext("Include both letters and numbers")}</li>
<li>{gettext("Consider using special characters")}</li>
</ul>
</div>
<%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
)}
</p>
</div>
<% end %>
</div> </div>
<% else %>
<%= if @user do %>
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
)}
</p>
</div>
<% else %>
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
)}
</p>
</div>
<% end %>
<% end %> <% end %>
</div>
<!-- Password Section -->
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6"> <div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2> <label class="flex items-center space-x-2">
<input
type="checkbox"
name="set_password"
phx-click="toggle_password_section"
checked={@show_password_fields}
class="checkbox checkbox-sm"
/>
<span class="text-sm font-medium">
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
</span>
</label>
<%= if @user && @user.member && !@unlink_member do %> <%= if @show_password_fields do %>
<!-- Show linked member with unlink button --> <div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<div class="p-4 border border-green-200 rounded-lg bg-green-50"> <%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="flex items-center justify-between"> <div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<div> <p class="text-sm font-semibold text-red-800">
<p class="font-medium text-green-900"> {gettext("SSO / OIDC user")}
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p> </p>
<p class="text-sm text-green-700">{@user.member.email}</p> <p class="mt-1 text-sm text-red-700">
</div> {gettext(
<.button "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
type="button"
variant="danger"
size="sm"
phx-click="unlink_member"
>
{gettext("Unlink Member")}
</.button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
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"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
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"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
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, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
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 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">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)} )}
</p> </p>
</div> </div>
<% end %> <% end %>
<.input
field={@form[:password]}
label={gettext("Password")}
type="password"
required
autocomplete="new-password"
/>
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input
field={@form[:password_confirmation]}
label={gettext("Confirm Password")}
type="password"
required
autocomplete="new-password"
/>
<% end %>
<%= if @selected_member_id && @selected_member_name do %> <div class="text-sm text-gray-600">
<div <p><strong>{gettext("Password requirements")}:</strong></p>
id="member-selected" <ul class="mt-1 space-y-1 text-xs list-disc list-inside">
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50" <li>{gettext("At least 8 characters")}</li>
> <li>{gettext("Include both letters and numbers")}</li>
<p class="text-sm text-blue-800"> <li>{gettext("Consider using special characters")}</li>
<strong>{gettext("Selected")}:</strong> {@selected_member_name} </ul>
</p> </div>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")} <%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
)}
</p> </p>
</div> </div>
<% end %> <% end %>
</div> </div>
<% else %>
<%= if @user do %>
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
)}
</p>
</div>
<% else %>
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
)}
</p>
</div>
<% end %>
<% end %> <% end %>
</div> </div>
<% end %>
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%!-- Danger zone: canonical pattern (same as member form) --%> <%= if @user && @user.member && !@unlink_member do %>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> <!-- Show linked member with unlink button -->
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <div class="p-4 border border-green-200 rounded-lg bg-green-50">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> <div class="flex items-center justify-between">
{gettext("Danger zone")} <div>
</h2> <p class="font-medium text-green-900">
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
<p class="text-base-content/70 mb-4"> </p>
{gettext( <p class="text-sm text-green-700">{@user.member.email}</p>
"Deleting this user cannot be undone. The user account and any linked member association will be affected." </div>
)} <.button
</p> type="button"
<.button variant="danger"
type="button" size="sm"
variant="danger" phx-click="unlink_member"
phx-click="open_delete_modal" >
data-testid="user-delete" {gettext("Unlink Member")}
aria-label={gettext("Delete user %{email}", email: @user.email)} </.button>
> </div>
<.icon name="hero-trash" class="size-4" /> </div>
{gettext("Delete user")} <% else %>
</.button> <%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
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"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
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"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
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, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
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 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">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div> </div>
</section> <% end %>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> <%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && assigns[:show_delete_modal] do %> <%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
<dialog <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
id="delete-user-form-modal" <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
class="modal modal-open" {gettext("Danger zone")}
role="dialog" </h2>
aria-labelledby="delete-user-form-modal-title" <div class="border border-base-300 rounded-lg p-4 bg-base-100">
> <p class="text-base-content/70 mb-4">
<div class="modal-box"> {gettext(
<h3 id="delete-user-form-modal-title" class="text-lg font-bold"> "Deleting this user cannot be undone. The user account and any linked member association will be affected."
{gettext("Delete User")} )}
</h3> </p>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button <.button
id="delete-user-form-trigger"
type="button" type="button"
variant="danger" variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})} phx-click="open_delete_modal"
aria-label={gettext("Delete user")} data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
> >
{gettext("Delete")} <.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button> </.button>
</div> </div>
</div> </section>
</dialog> <% end %>
<% end %>
<div class="mt-4"> <%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<.button navigate={return_path(@return_to, @user)} variant="neutral"> <%= if @user && assigns[:show_delete_modal] do %>
{gettext("Cancel")} <dialog
</.button> id="delete-user-form-modal"
<.button phx-disable-with={gettext("Saving...")} variant="primary"> class="modal modal-open"
{gettext("Save User")} role="dialog"
</.button> aria-labelledby="delete-user-form-modal-title"
</div> phx-keydown="dialog_keydown"
</.form> >
<div class="modal-box">
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
{gettext("Delete User")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})}
aria-label={gettext("Delete user")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
<div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
</div>
</.form>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -496,9 +504,25 @@ defmodule MvWeb.UserLive.Form do
@impl true @impl true
def handle_event("cancel_delete_modal", _params, socket) do def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)} {:noreply, close_delete_modal_and_restore_focus(socket)}
end end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -660,6 +684,12 @@ defmodule MvWeb.UserLive.Form do
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
end
defp handle_member_linking(socket, user, actor) do defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor) result = perform_member_link_action(socket, user, actor)

View file

@ -58,98 +58,106 @@ defmodule MvWeb.UserLive.Show do
</:actions> </:actions>
</.header> </.header>
<.list> <div
<:item title={gettext("Email")}>{@user.email}</:item> id="user-show-focus-root"
<:item title={gettext("Role")}>{@user.role.name}</:item> phx-hook="FocusRestore"
<:item title={gettext("Password Authentication")}> phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
{if MvWeb.Helpers.UserHelpers.has_password?(@user), >
do: gettext("Enabled"), <.list>
else: gettext("Not enabled")} <:item title={gettext("Email")}>{@user.email}</:item>
</:item> <:item title={gettext("Role")}>{@user.role.name}</:item>
<:item title={gettext("OIDC")}> <:item title={gettext("Password Authentication")}>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user), {if MvWeb.Helpers.UserHelpers.has_password?(@user),
do: gettext("Linked"), do: gettext("Enabled"),
else: gettext("Not linked")} else: gettext("Not enabled")}
</:item> </:item>
<:item title={gettext("Linked Member")}> <:item title={gettext("OIDC")}>
<%= if @user.member do %> {if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
<.link do: gettext("Linked"),
navigate={~p"/members/#{@user.member}"} else: gettext("Not linked")}
class="text-blue-600 underline hover:text-blue-800" </:item>
> <:item title={gettext("Linked Member")}>
<.icon name="hero-users" class="inline w-4 h-4 mr-1" /> <%= if @user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} <.link
</.link> navigate={~p"/members/#{@user.member}"}
<% else %> class="text-blue-600 underline hover:text-blue-800"
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
variant="danger"
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-user-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-modal-title"
>
<div class="modal-box">
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-modal-cancel"
aria-label={gettext("Cancel")}
> >
{gettext("Cancel")} <.icon name="hero-users" class="inline w-4 h-4 mr-1" />
</.button> {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</.link>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button <.button
type="button" id="delete-user-trigger"
variant="danger" variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})} phx-click="open_delete_modal"
aria-label={gettext("Delete user")} data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
> >
{gettext("Delete")} <.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button> </.button>
</div> </div>
</div> </section>
</dialog> <% end %>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-user-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})}
aria-label={gettext("Delete user")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -182,9 +190,25 @@ defmodule MvWeb.UserLive.Show do
@impl true @impl true
def handle_event("cancel_delete_modal", _params, socket) do def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)} {:noreply, close_delete_modal_and_restore_focus(socket)}
end end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -229,4 +253,10 @@ defmodule MvWeb.UserLive.Show do
|> assign(:show_delete_modal, false)} |> assign(:show_delete_modal, false)}
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-trigger"})
end
end end