chore: added runed example

This commit is contained in:
Denis Donici 2026-03-06 12:00:29 +02:00 committed by Wout De Puysseleir
parent d7c0cf8fae
commit d2fdcc20ef
12 changed files with 347 additions and 8 deletions

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

View file

@ -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"}
]
},

View file

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

View 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

View file

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

View file

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

View file

@ -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"
},

View file

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

View 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

View file

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

View file

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

View file

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