added drag and drop example

This commit is contained in:
Denis Donici 2026-03-06 10:43:07 +02:00 committed by Wout De Puysseleir
parent c8cc0b91ec
commit 7a5baead81
11 changed files with 365 additions and 1826 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
<script lang="ts">
import {dndzone} from "svelte-dnd-action"
let {items, live} = $props()
let localItems = $derived(items)
function handleConsider(e) {
localItems = e.detail.items
}
function handleFinalize(e) {
localItems = e.detail.items
live.pushEvent("reorder", {ids: localItems.map(i => i.id)})
}
$inspect(items)
</script>
<div use:dndzone={{items: localItems}} onconsider={handleConsider} onfinalize={handleFinalize} class="flex flex-col gap-2">
{#each localItems as item (item.id)}
<div data-testid="drag-item" class="card bg-base-200 border border-base-300 cursor-grab active:cursor-grabbing select-none">
<div class="card-body py-3 px-4 flex-row items-center gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-base-content/30 shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm8-12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0z"
/>
</svg>
<span data-testid="drag-item-name" class="text-sm font-medium">{item.name}</span>
</div>
</div>
{/each}
</div>

View file

@ -23,6 +23,7 @@ defmodule ExampleWeb.Layouts do
%{label: "Plus/Minus (Static)", to: ~p"/plus-minus-svelte"},
%{label: "Plus/Minus (Live)", to: ~p"/live-plus-minus"},
%{label: "Hybrid Counter", to: ~p"/live-plus-minus-hybrid"},
%{label: "Drag & Drop", to: ~p"/live-drag-drop"},
%{label: "Static + List", to: ~p"/live-static-color"}
]
},

View file

@ -85,6 +85,12 @@
</a>
<span class="text-base-content/50 text-sm">- Svelte component with dynamic list</span>
</li>
<li>
<a href={~p"/live-drag-drop"} class="link link-primary">
Drag & Drop
</a>
<span class="text-base-content/50 text-sm">- Svelte component with drag & drop</span>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,60 @@
defmodule ExampleWeb.LiveDragDrop do
use ExampleWeb, :live_view
@initial_items [
%{id: 1, name: "Design mockups"},
%{id: 2, name: "Set up database"},
%{id: 3, name: "Write API endpoints"},
%{id: 4, name: "Build frontend"},
%{id: 5, name: "Write tests"},
%{id: 6, name: "Deploy to production"}
]
def mount(_params, _session, socket) do
{:ok, assign(socket, items: @initial_items)}
end
def handle_event("reorder", %{"ids" => ids}, socket) do
ordered =
Enum.map(ids, fn id ->
Enum.find(socket.assigns.items, &(&1.id == id))
end)
{:noreply, assign(socket, items: ordered)}
end
def render(assigns) do
~H"""
<div class="min-h-screen bg-base-200/40 py-8 px-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-center text-2xl font-light my-4">Drag & Drop Demo</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Reorder tasks with drag and drop. The new order is synced to the server via pushEvent.
</p>
<div class="flex flex-col gap-8">
<section class="card bg-base-100 shadow-lg border border-base-300/50">
<div class="card-body gap-4">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
LiveSvelte
</span>
<.svelte name="DragDrop" props={%{items: @items}} socket={@socket} />
</div>
</section>
<section class="card bg-base-100 shadow-lg border border-base-300/50">
<div class="card-body gap-4">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
Server order
</span>
<ol data-testid="server-order-list" class="list-decimal list-inside space-y-1 text-sm">
<li :for={item <- @items} data-testid="server-order-item">{item.name}</li>
</ol>
</div>
</section>
</div>
</div>
</div>
"""
end
end

View file

@ -28,6 +28,7 @@ defmodule ExampleWeb.Router do
get("/plus-minus-svelte", PageController, :plus_minus_svelte)
live("/live-plus-minus", LivePlusMinus)
live("/live-plus-minus-hybrid", LivePlusMinusHybrid)
live("/live-drag-drop", LiveDragDrop)
live("/live-static-color", LiveStaticColor)
live("/live-log-list", LiveLogList)
live("/live-breaking-news", LiveBreakingNews)

