chore: added file upload

This commit is contained in:
Denis Donici 2026-02-25 09:16:04 +02:00
parent ca4353a562
commit fa56ed9790
10 changed files with 1278 additions and 1 deletions

View file

@ -15,3 +15,5 @@ export type {
FormFieldArray,
UseLiveFormReturn,
} from "./useLiveForm";
export { useLiveUpload } from "./useLiveUpload";
export type { UploadEntry, UploadConfig, UploadOptions, UseLiveUploadReturn } from "./useLiveUpload";

View file

@ -253,3 +253,109 @@ export declare function useLiveForm<T extends object>(
options?: FormOptions
): UseLiveFormReturn<T>;
// ---------------------------------------------------------------------------
// useLiveUpload types
// Keep in sync with useLiveUpload.ts — that file is the source of truth.
// ---------------------------------------------------------------------------
/**
* Shape of a single upload entry from Phoenix.LiveView.UploadEntry,
* encoded by LiveSvelte.Encoder.
*/
export interface UploadEntry {
/** Phoenix upload ref for this entry (e.g. "phx-ref-0"). */
ref: string;
/** Original filename from the client. */
client_name: string;
/** File size in bytes. */
client_size: number;
/** MIME type. */
client_type: string;
/** Upload progress 0100. */
progress: number;
/** Whether the upload has completed. */
done: boolean;
/** Whether the entry passes accept/size validations. */
valid: boolean;
/** Whether Phoenix has acknowledged (preflighted) this entry. */
preflighted: boolean;
/** Entry-specific validation error messages. */
errors: string[];
}
/**
* Shape of a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder.
* Pass `@uploads.name` as a prop from the LiveView.
*/
export interface UploadConfig {
/** Phoenix upload ref (e.g. "phx-abc123"). */
ref: string;
/** Upload name matching `allow_upload(:name, ...)` in the LiveView. */
name: string;
/** Accepted file types (e.g. ".jpg,.png") or false for any. */
accept: string | false;
/** Maximum number of concurrent uploads. */
max_entries: number;
/** When true, uploads begin as soon as files are selected. */
auto_upload: boolean;
/** Current upload entries. */
entries: UploadEntry[];
/** Top-level upload config errors (ref + error message pairs). */
errors: { ref: string; error: string }[];
}
/** Options for `useLiveUpload`. */
export interface UploadOptions {
/** Server event name for Phoenix phx-change (validation). Optional. */
changeEvent?: string;
/** Server event name for Phoenix phx-submit (required). */
submitEvent: string;
}
/** Return value of `useLiveUpload`. */
export interface UseLiveUploadReturn {
/** Reactive list of current upload entries from the server. */
entries: Readable<UploadEntry[]>;
/** Overall upload progress 0100 averaged across all entries. */
progress: Readable<number>;
/** True when the upload config has no top-level errors. */
valid: Readable<boolean>;
/** The underlying hidden `<input type="file">` element store. */
inputEl: Readable<HTMLInputElement | null>;
/** Opens the native file-picker dialog. */
showFilePicker(): void;
/** Enqueue files from an array or DataTransfer (for drag-drop). */
addFiles(files: File[] | DataTransfer): void;
/** Dispatch a form submit event to trigger Phoenix upload (manual upload). */
submit(): void;
/** Cancel a specific entry by ref, or all entries when omitted. */
cancel(ref?: string): void;
/** Reset the hidden input value to clear the file queue. */
clear(): void;
/**
* Merge an updated UploadConfig from the server into the composable.
* Call from a Svelte `$effect(() => { upload.sync(props.uploads.avatar) })`.
*/
sync(newConfig: UploadConfig): void;
}
/**
* Bind to a Phoenix.LiveView.UploadConfig prop for reactive file upload management.
*
* @example
* ```svelte
* <script lang="ts">
* import { useLiveUpload } from "live_svelte"
* let { uploads } = $props()
* const upload = useLiveUpload(uploads.avatar, { changeEvent: "validate", submitEvent: "save" })
* $effect(() => { upload.sync(uploads.avatar) })
* </script>
* <button onclick={() => upload.showFilePicker()}>Select Files</button>
* {#each $upload.entries as entry}{entry.client_name}{/each}
* ```
*/
export declare function useLiveUpload(
uploadConfig: UploadConfig,
options: UploadOptions
): UseLiveUploadReturn;

View file

