feat: add accessible drag&drop table component
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
fa738aae88
commit
05e2a298fe
9 changed files with 386 additions and 15 deletions
|
|
@ -1150,6 +1150,124 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
defp table_th_sticky_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||
|
||||
Uses the SortableList hook for accessible drag-and-drop and keyboard reorder
|
||||
(Space to grab/drop, Arrow up/down to move, Escape to cancel). Pushes the
|
||||
given reorder event with `from_index` and `to_index`; the parent LiveView
|
||||
should reorder the list and re-render.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:id` – Required. Unique id for the list (used for hook and live region).
|
||||
* `:rows` – Required. List of row data.
|
||||
* `:row_id` – Required. Function to get a unique id for each row (e.g. for locked_ids).
|
||||
* `:locked_ids` – List of row ids that cannot be reordered (e.g. `["join-field-email"]`). Default `[]`.
|
||||
* `:reorder_event` – Required. LiveView event name to push on reorder (payload: `%{from_index: i, to_index: j}`).
|
||||
* `:row_item` – Function to map row before passing to slots. Default `&Function.identity/1`.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:col` – Data columns (attr `:label`, `:class`). Content is rendered with the row item.
|
||||
* `:action` – Optional. Last column (e.g. delete button) with the row item.
|
||||
|
||||
## Examples
|
||||
|
||||
<.sortable_table
|
||||
id="join-form-fields"
|
||||
rows={@join_form_fields}
|
||||
row_id={fn f -> "join-field-\#{f.id}" end}
|
||||
locked_ids={["join-field-email"]}
|
||||
reorder_event="reorder_join_form_field"
|
||||
>
|
||||
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">{field.label}</:col>
|
||||
<:col :let={field} label={gettext("Required")}>...</:col>
|
||||
<:action :let={field}><.button>Remove</.button></:action>
|
||||
</.sortable_table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, required: true
|
||||
attr :locked_ids, :list, default: []
|
||||
attr :reorder_event, :string, required: true
|
||||
attr :row_item, :any, default: &Function.identity/1
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string, required: true
|
||||
attr :class, :string
|
||||
end
|
||||
|
||||
slot :action
|
||||
|
||||
def sortable_table(assigns) do
|
||||
assigns = assign(assigns, :locked_set, MapSet.new(assigns.locked_ids))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-hook="SortableList"
|
||||
data-reorder-event={@reorder_event}
|
||||
data-locked-ids={Jason.encode!(@locked_ids)}
|
||||
data-list-id={@id}
|
||||
class="overflow-auto"
|
||||
>
|
||||
<span
|
||||
id={"#{@id}-announcement"}
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
class="sr-only"
|
||||
/>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10" aria-label={gettext("Reorder")}></th>
|
||||
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
||||
<th :if={@action != []} class="w-0 font-semibold">{gettext("Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
:for={{row, idx} <- Enum.with_index(@rows)}
|
||||
id={@row_id.(row)}
|
||||
data-row-index={idx}
|
||||
data-locked={if(MapSet.member?(@locked_set, @row_id.(row)), do: "true", else: nil)}
|
||||
tabindex={if(not MapSet.member?(@locked_set, @row_id.(row)), do: "0", else: nil)}
|
||||
>
|
||||
<td class="w-10 align-middle" data-sortable-handle>
|
||||
<span
|
||||
:if={MapSet.member?(@locked_set, @row_id.(row))}
|
||||
class="inline-block w-6 text-base-content/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="size-4" />
|
||||
</span>
|
||||
<span
|
||||
:if={not MapSet.member?(@locked_set, @row_id.(row))}
|
||||
class="inline-flex items-center justify-center w-6 h-6 cursor-grab text-base-content/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="size-4" />
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
class={["max-w-xs", Map.get(col, :class) || ""] |> Enum.join(" ")}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,10 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
type="button"
|
||||
variant="primary"
|
||||
phx-click="toggle_add_field_dropdown"
|
||||
disabled={Enum.empty?(@available_join_form_member_fields) and Enum.empty?(@available_join_form_custom_fields)}
|
||||
disabled={
|
||||
Enum.empty?(@available_join_form_member_fields) and
|
||||
Enum.empty?(@available_join_form_custom_fields)
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={to_string(@show_add_field_dropdown)}
|
||||
>
|
||||
|
|
@ -190,7 +193,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{field.label}
|
||||
</div>
|
||||
</div>
|
||||
<div :if={not Enum.empty?(@available_join_form_custom_fields)} class={if(Enum.empty?(@available_join_form_member_fields), do: "pt-2", else: "border-t border-base-300")}>
|
||||
<div
|
||||
:if={not Enum.empty?(@available_join_form_custom_fields)}
|
||||
class={
|
||||
if(Enum.empty?(@available_join_form_member_fields),
|
||||
do: "pt-2",
|
||||
else: "border-t border-base-300"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div class="px-4 py-1 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
|
||||
{gettext("Individual fields")}
|
||||
</div>
|
||||
|
|
@ -213,12 +224,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{gettext("No fields selected. Add at least the email field.")}
|
||||
</p>
|
||||
|
||||
<%!-- Fields table (compact width) --%>
|
||||
<%!-- Fields table (compact width, reorderable) --%>
|
||||
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl">
|
||||
<.table
|
||||
<.sortable_table
|
||||
id="join-form-fields-table"
|
||||
rows={@join_form_fields}
|
||||
row_id={fn field -> "join-field-#{field.id}" end}
|
||||
reorder_event="reorder_join_form_field"
|
||||
>
|
||||
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
|
||||
{field.label}
|
||||
|
|
@ -250,7 +262,10 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.button>
|
||||
</.tooltip>
|
||||
</:action>
|
||||
</.table>
|
||||
</.sortable_table>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
{gettext("The order of rows determines the field order in the join form.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
|
@ -642,6 +657,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
custom_fields = socket.assigns.join_form_custom_fields
|
||||
new_fields = Enum.reject(current, &(&1.id == field_id))
|
||||
new_ids = Enum.map(new_fields, & &1.id)
|
||||
|
||||
%{member_fields: new_member, custom_fields: new_custom} =
|
||||
build_available_join_form_fields(new_ids, custom_fields)
|
||||
|
||||
|
|
@ -670,6 +686,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"reorder_join_form_field",
|
||||
%{"from_index" => from_idx, "to_index" => to_idx},
|
||||
socket
|
||||
)
|
||||
when is_integer(from_idx) and is_integer(to_idx) do
|
||||
fields = socket.assigns.join_form_fields
|
||||
new_fields = reorder_list(fields, from_idx, to_idx)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:join_form_fields, new_fields)
|
||||
|> persist_join_form_settings()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Ignore malformed reorder events (e.g. nil indices from aborted drags)
|
||||
def handle_event("reorder_join_form_field", _params, socket), do: {:noreply, socket}
|
||||
|
||||
defp persist_join_form_settings(socket) do
|
||||
settings = socket.assigns.settings
|
||||
field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id)
|
||||
|
|
@ -990,6 +1027,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
required_config = settings.join_form_field_required || %{}
|
||||
|
||||
join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields)
|
||||
|
||||
%{member_fields: member_avail, custom_fields: custom_avail} =
|
||||
build_available_join_form_fields(field_ids, custom_fields)
|
||||
|
||||
|
|
@ -1054,6 +1092,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
defp toggle_required_if_matches(field, _field_id), do: field
|
||||
|
||||
defp reorder_list(list, from_index, to_index) do
|
||||
item = Enum.at(list, from_index)
|
||||
rest = List.delete_at(list, from_index)
|
||||
List.insert_at(rest, to_index, item)
|
||||
end
|
||||
|
||||
defp member_field_id_strings do
|
||||
Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue