chore: add live form support via ecto changesets

This commit is contained in:
Denis Donici 2026-02-25 02:14:26 +02:00
parent 434e362ce0
commit 2ab54b8690
24 changed files with 3120 additions and 9 deletions

View file

@ -0,0 +1,261 @@
/**
* Unit tests for LiveSvelte composables.
* Uses vi.mock to intercept getContext/onDestroy calls (called during component init).
*/
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
// Mock svelte BEFORE importing composables (hoisted by vitest)
vi.mock("svelte", () => ({
getContext: vi.fn(),
onDestroy: vi.fn(),
}))
import { getContext, onDestroy } from "svelte"
import {
useLiveSvelte,
useLiveEvent,
useLiveConnection,
useLiveNavigation,
LIVE_SYMBOL,
CONNECTION_SYMBOL,
} from "./composables"
import type { Live } from "./types"
const mockLiveSocket = {
pushHistoryPatch: vi.fn(),
historyRedirect: vi.fn(),
}
const mockLive: Live = {
pushEvent: vi.fn().mockReturnValue(1),
pushEventTo: vi.fn().mockReturnValue(2),
handleEvent: vi.fn().mockReturnValue(() => {}),
removeHandleEvent: vi.fn(),
upload: vi.fn(),
uploadTo: vi.fn(),
liveSocket: mockLiveSocket,
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// useLiveSvelte
// ---------------------------------------------------------------------------
describe("useLiveSvelte", () => {
it("returns live ref from context", () => {
vi.mocked(getContext).mockReturnValue(mockLive)
const result = useLiveSvelte()
expect(getContext).toHaveBeenCalledWith(LIVE_SYMBOL)
expect(result.live).toBe(mockLive)
})
it("throws when context is not set", () => {
vi.mocked(getContext).mockReturnValue(undefined)
expect(() => useLiveSvelte()).toThrow("useLiveSvelte()")
})
it("pushEvent delegates to live.pushEvent", () => {
vi.mocked(getContext).mockReturnValue(mockLive)
const result = useLiveSvelte()
result.pushEvent("my_event", { key: "val" })
expect(mockLive.pushEvent).toHaveBeenCalledWith("my_event", { key: "val" })
})
it("pushEventTo delegates to live.pushEventTo", () => {
vi.mocked(getContext).mockReturnValue(mockLive)
const result = useLiveSvelte()
result.pushEventTo("#target", "my_event", { key: "val" })
expect(mockLive.pushEventTo).toHaveBeenCalledWith("#target", "my_event", { key: "val" })
})
it("pushEvent returns ref number from live.pushEvent", () => {
vi.mocked(mockLive.pushEvent).mockReturnValue(42)
vi.mocked(getContext).mockReturnValue(mockLive)
const result = useLiveSvelte()
const ref = result.pushEvent("evt", {})
expect(ref).toBe(42)
})
})
// ---------------------------------------------------------------------------
// useLiveEvent
// ---------------------------------------------------------------------------
describe("useLiveEvent", () => {
it("throws when called outside LiveSvelte context", () => {
vi.mocked(getContext).mockReturnValue(undefined)
expect(() => useLiveEvent("some_event", vi.fn())).toThrow("useLiveEvent()")
})
it("calls live.handleEvent with the event name and callback", () => {
vi.mocked(getContext).mockReturnValue(mockLive)
const cb = vi.fn()
useLiveEvent("server_event", cb)
expect(mockLive.handleEvent).toHaveBeenCalledWith("server_event", cb)
})
it("registers cleanup via onDestroy", () => {
const cleanupFn = vi.fn()
vi.mocked(mockLive.handleEvent).mockReturnValue(cleanupFn)
vi.mocked(getContext).mockReturnValue(mockLive)
useLiveEvent("server_event", vi.fn())
expect(onDestroy).toHaveBeenCalledWith(cleanupFn)
})
it("cleanup fn returned by handleEvent is passed to onDestroy", () => {
const cleanup = vi.fn()
vi.mocked(mockLive.handleEvent).mockReturnValue(cleanup)
vi.mocked(getContext).mockReturnValue(mockLive)
useLiveEvent("server_event", vi.fn())
const registeredCleanup = vi.mocked(onDestroy).mock.calls[0][0] as () => void
registeredCleanup()
expect(cleanup).toHaveBeenCalledOnce()
})
})
// ---------------------------------------------------------------------------
// useLiveConnection
// ---------------------------------------------------------------------------
describe("useLiveConnection", () => {
it("reads connected from context state", () => {
const fakeState = { connected: true }
vi.mocked(getContext).mockImplementation((key) => {
if (key === CONNECTION_SYMBOL) return fakeState
return undefined
})
const conn = useLiveConnection()
expect(conn.connected).toBe(true)
})
it("reflects state changes via getter", () => {
const fakeState = { connected: true }
vi.mocked(getContext).mockImplementation((key) => {
if (key === CONNECTION_SYMBOL) return fakeState
return undefined
})
const conn = useLiveConnection()
fakeState.connected = false
expect(conn.connected).toBe(false)
})
it("returns connected: true when no context present (fallback)", () => {
vi.mocked(getContext).mockReturnValue(undefined)
const conn = useLiveConnection()
expect(conn.connected).toBe(true)
})
it("calls getContext with CONNECTION_SYMBOL", () => {
vi.mocked(getContext).mockReturnValue(undefined)
useLiveConnection()
expect(getContext).toHaveBeenCalledWith(CONNECTION_SYMBOL)
})
})
// ---------------------------------------------------------------------------
// useLiveNavigation
// ---------------------------------------------------------------------------
describe("useLiveNavigation", () => {
beforeEach(() => {
vi.stubGlobal("location", { pathname: "/current-path" })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it("throws when live context is absent", () => {
vi.mocked(getContext).mockReturnValue(undefined)
expect(() => useLiveNavigation()).toThrow("useLiveNavigation()")
})
it("throws when liveSocket is not available", () => {
const liveWithoutSocket: Live = { ...mockLive, liveSocket: undefined }
vi.mocked(getContext).mockReturnValue(liveWithoutSocket)
expect(() => useLiveNavigation()).toThrow("LiveSocket not initialized")
})
describe("patch()", () => {
beforeEach(() => {
vi.mocked(getContext).mockReturnValue(mockLive)
})
it("calls pushHistoryPatch with string href and push by default", () => {
const { patch } = useLiveNavigation()
patch("/new-path")
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
expect.any(Event),
"/new-path",
"push",
null
)
})
it("calls pushHistoryPatch with replace kind when replace: true", () => {
const { patch } = useLiveNavigation()
patch("/new-path", { replace: true })
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
expect.any(Event),
"/new-path",
"replace",
null
)
})
it("builds href from query params object using current pathname", () => {
const { patch } = useLiveNavigation()
patch({ page: "2", filter: "active" })
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
expect.any(Event),
"/current-path?page=2&filter=active",
"push",
null
)
})
it("builds href from query params object with replace option", () => {
const { patch } = useLiveNavigation()
patch({ search: "test" }, { replace: true })
expect(mockLiveSocket.pushHistoryPatch).toHaveBeenCalledWith(
expect.any(Event),
"/current-path?search=test",
"replace",
null
)
})
})
describe("navigate()", () => {
beforeEach(() => {
vi.mocked(getContext).mockReturnValue(mockLive)
})
it("calls historyRedirect with href and push by default", () => {
const { navigate } = useLiveNavigation()
navigate("/other-live-view")
expect(mockLiveSocket.historyRedirect).toHaveBeenCalledWith(
expect.any(Event),
"/other-live-view",
"push",
null,
null
)
})
it("calls historyRedirect with replace kind when replace: true", () => {
const { navigate } = useLiveNavigation()
navigate("/other-live-view", { replace: true })
expect(mockLiveSocket.historyRedirect).toHaveBeenCalledWith(
expect.any(Event),
"/other-live-view",
"replace",
null,
null
)
})
})
})

View file

