feat: add accessible drag&drop table component
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-10 15:40:28 +01:00
parent fa738aae88
commit 05e2a298fe
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
9 changed files with 386 additions and 15 deletions

View file

@ -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.