fix: color contrast dark mode and keyboard moadals
This commit is contained in:
parent
5516c7fe62
commit
c71c7d6ed6
14 changed files with 1067 additions and 740 deletions
|
|
@ -38,300 +38,311 @@ 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">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
<%= if @member do %>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
<%= if @member do %>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||
<div
|
||||
role="tablist"
|
||||
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"
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||
<div
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
</div>
|
||||
<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" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||
<div
|
||||
id="member-tabpanel-contact"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-contact"
|
||||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||
<div
|
||||
id="member-tabpanel-contact"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-contact"
|
||||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("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 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 --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.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 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 --%>
|
||||
<div class="flex gap-4">
|
||||
<%!-- Street and Nr. below --%>
|
||||
<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">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
</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>
|
||||
<.input
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<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(
|
||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- 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>
|
||||
<%!-- 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>
|
||||
|
||||
<%!-- Membership Fee Section --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Membership Fee")}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<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">
|
||||
<%!-- 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(
|
||||
"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>
|
||||
</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
|
||||
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"
|
||||
id="delete-member-form-trigger"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||
aria-label={gettext("Delete member")}
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</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"
|
||||
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>
|
||||
</.form>
|
||||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue