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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
136
assets/js/app.js
136
assets/js/app.js
|
|
@ -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
2
assets/vendor/sortable.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue