feat: adds keyboard accessibility to tabs

This commit is contained in:
carla 2026-02-26 10:37:57 +01:00
parent 615b4b866b
commit 2922a4d1ee
11 changed files with 680 additions and 613 deletions

View file

@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do
>
<%!-- 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]}
/>
<%!-- 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>
<.button
variant="danger"
type="button"
phx-click="delete"
phx-value-id={@member.id}
data-confirm={
gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
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>
</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="delete"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
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 %>
</section>
<% end %>
</div>
</div>
</.form>