feat: adds keyboard accessibility to tabs
This commit is contained in:
parent
615b4b866b
commit
2922a4d1ee
11 changed files with 680 additions and 613 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue