mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: added runed example
This commit is contained in:
parent
d7c0cf8fae
commit
d2fdcc20ef
12 changed files with 347 additions and 8 deletions
96
example_project/assets/svelte/RunedDemo.svelte
Normal file
96
example_project/assets/svelte/RunedDemo.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import { Debounced, ElementSize, PressedKeys } from "runed"
|
||||
|
||||
let { items, matches, lastSize, comboCount, live } = $props()
|
||||
|
||||
// ── 1. Debounced search ──────────────────────────────────────────────────────
|
||||
let query = $state("")
|
||||
const debounced = new Debounced(() => query, 400)
|
||||
|
||||
$effect(() => {
|
||||
live.pushEvent("search", { query: debounced.current })
|
||||
})
|
||||
|
||||
// ── 2. ElementSize ───────────────────────────────────────────────────────────
|
||||
let el = $state<HTMLElement>()
|
||||
const size = new ElementSize(() => el)
|
||||
const debouncedWidth = new Debounced(() => size.width, 300)
|
||||
const debouncedHeight = new Debounced(() => size.height, 300)
|
||||
|
||||
$effect(() => {
|
||||
if (debouncedWidth.current > 0) {
|
||||
live.pushEvent("resize", {
|
||||
width: debouncedWidth.current,
|
||||
height: debouncedHeight.current,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── 3. PressedKeys ───────────────────────────────────────────────────────────
|
||||
const keys = new PressedKeys()
|
||||
keys.onKeys(["Control", "Enter"], () => live.pushEvent("combo", {}))
|
||||
</script>
|
||||
|
||||
<!-- Section 1: Debounced search -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">
|
||||
Debounced
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search languages..."
|
||||
data-testid="search-input"
|
||||
class="input input-bordered w-full text-sm"
|
||||
/>
|
||||
<div class="flex gap-4 mt-2 text-xs text-base-content/50">
|
||||
<span>Typing: <span data-testid="typed-value" class="font-mono">{query}</span></span>
|
||||
<span>Debounced: <span data-testid="debounced-value" class="font-mono">{debounced.current}</span></span>
|
||||
</div>
|
||||
<ul data-testid="matches-list" class="mt-3 flex flex-wrap gap-1">
|
||||
{#each matches as item}
|
||||
<li class="badge badge-ghost text-xs">{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- Section 2: ElementSize -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">
|
||||
ElementSize
|
||||
</p>
|
||||
<textarea
|
||||
bind:this={el}
|
||||
data-testid="resizable-element"
|
||||
class="textarea textarea-bordered w-full min-h-[80px] resize text-sm"
|
||||
placeholder="Resize me! My dimensions sync to the server."
|
||||
></textarea>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
Live: <span data-testid="live-size" class="font-mono">{size.width}×{size.height}px</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<!-- Section 3: PressedKeys -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">
|
||||
PressedKeys
|
||||
</p>
|
||||
<div data-testid="pressed-keys" class="flex flex-wrap gap-1 min-h-[28px]">
|
||||
{#each [...keys.all] as key}
|
||||
<kbd class="kbd kbd-sm">{key}</kbd>
|
||||
{/each}
|
||||
{#if keys.all.size === 0}
|
||||
<span class="text-xs text-base-content/30 italic">Hold any keys...</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-2">
|
||||
Press <kbd class="kbd kbd-xs">Ctrl</kbd>+<kbd class="kbd kbd-xs">Enter</kbd>
|
||||
to increment the server counter (current: <span data-testid="combo-display">{comboCount}</span>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,6 +49,7 @@ defmodule ExampleWeb.Layouts do
|
|||
links: [
|
||||
%{label: "Client Loading", to: ~p"/live-client-side-loading"},
|
||||
%{label: "Rich Editor (@attach)", to: ~p"/live-editor"},
|
||||
%{label: "Runed Utilities", to: ~p"/live-runed"},
|
||||
%{label: "SSR Demo", to: ~p"/live-ssr"}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -186,6 +186,12 @@
|
|||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Server-side rendering with NodeJS</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={~p"/live-editor"} class="link link-primary">
|
||||
Right Editor (@attach)
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Use Editor.js via Svelte's @attach directive</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
89
example_project/lib/example_web/live/live_runed.ex
Normal file
89
example_project/lib/example_web/live/live_runed.ex
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
defmodule ExampleWeb.LiveRuned do
|
||||
use ExampleWeb, :live_view
|
||||
|
||||
@items ~w(Elixir Erlang Phoenix LiveView Svelte Vue React Angular TypeScript
|
||||
JavaScript Rust Go Python Ruby Java Kotlin Swift Haskell Clojure Scala)
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
items: @items,
|
||||
matches: @items,
|
||||
last_size: %{width: nil, height: nil},
|
||||
combo_count: 0
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
q = String.downcase(query)
|
||||
matches = Enum.filter(@items, &String.contains?(String.downcase(&1), q))
|
||||
{:noreply, assign(socket, matches: matches)}
|
||||
end
|
||||
|
||||
def handle_event("resize", %{"width" => w, "height" => h}, socket) do
|
||||
{:noreply, assign(socket, last_size: %{width: round(w), height: round(h)})}
|
||||
end
|
||||
|
||||
def handle_event("combo", _params, socket) do
|
||||
{:noreply, assign(socket, combo_count: socket.assigns.combo_count + 1)}
|
||||
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">Runed Utilities</h1>
|
||||
<p class="text-sm text-base-content/50 mb-8 text-center">
|
||||
Three utilities from <a href="https://runed.dev" class="link">runed</a>
|
||||
— Debounced, ElementSize, PressedKeys — each syncing client state back to Phoenix LiveView.
|
||||
</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">
|
||||
RunedDemo
|
||||
</span>
|
||||
<.svelte
|
||||
name="RunedDemo"
|
||||
props={%{
|
||||
items: @items,
|
||||
matches: @matches,
|
||||
lastSize: @last_size,
|
||||
comboCount: @combo_count
|
||||
}}
|
||||
socket={@socket}
|
||||
ssr={false}
|
||||
/>
|
||||
</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 state
|
||||
</span>
|
||||
<p class="text-sm">
|
||||
Search matches: <strong data-testid="match-count">{length(@matches)}</strong>
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Last synced size:
|
||||
<strong data-testid="server-size">
|
||||
<%= if @last_size.width do %>
|
||||
{@last_size.width}×{@last_size.height}px
|
||||
<% else %>
|
||||
not yet synced
|
||||
<% end %>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Ctrl+Enter combos: <strong data-testid="combo-count">{@combo_count}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -48,6 +48,7 @@ defmodule ExampleWeb.Router do
|
|||
live("/live-navigation", LiveNavigation)
|
||||
live("/live-navigation/:page", LiveNavigation)
|
||||
live("/live-editor", LiveEditor)
|
||||
live("/live-runed", LiveRuned)
|
||||
live("/live-ssr", LiveSsr)
|
||||
live("/live-composition", LiveComposition)
|
||||
end
|
||||
|
|
|
|||
39
example_project/package-lock.json
generated
39
example_project/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"phoenix": "file:./deps/phoenix",
|
||||
"phoenix_html": "file:./deps/phoenix_html",
|
||||
"phoenix_live_view": "file:./deps/phoenix_live_view",
|
||||
"runed": "^0.37.1",
|
||||
"svelte-dnd-action": "^0.9.56",
|
||||
"topbar": "^3.0.0"
|
||||
},
|
||||
|
|
@ -7843,7 +7844,6 @@
|
|||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -13266,6 +13266,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.0.tgz",
|
||||
|
|
@ -15718,6 +15727,34 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/runed": {
|
||||
"version": "0.37.1",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.37.1.tgz",
|
||||
"integrity": "sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"esm-env": "^1.0.0",
|
||||
"lz-string": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"svelte": "^5.7.0",
|
||||
"zod": "^4.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"phoenix": "file:./deps/phoenix",
|
||||
"phoenix_html": "file:./deps/phoenix_html",
|
||||
"phoenix_live_view": "file:./deps/phoenix_live_view",
|
||||
"runed": "^0.37.1",
|
||||
"svelte-dnd-action": "^0.9.56",
|
||||
"topbar": "^3.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@
|
|||
"isDynamicEntry": true
|
||||
},
|
||||
"css/app.css": {
|
||||
"file": "assets/app-fZIL_d3Z.css",
|
||||
"file": "assets/app-DwTE1PSy.css",
|
||||
"src": "css/app.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"js/app.js": {
|
||||
"file": "assets/app-EcY7_2bB.js",
|
||||
"file": "assets/app-Bmf8wBN3.js",
|
||||
"name": "app",
|
||||
"src": "js/app.js",
|
||||
"isEntry": true,
|
||||
|
|
|
|||
33
example_project/test/example_web/live/live_runed_test.exs
Normal file
33
example_project/test/example_web/live/live_runed_test.exs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule ExampleWeb.LiveRunedTest do
|
||||
@moduledoc """
|
||||
E2E tests for the /live-runed LiveView with the RunedDemo Svelte component.
|
||||
Validates the full pipeline: LiveView → LiveSvelte hook → Svelte mounts runed utilities.
|
||||
"""
|
||||
use ExampleWeb.FeatureCase, async: false
|
||||
|
||||
@moduletag :e2e
|
||||
|
||||
test "page loads and shows heading", %{session: session} do
|
||||
session
|
||||
|> visit("/live-runed")
|
||||
|> assert_has(Query.css("h1", text: "Runed Utilities"))
|
||||
end
|
||||
|
||||
test "search input renders after Svelte mounts", %{session: session} do
|
||||
session
|
||||
|> visit("/live-runed")
|
||||
|> assert_has(Query.css("[data-testid='search-input']"))
|
||||
end
|
||||
|
||||
test "resizable element renders after Svelte mounts", %{session: session} do
|
||||
session
|
||||
|> visit("/live-runed")
|
||||
|> assert_has(Query.css("[data-testid='resizable-element']"))
|
||||
end
|
||||
|
||||
test "pressed-keys container renders after Svelte mounts", %{session: session} do
|
||||
session
|
||||
|> visit("/live-runed")
|
||||
|> assert_has(Query.css("[data-testid='pressed-keys']"))
|
||||
end
|
||||
end
|
||||
|
|
@ -90,6 +90,9 @@ defmodule ExampleWeb.StreamsTest do
|
|||
|> click(Query.css("[data-testid='clear-button']"))
|
||||
|> click(Query.css("[data-testid='reset-button-at-0']"))
|
||||
|
||||
# Wait for all 3 items to appear before checking order
|
||||
session |> find(Query.css("[data-testid^='item-name-']", count: 3))
|
||||
|
||||
# All 3 items back but in reversed order (each prepended at 0)
|
||||
names = all(session, Query.css("[data-testid^='item-name-']"))
|
||||
name_texts = Enum.map(names, &Wallaby.Element.text/1)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
defmodule ExampleWeb.PhoenixTest.LiveEditorTest do
|
||||
@moduledoc """
|
||||
PhoenixTest (in-process) for LiveEditor (/live-editor).
|
||||
Validates server-side rendering, data-props contract, and save_content event handling.
|
||||
Validates server-side rendering, data-props contract, and sync_content event handling.
|
||||
Editor.js is browser-only; these tests skip SSR and validate the LiveView layer only.
|
||||
"""
|
||||
use ExampleWeb.ConnCase, async: false
|
||||
|
|
@ -42,13 +42,13 @@ defmodule ExampleWeb.PhoenixTest.LiveEditorTest do
|
|||
|> assert_has("[data-testid='no-save-yet']")
|
||||
end
|
||||
|
||||
test "save_content event updates block count and removes no-save message", %{conn: conn} do
|
||||
test "sync_content event updates block count and removes no-save message", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-editor")
|
||||
|> assert_has("[data-testid='no-save-yet']")
|
||||
|> assert_has("[data-testid='block-count']", text: "2")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "save_content", %{
|
||||
render_click(view, "sync_content", %{
|
||||
"blocks" => [
|
||||
%{"type" => "header", "data" => %{"text" => "Hello", "level" => 2}},
|
||||
%{"type" => "paragraph", "data" => %{"text" => "World"}},
|
||||
|
|
@ -60,11 +60,11 @@ defmodule ExampleWeb.PhoenixTest.LiveEditorTest do
|
|||
|> refute_has("[data-testid='no-save-yet']")
|
||||
end
|
||||
|
||||
test "save_content event with single block updates count to 1", %{conn: conn} do
|
||||
test "sync_content event with single block updates count to 1", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-editor")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "save_content", %{
|
||||
render_click(view, "sync_content", %{
|
||||
"blocks" => [
|
||||
%{"type" => "header", "data" => %{"text" => "Only block", "level" => 2}}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
defmodule ExampleWeb.PhoenixTest.LiveRunedTest do
|
||||
@moduledoc """
|
||||
PhoenixTest (in-process) for LiveRuned (/live-runed).
|
||||
Validates server-side rendering, data-props contract, and event handling.
|
||||
RunedDemo is browser-only (ssr={false}); these tests validate the LiveView layer only.
|
||||
"""
|
||||
use ExampleWeb.ConnCase, async: false
|
||||
import PhoenixTest
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
@moduletag :phoenix_test
|
||||
|
||||
test "renders page heading", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("h1", text: "Runed Utilities")
|
||||
end
|
||||
|
||||
test "renders RunedDemo Svelte component with correct props", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("[data-name='RunedDemo']", count: 1)
|
||||
|> assert_has("[data-props*='matches']")
|
||||
|> assert_has("[data-props*='comboCount']")
|
||||
end
|
||||
|
||||
test "initial match count is 20 (all items)", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("[data-testid='match-count']", text: "20")
|
||||
end
|
||||
|
||||
test "search event filters matches", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("[data-testid='match-count']", text: "20")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "search", %{"query" => "eli"})
|
||||
end)
|
||||
|> assert_has("[data-testid='match-count']", text: "1")
|
||||
end
|
||||
|
||||
test "resize event updates server-size display", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "resize", %{"width" => 400, "height" => 200})
|
||||
end)
|
||||
|> assert_has("[data-testid='server-size']", text: "400×200px")
|
||||
end
|
||||
|
||||
test "combo event increments combo-count", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("[data-testid='combo-count']", text: "0")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "combo", %{})
|
||||
end)
|
||||
|> assert_has("[data-testid='combo-count']", text: "1")
|
||||
|> unwrap(fn view ->
|
||||
render_click(view, "combo", %{})
|
||||
end)
|
||||
|> assert_has("[data-testid='combo-count']", text: "2")
|
||||
end
|
||||
|
||||
test "server size shows not yet synced initially", %{conn: conn} do
|
||||
conn
|
||||
|> visit("/live-runed")
|
||||
|> assert_has("[data-testid='server-size']", text: "not yet synced")
|
||||
|> refute_has("[data-testid='server-size']", text: "px")
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue