chore: added svelte store example

This commit is contained in:
Denis Donici 2026-03-06 12:20:42 +02:00
parent 1c96a2d11e
commit fb1e73c451
9 changed files with 270 additions and 2 deletions

View file

@ -0,0 +1,44 @@
<script lang="ts">
import {sharedCount, sharedCountIsOdd} from "./store"
let {label, live}: {label: string; live: any} = $props()
</script>
<div class="card bg-base-100 shadow-lg border border-base-300/50 h-full">
<div class="card-body gap-4 items-center text-center">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
{label}
</span>
<div class="flex items-center gap-6 py-2">
<button
class="btn btn-sm btn-outline border-base-300 hover:border-error hover:text-error"
data-testid="store-decrement"
onclick={() => sharedCount.update(n => n - 1)}
>
1
</button>
<span class="text-4xl font-bold tabular-nums text-brand min-w-12" data-testid="store-count">
{$sharedCount}
</span>
<button class="btn btn-sm btn-success border-0" data-testid="store-increment" onclick={() => sharedCount.update(n => n + 1)}>
+1
</button>
</div>
<span class="badge badge-sm badge-ghost">{$sharedCountIsOdd ? "Odd" : "Even"}</span>
<div class="flex gap-2">
<button class="btn btn-xs btn-ghost" data-testid="store-reset" onclick={() => sharedCount.set(0)}> Reset </button>
<button
class="btn btn-xs bg-brand text-white"
data-testid="store-sync"
onclick={() => live.pushEvent("sync_store", {value: $sharedCount})}
>
Sync to server
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,7 @@
import {writable, derived} from "svelte/store"
// Shared writable store — a module singleton.
// Every component that imports this gets the exact same store instance,
// so any write is immediately visible in all subscribers across the page.
export const sharedCount = writable(0)
export const sharedCountIsOdd = derived(sharedCount, count => count % 2 === 1)

View file

@ -50,6 +50,7 @@ defmodule ExampleWeb.Layouts do
%{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: "Svelte Stores", to: ~p"/live-stores"},
%{label: "SSR Demo", to: ~p"/live-ssr"}
]
},

View file

