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

@ -656,3 +656,34 @@
}
/* This file is for your main application CSS */
/* ============================================
SortableList: drag-and-drop table rows
============================================ */
/* Ghost row: placeholder showing where the dragged item will be dropped.
Background fills the gap; text invisible so layout matches original row. */
.sortable-ghost {
background-color: var(--color-base-300) !important;
opacity: 0.5;
}
.sortable-ghost td {
border-color: transparent !important;
}
/* Chosen row: the row being actively dragged (follows the cursor). */
.sortable-chosen {
background-color: var(--color-base-200);
box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18);
cursor: grabbing !important;
}
/* Drag handle button: only grab cursor, no hover effect for mouse users.
Keyboard outline is handled via JS outline style. */
[data-sortable-handle] button {
cursor: grab;
}
[data-sortable-handle] button:hover {
background-color: transparent !important;
color: inherit;
}

View file

@ -21,6 +21,7 @@ import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Sortable from "../vendor/sortable"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
@ -120,6 +121,141 @@ Hooks.TabListKeydown = {
}
}
// SortableList hook: Accessible reorderable table/list.
// Mouse drag: SortableJS (smooth animation, ghost row, items push apart).
// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern.
// Container must have data-reorder-event and data-list-id.
// Each row (tr) must have data-row-index; locked rows have data-locked="true".
// Pushes event with { from_index, to_index } (both integers) on reorder.
Hooks.SortableList = {
mounted() {
this.reorderEvent = this.el.dataset.reorderEvent
this.listId = this.el.dataset.listId
// Keyboard state: store grabbed row id so it survives LiveView re-renders
this.grabbedRowId = null
this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null
const announce = (msg) => {
if (!this.announcementEl) return
// Clear then re-set to force screen reader re-read
this.announcementEl.textContent = ""
setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50)
}
const tbody = this.el.querySelector("tbody")
if (!tbody) return
this.getRows = () => Array.from(tbody.querySelectorAll("tr"))
this.getRowIndex = (tr) => {
const idx = tr.getAttribute("data-row-index")
return idx != null ? parseInt(idx, 10) : -1
}
this.isLocked = (tr) => tr.getAttribute("data-locked") === "true"
// SortableJS for mouse drag-and-drop with animation
this.sortable = new Sortable(tbody, {
animation: 150,
handle: "[data-sortable-handle]",
// Disable sorting for locked rows (first row = email)
filter: "[data-locked='true']",
preventOnFilter: true,
// Ghost (placeholder showing where the item will land)
ghostClass: "sortable-ghost",
// The item being dragged
chosenClass: "sortable-chosen",
// Cursor while dragging
dragClass: "sortable-drag",
// Don't trigger on handle area clicks (only actual drag)
delay: 0,
onEnd: (e) => {
if (e.oldIndex === e.newIndex) return
this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex })
announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`)
// LiveView will reconcile the DOM order after re-render
}
})
// Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel)
this.handleKeyDown = (e) => {
// Don't intercept Space on interactive elements (checkboxes, buttons, inputs)
const tag = e.target.tagName
if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return
const tr = e.target.closest("tr")
if (!tr || this.isLocked(tr)) return
const rows = this.getRows()
const idx = this.getRowIndex(tr)
if (idx < 0) return
const total = rows.length
if (e.key === " ") {
e.preventDefault()
const rowId = tr.id
if (this.grabbedRowId === rowId) {
// Drop
this.grabbedRowId = null
tr.style.outline = ""
announce(`Dropped. Position ${idx + 1} of ${total}.`)
} else {
// Grab
this.grabbedRowId = rowId
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`)
}
return
}
if (e.key === "Escape") {
if (this.grabbedRowId != null) {
e.preventDefault()
const grabbedTr = document.getElementById(this.grabbedRowId)
if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" }
this.grabbedRowId = null
announce("Reorder cancelled.")
}
return
}
if (this.grabbedRowId == null) return
if (e.key === "ArrowUp" && idx > 0) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 })
announce(`Position ${idx} of ${total}.`)
} else if (e.key === "ArrowDown" && idx < total - 1) {
e.preventDefault()
this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 })
announce(`Position ${idx + 2} of ${total}.`)
}
}
this.el.addEventListener("keydown", this.handleKeyDown, true)
},
updated() {
// Re-apply keyboard outline and restore focus after LiveView re-render.
// LiveView DOM patching loses focus; without explicit re-focus the next keypress
// goes to document.body (Space scrolls the page instead of triggering our handler).
if (this.grabbedRowId) {
const tr = document.getElementById(this.grabbedRowId)
if (tr) {
tr.style.outline = "2px solid var(--color-primary)"
tr.style.outlineOffset = "-2px"
tr.focus()
} else {
// Row no longer exists (removed while grabbed), clear state
this.grabbedRowId = null
}
}
},
destroyed() {
if (this.sortable) this.sortable.destroy()
this.el.removeEventListener("keydown", this.handleKeyDown, true)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {

2
assets/vendor/sortable.js vendored Normal file

File diff suppressed because one or more lines are too long

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.

View file

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

View file

@ -3384,10 +3384,20 @@ msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr "Persönliche Daten"
msgid "Individual fields"
msgstr "Individuelle Felder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr "Individuelle Felder"
msgid "Personal data"
msgstr "Persönliche Daten"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr "Umordnen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular."

View file

@ -3381,3 +3381,23 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Board approval required (in development)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr ""
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr ""

View file

@ -3384,10 +3384,20 @@ msgstr "Board approval required (in development)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Personal data"
msgstr "Personal data"
msgid "Individual fields"
msgstr "Individual fields"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Individual fields"
msgstr "Individual fields"
msgid "Personal data"
msgstr "Personal data"
#: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format
msgid "Reorder"
msgstr "Reorder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "The order of rows determines the field order in the join form."

View file

@ -13,9 +13,9 @@ defmodule Mv.Membership.SettingJoinFormTest do
"""
use Mv.DataCase, async: false
alias Mv.Membership
alias Mv.Helpers.SystemActor
alias Mv.Constants
alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
{:ok, settings} = Membership.get_settings()