@ -0,0 +1,191 @@
/**
* LiveSvelte composables ergonomic access to the Phoenix hook context
* from any Svelte component mounted by SvelteHook, without prop drilling.
*
* Context architecture:
* The SvelteHook passes live ref and connection state via Svelte's context
* system (mount/hydrate `context` option). Composables read from that context.
*
* Backward compatibility:
* The `live` prop is still passed to Svelte components for existing code.
* Composables are an additive ergonomics improvement.
*/
import { getContext, onDestroy } from "svelte"
import type { Live, LiveSocket, UseLiveNavigationResult } from "./types"
/** Context key for the Phoenix hook reference (`this` inside SvelteHook). */
export const LIVE_SYMBOL = Symbol("__live_svelte__")
/**
* Context key for the reactive connection state object.
* The SvelteHook sets this to `{ connected: boolean }` and updates it
* via its `disconnected()` / `reconnected()` lifecycle callbacks.
*/
export const CONNECTION_SYMBOL = Symbol("__live_svelte_connection__")
/** Shape of the connection state object shared via context. */
export interface LiveConnectionState {
connected: boolean
}
/** Return type of useLiveSvelte(). */
export interface UseLiveSvelteResult {
/** The raw Phoenix hook context. Prefer pushEvent/pushEventTo for type safety. */
readonly live: Live
pushEvent: Live["pushEvent"]
pushEventTo: Live["pushEventTo"]
}
/** Return type of useLiveConnection(). */
export interface UseLiveConnectionResult {
/** `true` when the Phoenix WebSocket is connected, `false` when disconnected. */
readonly connected: boolean
}
/**
* Access the Phoenix hook context from any Svelte component mounted by SvelteHook.
*
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
*
* @example
* ```svelte
* <script>
* import { useLiveSvelte } from "live_svelte"
* const { pushEvent } = useLiveSvelte()
* function save() { pushEvent("save", { value }) }
* </script>
* ```
*/
export function useLiveSvelte(): UseLiveSvelteResult {
const live = getContext<Live>(LIVE_SYMBOL)
if (!live) {
if (typeof window === "undefined") {
const noop = () => {}
return { live: null as unknown as Live, pushEvent: noop as Live["pushEvent"], pushEventTo: noop as Live["pushEventTo"] }
}
throw new Error("useLiveSvelte() must be called inside a LiveSvelte-mounted component")
}
return {
get live() {
return live
},
pushEvent: (...args: Parameters<Live["pushEvent"]>) => live.pushEvent(...args),
pushEventTo: (...args: Parameters<Live["pushEventTo"]>) => live.pushEventTo(...args),
}
}
/**
* Subscribe to a server-sent LiveView event with automatic cleanup on component destroy.
*
* Calls `live.handleEvent(event, callback)` and registers the returned cleanup
* function with `onDestroy` no manual `removeHandleEvent` needed.
*
* @note Calling `useLiveEvent` multiple times with the same event name registers
* multiple independent subscriptions all callbacks fire on each event.
* This is intentional; deduplicate in the caller if needed.
*
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
*
* @example
* ```svelte
* <script>
* import { useLiveEvent } from "live_svelte"
* useLiveEvent("item_added", (payload) => { console.log(payload) })
* </script>
* ```
*/
export function useLiveEvent(event: string, callback: (payload: unknown) => void): void {
const live = getContext<Live>(LIVE_SYMBOL)
if (!live) {
if (typeof window === "undefined") return
throw new Error("useLiveEvent() must be called inside a LiveSvelte-mounted component")
}
const cleanup = live.handleEvent(event, callback)
onDestroy(cleanup)
}
/**
* Observe the Phoenix WebSocket connection status as a reactive value.
*
* Connection state is managed by the SvelteHook via `disconnected()` /
* `reconnected()` lifecycle callbacks. Returns `{ connected: true }` when
* called outside a LiveSvelte context (e.g. SSR, tests).
*
* @example
* ```svelte
* <script>
* import { useLiveConnection } from "live_svelte"
* const conn = useLiveConnection()
* </script>
* {#if !conn.connected}
* <p>Reconnecting</p>
* {/if}
* ```
*/
export function useLiveConnection(): UseLiveConnectionResult {
const state = getContext<LiveConnectionState>(CONNECTION_SYMBOL)
if (!state) {
// Outside LiveSvelte context — assume connected (SSR, unit testing)
return { connected: true }
}
return {
get connected() {
return state.connected
},
}
}
/**
* Client-side LiveView navigation from a Svelte component.
*
* Uses the `liveSocket` instance on the Phoenix hook context to perform
* navigation without full page reloads. Mirrors LiveVue's `useLiveNavigation`.
*
* - `patch()` patches the current LiveView (triggers `handle_params/3`).
* - `navigate()` mounts a new LiveView process (within the same live_session).
* - Both accept `{ replace: true }` to use `history.replaceState`.
*
* @throws {Error} When called outside a LiveSvelte-mounted component tree.
* @throws {Error} When `live.liveSocket` is not initialized.
*
* @example
* ```svelte
* <script>
* import { useLiveNavigation } from "live_svelte"
* const { patch, navigate } = useLiveNavigation()
* </script>
* <button onclick={() => patch("?page=2")}>Next page</button>
* <button onclick={() => navigate("/other-view")}>Go elsewhere</button>
* ```
*/
export function useLiveNavigation(): UseLiveNavigationResult {
const live = getContext<Live>(LIVE_SYMBOL)
if (!live) {
if (typeof window === "undefined") return { patch: () => {}, navigate: () => {} }
throw new Error("useLiveNavigation() must be called inside a LiveSvelte-mounted component")
}
const liveSocket = live.liveSocket as LiveSocket | undefined
if (!liveSocket) throw new Error("LiveSocket not initialized")
const patch = (
hrefOrQueryParams: string | Record<string, string>,
opts: { replace?: boolean } = {}
): void => {
let href =
typeof hrefOrQueryParams === "string"
? hrefOrQueryParams
: globalThis.location.pathname
if (typeof hrefOrQueryParams === "object") {
const queryParams = new URLSearchParams(hrefOrQueryParams)
href = `${href}?${queryParams.toString()}`
}
liveSocket.pushHistoryPatch(new Event("click"), href, opts.replace ? "replace" : "push", null)
}
const navigate = (href: string, opts: { replace?: boolean } = {}): void => {
liveSocket.historyRedirect(new Event("click"), href, opts.replace ? "replace" : "push", null, null)
}
return { patch, navigate }
}

View file

