sortable_list/1 is a list that people reorder: tasks, a playlist, the
links of a menu.
<.sortable_list id="tasks" items={@streams.tasks} on_move="move" multiple label="Tasks">
<:item :let={task}>{task.title}</:item>
</.sortable_list>The items
items is a list or a stream. Each item is a struct or a map. The list
reads its id from :id, and its label from :name, :title or :label;
item_id and item_label take another key or a function:
<.sortable_list id="songs" items={@songs} on_move="move" item_id={& &1.uuid} item_label={:song_name} />The label is what a screen reader hears as the item moves, and what
shows when there is no item slot. Give the list a label too: it names
the whole list.
The move
A drop sends on_move to the LiveView, or to the component of the
list's phx-target:
%{"v" => 1, "ids" => ["7"], "parent" => nil, "after" => "3", "before" => "9", "index" => 2}The items moved (several when selected), and where: right after one
item, right before another, at an index among the new neighbours. Ids are
text. Narabe.Move.parse/2 reads the params into a Narabe.Move with
the ids cast to the type of a schema's primary key (or to an Ecto type
such as :id, :binary_id or :string). Narabe.Move.parse!/2 raises
for params that are no move.
The place is said by ids, not only by an index, so that a screen a moment
out of date still means what the person saw. after wins over before;
index is for a neighbour that is gone.
def handle_event("move", params, socket) do
move = Narabe.Move.parse!(params, Task)
result = Narabe.Ecto.move(Repo, Task, move)
{:reply, Narabe.Live.reply(result), socket}
endThe browser has moved the items already. Reply %{ok: true} to keep
them there, or %{error: reason} to put them back. Narabe.Live.reply/1
builds the reply from {:ok, _}, {:error, atom}, a failed
Ecto.Multi, or {:error, "a message"}, which the person hears. A drop
where the items already were sends nothing.
Keeping the order yourself
Narabe.Move.apply/3 applies a move to a list in memory, for an app
that keeps the order without Narabe.Ecto:
def handle_event("move", params, socket) do
move = Narabe.Move.parse!(params, :id)
case Narabe.Move.apply(socket.assigns.tasks, move) do
{:ok, tasks} -> {:reply, %{ok: true}, assign(socket, tasks: tasks)}
error -> {:reply, Narabe.Live.reply(error), socket}
end
endStreams and other viewers
With a stream, the person who moved an item sees it in its place already.
Tell the other viewers, and put it in its place in their stream with
Narabe.Live.stream_move/4:
def handle_event("move", params, socket) do
move = Narabe.Move.parse!(params, Task)
result = Narabe.Ecto.move(Repo, Task, move)
with {:ok, tasks} <- result do
Phoenix.PubSub.broadcast_from(MyApp.PubSub, self(), "tasks", {:moved, tasks, move})
end
{:reply, Narabe.Live.reply(result), socket}
end
def handle_info({:moved, tasks, move}, socket) do
{:noreply, Narabe.Live.stream_move(socket, :tasks, tasks, move)}
endSubscribe to the topic in mount/3 when connected. Insert and delete in
the stream as usual.
Selecting several
With multiple, a click selects an item, Shift+click a range and
Ctrl+click (Cmd+click) one more; Shift+arrows extend the selection,
Ctrl+A (Cmd+A) selects all, Escape clears it. Dragging or lifting a
selected item moves every selected item, in their order.
on_select sends %{"v" => 1, "ids" => [...]} when the selection
changes. Narabe.Live.select/3 selects items from the server, with no
event back.
Handles
By default an item drags from anywhere on it. With handle, each item
shows a handle, and a mouse starts a drag only there; a finger still
holds the item anywhere, since a handle is small under a finger.
A drag never starts on a link, a button, a form field, a label, editable
text, or an element with data-narabe-nodrag, so they keep their clicks.
Locked items
item_locked gives a text for an item that may not move, or nil. The
item shows a lock and cannot be lifted; the person hears the "locked"
message ("{name} is moving this") with the text as {name}. Change that
message with messages if your text is not a name.
Swipe actions
The actions slot holds buttons that a finger swipes into view on a
phone: a swipe left shows them, past half their width they stay open. On
a mouse they are hidden.
<:actions :let={task}>
<button type="button" phx-click="done" phx-value-id={task.id}>Done</button>
</:actions>Read-only
readonly, or no on_move, keeps focus and selection and moves
nothing.
Events in the page
The list dispatches DOM events that bubble: narabe:move (its detail
is the move sent) and narabe:refused (the server's error reply).
Without LiveView
On a page without a LiveSocket, mountNarabe() mounts the lists: they
focus, select, filter and expand, but a move has no server to go to.
import { mountNarabe } from "narabe"
window.addEventListener("DOMContentLoaded", () => mountNarabe())