@ -192,6 +192,18 @@
</a>
<span class="text-base-content/50 text-sm">- Use Editor.js via Svelte's @attach directive</span>
</li>
<li>
<a href={~p"/live-runed"} class="link link-primary">
Runed utilites
</a>
<span class="text-base-content/50 text-sm">- Svelte runed utilities syncing with backend</span>
</li>
<li>
<a href={~p"/live-stores"} class="link link-primary">
Svelte stores
</a>
<span class="text-base-content/50 text-sm">- Svelte writable stores used across components</span>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,66 @@
defmodule ExampleWeb.LiveStores do
use ExampleWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, server_value: nil, sync_count: 0)}
end
def handle_event("sync_store", %{"value" => value}, socket) do
{:noreply, assign(socket, server_value: value, sync_count: socket.assigns.sync_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">Svelte Stores</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Two instances of the same component share a single
<code class="font-mono">writable</code>
store. Clicking +1 in either card instantly updates both no props, no events, no server round-trip.
</p>
<div class="flex flex-col gap-8">
<div class="grid grid-cols-2 gap-4">
<div data-testid="store-instance-1">
<.svelte
name="StoreCounter"
props={%{label: "Instance A"}}
socket={@socket}
/>
</div>
<div data-testid="store-instance-2">
<.svelte
name="StoreCounter"
props={%{label: "Instance B"}}
socket={@socket}
/>
</div>
</div>
<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">
Last synced value:
<strong data-testid="server-value">
<%= if @server_value != nil do %>
{@server_value}
<% else %>
not yet synced
<% end %>
</strong>
</p>
<p class="text-sm">
Sync count: <strong data-testid="sync-count">{@sync_count}</strong>
</p>
</div>
</section>
</div>
</div>
</div>
"""
end
end

View file

@ -49,6 +49,7 @@ defmodule ExampleWeb.Router do
live("/live-navigation/:page", LiveNavigation)
live("/live-editor", LiveEditor)
live("/live-runed", LiveRuned)
live("/live-stores", LiveStores)
live("/live-ssr", LiveSsr)
live("/live-composition", LiveComposition)
end

View file

@ -18,12 +18,12 @@
"isDynamicEntry": true
},
"css/app.css": {
"file": "assets/app-DwTE1PSy.css",
"file": "assets/app-DB4pPYVV.css",
"src": "css/app.css",
"isEntry": true
},
"js/app.js": {
"file": "assets/app-Bmf8wBN3.js",
"file": "assets/app-LUJMG2_W.js",
"name": "app",
"src": "js/app.js",
"isEntry": true,

View file

@ -0,0 +1,67 @@
defmodule ExampleWeb.LiveStoresTest do
@moduledoc """
E2E tests for the /live-stores LiveView with the StoreCounter Svelte component.
The key assertion: both component instances share the same writable store
incrementing in one card immediately updates the other.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
test "page loads and shows heading", %{session: session} do
session
|> visit("/live-stores")
|> assert_has(Query.css("h1", text: "Svelte Stores"))
end
test "both StoreCounter instances render", %{session: session} do
session
|> visit("/live-stores")
|> assert_has(Query.css("[data-testid='store-instance-1']"))
|> assert_has(Query.css("[data-testid='store-instance-2']"))
end
test "both instances show initial count of 0", %{session: session} do
session = visit(session, "/live-stores")
# Wait for Svelte to mount
session |> find(Query.css("[data-testid='store-count']", count: 2))
counts = all(session, Query.css("[data-testid='store-count']"))
assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"]
end
test "incrementing in instance 1 updates both components (store is shared)", %{
session: session
} do
session = visit(session, "/live-stores")
# Wait for both components to mount
session |> find(Query.css("[data-testid='store-count']", count: 2))
# Click +1 inside instance 1 only
instance1 = find(session, Query.css("[data-testid='store-instance-1']"))
click(instance1, Query.css("[data-testid='store-increment']"))
# Both counters must now show 1 — the store is shared
session |> find(Query.css("[data-testid='store-count']", count: 2, minimum: 2))
counts = all(session, Query.css("[data-testid='store-count']"))
assert Enum.map(counts, &Wallaby.Element.text/1) == ["1", "1"]
end
test "reset button sets store back to 0 in both components", %{session: session} do
session = visit(session, "/live-stores")
session |> find(Query.css("[data-testid='store-count']", count: 2))
# Increment via instance 2, then reset via instance 1
instance2 = find(session, Query.css("[data-testid='store-instance-2']"))
click(instance2, Query.css("[data-testid='store-increment']"))
instance1 = find(session, Query.css("[data-testid='store-instance-1']"))
click(instance1, Query.css("[data-testid='store-reset']"))
counts = all(session, Query.css("[data-testid='store-count']"))
assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"]
end
end

View file

@ -0,0 +1,70 @@
defmodule ExampleWeb.PhoenixTest.LiveStoresTest do
@moduledoc """
PhoenixTest (in-process) for LiveStores (/live-stores).
Validates server-side rendering, data-props contract, and sync_store event handling.
Store sharing between instances is client-only; these tests validate the LiveView layer.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
test "renders page heading", %{conn: conn} do
conn
|> visit("/live-stores")
|> assert_has("h1", text: "Svelte Stores")
end
test "renders two StoreCounter components", %{conn: conn} do
conn
|> visit("/live-stores")
|> assert_has("[data-name='StoreCounter']", count: 2)
end
test "both components receive label prop", %{conn: conn} do
conn
|> visit("/live-stores")
|> assert_has("[data-props*='Instance A']")
|> assert_has("[data-props*='Instance B']")
end
test "server value shows not yet synced initially", %{conn: conn} do
conn
|> visit("/live-stores")
|> assert_has("[data-testid='server-value']", text: "not yet synced")
end
test "initial sync count is 0", %{conn: conn} do
conn
|> visit("/live-stores")
|> assert_has("[data-testid='sync-count']", text: "0")
end
test "sync_store event updates server value", %{conn: conn} do
conn
|> visit("/live-stores")
|> unwrap(fn view ->
render_click(view, "sync_store", %{"value" => 42})
end)
|> assert_has("[data-testid='server-value']", text: "42")
end
test "sync_store event increments sync count", %{conn: conn} do
conn
|> visit("/live-stores")
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 1}) end)
|> assert_has("[data-testid='sync-count']", text: "1")
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 2}) end)
|> assert_has("[data-testid='sync-count']", text: "2")
end
test "sync_store event replaces previous server value", %{conn: conn} do
conn
|> visit("/live-stores")
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 5}) end)
|> assert_has("[data-testid='server-value']", text: "5")
|> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 99}) end)
|> assert_has("[data-testid='server-value']", text: "99")
end
end