@ -0,0 +1,527 @@
/**
* Tests for useLiveUpload composable.
*
* Mocking strategy:
* - `svelte` module is mocked: getContext (for useLiveSvelte), onMount (auto-invoke callback),
* onDestroy (no-op). vi.mock is hoisted before imports.
* - The onMount mock invokes the callback immediately (synchronous) so DOM operations
* execute during each test without needing a real Svelte component lifecycle.
* - jsdom environment (from vitest.config.js) provides document.createElement, DataTransfer, etc.
*/
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
import { get } from "svelte/store"
// jsdom does not implement DataTransfer. Polyfill it so addFiles() tests can run.
if (typeof DataTransfer === "undefined") {
class MockDataTransfer {
private _files: File[] = []
items = {
add: (file: File) => {
this._files.push(file)
},
}
get files(): FileList {
const arr = this._files
return Object.assign(arr, {
item: (i: number) => arr[i] ?? null,
}) as unknown as FileList
}
}
;(globalThis as any).DataTransfer = MockDataTransfer
}
// CRITICAL: vi.mock is hoisted before imports by Vitest. Must appear before
// any import of the module under test.
vi.mock("svelte", () => ({
getContext: vi.fn(),
onMount: vi.fn((fn: () => (() => void) | void) => {
const cleanup = fn()
// Store cleanup on the mock for explicit teardown in tests if needed.
;(vi.mocked(onMount) as any).__lastCleanup = cleanup
}),
onDestroy: vi.fn(),
}))
import { getContext, onMount } from "svelte"
import { useLiveUpload } from "./useLiveUpload"
import type { UploadConfig, UploadEntry } from "./useLiveUpload"
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const mockEl = document.createElement("div")
document.body.appendChild(mockEl)
const mockPushEvent = vi.fn().mockReturnValue(1)
const mockLive = {
pushEvent: mockPushEvent,
pushEventTo: vi.fn(),
handleEvent: vi.fn().mockReturnValue(() => {}),
removeHandleEvent: vi.fn(),
upload: vi.fn(),
uploadTo: vi.fn(),
liveSocket: undefined,
el: mockEl,
}
function makeEntry(partial: Partial<UploadEntry> = {}): UploadEntry {
return {
ref: "e1",
client_name: "test.txt",
client_size: 1024,
client_type: "text/plain",
progress: 0,
done: false,
valid: true,
preflighted: false,
errors: [],
...partial,
}
}
const baseConfig: UploadConfig = {
ref: "phx-ref-0",
name: "avatar",
accept: ".jpg,.png",
max_entries: 1,
auto_upload: false,
entries: [],
errors: [],
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getContext).mockReturnValue(mockLive)
// Clear children of mockEl between tests.
mockEl.innerHTML = ""
;(vi.mocked(onMount) as any).__lastCleanup = undefined
})
afterEach(() => {
// Run cleanup function if the onMount callback returned one.
const cleanup = (vi.mocked(onMount) as any).__lastCleanup
if (typeof cleanup === "function") cleanup()
})
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
describe("initialization", () => {
it("returns all expected properties", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(upload).toHaveProperty("entries")
expect(upload).toHaveProperty("progress")
expect(upload).toHaveProperty("valid")
expect(upload).toHaveProperty("inputEl")
expect(upload).toHaveProperty("showFilePicker")
expect(upload).toHaveProperty("addFiles")
expect(upload).toHaveProperty("submit")
expect(upload).toHaveProperty("cancel")
expect(upload).toHaveProperty("clear")
expect(upload).toHaveProperty("sync")
})
it("onMount is called during composable initialisation", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
expect(vi.mocked(onMount)).toHaveBeenCalledTimes(1)
})
it("creates hidden form appended to live.el", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const form = mockEl.querySelector("form")
expect(form).not.toBeNull()
expect(form?.style.display).toBe("none")
})
it("creates input with required Phoenix upload attributes", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input).not.toBeNull()
expect(input.id).toBe(baseConfig.ref)
expect(input.name).toBe(baseConfig.name)
expect(input.getAttribute("data-phx-hook")).toBe("Phoenix.LiveFileUpload")
expect(input.getAttribute("data-phx-update")).toBe("ignore")
expect(input.getAttribute("data-phx-upload-ref")).toBe(baseConfig.ref)
})
it("sets accept attribute when config.accept is a string", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.accept).toBe(".jpg,.png")
})
it("does NOT set accept attribute when config.accept is false", () => {
const config = { ...baseConfig, accept: false as const }
useLiveUpload(config, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.accept).toBe("")
})
it("sets multiple attribute when max_entries > 1", () => {
const config = { ...baseConfig, max_entries: 3 }
useLiveUpload(config, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.multiple).toBe(true)
})
it("does NOT set multiple when max_entries = 1", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.multiple).toBe(false)
})
it("sets data-phx-auto-upload when auto_upload is true", () => {
const config = { ...baseConfig, auto_upload: true }
useLiveUpload(config, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.getAttribute("data-phx-auto-upload")).toBe("true")
})
it("does NOT set data-phx-auto-upload when auto_upload is false", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const input = mockEl.querySelector("input[type=file]") as HTMLInputElement
expect(input.getAttribute("data-phx-auto-upload")).toBeNull()
})
it("sets phx-change on form when changeEvent is provided", () => {
useLiveUpload(baseConfig, { changeEvent: "validate", submitEvent: "save" })
const form = mockEl.querySelector("form")
expect(form?.getAttribute("phx-change")).toBe("validate")
})
it("does NOT set phx-change on form when changeEvent is omitted", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const form = mockEl.querySelector("form")
expect(form?.getAttribute("phx-change")).toBeNull()
})
it("sets phx-submit on form", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
const form = mockEl.querySelector("form")
expect(form?.getAttribute("phx-submit")).toBe("save")
})
it("exposes inputEl store with the created input element", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)
expect(input).not.toBeNull()
expect(input?.tagName).toBe("INPUT")
})
})
// ---------------------------------------------------------------------------
// Reactive stores — initial state
// ---------------------------------------------------------------------------
describe("reactive stores — initial state", () => {
it("entries store is empty initially", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.entries)).toEqual([])
})
it("progress returns 0 when there are no entries", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.progress)).toBe(0)
})
it("valid returns true when errors is empty", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.valid)).toBe(true)
})
it("valid returns false when config has top-level errors", () => {
const config = { ...baseConfig, errors: [{ ref: "phx-ref-0", error: "too many files" }] }
const upload = useLiveUpload(config, { submitEvent: "save" })
expect(get(upload.valid)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// sync()
// ---------------------------------------------------------------------------
describe("sync()", () => {
it("updates entries store when new config has entries", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const entry = makeEntry({ client_name: "img.jpg", progress: 50 })
upload.sync({ ...baseConfig, entries: [entry] })
expect(get(upload.entries)).toHaveLength(1)
expect(get(upload.entries)[0].client_name).toBe("img.jpg")
})
it("clears entries when synced with empty entries", () => {
const entry = makeEntry()
const configWithEntry = { ...baseConfig, entries: [entry] }
const upload = useLiveUpload(configWithEntry, { submitEvent: "save" })
upload.sync(baseConfig)
expect(get(upload.entries)).toHaveLength(0)
})
it("updates progress after sync", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
upload.sync({
...baseConfig,
entries: [
makeEntry({ ref: "e1", progress: 60 }),
makeEntry({ ref: "e2", progress: 40 }),
],
})
expect(get(upload.progress)).toBe(50)
})
it("rounds fractional progress", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
upload.sync({
...baseConfig,
entries: [
makeEntry({ ref: "e1", progress: 33 }),
makeEntry({ ref: "e2", progress: 34 }),
makeEntry({ ref: "e3", progress: 34 }),
],
})
// (33+34+34)/3 = 33.666... → rounds to 34
expect(get(upload.progress)).toBe(34)
})
it("updates valid after sync changes errors", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.valid)).toBe(true)
upload.sync({ ...baseConfig, errors: [{ ref: "phx-ref-0", error: "too large" }] })
expect(get(upload.valid)).toBe(false)
})
it("updates input ref attributes when entries change", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
expect(input.getAttribute("data-phx-active-refs")).toBe("")
const entry = makeEntry({ ref: "e99", done: false, preflighted: false })
upload.sync({ ...baseConfig, entries: [entry] })
expect(input.getAttribute("data-phx-active-refs")).toBe("e99")
})
it("sets data-phx-done-refs from done entries", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
const entry = makeEntry({ ref: "e1", done: true })
upload.sync({ ...baseConfig, entries: [entry] })
expect(input.getAttribute("data-phx-done-refs")).toBe("e1")
})
it("sets data-phx-preflighted-refs from preflighted entries", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
const entry = makeEntry({ ref: "e1", preflighted: true })
upload.sync({ ...baseConfig, entries: [entry] })
expect(input.getAttribute("data-phx-preflighted-refs")).toBe("e1")
})
})
// ---------------------------------------------------------------------------
// showFilePicker()
// ---------------------------------------------------------------------------
describe("showFilePicker()", () => {
it("calls click() on the hidden input", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
const clickSpy = vi.spyOn(input, "click")
upload.showFilePicker()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
})
// ---------------------------------------------------------------------------
// addFiles()
// ---------------------------------------------------------------------------
describe("addFiles()", () => {
// jsdom does not support assigning arbitrary FileList to input.files.
// We override the files property on the specific input element to make it
// writable before calling addFiles(), then verify the change event fires.
it("accepts an array of File objects and dispatches change event", () => {
vi.useFakeTimers()
try {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
// Make files writable on this instance so jsdom doesn't throw.
Object.defineProperty(input, "files", { writable: true, configurable: true, value: null })
const dispatchSpy = vi.spyOn(input, "dispatchEvent")
const file = new File(["content"], "test.txt", { type: "text/plain" })
upload.addFiles([file])
vi.runAllTimers()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "change", bubbles: true })
)
} finally {
vi.useRealTimers()
}
})
it("accepts a DataTransfer object and dispatches change event", () => {
vi.useFakeTimers()
try {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
Object.defineProperty(input, "files", { writable: true, configurable: true, value: null })
const dispatchSpy = vi.spyOn(input, "dispatchEvent")
const dt = new DataTransfer()
dt.items.add(new File(["content"], "file.png", { type: "image/png" }))
upload.addFiles(dt)
vi.runAllTimers()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "change", bubbles: true })
)
} finally {
vi.useRealTimers()
}
})
it("does NOT dispatch change event when DataTransfer is unavailable", () => {
vi.stubGlobal("DataTransfer", undefined)
vi.useFakeTimers()
try {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
const dispatchSpy = vi.spyOn(input, "dispatchEvent")
upload.addFiles([new File(["content"], "test.txt")])
vi.runAllTimers()
expect(dispatchSpy).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
vi.unstubAllGlobals()
}
})
})
// ---------------------------------------------------------------------------
// submit()
// ---------------------------------------------------------------------------
describe("submit()", () => {
it("dispatches submit event on the form", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
const form = input.form!
const dispatchSpy = vi.spyOn(form, "dispatchEvent")
upload.submit()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "submit", bubbles: true })
)
})
})
// ---------------------------------------------------------------------------
// cancel()
// ---------------------------------------------------------------------------
describe("cancel()", () => {
it("cancel(ref) calls pushEvent with cancel-upload and the ref", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
upload.cancel("e1")
expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e1" })
})
it("cancel() without args cancels all current entries", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
upload.sync({
...baseConfig,
entries: [makeEntry({ ref: "e1" }), makeEntry({ ref: "e2" })],
})
upload.cancel()
expect(mockPushEvent).toHaveBeenCalledTimes(2)
expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e1" })
expect(mockPushEvent).toHaveBeenCalledWith("cancel-upload", { ref: "e2" })
})
it("cancel() with no entries makes no pushEvent calls", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
upload.cancel()
expect(mockPushEvent).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// clear()
// ---------------------------------------------------------------------------
describe("clear()", () => {
it("resets hidden input value", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
const input = get(upload.inputEl)!
// Simulate a value being present.
Object.defineProperty(input, "value", { writable: true, value: "C:\\fakepath\\file.txt" })
upload.clear()
expect(input.value).toBe("")
})
})
// ---------------------------------------------------------------------------
// DOM cleanup on destroy
// ---------------------------------------------------------------------------
describe("DOM cleanup", () => {
it("removes form from DOM when cleanup is called", () => {
useLiveUpload(baseConfig, { submitEvent: "save" })
expect(mockEl.querySelector("form")).not.toBeNull()
// Run the cleanup function returned by onMount.
const cleanup = (vi.mocked(onMount) as any).__lastCleanup
expect(typeof cleanup).toBe("function")
cleanup()
expect(mockEl.querySelector("form")).toBeNull()
})
it("sets inputEl to null on cleanup", () => {
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.inputEl)).not.toBeNull()
const cleanup = (vi.mocked(onMount) as any).__lastCleanup
cleanup()
expect(get(upload.inputEl)).toBeNull()
})
})
// ---------------------------------------------------------------------------
// Graceful degradation (no LiveSvelte context)
// ---------------------------------------------------------------------------
describe("graceful degradation without LiveSvelte context", () => {
it("initialises without throwing when getContext returns null", () => {
vi.mocked(getContext).mockReturnValue(null)
expect(() => useLiveUpload(baseConfig, { submitEvent: "save" })).not.toThrow()
})
it("does not append form to any element when live context is absent", () => {
vi.mocked(getContext).mockReturnValue(null)
useLiveUpload(baseConfig, { submitEvent: "save" })
// mockEl should have no children since liveCtx is null.
expect(mockEl.querySelector("form")).toBeNull()
})
it("cancel() silently does nothing when context is absent", () => {
vi.mocked(getContext).mockReturnValue(null)
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(() => upload.cancel("e1")).not.toThrow()
expect(mockPushEvent).not.toHaveBeenCalled()
})
it("stores still work without live context", () => {
vi.mocked(getContext).mockReturnValue(null)
const upload = useLiveUpload(baseConfig, { submitEvent: "save" })
expect(get(upload.entries)).toEqual([])
expect(get(upload.progress)).toBe(0)
expect(get(upload.valid)).toBe(true)
upload.sync({ ...baseConfig, entries: [makeEntry()] })
expect(get(upload.entries)).toHaveLength(1)
})
})

View file

@ -0,0 +1,289 @@
/**
* useLiveUpload Svelte composable for Phoenix LiveView file uploads.
*
* Binds to a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder,
* managing a hidden <form>/<input type=file> with the required Phoenix upload
* attributes, and exposing reactive stores for entries, progress, and validity.
*
* Usage:
* ```svelte
* <script lang="ts">
* import { useLiveUpload } from "live_svelte"
* import type { UploadConfig } from "live_svelte"
*
* interface Props { uploads: { avatar: UploadConfig }; uploaded_files: { name: string }[] }
* let { uploads, uploaded_files }: Props = $props()
*
* const upload = useLiveUpload(uploads.avatar, { changeEvent: "validate", submitEvent: "save" })
* $effect(() => { upload.sync(uploads.avatar) })
* </script>
*
* <button onclick={() => upload.showFilePicker()}>Select Files</button>
* {#each $upload.entries as entry (entry.ref)}
* <div>{entry.client_name} {entry.progress}%</div>
* {/each}
* ```
*/
import { writable, derived, get, type Readable, type Writable } from "svelte/store"
import { onMount } from "svelte"
import { useLiveSvelte } from "./composables"
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* Shape of a single upload entry from Phoenix.LiveView.UploadEntry,
* encoded by LiveSvelte.Encoder.
*/
export interface UploadEntry {
/** Phoenix upload ref for this entry (e.g. "phx-ref-0"). */
ref: string
/** Original filename from the client. */
client_name: string
/** File size in bytes. */
client_size: number
/** MIME type. */
client_type: string
/** Upload progress 0100. */
progress: number
/** Whether the upload has completed. */
done: boolean
/** Whether the entry passes accept/size validations. */
valid: boolean
/** Whether Phoenix has acknowledged (preflighted) this entry. */
preflighted: boolean
/** Entry-specific validation error messages. */
errors: string[]
}
/**
* Shape of a Phoenix.LiveView.UploadConfig encoded by LiveSvelte.Encoder.
* Pass `@uploads.name` as a prop from the LiveView.
*/
export interface UploadConfig {
/** Phoenix upload ref (e.g. "phx-abc123"). */
ref: string
/** Upload name matching `allow_upload(:name, ...)` in the LiveView. */
name: string
/** Accepted file types (e.g. ".jpg,.png") or false for any. */
accept: string | false
/** Maximum number of concurrent uploads. */
max_entries: number
/** When true, uploads begin as soon as files are selected. */
auto_upload: boolean
/** Current upload entries. */
entries: UploadEntry[]
/** Top-level upload config errors. */
errors: { ref: string; error: string }[]
}
/** Options for `useLiveUpload`. */
export interface UploadOptions {
/** Server event name for Phoenix phx-change (validation). */
changeEvent?: string
/** Server event name for Phoenix phx-submit (required). */
submitEvent: string
}
/** Return value of `useLiveUpload`. */
export interface UseLiveUploadReturn {
/** Reactive list of current upload entries from the server. */
entries: Readable<UploadEntry[]>
/** Overall upload progress 0100 averaged across all entries. */
progress: Readable<number>
/** True when the upload config has no top-level errors. */
valid: Readable<boolean>
/** The underlying hidden `<input type="file">` element store. */
inputEl: Readable<HTMLInputElement | null>
/** Opens the native file-picker dialog. */
showFilePicker(): void
/** Enqueue files from an array or DataTransfer (for drag-drop). */
addFiles(files: File[] | DataTransfer): void
/** Dispatch a form submit event to trigger Phoenix upload (manual upload). */
submit(): void
/** Cancel a specific entry by ref, or all entries when omitted. */
cancel(ref?: string): void
/** Reset the hidden input value to clear the file queue. */
clear(): void
/**
* Merge an updated UploadConfig from the server into the composable.
* Call from a Svelte `$effect(() => { upload.sync(props.uploads.avatar) })`.
*/
sync(newConfig: UploadConfig): void
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export function useLiveUpload(
uploadConfig: UploadConfig,
options: UploadOptions
): UseLiveUploadReturn {
// Graceful degradation: 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.
}
// Core reactive store for the upload config.
const configStore: Writable<UploadConfig> = writable(uploadConfig)
// Reactive hidden input element store.
const inputElStore: Writable<HTMLInputElement | null> = writable(null)
// Derived stores.
const entries: Readable<UploadEntry[]> = derived(configStore, ($config) => $config.entries ?? [])
const progress: Readable<number> = derived(entries, ($entries) => {
if ($entries.length === 0) return 0
const total = $entries.reduce((sum, entry) => sum + (entry.progress ?? 0), 0)
return Math.round(total / $entries.length)
})
const valid: Readable<boolean> = derived(
configStore,
($config) => ($config.errors ?? []).length === 0
)
// DOM setup: create the hidden form+input with Phoenix upload attributes.
onMount(() => {
const config = get(configStore)
// Outer form carries phx-change / phx-submit so Phoenix hooks it.
const form = document.createElement("form")
if (options.changeEvent) form.setAttribute("phx-change", options.changeEvent)
form.setAttribute("phx-submit", options.submitEvent)
form.style.display = "none"
const input = document.createElement("input")
input.type = "file"
input.id = config.ref
input.name = config.name
// REQUIRED Phoenix LiveFileUpload hook attributes.
input.setAttribute("data-phx-hook", "Phoenix.LiveFileUpload")
input.setAttribute("data-phx-update", "ignore")
input.setAttribute("data-phx-upload-ref", config.ref)
// Optional attributes derived from the upload config.
if (config.accept && typeof config.accept === "string") {
input.accept = config.accept
}
if (config.auto_upload) {
input.setAttribute("data-phx-auto-upload", "true")
}
if (config.max_entries > 1) {
input.multiple = true
}
form.appendChild(input)
// Append to the LiveView element so Phoenix can find the input.
const liveEl = (liveCtx?.live as { el?: Element } | null)?.el
if (liveEl) {
liveEl.appendChild(form)
}
inputElStore.set(input)
// Subscribe to configStore to keep Phoenix ref attributes up-to-date.
const unsub = configStore.subscribe(($config) => {
const joinRefs = (es: UploadEntry[]) => es.map((e) => e.ref).join(",")
input.setAttribute("data-phx-active-refs", joinRefs($config.entries))
input.setAttribute("data-phx-done-refs", joinRefs($config.entries.filter((e) => e.done)))
input.setAttribute(
"data-phx-preflighted-refs",
joinRefs($config.entries.filter((e) => e.preflighted))
)
})
// Cleanup: unsubscribe, remove the form, and clear the store.
return () => {
unsub()
form.remove()
inputElStore.set(null)
}
})
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function showFilePicker(): void {
const input = get(inputElStore)
if (input) input.click()
}
function addFiles(files: File[] | DataTransfer): void {
const input = get(inputElStore)
if (!input) return
const DataTransferClass =
typeof DataTransfer !== "undefined" ? DataTransfer : null
let filesSet = false
if (DataTransferClass && files instanceof DataTransferClass) {
input.files = (files as DataTransfer).files
filesSet = true
} else if (DataTransferClass) {
const dt = new DataTransferClass()
;(files as File[]).forEach((f) => dt.items.add(f))
input.files = dt.files
filesSet = true
}
// Dispatch asynchronously so Phoenix has initialised the upload system.
// Only dispatch if files were actually set — avoids spurious events in
// environments where DataTransfer is unavailable (e.g. SSR/Node.js).
if (filesSet) {
setTimeout(() => {
const current = get(inputElStore)
if (current) current.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }))
}, 0)
}
}
function submit(): void {
const input = get(inputElStore)
if (input?.form) {
input.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))
}
}
function cancel(ref?: string): void {
if (!liveCtx) return
if (ref !== undefined) {
liveCtx.pushEvent("cancel-upload", { ref })
} else {
get(entries).forEach((entry) => {
liveCtx!.pushEvent("cancel-upload", { ref: entry.ref })
})
}
}
function clear(): void {
const input = get(inputElStore)
if (input) input.value = ""
}
function sync(newConfig: UploadConfig): void {
configStore.set(newConfig)
}
return {
entries,
progress,
valid,
inputEl: { subscribe: inputElStore.subscribe },
showFilePicker,
addFiles,
submit,
cancel,
clear,
sync,
}
}

View file

@ -0,0 +1,153 @@
<script lang="ts">
import {useLiveUpload} from "live_svelte"
import type {UploadConfig} from "live_svelte"
interface Props {
uploads: {test_files: UploadConfig}
uploaded_files: {name: string; size: number}[]
}
let {uploads, uploaded_files}: Props = $props()
const upload = useLiveUpload(uploads.test_files, {
changeEvent: "validate",
submitEvent: "save",
})
// Sync server-side upload config updates into the composable.
$effect(() => {
upload.sync(uploads.test_files)
})
// Reactive store aliases for template use.
const entries = upload.entries
const progress = upload.progress
const valid = upload.valid
</script>
<div
data-testid="upload-container"
class="w-full max-w-md mx-auto card bg-base-100 shadow-md border border-base-300/50"
>
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
File Upload
</span>
<!-- File picker and drag-drop zone -->
<div
data-testid="drop-zone"
role="region"
aria-label="File drop zone"
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-brand/50 transition-colors"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => {
e.preventDefault()
if (e.dataTransfer) upload.addFiles(e.dataTransfer)
}}
>
<p class="text-sm text-base-content/50 mb-3">Drag and drop files here, or</p>
<button
data-testid="pick-files-btn"
type="button"
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
onclick={() => upload.showFilePicker()}
>
Select Files
</button>
<p class="text-xs text-base-content/40 mt-2">
Accepts .txt, .pdf, .jpg, .png — max 5 MB each, up to 3 files
</p>
</div>
<!-- Entry list -->
{#if $entries.length > 0}
<ul class="flex flex-col gap-2">
{#each $entries as entry (entry.ref)}
<li data-testid="upload-entry" class="flex items-center gap-3 text-sm">
<div class="flex-1 min-w-0">
<span data-testid="entry-name" class="font-medium truncate block">
{entry.client_name}
</span>
<span class="text-xs text-base-content/50">
{(entry.client_size / 1024).toFixed(1)} KB
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span data-testid="entry-progress" class="text-xs w-8 text-right">
{entry.progress}%
</span>
{#if !entry.valid}
<span data-testid="entry-error" class="text-xs text-error">
{entry.errors[0] ?? "invalid"}
</span>
{/if}
<button
data-testid="cancel-entry-btn"
type="button"
class="btn btn-xs btn-ghost text-error"
onclick={() => upload.cancel(entry.ref)}
>
</button>
</div>
</li>
{/each}
</ul>
<!-- Overall progress bar -->
{#if $progress > 0 && $progress < 100}
<div class="w-full bg-base-200 rounded-full h-1.5">
<div
data-testid="progress-bar"
class="bg-brand h-1.5 rounded-full transition-all"
style="width: {$progress}%"
></div>
</div>
{/if}
<!-- Action buttons -->
<div class="flex gap-2">
{#if !uploads.test_files.auto_upload}
<button
data-testid="upload-submit-btn"
type="button"
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
disabled={!$valid}
onclick={() => upload.submit()}
>
Upload
</button>
{/if}
<button
data-testid="cancel-all-btn"
type="button"
class="btn btn-sm btn-ghost"
onclick={() => upload.cancel()}
>
Cancel All
</button>
</div>
{/if}
<!-- Uploaded files list -->
{#if uploaded_files.length > 0}
<div>
<h3 class="text-xs font-medium text-base-content/50 uppercase tracking-wide mb-2">
Uploaded
</h3>
<ul class="flex flex-col gap-1">
{#each uploaded_files as file}
<li data-testid="uploaded-file" class="flex items-center gap-2 text-sm">
<span class="text-success"></span>
<span data-testid="uploaded-name">{file.name}</span>
<span class="text-xs text-base-content/40 ml-auto">
{(file.size / 1024).toFixed(1)} KB
</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>

View file

@ -61,7 +61,8 @@ defmodule ExampleWeb.Layouts do
links: [
%{label: "Form (useLiveForm)", to: ~p"/live-form"},
%{label: "Navigation (useLiveNavigation)", to: ~p"/live-navigation"},
%{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"}
%{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"},
%{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"}
]
}
]

View file

@ -246,6 +246,12 @@
</a>
<span class="text-base-content/50 text-sm">- pushEvent from composed component trees</span>
</li>
<li>
<a href={~p"/live-upload"} class="link link-primary">
File Upload (useLiveUpload)
</a>
<span class="text-base-content/50 text-sm">- Upload files using Phoenix LiveView uploads</span>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,56 @@
defmodule ExampleWeb.LiveUpload do
@moduledoc """
LiveView demo for the `useLiveUpload()` composable.
Demonstrates file selection, progress tracking, submit, and cancel.
"""
use ExampleWeb, :live_view
def mount(_params, _session, socket) do
socket =
socket
|> allow_upload(:test_files,
accept: ~w(.txt .pdf .jpg .png),
max_entries: 3,
max_file_size: 5_000_000
)
|> assign(uploaded_files: [])
{:ok, socket}
end
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
def handle_event("save", _params, socket) do
uploaded =
consume_uploaded_entries(socket, :test_files, fn _meta, entry ->
{:ok, %{name: entry.client_name, size: entry.client_size}}
end)
{:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded))}
end
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :test_files, ref)}
end
def render(assigns) do
~H"""
<div class="flex flex-col items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">File Upload (useLiveUpload)</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">
File selection, progress tracking, and server-side upload via Phoenix LiveView uploads.
</p>
<.svelte
name="UploadDemo"
props={%{
uploads: %{test_files: @uploads.test_files},
uploaded_files: @uploaded_files
}}
socket={@socket}
/>
</div>
"""
end
end

View file

@ -42,6 +42,7 @@ defmodule ExampleWeb.Router do
# Ecto Examples
live "/live-notes-otp", LiveNotesOtp
live "/live-form", LiveForm
live "/live-upload", LiveUpload
live "/live-navigation", LiveNavigation
live "/live-navigation/:page", LiveNavigation
# not referenced in app.html.heex:

View file

@ -0,0 +1,136 @@
defmodule ExampleWeb.LiveUploadTest do
@moduledoc """
E2E tests for the LiveUpload LiveView (/live-upload).
Validates useLiveUpload() composable: initial render, file selection,
upload submission, and entry cancellation.
"""
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
# Wait for an element with the given testid to disappear (retries up to 3s).
defp wait_for_gone(session, testid, attempts \\ 30)
defp wait_for_gone(_session, testid, 0),
do: raise("timeout waiting for [data-testid='#{testid}'] to disappear")
defp wait_for_gone(session, testid, attempts) do
els = session |> all(Query.css("[data-testid='#{testid}']"))
if length(els) == 0 do
session
else
:timer.sleep(100)
wait_for_gone(session, testid, attempts - 1)
end
end
# Create a temp file for upload tests, cleaned up after the test.
defp tmp_upload_file(content \\ "test upload content") do
path = Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt")
File.write!(path, content)
path
end
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
test "initial upload page renders container and select button with no entries", %{
session: session
} do
session
|> visit("/live-upload")
|> wait_for("upload-container")
|> assert_has(Query.css("[data-testid='upload-container']"))
|> assert_has(Query.css("[data-testid='pick-files-btn']"))
|> assert_has(Query.css("[data-testid='drop-zone']"))
|> refute_has(Query.css("[data-testid='upload-entry']"))
|> refute_has(Query.css("[data-testid='uploaded-file']"))
end
test "selecting a file shows it in the entry list", %{session: session} do
path = tmp_upload_file("hello from test")
on_exit(fn -> File.rm(path) end)
session =
session
|> visit("/live-upload")
|> wait_for("pick-files-btn")
# The hidden file input is created by useLiveUpload's onMount.
# Use visible: false so Wallaby can locate the hidden input.
session =
session
|> attach_file(Query.css("input[type=file]", visible: false), path: path)
session
|> wait_for("upload-entry")
|> assert_has(Query.css("[data-testid='upload-entry']"))
|> assert_has(Query.css("[data-testid='entry-name']"))
|> assert_has(Query.css("[data-testid='cancel-entry-btn']"))
end
test "uploading a file triggers save event and shows it in uploaded list", %{session: session} do
path = tmp_upload_file("upload me please")
on_exit(fn -> File.rm(path) end)
session =
session
|> visit("/live-upload")
|> wait_for("pick-files-btn")
|> attach_file(Query.css("input[type=file]", visible: false), path: path)
session =
session
|> wait_for("upload-submit-btn")
|> click(Query.css("[data-testid='upload-submit-btn']"))
session
|> wait_for("uploaded-file")
|> assert_has(Query.css("[data-testid='uploaded-file']"))
|> assert_has(Query.css("[data-testid='uploaded-name']"))
end
test "cancelling an entry removes it from the list", %{session: session} do
path = tmp_upload_file("cancel me")
on_exit(fn -> File.rm(path) end)
session =
session
|> visit("/live-upload")
|> wait_for("pick-files-btn")
|> attach_file(Query.css("input[type=file]", visible: false), path: path)
session =
session
|> wait_for("cancel-entry-btn")
|> click(Query.css("[data-testid='cancel-entry-btn']"))
session
|> wait_for_gone("upload-entry")
|> refute_has(Query.css("[data-testid='upload-entry']"))
end
end