Enhances accessibiity closes #421 #450

Merged
carla merged 15 commits from feat/421_accessibility into main 2026-02-26 21:03:02 +01:00
14 changed files with 1067 additions and 740 deletions
Showing only changes of commit c71c7d6ed6 - Show all commits

View file

@ -3016,6 +3016,8 @@ end
- [ ] 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)
- [ ] 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
@ -3043,7 +3045,8 @@ Use a consistent, keyboard-accessible pattern for all confirmation and form moda
**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.
- **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).

View file

@ -538,9 +538,14 @@
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
[data-theme="dark"] {
--color-primary-content: oklch(0.97 0.02 277);
--color-error: oklch(55% 0.253 17.585);
--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 */

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
Hooks.SidebarState = {
mounted() {

View file

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

View file

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

View file

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

View file

@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
socket
|> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)}
|> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
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
def render(assigns) do
~H"""
@ -35,8 +58,14 @@ defmodule MvWeb.DatafieldsLive do
</:subtitle>
</.header>
<%!-- Overview: both sections with form_section wrappers --%>
<div :if={@active_editing_section == nil} class="mt-6 space-y-6">
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
<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")}>
<.live_component
module={MvWeb.MemberFieldLive.IndexComponent}
@ -63,7 +92,13 @@ defmodule MvWeb.DatafieldsLive do
/>
</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
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
@ -74,6 +109,11 @@ defmodule MvWeb.DatafieldsLive do
"""
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
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
</:actions>
</.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 --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
@ -353,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
)}
</p>
<.button
id="delete-group-trigger"
variant="danger"
type="button"
phx-click="open_delete_modal"
@ -373,6 +379,7 @@ defmodule MvWeb.GroupLive.Show do
class="modal modal-open"
role="dialog"
aria-labelledby="delete-group-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
@ -453,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")}
{:noreply, close_delete_modal_and_restore_focus(socket)}
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
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)}
@ -939,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
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
case Membership.destroy_group(group, actor: actor) do
:ok ->

View file

@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.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>
<:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral">
@ -181,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
<%= 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: ""}>
<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]}
@ -275,6 +282,7 @@ defmodule MvWeb.MemberLive.Form do
)}
</p>
<.button
id="delete-member-form-trigger"
variant="danger"
type="button"
phx-click="open_delete_modal"
@ -299,13 +307,15 @@ defmodule MvWeb.MemberLive.Form do
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.",
{gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
@ -334,6 +344,7 @@ defmodule MvWeb.MemberLive.Form do
<% end %>
</div>
</div>
</div>
</.form>
</Layouts.app>
"""
@ -461,9 +472,25 @@ defmodule MvWeb.MemberLive.Form do
@impl true
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
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
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
@ -511,6 +538,12 @@ defmodule MvWeb.MemberLive.Form do
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
notify_parent({:saved, member})

View file

@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<div
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
id="member-tablist"
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
phx-hook="TabListKeydown"
phx-keydown="tab_keydown"
>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :contact}
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
aria-controls="member-tabpanel-contact"
class={[
"tab flex items-center gap-2",
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
id="member-tab-membership_fees"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :membership_fees}
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
aria-controls="member-tabpanel-membership_fees"
class={[
"tab flex items-center gap-2",
@ -315,6 +323,7 @@ defmodule MvWeb.MemberLive.Show do
)}
</p>
<.button
id="delete-member-trigger"
variant="danger"
phx-click="open_delete_modal"
data-testid="member-delete"
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Show do
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<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)}
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
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
@ -441,9 +466,26 @@ defmodule MvWeb.MemberLive.Show do
@impl true
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
# 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
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
@ -493,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :vereinfacht_receipts, response)}
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
@impl true
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-target={@myself}
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")}
>
<.icon name="hero-check-circle" class="size-4" />
@ -244,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="suspended"
phx-target={@myself}
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")}
>
<.icon name="hero-pause-circle" class="size-4" />
@ -257,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="unpaid"
phx-target={@myself}
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")}
>
<.icon name="hero-x-circle" class="size-4" />
@ -301,6 +301,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="modal modal-open"
role="dialog"
aria-labelledby="edit-cycle-amount-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<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"
role="dialog"
aria-labelledby="delete-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<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"
role="dialog"
aria-labelledby="delete-all-cycles-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<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"
role="dialog"
aria-labelledby="create-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<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)}
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
date =
case Date.from_iso8601(date_str) do

View file

@ -101,9 +101,25 @@ defmodule MvWeb.RoleLive.Show do
@impl true
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
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
if role.is_system_role do
{:noreply,
@ -167,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor)
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
def render(assigns) do
~H"""
@ -198,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
</:actions>
</.header>
<div
id="role-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
@ -235,6 +262,7 @@ defmodule MvWeb.RoleLive.Show do
)}
</p>
<.button
id="delete-role-trigger"
variant="danger"
phx-click="open_delete_modal"
data-testid="role-delete"
@ -254,6 +282,7 @@ defmodule MvWeb.RoleLive.Show do
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>
@ -286,6 +315,7 @@ defmodule MvWeb.RoleLive.Show do
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end

View file

@ -65,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
</:actions>
</.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">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
@ -310,6 +315,7 @@ defmodule MvWeb.UserLive.Form do
)}
</p>
<.button
id="delete-user-form-trigger"
type="button"
variant="danger"
phx-click="open_delete_modal"
@ -330,6 +336,7 @@ defmodule MvWeb.UserLive.Form do
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
@ -374,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
</.button>
</div>
</.form>
</div>
</Layouts.app>
"""
end
@ -496,9 +504,25 @@ defmodule MvWeb.UserLive.Form do
@impl true
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
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
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
@ -660,6 +684,12 @@ defmodule MvWeb.UserLive.Form do
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
result = perform_member_link_action(socket, user, actor)

View file

@ -58,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
</:actions>
</.header>
<div
id="user-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
@ -99,6 +104,7 @@ defmodule MvWeb.UserLive.Show do
)}
</p>
<.button
id="delete-user-trigger"
variant="danger"
phx-click="open_delete_modal"
data-testid="user-delete"
@ -118,6 +124,7 @@ defmodule MvWeb.UserLive.Show do
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>
@ -150,6 +157,7 @@ defmodule MvWeb.UserLive.Show do
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end
@ -182,9 +190,25 @@ defmodule MvWeb.UserLive.Show do
@impl true
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
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
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
@ -229,4 +253,10 @@ defmodule MvWeb.UserLive.Show do
|> assign(:show_delete_modal, false)}
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