@ -1,6 +1,7 @@
import { decodeB64ToUTF8, normalizeComponents } from "./utils"
import { mount, hydrate, unmount, createRawSnippet } from "svelte"
import { applyPatch } from "./jsonPatch.js"
import { LIVE_SYMBOL, CONNECTION_SYMBOL } from "./composables"
function getAttributeJson(ref, attributeName) {
const data = ref.el.getAttribute(attributeName)
@ -116,6 +117,8 @@ export function getHooks(components) {
const SvelteHook = {
mounted() {
let state = $state(getProps(this))
let connectionState = $state({ connected: true })
const componentName = this.el.getAttribute("data-name")
if (!componentName) throw new Error("Component name must be provided")
@ -140,8 +143,10 @@ export function getHooks(components) {
this._instance = hydrateOrMount(Component, {
target,
props: state,
context: new Map([[LIVE_SYMBOL, this], [CONNECTION_SYMBOL, connectionState]]),
})
this._instance.state = state
this._instance.connectionState = connectionState
// Apply initial stream items from data-streams-diff
const initialStreamsDiff = getDiff(this, "data-streams-diff")
@ -154,6 +159,23 @@ export function getHooks(components) {
update_state(this)
},
// Updates the reactive connection state shared via Svelte context with
// useLiveConnection(). Unit-testing these is impractical (requires Svelte
// $state outside a component context); correctness is validated by
// the Chat.svelte `data-testid="chat-reconnecting"` E2E happy-path test
// and by the Chat page remaining functional through connect/disconnect cycles.
disconnected() {
if (this._instance?.connectionState) {
this._instance.connectionState.connected = false
}
},
reconnected() {
if (this._instance?.connectionState) {
this._instance.connectionState.connected = true
}
},
destroyed() {
if (this._instance) window.addEventListener("phx:page-loading-stop", () => unmount(this._instance), { once: true })
},

View file

@ -1,2 +1,17 @@
export { getRender } from "./render";
export { getHooks } from "./hooks.svelte";
export { useLiveSvelte, useLiveEvent, useLiveConnection, useLiveNavigation } from "./composables";
export type { UseLiveSvelteResult, UseLiveConnectionResult, LiveConnectionState, UseLiveNavigationResult } from "./composables";
export { default as Link } from "./Link.svelte";
export { useLiveForm } from "./useLiveForm";
export type {
Form,
FormErrors,
FormOptions,
FieldOptions,
FieldState,
FieldAttrs,
FormField,
FormFieldArray,
UseLiveFormReturn,
} from "./useLiveForm";

View file

@ -79,6 +79,12 @@ describe("$$dom_id path syntax (remove / replace via dom_id)", () => {
expect(items[0].name).toBe("Updated")
expect(items.length).toBe(2)
})
it("replace with unknown $$dom_id is silently skipped (update_only: true, item absent)", () => {
const state: Record<string, unknown> = { items: [makeItem(1)] }
applyPatch(state, [["replace", "/items/$$items-999", makeItem(999, { name: "Ghost" })]])
expect((state.items as { id: number }[]).map((i) => i.id)).toEqual([1])
})
})
// ---------------------------------------------------------------------------

View file

@ -3,6 +3,15 @@
* No `any` in public API; consumers get type safety and exported types.
*/
/**
* Minimal LiveSocket interface for navigation composable.
* Exposes only the methods needed for patch/navigate.
*/
export interface LiveSocket {
pushHistoryPatch(event: Event, href: string, kind: "push" | "replace", target: Element | null): void;
historyRedirect(event: Event, href: string, kind: "push" | "replace", flash: unknown, callback: (() => void) | null): void;
}
/** LiveView socket/channel push and event types (payloads from server are unknown) */
export type Live = {
pushEvent(
@ -22,6 +31,9 @@ export type Live = {
upload(name: string, files: FileList | File[]): void;
uploadTo(phxTarget: unknown, name: string, files: FileList | File[]): void;
/** The Phoenix LiveSocket instance — available on the hook `this` context. */
liveSocket?: LiveSocket;
};
/** Component module input: default = modules, filenames = paths */
@ -57,3 +69,187 @@ export declare const getHooks: (components: ComponentModuleInput) => SvelteHooks
export declare const getRender: (
components: ComponentModuleInput
) => LiveSvelteRenderFn;
/**
* Shape of the reactive connection-state object shared via Svelte context.
* Keep in sync with the `LiveConnectionState` interface in composables.ts.
*/
export interface LiveConnectionState {
connected: boolean;
}
/** Return type of useLiveSvelte() */
export interface UseLiveSvelteResult {
readonly live: Live;
pushEvent(event: string, payload?: object, onReply?: (reply: unknown, ref: number) => void): number;
pushEventTo(phxTarget: unknown, event: string, payload?: object, onReply?: (reply: unknown, ref: number) => void): number;
}
/** Return type of useLiveConnection() */
export interface UseLiveConnectionResult {
readonly connected: boolean;
}
/**
* Access the Phoenix hook context from any Svelte component mounted by SvelteHook.
* Returns pushEvent, pushEventTo, and the raw live ref.
* @throws {Error} when called outside a LiveSvelte-mounted component tree
*/
export declare function useLiveSvelte(): UseLiveSvelteResult;
/**
* Subscribe to a server-sent LiveView event with automatic cleanup on component destroy.
* Calls live.handleEvent and registers the cleanup via onDestroy.
* @throws {Error} when called outside a LiveSvelte-mounted component tree
*/
export declare function useLiveEvent(event: string, callback: (payload: unknown) => void): void;
/**
* Observe the Phoenix WebSocket connection status.
* Returns { connected } which is true when connected, false when disconnected.
* Falls back to { connected: true } outside a LiveSvelte-mounted component.
*/
export declare function useLiveConnection(): UseLiveConnectionResult;
/** Return type of useLiveNavigation() */
export interface UseLiveNavigationResult {
/** Patch the current LiveView — updates URL and triggers handle_params without a full reload. */
patch(hrefOrQueryParams: string | Record<string, string>, opts?: { replace?: boolean }): void;
/** Navigate to a new LiveView — mounts a new LV process without a full page reload. */
navigate(href: string, opts?: { replace?: boolean }): void;
}
/**
* Client-side LiveView navigation from a Svelte component.
*
* `patch()` updates the current LiveView (calls handle_params).
* `navigate()` mounts a new LiveView process.
* Both support `{ replace: true }` to use replaceState instead of pushState.
*
* @throws {Error} when called outside a LiveSvelte-mounted component tree
* @throws {Error} when LiveSocket is not initialized
*/
export declare function useLiveNavigation(): UseLiveNavigationResult;
// ---------------------------------------------------------------------------
// useLiveForm types
// Keep in sync with useLiveForm.ts — that file is the source of truth.
// ---------------------------------------------------------------------------
import type { Readable } from "svelte/store";
/**
* Recursive type for Ecto changeset error maps.
* Each key maps to either an array of error strings or a nested error map.
*/
export type FormErrors<T extends object> = {
[K in keyof T]?: string[] | Record<string, unknown>;
};
/**
* Shape of a Phoenix.HTML.Form encoded by LiveSvelte.Encoder.
* Produced by `to_form(changeset)` in the LiveView and passed as a prop.
*/
export interface Form<T extends object> {
name: string;
values: T;
errors: FormErrors<T>;
valid: boolean;
}
/** Options for `field()` to specify input type and checkbox value. */
export interface FieldOptions {
type?: string;
value?: unknown;
}
/** Options for `useLiveForm`. */
export interface FormOptions {
changeEvent?: string | null;
submitEvent?: string;
debounceInMiliseconds?: number;
prepareData?: (data: Record<string, unknown>) => Record<string, unknown>;
}
/** Attributes to spread onto an `<input>` element. */
export interface FieldAttrs {
name: string;
id: string;
oninput: (e: Event) => void;
onblur: () => void;
"aria-invalid": boolean;
"aria-describedby"?: string;
value?: unknown;
checked?: boolean;
type?: string;
}
/** Reactive state snapshot for a single form field (the store's value). */
export interface FieldState<V> {
value: V;
errors: string[];
errorMessage: string | undefined;
isValid: boolean;
isDirty: boolean;
isTouched: boolean;
attrs: FieldAttrs;
}
/**
* A reactive field store. Subscribe via `$nameField` in Svelte templates.
* Call `nameField.set(value)` to update the field value programmatically.
*/
export interface FormField<V> extends Readable<FieldState<V>> {
set(value: V): void;
update(updater: (currentValue: V) => V): void;
field(subPath: string, options?: FieldOptions): FormField<any>;
fieldArray(subPath: string): FormFieldArray<any>;
}
/**
* A reactive array-field store with per-item field stores and
* add/remove/move operations.
*/
export interface FormFieldArray<V> extends Readable<FieldState<V[]>> {
set(value: V[]): void;
fields: Readable<FormField<V>[]>;
add(item?: Partial<V>): void;
remove(index: number): void;
move(from: number, to: number): void;
}
/** Return value of `useLiveForm`. */
export interface UseLiveFormReturn<T extends object> {
isValid: Readable<boolean>;
isDirty: Readable<boolean>;
isTouched: Readable<boolean>;
isValidating: Readable<boolean>;
submitCount: Readable<number>;
initialValues: Readonly<T>;
field<V = unknown>(path: string, options?: FieldOptions): FormField<V>;
fieldArray<V = unknown>(path: string): FormFieldArray<V>;
submit(): Promise<any>;
reset(): void;
sync(newForm: Form<T>): void;
}
/**
* Bind to an Ecto changeset form prop with reactive field instances.
*
* @example
* ```svelte
* <script lang="ts">
* import { useLiveForm } from "live_svelte"
* let { form: serverForm } = $props()
* const liveForm = useLiveForm(serverForm, { changeEvent: "validate", submitEvent: "submit" })
* $effect(() => { liveForm.sync(serverForm) })
* const nameField = liveForm.field("name")
* </script>
* <input {...$nameField.attrs} />
* ```
*/
export declare function useLiveForm<T extends object>(
form: Form<T>,
options?: FormOptions
): UseLiveFormReturn<T>;

View file

@ -0,0 +1,782 @@
/**
* Unit tests for useLiveForm composable.
* Uses vi.mock to intercept getContext calls (called inside useLiveSvelte).
* Uses `get(store)` from svelte/store for synchronous store reads.
*/
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
// Mock svelte BEFORE importing composables (hoisted by vitest).
vi.mock("svelte", () => ({
getContext: vi.fn(),
onDestroy: vi.fn(),
}))
import { get } from "svelte/store"
import { getContext } from "svelte"
import { useLiveForm } from "./useLiveForm"
import type { Form } from "./useLiveForm"
import type { Live } from "./types"
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const mockPushEvent = vi.fn().mockReturnValue(1)
const mockLive: Live = {
pushEvent: mockPushEvent,
pushEventTo: vi.fn().mockReturnValue(2),
handleEvent: vi.fn().mockReturnValue(() => {}),
removeHandleEvent: vi.fn(),
upload: vi.fn(),
uploadTo: vi.fn(),
liveSocket: undefined,
}
const testForm: Form<{ name: string; email: string }> = {
name: "user",
values: { name: "", email: "" },
errors: {},
valid: true,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getContext).mockReturnValue(mockLive)
})
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
describe("useLiveForm — initialization", () => {
it("returns required API surface", () => {
const form = useLiveForm(testForm)
expect(form.field).toBeDefined()
expect(form.fieldArray).toBeDefined()
expect(form.submit).toBeDefined()
expect(form.reset).toBeDefined()
expect(form.sync).toBeDefined()
expect(form.isValid).toBeDefined()
expect(form.isDirty).toBeDefined()
expect(form.isTouched).toBeDefined()
expect(form.isValidating).toBeDefined()
expect(form.submitCount).toBeDefined()
expect(form.initialValues).toBeDefined()
})
it("exposes a frozen initialValues snapshot", () => {
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "a@b.com" } })
expect(form.initialValues.name).toBe("Alice")
expect(form.initialValues.email).toBe("a@b.com")
expect(Object.isFrozen(form.initialValues)).toBe(true)
})
it("isValid is true when no errors", () => {
const form = useLiveForm(testForm)
expect(get(form.isValid)).toBe(true)
})
it("isValid is false when errors present", () => {
const form = useLiveForm({
...testForm,
errors: { name: ["can't be blank"] },
valid: false,
})
expect(get(form.isValid)).toBe(false)
})
it("isDirty is false initially", () => {
const form = useLiveForm(testForm)
expect(get(form.isDirty)).toBe(false)
})
it("isTouched is false initially", () => {
const form = useLiveForm(testForm)
expect(get(form.isTouched)).toBe(false)
})
it("submitCount is 0 initially", () => {
const form = useLiveForm(testForm)
expect(get(form.submitCount)).toBe(0)
})
})
// ---------------------------------------------------------------------------
// field() — value
// ---------------------------------------------------------------------------
describe("field() — value", () => {
it("reflects initial form values", () => {
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
const nameField = form.field("name")
expect(get(nameField).value).toBe("Alice")
})
it("reflects empty string initial value", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(nameField).value).toBe("")
})
it("set() updates the field value in the store", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
nameField.set("Bob")
expect(get(nameField).value).toBe("Bob")
})
it("update() transforms the field value", () => {
const form = useLiveForm({ ...testForm, values: { name: "hello", email: "" } })
const nameField = form.field<string>("name")
nameField.update((v) => v.toUpperCase())
expect(get(nameField).value).toBe("HELLO")
})
it("isDirty becomes true after set()", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(form.isDirty)).toBe(false)
nameField.set("Bob")
expect(get(form.isDirty)).toBe(true)
})
it("field isDirty is false initially, true after mutation", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(nameField).isDirty).toBe(false)
nameField.set("changed")
expect(get(nameField).isDirty).toBe(true)
})
it("memoizes — same field instance returned for same path", () => {
const form = useLiveForm(testForm)
const a = form.field("name")
const b = form.field("name")
expect(a).toBe(b)
})
it("separate instances for same path with different options", () => {
const form = useLiveForm(testForm)
const a = form.field("agree", { type: "checkbox", value: "yes" })
const b = form.field("agree", { type: "checkbox", value: "no" })
expect(a).not.toBe(b)
})
})
// ---------------------------------------------------------------------------
// field() — errors
// ---------------------------------------------------------------------------
describe("field() — errors", () => {
it("errors is empty array when no errors", () => {
const form = useLiveForm(testForm)
expect(get(form.field("name")).errors).toEqual([])
})
it("errors reflects form.errors for a field", () => {
const form = useLiveForm({
...testForm,
errors: { name: ["can't be blank"] },
})
expect(get(form.field("name")).errors).toEqual(["can't be blank"])
})
it("errors can contain multiple messages", () => {
const form = useLiveForm({
...testForm,
errors: { email: ["is invalid", "can't be blank"] },
})
expect(get(form.field("email")).errors).toEqual(["is invalid", "can't be blank"])
})
it("errorMessage is undefined when no errors", () => {
const form = useLiveForm(testForm)
expect(get(form.field("name")).errorMessage).toBeUndefined()
})
it("errorMessage returns first error string", () => {
const form = useLiveForm({
...testForm,
errors: { name: ["can't be blank", "is too short"] },
})
expect(get(form.field("name")).errorMessage).toBe("can't be blank")
})
it("isValid is false when field has errors", () => {
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
expect(get(form.field("name")).isValid).toBe(false)
})
it("isValid is true when field has no errors", () => {
const form = useLiveForm(testForm)
expect(get(form.field("name")).isValid).toBe(true)
})
})
// ---------------------------------------------------------------------------
// field() — attrs
// ---------------------------------------------------------------------------
describe("field() — attrs", () => {
it("attrs includes name and id", () => {
const form = useLiveForm(testForm)
const attrs = get(form.field("name")).attrs
expect(attrs.name).toBe("name")
expect(attrs.id).toBe("name")
})
it("attrs includes value reflecting current field value", () => {
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
const attrs = get(form.field("name")).attrs
expect(attrs.value).toBe("Alice")
})
it("attrs.aria-invalid is false when field is valid", () => {
const form = useLiveForm(testForm)
expect(get(form.field("name")).attrs["aria-invalid"]).toBe(false)
})
it("attrs.aria-invalid is true when field has errors", () => {
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
expect(get(form.field("name")).attrs["aria-invalid"]).toBe(true)
})
it("attrs.aria-describedby is set when field has errors", () => {
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
expect(get(form.field("name")).attrs["aria-describedby"]).toBe("name-error")
})
it("attrs.aria-describedby is absent when field has no errors", () => {
const form = useLiveForm(testForm)
expect(get(form.field("name")).attrs["aria-describedby"]).toBeUndefined()
})
it("attrs includes oninput and onblur handlers", () => {
const form = useLiveForm(testForm)
const attrs = get(form.field("name")).attrs
expect(typeof attrs.oninput).toBe("function")
expect(typeof attrs.onblur).toBe("function")
})
it("attrs.oninput updates field value via event", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
const attrs = get(nameField).attrs
const fakeEvent = { target: { value: "Carol" } } as unknown as Event
attrs.oninput(fakeEvent)
expect(get(nameField).value).toBe("Carol")
})
it("attrs.onblur marks field as touched", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(nameField).isTouched).toBe(false)
get(nameField).attrs.onblur()
expect(get(nameField).isTouched).toBe(true)
})
it("sanitizes dotted paths to valid id strings", () => {
const form = useLiveForm({
name: "user",
values: { profile: { bio: "" } } as any,
errors: {},
valid: true,
})
const attrs = get(form.field("profile.bio")).attrs
expect(attrs.id).toBe("profile_bio")
expect(attrs.name).toBe("profile.bio")
})
})
// ---------------------------------------------------------------------------
// Checkbox attrs
// ---------------------------------------------------------------------------
describe("field() — checkbox attrs", () => {
it("single checkbox checked when value matches", () => {
const form = useLiveForm({
name: "prefs",
values: { agree: true } as any,
errors: {},
valid: true,
})
const attrs = get(form.field("agree", { type: "checkbox" })).attrs
expect(attrs.checked).toBe(true)
expect(attrs.type).toBe("checkbox")
})
it("single checkbox not checked when value is false", () => {
const form = useLiveForm({
name: "prefs",
values: { agree: false } as any,
errors: {},
valid: true,
})
const attrs = get(form.field("agree", { type: "checkbox" })).attrs
expect(attrs.checked).toBe(false)
})
it("multi-checkbox checked when optValue is in array", () => {
const form = useLiveForm({
name: "prefs",
values: { roles: ["admin", "editor"] } as any,
errors: {},
valid: true,
})
const adminAttrs = get(form.field("roles", { type: "checkbox", value: "admin" })).attrs
const userAttrs = get(form.field("roles", { type: "checkbox", value: "user" })).attrs
expect(adminAttrs.checked).toBe(true)
expect(userAttrs.checked).toBe(false)
})
})
// ---------------------------------------------------------------------------
// fieldArray()
// ---------------------------------------------------------------------------
describe("fieldArray()", () => {
const arrayForm: Form<{ items: { title: string }[] }> = {
name: "data",
values: { items: [{ title: "first" }, { title: "second" }, { title: "third" }] },
errors: {},
valid: true,
}
it("fields length matches initial array", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
expect(get(items.fields).length).toBe(3)
})
it("fields array contains FormField instances with correct values", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
const fields = get(items.fields)
expect(get(fields[0]).value).toEqual({ title: "first" })
expect(get(fields[1]).value).toEqual({ title: "second" })
})
it("add() appends an item", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
items.add({ title: "fourth" })
expect(get(items.fields).length).toBe(4)
expect(get(get(items.fields)[3]).value).toEqual({ title: "fourth" })
})
it("add() with no args appends an empty object", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
items.add()
expect(get(items.fields).length).toBe(4)
})
it("remove() removes item at index", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
items.remove(0)
expect(get(items.fields).length).toBe(2)
expect(get(get(items.fields)[0]).value).toEqual({ title: "second" })
})
it("move() swaps items", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
items.move(0, 1)
const fields = get(items.fields)
expect(get(fields[0]).value).toEqual({ title: "second" })
expect(get(fields[1]).value).toEqual({ title: "first" })
})
it("move() with out-of-bounds indices is a no-op", () => {
const form = useLiveForm(arrayForm)
const items = form.fieldArray("items")
items.move(0, 99)
expect(get(items.fields).length).toBe(3)
expect(get(get(items.fields)[0]).value).toEqual({ title: "first" })
})
it("fieldArray is memoized — same instance returned for same path", () => {
const form = useLiveForm(arrayForm)
expect(form.fieldArray("items")).toBe(form.fieldArray("items"))
})
})
// ---------------------------------------------------------------------------
// sync()
// ---------------------------------------------------------------------------
describe("sync()", () => {
it("updates currentErrors with new errors", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(nameField).errors).toEqual([])
form.sync({
...testForm,
errors: { name: ["can't be blank"] },
valid: false,
})
expect(get(nameField).errors).toEqual(["can't be blank"])
})
it("updates currentValues when not validating", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
expect(get(nameField).value).toBe("")
form.sync({ ...testForm, values: { name: "Server", email: "" } })
expect(get(nameField).value).toBe("Server")
})
it("updates isValid based on synced errors", () => {
const form = useLiveForm(testForm)
expect(get(form.isValid)).toBe(true)
form.sync({ ...testForm, errors: { email: ["is invalid"] }, valid: false })
expect(get(form.isValid)).toBe(false)
})
it("does not overwrite values while validating (debounce in-flight)", () => {
vi.useFakeTimers()
const form = useLiveForm(testForm, {
changeEvent: "validate",
debounceInMiliseconds: 300,
})
const nameField = form.field("name")
// User types — debounce timer starts, isValidating = true.
nameField.set("typing")
// Server responds with old data before debounce fires — should NOT overwrite.
form.sync({ ...testForm, values: { name: "", email: "" } })
expect(get(nameField).value).toBe("typing")
vi.useRealTimers()
})
})
// ---------------------------------------------------------------------------
// Debounce and pushEvent
// ---------------------------------------------------------------------------
describe("debounce and pushEvent", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("does not call pushEvent immediately on set()", () => {
const form = useLiveForm(testForm, { changeEvent: "validate" })
form.field("name").set("Bob")
expect(mockPushEvent).not.toHaveBeenCalled()
})
it("calls pushEvent after debounce delay", () => {
const form = useLiveForm(testForm, {
changeEvent: "validate",
debounceInMiliseconds: 300,
})
form.field("name").set("Bob")
vi.advanceTimersByTime(300)
expect(mockPushEvent).toHaveBeenCalledWith("validate", { user: { name: "Bob", email: "" } })
})
it("debounces multiple rapid set() calls into one pushEvent", () => {
const form = useLiveForm(testForm, {
changeEvent: "validate",
debounceInMiliseconds: 300,
})
const nameField = form.field("name")
nameField.set("B")
nameField.set("Bo")
nameField.set("Bob")
vi.advanceTimersByTime(300)
expect(mockPushEvent).toHaveBeenCalledTimes(1)
expect(mockPushEvent).toHaveBeenCalledWith("validate", { user: { name: "Bob", email: "" } })
})
it("does not call pushEvent when changeEvent is null", () => {
const form = useLiveForm(testForm, { changeEvent: null })
form.field("name").set("Bob")
vi.advanceTimersByTime(1000)
expect(mockPushEvent).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// submit()
// ---------------------------------------------------------------------------
describe("submit()", () => {
it("increments submitCount", async () => {
const form = useLiveForm(testForm)
expect(get(form.submitCount)).toBe(0)
// Mock pushEvent to call the reply callback
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
await form.submit()
expect(get(form.submitCount)).toBe(1)
})
it("calls pushEvent with submitEvent and current values", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm(
{ ...testForm, values: { name: "Alice", email: "a@b.com" } },
{ submitEvent: "submit" }
)
await form.submit()
expect(mockPushEvent).toHaveBeenCalledWith(
"submit",
{ user: { name: "Alice", email: "a@b.com" } },
expect.any(Function)
)
})
it("resets form when server replies with { reset: true }", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({ reset: true }, 1)
return 1
})
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
form.field("name").set("Bob")
expect(get(form.isDirty)).toBe(true)
await form.submit()
expect(get(form.isDirty)).toBe(false)
expect(get(form.submitCount)).toBe(0)
})
it("does not reset when server does not reply with reset", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
form.field("name").set("Bob")
await form.submit()
expect(get(form.isDirty)).toBe(true)
})
it("gracefully skips pushEvent when no live context", async () => {
vi.mocked(getContext).mockReturnValue(undefined)
const form = useLiveForm(testForm)
const result = await form.submit()
expect(result).toBeUndefined()
expect(mockPushEvent).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// reset()
// ---------------------------------------------------------------------------
describe("reset()", () => {
it("restores field values to initial values", () => {
const form = useLiveForm({ ...testForm, values: { name: "Alice", email: "" } })
const nameField = form.field("name")
nameField.set("Bob")
expect(get(nameField).value).toBe("Bob")
form.reset()
expect(get(nameField).value).toBe("Alice")
})
it("clears dirty state", () => {
const form = useLiveForm(testForm)
form.field("name").set("dirty")
expect(get(form.isDirty)).toBe(true)
form.reset()
expect(get(form.isDirty)).toBe(false)
})
it("clears errors", () => {
const form = useLiveForm({ ...testForm, errors: { name: ["required"] } })
form.reset()
expect(get(form.isValid)).toBe(true)
expect(get(form.field("name")).errors).toEqual([])
})
it("resets submitCount to 0", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm(testForm)
await form.submit()
expect(get(form.submitCount)).toBe(1)
form.reset()
expect(get(form.submitCount)).toBe(0)
})
it("clears touched state", () => {
const form = useLiveForm(testForm)
const nameField = form.field("name")
get(nameField).attrs.onblur()
expect(get(form.isTouched)).toBe(true)
form.reset()
expect(get(form.isTouched)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// submit() — debounce cancellation (M1 fix)
// ---------------------------------------------------------------------------
describe("submit() — debounce cancellation", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("cancels pending debounce timer before sending submit", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm(testForm, { changeEvent: "validate", debounceInMiliseconds: 300 })
// User types — debounce timer starts.
form.field("name").set("Bob")
expect(get(form.isValidating)).toBe(true)
// User submits before debounce fires.
await form.submit()
// Advance past debounce window — validate pushEvent must NOT fire.
vi.advanceTimersByTime(300)
// Only the submit pushEvent should have been called, not validate.
expect(mockPushEvent).toHaveBeenCalledTimes(1)
expect(mockPushEvent).toHaveBeenCalledWith("submit", expect.any(Object), expect.any(Function))
})
it("clears isValidating flag when submit cancels the debounce", async () => {
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm(testForm, { changeEvent: "validate", debounceInMiliseconds: 300 })
form.field("name").set("Bob")
expect(get(form.isValidating)).toBe(true)
await form.submit()
expect(get(form.isValidating)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// prepareData option (L2)
// ---------------------------------------------------------------------------
describe("prepareData option", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("transforms values before pushEvent on change", () => {
const prepareData = vi.fn((data: Record<string, unknown>) => ({ ...data, _extra: "yes" }))
const form = useLiveForm(testForm, {
changeEvent: "validate",
debounceInMiliseconds: 300,
prepareData,
})
form.field("name").set("Bob")
vi.advanceTimersByTime(300)
expect(prepareData).toHaveBeenCalledWith({ name: "Bob", email: "" })
expect(mockPushEvent).toHaveBeenCalledWith("validate", {
user: { name: "Bob", email: "", _extra: "yes" },
})
})
it("transforms values before pushEvent on submit", async () => {
const prepareData = vi.fn((data: Record<string, unknown>) => ({ ...data, _token: "tok" }))
vi.mocked(mockPushEvent).mockImplementation((_event, _payload, onReply) => {
onReply?.({}, 1)
return 1
})
const form = useLiveForm(
{ ...testForm, values: { name: "Alice", email: "a@b.com" } },
{ prepareData }
)
await form.submit()
expect(mockPushEvent).toHaveBeenCalledWith(
"submit",
{ user: { name: "Alice", email: "a@b.com", _token: "tok" } },
expect.any(Function)
)
})
})
// ---------------------------------------------------------------------------
// FormField sub-field and sub-fieldArray methods (L3)
// ---------------------------------------------------------------------------
describe("FormField.field() and FormField.fieldArray() sub-access", () => {
const nestedForm = {
name: "data",
values: { profile: { bio: "hello" }, items: [{ title: "one" }] },
errors: {} as Record<string, unknown>,
valid: true,
}
it("field().field() accesses a nested sub-path", () => {
const form = useLiveForm(nestedForm as any)
const profileField = form.field("profile")
const bioField = profileField.field("bio")
expect(get(bioField).value).toBe("hello")
})
it("field().field() sub-field path is equivalent to full dot-path", () => {
const form = useLiveForm(nestedForm as any)
const viaSubField = form.field("profile").field("bio")
const viaFullPath = form.field("profile.bio")
// Both should refer to the same memoized instance.
expect(viaSubField).toBe(viaFullPath)
})
it("field().field() set() updates the nested value", () => {
const form = useLiveForm(nestedForm as any)
const bioField = form.field("profile").field<string>("bio")
bioField.set("updated")
expect(get(bioField).value).toBe("updated")
})
it("field().fieldArray() accesses a nested array sub-path", () => {
const form = useLiveForm(nestedForm as any)
const itemsArray = form.field("items").fieldArray("") // via field sub-access
// Direct fieldArray should be equivalent
const directItems = form.fieldArray("items")
expect(get(directItems.fields).length).toBe(1)
})
})

View file

@ -0,0 +1,521 @@
/**
* useLiveForm Svelte composable for Phoenix LiveView form binding.
*
* Binds to an Ecto changeset-backed form prop, providing reactive field
* instances with value, errors, and attrs for inputs. Uses Svelte stores
* (`writable`/`derived` from `svelte/store`) for reactivity so it compiles
* in plain `.ts` without the Svelte compiler plugin in Vitest.
*
* Usage:
* ```svelte
* <script lang="ts">
* import { useLiveForm } from "live_svelte"
* let { form: serverForm } = $props()
* const liveForm = useLiveForm(serverForm, { changeEvent: "validate", submitEvent: "submit" })
* $effect(() => { liveForm.sync(serverForm) })
* const nameField = liveForm.field("name")
* </script>
* <input {...$nameField.attrs} />
* ```
*/
import { writable, derived, get, type Readable, type Writable } from "svelte/store"
import { useLiveSvelte } from "./composables"
// ---------------------------------------------------------------------------
// Utility functions
// ---------------------------------------------------------------------------
function deepClone<T>(val: T): T {
return JSON.parse(JSON.stringify(val))
}
function parsePath(path: string): string[] {
return path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean)
}
function getByPath(obj: any, keys: string[]): any {
return keys.reduce((acc: any, key: string) => (acc != null ? acc[key] : undefined), obj)
}
/** In-place mutation on an already-cloned object. */
function setByPath(obj: any, keys: string[], value: any): void {
if (keys.length === 0) return
const last = keys[keys.length - 1]
const parent = keys.slice(0, -1).reduce((acc: any, k: string) => acc?.[k], obj)
if (parent !== undefined) parent[last] = value
}
function sanitizeId(path: string): string {
return path.replace(/[\[\].]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "")
}
function hasAnyErrors(errors: any): boolean {
if (errors == null) return false
if (Array.isArray(errors)) {
if (errors.length === 0) return false
if (typeof errors[0] === "string") return true
return errors.some((e: any) => hasAnyErrors(e))
}
if (typeof errors === "object") {
return Object.values(errors).some((v: any) => hasAnyErrors(v))
}
return false
}
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* Recursive type for Ecto changeset error maps.
* Each key maps to either an array of error strings or a nested error map.
*/
export type FormErrors<T extends object> = {
[K in keyof T]?: string[] | Record<string, unknown>
}
/**
* Shape of the Phoenix.HTML.Form encoded by LiveSvelte.Encoder.
* Produced by `to_form(changeset)` in the LiveView and passed as a prop.
*/
export interface Form<T extends object> {
/** Form name used as the key in `pushEvent` payloads: `{ [name]: values }` */
name: string
/** Current field values */
values: T
/** Validation errors per field */
errors: FormErrors<T>
/** Whether the changeset is valid */
valid: boolean
}
/** Options for `field()` to specify input type and checkbox value. */
export interface FieldOptions {
/** HTML input type, e.g. `"checkbox"`, `"radio"`, `"number"`. */
type?: string
/** For checkboxes: the value this input represents when checked. */
value?: unknown
}
/** Options for `useLiveForm`. */
export interface FormOptions {
/** Server event for validation. `null` disables automatic validation events. */
changeEvent?: string | null
/** Server event for form submission. Default: `"submit"`. */
submitEvent?: string
/** Debounce delay in ms for change events. Default: `300`. */
debounceInMiliseconds?: number
/** Transform form data before sending to server. */
prepareData?: (data: Record<string, unknown>) => Record<string, unknown>
}
/** Attributes to spread onto an `<input>` element. */
export interface FieldAttrs {
name: string
id: string
oninput: (e: Event) => void
onblur: () => void
"aria-invalid": boolean
"aria-describedby"?: string
value?: unknown
checked?: boolean
type?: string
}
/** Reactive state snapshot for a single form field (the store's value). */
export interface FieldState<V> {
value: V
errors: string[]
errorMessage: string | undefined
isValid: boolean
isDirty: boolean
isTouched: boolean
attrs: FieldAttrs
}
/**
* A reactive field store. Subscribe via `$nameField` in Svelte templates.
* Call `nameField.set(value)` to update the field value programmatically.
*/
export interface FormField<V> extends Readable<FieldState<V>> {
set(value: V): void
update(updater: (currentValue: V) => V): void
/** Access a nested sub-field by dot-path relative to this field. */
field(subPath: string, options?: FieldOptions): FormField<any>
/** Access a nested array sub-field by dot-path relative to this field. */
fieldArray(subPath: string): FormFieldArray<any>
}
/**
* A reactive array-field store. Provides `fields` (array of item field stores)
* and `add`/`remove`/`move` operations.
*/
export interface FormFieldArray<V> extends Readable<FieldState<V[]>> {
set(value: V[]): void
/** Reactive store of per-item `FormField` instances. Subscribe via `$itemFields`. */
fields: Readable<FormField<V>[]>
add(item?: Partial<V>): void
remove(index: number): void
move(from: number, to: number): void
}
/** Return value of `useLiveForm`. */
export interface UseLiveFormReturn<T extends object> {
isValid: Readable<boolean>
isDirty: Readable<boolean>
isTouched: Readable<boolean>
isValidating: Readable<boolean>
submitCount: Readable<number>
/** Frozen snapshot of initial values. */
initialValues: Readonly<T>
field<V = unknown>(path: string, options?: FieldOptions): FormField<V>
fieldArray<V = unknown>(path: string): FormFieldArray<V>
/** Send submit event to the LiveView. Handles `{ reset: true }` reply. */
submit(): Promise<any>
/** Reset form to initial values and clear touched/dirty state. */
reset(): void
/**
* Merge server-side form updates into the composable.
* Always updates errors; updates values only when not validating.
* Call this from a Svelte `$effect(() => { liveForm.sync(serverForm) })`.
*/
sync(newForm: Form<T>): void
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export function useLiveForm<T extends object>(
form: Form<T>,
options: FormOptions = {}
): UseLiveFormReturn<T> {
const {
changeEvent = null,
submitEvent = "submit",
debounceInMiliseconds = 300,
prepareData = (d: Record<string, unknown>) => d,
} = options
// Graceful degradation: composable works without LiveSvelte context (SSR, tests without mock).
let liveCtx: ReturnType<typeof useLiveSvelte> | null = null
try {
liveCtx = useLiveSvelte()
} catch {
// SSR or test without LiveSvelte context — pushEvent unavailable.
}
// Snapshot initial values for dirty detection and reset.
const initialSnapshot = deepClone(form.values)
const initialValues = Object.freeze(initialSnapshot) as Readonly<T>
// Core reactive stores.
const currentValues: Writable<T> = writable(deepClone(form.values))
const currentErrors: Writable<FormErrors<T>> = writable(deepClone(form.errors))
const touchedPaths: Writable<Set<string>> = writable(new Set())
const submitCountStore: Writable<number> = writable(0)
const isValidatingStore: Writable<boolean> = writable(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Form-level derived stores.
const isValid: Readable<boolean> = derived(
currentErrors,
($errors) => !hasAnyErrors($errors)
)
const isDirty: Readable<boolean> = derived(
currentValues,
($values) => JSON.stringify($values) !== JSON.stringify(initialSnapshot)
)
const isTouched: Readable<boolean> = derived(
[submitCountStore, touchedPaths],
([$count, $touched]) => $count > 0 || $touched.size > 0
)
function sendChange(): void {
if (!changeEvent || !liveCtx) return
if (debounceTimer) clearTimeout(debounceTimer)
isValidatingStore.set(true)
debounceTimer = setTimeout(() => {
const values = get(currentValues)
const data = prepareData(values as Record<string, unknown>)
liveCtx!.pushEvent(changeEvent, { [form.name]: data })
isValidatingStore.set(false)
debounceTimer = null
}, debounceInMiliseconds)
}
// Memoize field instances per (path, opts) to prevent recreation on re-renders.
const fieldCache = new Map<string, FormField<any>>()
const fieldArrayCache = new Map<string, FormFieldArray<any>>()
function createField<V>(path: string, opts: FieldOptions = {}): FormField<V> {
const cacheKey = `${path}::${JSON.stringify(opts)}`
if (fieldCache.has(cacheKey)) return fieldCache.get(cacheKey) as FormField<V>
const keys = parsePath(path)
const optValue = opts.value
const fieldId =
sanitizeId(path) + (optValue !== undefined ? `_${sanitizeId(String(optValue))}` : "")
// Stable event handlers closed over constants (not re-created per derive).
const onblurHandler = () => {
touchedPaths.update((s) => {
s.add(path)
return s
})
}
let oninputHandler: (e: Event) => void
if (opts.type === "checkbox") {
oninputHandler = (e: Event) => {
const target = e.target as HTMLInputElement
currentValues.update((vals) => {
const clone = deepClone(vals)
const current = getByPath(clone, keys)
if (Array.isArray(current)) {
// Multi-checkbox: toggle value in array.
const arr = [...current]
const idx = arr.indexOf(optValue)
if (target.checked && idx === -1) arr.push(optValue)
else if (!target.checked && idx !== -1) arr.splice(idx, 1)
setByPath(clone, keys, arr)
} else {
// Single checkbox: set value or null.
const checkedValue = optValue !== undefined ? optValue : true
setByPath(clone, keys, target.checked ? checkedValue : null)
}
return clone
})
sendChange()
}
} else {
oninputHandler = (e: Event) => {
const target = e.target as HTMLInputElement
currentValues.update((vals) => {
const clone = deepClone(vals)
setByPath(clone, keys, target.value)
return clone
})
sendChange()
}
}
const stateStore: Readable<FieldState<V>> = derived(
[currentValues, currentErrors, touchedPaths, submitCountStore],
([$values, $errors, $touched, $count]) => {
const value = getByPath($values, keys) as V
const rawErrors = getByPath($errors, keys)
// Errors are a string[] if the array's first element is a string (or empty).
const errors: string[] =
Array.isArray(rawErrors) &&
(rawErrors.length === 0 || typeof rawErrors[0] === "string")
? (rawErrors as string[])
: []
const errorMessage = errors.length > 0 ? errors[0] : undefined
const isFieldValid = errors.length === 0
const isTouchedField = $count > 0 || $touched.has(path)
const initialVal = getByPath(initialSnapshot, keys)
const isFieldDirty = JSON.stringify(value) !== JSON.stringify(initialVal)
let attrs: FieldAttrs
if (opts.type === "checkbox") {
const isMulti = Array.isArray(value)
attrs = {
name: path,
id: fieldId,
type: "checkbox",
...(optValue !== undefined ? { value: optValue } : {}),
checked: isMulti
? ((value as unknown as any[]) ?? []).includes(optValue)
: value === (optValue !== undefined ? optValue : true),
oninput: oninputHandler,
onblur: onblurHandler,
"aria-invalid": !isFieldValid,
...(errors.length > 0 ? { "aria-describedby": `${fieldId}-error` } : {}),
}
} else {
attrs = {
name: path,
id: fieldId,
...(opts.type ? { type: opts.type } : {}),
value: value as unknown,
oninput: oninputHandler,
onblur: onblurHandler,
"aria-invalid": !isFieldValid,
...(errors.length > 0 ? { "aria-describedby": `${fieldId}-error` } : {}),
}
}
return {
value,
errors,
errorMessage,
isValid: isFieldValid,
isDirty: isFieldDirty,
isTouched: isTouchedField,
attrs,
}
}
)
const fieldStore: FormField<V> = {
subscribe: stateStore.subscribe,
set(value: V) {
currentValues.update((vals) => {
const clone = deepClone(vals)
setByPath(clone, keys, value)
return clone
})
sendChange()
},
update(updater: (v: V) => V) {
currentValues.update((vals) => {
const current = getByPath(vals, keys) as V
const clone = deepClone(vals)
setByPath(clone, keys, updater(current))
return clone
})
sendChange()
},
field(subPath: string, subOpts?: FieldOptions): FormField<any> {
return createField(`${path}.${subPath}`, subOpts)
},
fieldArray(subPath: string): FormFieldArray<any> {
return createFieldArray(`${path}.${subPath}`)
},
}
fieldCache.set(cacheKey, fieldStore)
return fieldStore
}
function createFieldArray<V>(path: string): FormFieldArray<V> {
if (fieldArrayCache.has(path)) return fieldArrayCache.get(path) as FormFieldArray<V>
const keys = parsePath(path)
const baseField = createField<V[]>(path) as FormField<V[]>
const fieldsStore: Readable<FormField<V>[]> = derived(currentValues, ($values) => {
const arr: V[] = getByPath($values, keys) ?? []
return arr.map((_: V, i: number) => createField<V>(`${path}[${i}]`))
})
const arrayStore: FormFieldArray<V> = {
subscribe: baseField.subscribe,
set(value: V[]) {
baseField.set(value)
},
fields: fieldsStore,
add(item?: Partial<V>) {
currentValues.update((vals) => {
const clone = deepClone(vals)
const arr: V[] = getByPath(clone, keys) ?? []
setByPath(clone, keys, [...arr, (item ?? {}) as V])
return clone
})
sendChange()
},
remove(index: number) {
currentValues.update((vals) => {
const clone = deepClone(vals)
const arr: V[] = [...(getByPath(clone, keys) ?? [])]
arr.splice(index, 1)
setByPath(clone, keys, arr)
return clone
})
sendChange()
},
move(from: number, to: number) {
currentValues.update((vals) => {
const clone = deepClone(vals)
const arr: V[] = [...(getByPath(clone, keys) ?? [])]
if (from >= 0 && from < arr.length && to >= 0 && to < arr.length) {
const [item] = arr.splice(from, 1)
arr.splice(to, 0, item)
setByPath(clone, keys, arr)
}
return clone
})
sendChange()
},
}
fieldArrayCache.set(path, arrayStore)
return arrayStore
}
async function submit(): Promise<any> {
// Cancel any pending debounce to prevent stale validate events after submit.
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
isValidatingStore.set(false)
}
if (!liveCtx) {
console.warn("LiveView hook not available, form submission skipped")
return Promise.resolve(undefined)
}
submitCountStore.update((n) => n + 1)
const values = get(currentValues)
const data = prepareData(values as Record<string, unknown>)
return new Promise((resolve) => {
liveCtx!.pushEvent(submitEvent, { [form.name]: data }, (result: any) => {
if (result && result.reset) {
reset()
}
resolve(result)
})
})
}
function reset(): void {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
currentValues.set(deepClone(initialSnapshot))
currentErrors.set({} as FormErrors<T>)
touchedPaths.set(new Set())
submitCountStore.set(0)
isValidatingStore.set(false)
}
function sync(newForm: Form<T>): void {
currentErrors.set(deepClone(newForm.errors))
if (!get(isValidatingStore)) {
currentValues.set(deepClone(newForm.values))
}
}
return {
isValid,
isDirty,
isTouched,
isValidating: { subscribe: isValidatingStore.subscribe },
submitCount: { subscribe: submitCountStore.subscribe },
initialValues,
field: createField,
fieldArray: createFieldArray,
submit,
reset,
sync,
}
}

596
assets/package-lock.json generated
View file

@ -13,6 +13,7 @@
"@vitest/coverage-v8": "^2.1.0",
"esbuild": "^0.24.0",
"esbuild-svelte": "^0.9.0",
"jsdom": "^28.1.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
@ -28,6 +29,13 @@
"version": "0.18.15",
"license": "MIT"
},
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@ -42,6 +50,64 @@
"node": ">=6.0.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.6"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@ -99,6 +165,153 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.1.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz",
"integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0"
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
@ -507,6 +720,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
"integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -1126,6 +1357,16 @@
"acorn": ">=8.9.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@ -1192,6 +1433,16 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
@ -1277,6 +1528,60 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.12.2",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssstyle": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
"integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.0",
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cssstyle/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1295,6 +1600,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -1319,6 +1631,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -1486,6 +1811,19 @@
"node": ">=8"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -1493,6 +1831,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -1503,6 +1869,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
@ -1590,6 +1963,48 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jsdom": {
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
"@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0",
"cssstyle": "^6.0.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
"undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -1649,6 +2064,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/minimatch": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
@ -1708,6 +2130,19 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -1800,6 +2235,26 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@ -1845,6 +2300,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -2068,6 +2536,13 @@
"node": ">=18"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
@ -2127,6 +2602,52 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.23"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2141,6 +2662,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@ -2722,6 +3253,54 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -2853,6 +3432,23 @@
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",

View file

@ -1,10 +1,11 @@
{
"devDependencies": {
"@vitest/coverage-v8": "^2.1.0",
"esbuild": "^0.24.0",
"esbuild-svelte": "^0.9.0",
"jsdom": "^28.1.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0",
"@vitest/coverage-v8": "^2.1.0"
"vitest": "^2.1.0"
},
"dependencies": {
"phoenix": "file:../deps/phoenix",

View file

@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config'
// No resolve alias needed unless a test imports them; then add resolve.alias in Vite config.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['**/*.test.js', '**/*.spec.js', '**/*.test.ts', '**/*.spec.ts'],
coverage: {
provider: 'v8',

View file

@ -2,9 +2,14 @@
import {preventDefault} from "svelte/legacy"
import {fly} from "svelte/transition"
import {elasticOut} from "svelte/easing"
import {useLiveSvelte, useLiveConnection} from "live_svelte"
/** @type {{ messages: any, name: any, live: any }} */
let {messages, name, live} = $props()
/** @type {{ messages: any, name: any }} */
let {messages, name} = $props()
const conn = useLiveConnection()
const { pushEvent } = useLiveSvelte()
let body = $state("")
let messagesElement = $state()
@ -17,7 +22,7 @@
function submitMessage() {
if (body === "") return
live.pushEvent("send_message", {body})
pushEvent("send_message", {body})
body = ""
}
</script>
@ -32,6 +37,11 @@
{name}
</span>
</div>
{#if !conn.connected}
<div data-testid="chat-reconnecting" class="px-4 py-1 text-xs text-warning bg-warning/10 text-center">
Reconnecting…
</div>
{/if}
<ul data-testid="chat-messages" bind:this={messagesElement} class="flex flex-col gap-3 flex-1 min-h-0 overflow-x-clip overflow-y-auto px-4 py-2">
{#each messages as message (message.id)}
{@const me = message.name === name}

View file

@ -0,0 +1,81 @@
<script lang="ts">
import {useLiveForm} from "live_svelte"
import type {Form} from "live_svelte"
interface Props {
form: Form<{name: string; email: string}>
}
let {form: serverForm}: Props = $props()
const liveForm = useLiveForm(serverForm, {
changeEvent: "validate",
submitEvent: "submit",
debounceInMiliseconds: 300,
})
// Sync server-side updates (new errors / values) into the composable.
$effect(() => {
liveForm.sync(serverForm)
})
// Field stores — memoized, same instance returned on every call.
const nameField = liveForm.field("name")
const emailField = liveForm.field("email")
// Form-level stores for template auto-subscription.
const isValid = liveForm.isValid
</script>
<div class="w-full min-w-sm mx-auto">
<form
onsubmit={e => {
e.preventDefault()
liveForm.submit()
}}
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden"
>
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit"> Contact form </span>
<!-- Name field -->
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-base-content/50">Name *</span>
<input
data-testid="form-name-input"
class="input input-bordered input-sm w-full bg-base-200/50 border-base-300"
{...$nameField.attrs}
/>
{#if $nameField.errorMessage}
<p data-testid="form-name-error" class="text-error text-xs">
{$nameField.errorMessage}
</p>
{/if}
</label>
<!-- Email field -->
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-base-content/50">Email *</span>
<input
data-testid="form-email-input"
class="input input-bordered input-sm w-full bg-base-200/50 border-base-300"
{...$emailField.attrs}
/>
{#if $emailField.errorMessage}
<p data-testid="form-email-error" class="text-error text-xs">
{$emailField.errorMessage}
</p>
{/if}
</label>
<div class="flex items-center justify-between">
<button data-testid="form-submit-btn" type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit">
Submit
</button>
<span data-testid="form-valid-indicator" class="badge badge-ghost border badge-sm font-medium">
{$isValid ? "valid" : "invalid"}
</span>
</div>
</div>
</form>
</div>

View file

@ -34,6 +34,10 @@
function updateItem(id) {
live.pushEvent("update_item", { id })
}
function addCappedItem() {
live.pushEvent("add_capped_item", {})
}
</script>
<div class="card bg-base-100 shadow-lg border border-base-300/50">
@ -77,6 +81,9 @@
<button class="btn btn-sm btn-outline" data-testid="reset-button-at-0" onclick={resetStreamAt0}>
Reset (at: 0)
</button>
<button class="btn btn-sm btn-secondary btn-outline" data-testid="add-capped-button" onclick={addCappedItem}>
Add Capped (max 3)
</button>
</div>
<!-- Item count -->

View file

@ -55,6 +55,12 @@ defmodule ExampleWeb.Layouts do
links: [
%{label: "Notes (OTP)", to: ~p"/live-notes-otp"}
]
},
%{
label: "Composables",
links: [
%{label: "Form (useLiveForm)", to: ~p"/live-form"}
]
}
]
end

View file

@ -219,6 +219,24 @@
</ul>
</div>
</div>
<%!-- Composables --%>
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-neutral badge-lg">7</span>
Composables
</h2>
<ul class="space-y-2">
<li>
<a href={~p"/live-form"} class="link link-primary">
Form (useLiveForm)
</a>
<span class="text-base-content/50 text-sm">- Server-side validation with Ecto changesets</span>
</li>
</ul>
</div>
</div>
</div>
<div class="mt-12 text-center text-base-content/50 text-sm">

View file

@ -0,0 +1,78 @@
defmodule ExampleWeb.LiveForm do
@moduledoc """
LiveView demo for `useLiveForm()` composable.
Demonstrates server-side validation with Ecto changesets and form reset on success.
"""
use ExampleWeb, :live_view
# ---------------------------------------------------------------------------
# Inline embedded schema (no database required)
# ---------------------------------------------------------------------------
defmodule Schema do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field(:name, :string)
field(:email, :string)
end
def changeset(schema \\ %__MODULE__{}, attrs) do
schema
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/, message: "must contain @")
|> validate_length(:name, min: 2, message: "must be at least 2 characters")
end
end
defp empty_form do
Ecto.Changeset.cast(%Schema{name: "", email: ""}, %{}, [:name, :email])
|> to_form(as: "form_data")
end
# ---------------------------------------------------------------------------
# LiveView callbacks
# ---------------------------------------------------------------------------
def mount(_params, _session, socket) do
{:ok, assign(socket, form: empty_form())}
end
def handle_event("validate", params, socket) do
attrs = params["form_data"] || %{}
form =
Schema.changeset(%Schema{}, attrs)
|> to_form(as: "form_data", action: :validate)
{:noreply, assign(socket, form: form)}
end
def handle_event("submit", params, socket) do
attrs = params["form_data"] || %{}
changeset = Schema.changeset(%Schema{}, attrs)
if changeset.valid? do
# Successful submit — tell the client to reset, return a clean form.
{:reply, %{reset: true}, assign(socket, form: empty_form())}
else
form = changeset |> to_form(as: "form_data", action: :validate)
{:reply, %{}, assign(socket, form: form)}
end
end
def render(assigns) do
~H"""
<div class="flex flex-col justify-center items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">Form (useLiveForm)</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">
Server-side Ecto changeset validation with debounced change events and automatic form reset on success.
</p>
<.svelte name="FormDemo" props={%{form: @form}} socket={@socket} />
</div>
"""
end
end

View file

@ -62,6 +62,21 @@ defmodule ExampleWeb.Streams do
{:noreply, stream_insert(socket, :items, updated)}
end
def handle_event("add_capped_item", _params, socket) do
new_item = %{
id: socket.assigns.next_id,
name: "Capped #{socket.assigns.next_id}",
description: "Stream keeps last 3 items"
}
socket =
socket
|> stream_insert(:items, new_item, limit: -3)
|> assign(:next_id, socket.assigns.next_id + 1)
{:noreply, socket}
end
def handle_event("clear_stream", _params, socket) do
{:noreply, stream(socket, :items, [], reset: true)}
end

View file

@ -41,6 +41,7 @@ defmodule ExampleWeb.Router do
live "/live-client-side-loading", LiveClientSideLoading
# Ecto Examples
live "/live-notes-otp", LiveNotesOtp
live "/live-form", LiveForm
# not referenced in app.html.heex:
live "/live-composition", LiveComposition
end

View file

@ -33,6 +33,15 @@ defmodule ExampleWeb.LiveChatTest do
|> assert_has(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
end
test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{session: session} do
session
|> visit("/live-chat")
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")
|> click(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
|> assert_has(Query.css("[data-testid='chat-message-input']"))
|> refute_has(Query.css("[data-testid='chat-reconnecting']"))
end
test "joining with a name shows chat UI with message input", %{session: session} do
session =
session

View file

@ -0,0 +1,98 @@
defmodule ExampleWeb.LiveFormTest do
@moduledoc """
E2E tests for the LiveForm LiveView (/live-form).
Validates useLiveForm() composable: initial render, server validation,
and successful submit with form reset.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Wait for an element with the given testid to appear (retries up to 3s).
defp wait_for(session, testid, attempts \\ 30)
defp wait_for(_session, testid, 0), do: raise("timeout waiting for [data-testid='#{testid}']")
defp wait_for(session, testid, attempts) do
els = session |> all(Query.css("[data-testid='#{testid}']"))
if length(els) > 0 do
session
else
:timer.sleep(100)
wait_for(session, testid, attempts - 1)
end
end
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
test "initial form renders with inputs, no errors, and submit button", %{session: session} do
session
|> visit("/live-form")
|> wait_for("form-name-input")
|> assert_has(Query.css("[data-testid='form-name-input']"))
|> assert_has(Query.css("[data-testid='form-email-input']"))
|> assert_has(Query.css("[data-testid='form-submit-btn']"))
|> refute_has(Query.css("[data-testid='form-name-error']"))
|> refute_has(Query.css("[data-testid='form-email-error']"))
end
test "server validation: submitting empty form shows required errors", %{session: session} do
session =
session
|> visit("/live-form")
|> wait_for("form-submit-btn")
|> click(Query.css("[data-testid='form-submit-btn']"))
# Wait for server round-trip to deliver errors.
:timer.sleep(500)
session
|> assert_has(Query.css("[data-testid='form-name-error']"))
|> assert_has(Query.css("[data-testid='form-email-error']"))
end
test "server validation: typing invalid email shows error after debounce", %{session: session} do
session =
session
|> visit("/live-form")
|> wait_for("form-name-input")
|> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
|> fill_in(Query.css("[data-testid='form-email-input']"), with: "not-an-email")
# Wait for debounce (300ms) + server round-trip (allow 600ms total).
:timer.sleep(600)
session
|> assert_has(Query.css("[data-testid='form-email-error']"))
end
test "submitting valid form resets fields (re-submit verifies empty values)", %{session: session} do
session =
session
|> visit("/live-form")
|> wait_for("form-name-input")
|> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
|> fill_in(Query.css("[data-testid='form-email-input']"), with: "alice@example.com")
|> click(Query.css("[data-testid='form-submit-btn']"))
# Wait for first submit to complete and form to reset.
:timer.sleep(600)
# Submit again with now-empty fields — server should reject with validation errors.
# This proves the form was reset (values are empty, not "Alice" / "alice@...").
session =
session
|> click(Query.css("[data-testid='form-submit-btn']"))
:timer.sleep(500)
session
|> assert_has(Query.css("[data-testid='form-name-error']"))
end
end

View file

@ -103,7 +103,6 @@ defmodule ExampleWeb.StreamsTest do
|> click(Query.css("[data-testid='update-1']"))
# Still exactly 3 items — no duplication
items = all(session, Query.css("[data-testid^='item-'][data-testid$='-1'], [data-testid='item-1'], [data-testid='item-2'], [data-testid='item-3']"))
session |> find(Query.css("[data-testid='item-1']"))
session |> find(Query.css("[data-testid='item-2']"))
session |> find(Query.css("[data-testid='item-3']"))
@ -115,6 +114,27 @@ defmodule ExampleWeb.StreamsTest do
session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
end
test "stream limit enforced: capped insert keeps only last 3 items", %{session: session} do
session =
session
|> visit("/streams")
# Initial state: items 1, 2, 3. Click "Add Capped (max 3)" once to add item 4.
# With limit: -3, the stream keeps the last 3 → items 2, 3, 4.
|> click(Query.css("[data-testid='add-capped-button']"))
# Items 2, 3, 4 are present
session |> find(Query.css("[data-testid='item-2']"))
session |> find(Query.css("[data-testid='item-3']"))
session |> find(Query.css("[data-testid='item-4']"))
# Item 1 was evicted by the limit
items_1 = all(session, Query.css("[data-testid='item-1']"))
assert items_1 == []
# Total item count is 3
session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
end
test "sequential add and remove maintains correct state", %{session: session} do
session =
session

View file

@ -348,7 +348,7 @@ defmodule LiveSvelte do
end
# Generates JSON Patch ops for a single %LiveStream{}.
# Handles both LV 0.18.x (3-tuple inserts, no reset?) and LV 1.0.x (4-tuple inserts, has reset?/limit).
# Handles LV 0.18.x (3-tuple), LV 1.0.x (4-tuple), and LV ≥ 1.0.x (5-tuple with update_only).
# Op order: reset → deletes → inserts (each prepended, then list reversed).
defp generate_stream_patches(stream_name, stream) do
reset? = Map.get(stream, :reset?, false)
@ -368,14 +368,27 @@ defmodule LiveSvelte do
|> Enum.reverse()
|> Enum.reduce(patches, fn insert, acc ->
case insert do
{dom_id, at, item, limit, update_only} ->
item_map = encode_stream_item(item, dom_id)
acc =
if update_only do
[%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item_map} | acc]
else
at_path = if at == -1, do: "-", else: to_string(at)
[%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
end
if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
{dom_id, at, item, limit} ->
item_map = Map.put(item, :__dom_id, dom_id)
item_map = encode_stream_item(item, dom_id)
at_path = if at == -1, do: "-", else: to_string(at)
acc = [%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
{dom_id, at, item} ->
item_map = Map.put(item, :__dom_id, dom_id)
item_map = encode_stream_item(item, dom_id)
at_path = if at == -1, do: "-", else: to_string(at)
[%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
end
@ -384,6 +397,15 @@ defmodule LiveSvelte do
Enum.reverse(patches)
end
# Encodes a stream item via LiveSvelte.Encoder before attaching __dom_id.
# Encoding MUST happen first so that @derive {only: [...]} restrictions are applied
# before __dom_id is added (otherwise __dom_id could be stripped by the struct encoder).
defp encode_stream_item(item, dom_id) do
item
|> LiveSvelte.Encoder.encode([])
|> Map.put(:__dom_id, dom_id)
end
# Encodes structs via LiveSvelte.Encoder so Jsonpatch can compare them.
defp encode_for_diff(struct) when is_struct(struct), do: LiveSvelte.Encoder.encode(struct)
defp encode_for_diff(other), do: other

View file

@ -1,7 +1,15 @@
# Struct used to test @derive {LiveSvelte.Encoder, only: [...]} in stream items.
# Must be defined at file level (compile-time) for @derive to work.
defmodule LiveSvelte.StreamsTest.SecretItem do
@derive {LiveSvelte.Encoder, only: [:id, :name]}
defstruct [:id, :name, :secret]
end
defmodule LiveSvelte.StreamsTest do
use ExUnit.Case, async: true
alias Phoenix.LiveView.LiveStream
alias LiveSvelte.StreamsTest.SecretItem
# Build a %LiveStream{} compatible with library's phoenix_live_view 0.18.15.
# Inserts are 3-tuples {dom_id, at, item} in 0.18.15 and 4-tuples {dom_id, at, item, limit}
@ -150,6 +158,28 @@ defmodule LiveSvelte.StreamsTest do
upsert_ops = Enum.filter(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert length(upsert_ops) == 2
end
test "4-tuple insert with non-nil limit emits limit op" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, 10}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
assert limit_op != nil, "expected limit op"
assert Enum.at(limit_op, 1) == "/items"
assert Enum.at(limit_op, 2) == 10
end
test "4-tuple insert with nil limit does not emit limit op" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, nil}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
assert limit_op == nil, "must not emit limit op when limit is nil"
end
end
describe "delete ops" do
@ -175,6 +205,130 @@ defmodule LiveSvelte.StreamsTest do
end
end
describe "struct encoding in stream items" do
test "struct with @derive only: [...] does not expose restricted fields in upsert value" do
item = %SecretItem{id: 1, name: "Alice", secret: "hidden_password"}
stream = make_stream(inserts: [{"items-1", -1, item}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert upsert_op != nil, "expected upsert op"
value = Enum.at(upsert_op, 2)
assert value["id"] == 1
assert value["name"] == "Alice"
assert value["__dom_id"] == "items-1"
refute Map.has_key?(value, "secret"), "sensitive field must not appear in stream diff"
end
test "struct encoding does not lose __dom_id even when only: [...] is used" do
item = %SecretItem{id: 42, name: "Bob", secret: "top_secret"}
stream = make_stream(inserts: [{"items-42", -1, item}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
value = Enum.at(upsert_op, 2)
assert value["__dom_id"] == "items-42", "__dom_id must always be present"
end
test "plain map stream items still work after encoder integration" do
item = %{id: 99, name: "Plain", extra: "visible"}
stream = make_stream(inserts: [{"items-99", -1, item}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
value = Enum.at(upsert_op, 2)
assert value["id"] == 99
assert value["name"] == "Plain"
assert value["extra"] == "visible"
assert value["__dom_id"] == "items-99"
end
end
describe "5-tuple inserts (update_only)" do
test "update_only: true generates replace op at $$dom_id path" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Updated"}, nil, true}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
replace_op = Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
end)
assert replace_op != nil, "expected replace op at $$dom_id path for update_only: true"
assert Enum.at(replace_op, 1) == "/items/$$items-1"
# Must NOT generate upsert for update_only: true
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert upsert_op == nil, "must NOT generate upsert when update_only: true"
end
test "update_only: false with 5-tuple generates upsert op" do
stream = make_stream(inserts: [{"items-2", -1, %{id: 2, name: "New"}, nil, false}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert upsert_op != nil, "expected upsert op when update_only: false"
assert Enum.at(upsert_op, 1) == "/items/-"
end
test "5-tuple with limit emits limit op" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, 5, false}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
assert limit_op != nil, "expected limit op"
assert Enum.at(limit_op, 1) == "/items"
assert Enum.at(limit_op, 2) == 5
end
test "5-tuple with nil limit does not emit limit op" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, nil, false}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
assert limit_op == nil, "must not emit limit op when limit is nil"
end
test "5-tuple with negative limit emits negative limit op (keep last N)" do
stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, -3, false}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
assert limit_op != nil, "expected limit op"
assert Enum.at(limit_op, 1) == "/items"
assert Enum.at(limit_op, 2) == -3
end
test "update_only: true value includes __dom_id" do
stream = make_stream(inserts: [{"items-5", -1, %{id: 5, name: "X"}, nil, true}])
assigns = base_assigns() |> Map.put(:items, stream)
html = render_html(assigns)
diff = decode_streams_diff(html)
replace_op = Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
end)
value = Enum.at(replace_op, 2)
assert value["__dom_id"] == "items-5"
assert value["id"] == 5
end
end
describe "multiple stream assigns" do
test "two stream assigns both appear in streams-diff" do
stream_a = make_stream(inserts: [{"a-1", -1, %{id: 1, name: "A"}}])