View file

@ -5,10 +5,12 @@
"packages": {
"": {
"dependencies": {
"dynamic-marquee": "^2.6.5",
"live_svelte": "file:../",
"phoenix": "file:./deps/phoenix",
"phoenix_html": "file:./deps/phoenix_html",
"phoenix_live_view": "file:./deps/phoenix_live_view",
"svelte-dnd-action": "^0.9.56",
"topbar": "^3.0.0"
},
"devDependencies": {
@ -5004,7 +5006,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -5015,7 +5016,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@ -5026,7 +5026,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -5036,14 +5035,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -5530,7 +5527,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@ -5935,7 +5931,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/extend": {
@ -6077,7 +6072,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@ -6678,7 +6672,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
@ -6845,7 +6838,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -6862,7 +6854,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -7394,7 +7385,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -7813,7 +7803,6 @@
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
@ -8004,6 +7993,12 @@
"node": ">= 0.4"
}
},
"node_modules/dynamic-marquee": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/dynamic-marquee/-/dynamic-marquee-2.6.5.tgz",
"integrity": "sha512-IfnOHhheSXGjTkLrrfv01x9Ws4O4mLIdzJxP8KTtINeO8hbK0OUV0iDv4qT1QPJRD+JawvrZvjvO7DuNZcO/dQ==",
"license": "Apache-2.0"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -8547,7 +8542,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/espree": {
@ -8599,7 +8593,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@ -9766,7 +9759,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@ -13133,7 +13125,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -13205,7 +13196,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@ -16051,7 +16041,6 @@
"version": "5.53.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.7.tgz",
"integrity": "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@ -16076,6 +16065,15 @@
"node": ">=18"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.69",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz",
"integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==",
"license": "MIT",
"peerDependencies": {
"svelte": ">=3.23.0 || ^5.0.0-next.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -17287,7 +17285,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zwitch": {

View file

@ -1,10 +1,12 @@
{
"type": "module",
"dependencies": {
"dynamic-marquee": "^2.6.5",
"live_svelte": "file:../",
"phoenix": "file:./deps/phoenix",
"phoenix_html": "file:./deps/phoenix_html",
"phoenix_live_view": "file:./deps/phoenix_live_view",
"svelte-dnd-action": "^0.9.56",
"topbar": "^3.0.0"
},
"devDependencies": {

View file

@ -1,15 +1,11 @@
{
"css/app.css": {
"file": "assets/app-DqfLVfXj.css",
"file": "assets/app-HULN4QXK.css",
"src": "css/app.css",
"isEntry": true,
"name": "app",
"names": [
"app.css"
]
"isEntry": true
},
"js/app.js": {
"file": "assets/app-BVVDu27N.js",
"file": "assets/app-B6UNF5gU.js",
"name": "app",
"src": "js/app.js",
"isEntry": true

View file

@ -0,0 +1,81 @@
defmodule ExampleWeb.LiveDragDropTest do
use ExampleWeb.FeatureCase, async: false
@moduledoc """
E2E tests for the /live-drag-drop LiveView with the DragDrop Svelte component.
Validates the full stack: LiveView LiveSvelte hook Svelte component renders items.
"""
@moduletag :e2e
@item_names [
"Design mockups",
"Set up database",
"Write API endpoints",
"Build frontend",
"Write tests",
"Deploy to production"
]
test "page loads and shows heading and description", %{session: session} do
session = visit(session, "/live-drag-drop")
session |> assert_has(Query.css("h1", text: "Drag & Drop Demo"))
session
|> assert_has(
Query.css("p",
text:
"Reorder tasks with drag and drop. The new order is synced to the server via pushEvent."
)
)
end
test "DragDrop Svelte component mounts and renders all 6 task items", %{session: session} do
session = visit(session, "/live-drag-drop")
# Wait for Svelte to mount and render the drag items
session = wait_for_drag_items(session, 6)
items = session |> all(Query.css("[data-testid='drag-item-name']"))
assert length(items) == 6
names = Enum.map(items, &Wallaby.Element.text/1)
assert Enum.sort(names) == Enum.sort(@item_names)
end
test "server order list renders all initial items in correct order", %{session: session} do
session = visit(session, "/live-drag-drop")
session |> assert_has(Query.css("[data-testid='server-order-item']", count: 6))
items = session |> all(Query.css("[data-testid='server-order-item']"))
names = Enum.map(items, &Wallaby.Element.text/1)
assert names == @item_names
end
test "all task names are rendered by Svelte component", %{session: session} do
session = visit(session, "/live-drag-drop")
session = wait_for_drag_items(session, 6)
Enum.each(@item_names, fn name ->
session |> assert_has(Query.css("[data-testid='drag-item-name']", text: name))
end)
end
defp wait_for_drag_items(session, expected, attempts \\ 80) do
count = session |> all(Query.css("[data-testid='drag-item-name']")) |> length()
cond do
count >= expected ->
session
attempts == 0 ->
raise "timeout waiting for #{expected} drag items, got #{count}"
true ->
:timer.sleep(100)
wait_for_drag_items(session, expected, attempts - 1)
end
end
end

View file

@ -0,0 +1,95 @@
defmodule ExampleWeb.PhoenixTest.LiveDragDropTest do
@moduledoc """
PhoenixTest (in-process) for LiveDragDrop (/live-drag-drop).
Validates that the page renders, the DragDrop Svelte component receives the
initial items as props, and that the reorder event updates the server-side order.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
@initial_items [
"Design mockups",
"Set up database",
"Write API endpoints",
"Build frontend",
"Write tests",
"Deploy to production"
]
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("h1", text: "Drag & Drop Demo")
|> assert_has("p",
text: "Reorder tasks with drag and drop. The new order is synced to the server via pushEvent."
)
end
test "renders DragDrop Svelte component with initial items in props", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("[data-name='DragDrop']", count: 1)
|> assert_has("[data-props*='\"id\":1']")
|> assert_has("[data-props*='Design mockups']")
end
test "server order list renders all initial items", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("[data-testid='server-order-item']", count: 6)
|> assert_has("[data-testid='server-order-list'] li:first-child",
text: "Design mockups"
)
end
test "all initial task names appear in the server order list", %{conn: conn} do
session = conn |> visit("/live-drag-drop")
Enum.each(@initial_items, fn name ->
assert_has(session, "[data-testid='server-order-item']", text: name)
end)
end
test "reorder event updates server-side item order", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("[data-testid='server-order-list'] li:first-child", text: "Design mockups")
|> unwrap(fn view ->
render_click(view, "reorder", %{"ids" => [2, 1, 3, 4, 5, 6]})
end)
|> assert_has("[data-testid='server-order-item']", count: 6)
|> assert_has("[data-testid='server-order-list'] li:first-child", text: "Set up database")
end
test "reorder event updates data-props to reflect new order", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("[data-props*='\"id\":1']")
|> unwrap(fn view ->
render_click(view, "reorder", %{"ids" => [2, 1, 3, 4, 5, 6]})
end)
|> assert_has("[data-name='DragDrop']", count: 1)
|> assert_has("[data-props*='\"id\":2']")
end
test "multiple reorders keep all 6 items in server order list", %{conn: conn} do
conn
|> visit("/live-drag-drop")
|> assert_has("[data-testid='server-order-item']", count: 6)
|> unwrap(fn view ->
render_click(view, "reorder", %{"ids" => [6, 5, 4, 3, 2, 1]})
end)
|> assert_has("[data-testid='server-order-item']", count: 6)
|> assert_has("[data-testid='server-order-list'] li:first-child",
text: "Deploy to production"
)
|> unwrap(fn view ->
render_click(view, "reorder", %{"ids" => [1, 2, 3, 4, 5, 6]})
end)
|> assert_has("[data-testid='server-order-item']", count: 6)
|> assert_has("[data-testid='server-order-list'] li:first-child", text: "Design mockups")
end
end