Enhances accessibiity closes #421 #450
14 changed files with 1067 additions and 740 deletions
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
{: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
|
socket
|
||||||
|> assign(:show_delete_modal, false)
|
|> assign(:show_delete_modal, false)
|
||||||
|> assign(:custom_field_to_delete, nil)
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|> assign(:slug_confirmation, "")}
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ 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">
|
||||||
|
|
@ -181,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
<%= for cf <- @sorted_custom_fields do %>
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
<%= 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]}>
|
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||||
<.input
|
<.input
|
||||||
field={value_form[:value]}
|
field={value_form[:value]}
|
||||||
|
|
@ -275,6 +282,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-member-form-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
|
|
@ -299,13 +307,15 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-member-form-modal-title"
|
aria-labelledby="delete-member-form-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||||
{gettext("Delete Member")}
|
{gettext("Delete Member")}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="py-4">
|
<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)
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -334,6 +344,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
|
||||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
|
||||||
<div
|
<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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="role-show-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||||
<:item title={gettext("Description")}>
|
<:item title={gettext("Description")}>
|
||||||
|
|
@ -235,6 +262,7 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-role-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
data-testid="role-delete"
|
data-testid="role-delete"
|
||||||
|
|
@ -254,6 +282,7 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-role-modal-title"
|
aria-labelledby="delete-role-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ 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" />
|
||||||
|
|
||||||
|
|
@ -310,6 +315,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-user-form-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
|
|
@ -330,6 +336,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-user-form-modal-title"
|
aria-labelledby="delete-user-form-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||||
|
|
@ -374,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="user-show-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||||
|
|
@ -99,6 +104,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-user-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
data-testid="user-delete"
|
data-testid="user-delete"
|
||||||
|
|
@ -118,6 +124,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-user-modal-title"
|
aria-labelledby="delete-user-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<% end %>
|
<% 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue