Last active
December 16, 2025 21:39
-
-
Save sillvva/6b85e530d4ae74ab342293761ee4bd04 to your computer and use it in GitHub Desktop.
Remote form factory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { beforeNavigate } from "$app/navigation"; | |
| import { page } from "$app/state"; | |
| import { debounce, deepEqual } from "@sillvva/utils"; | |
| import type { StandardSchemaV1 } from "@standard-schema/spec"; | |
| import type { RemoteForm, RemoteFormFields, RemoteFormInput, RemoteFormIssue } from "@sveltejs/kit"; | |
| import { onMount, tick, untrack } from "svelte"; | |
| import { toast } from "svelte-sonner"; | |
| import { v7 } from "uuid"; | |
| import { unknownErrorMessage } from "./util"; | |
| export type ExtractId<Input> = Input extends { id: infer Id } | |
| ? Id extends string | number | |
| ? Id | |
| : string | number | |
| : string | number; | |
| export interface RemoteFormOptions<Input extends RemoteFormInput, Output> { | |
| key?: ExtractId<Input>; | |
| schema?: StandardSchemaV1<Input, unknown>; | |
| data?: Input | $state.Snapshot<Input>; | |
| initialErrors?: boolean; | |
| resetOn?: "data" | "url"; | |
| navBlockMessage?: string; | |
| onsubmit?: <T>(ctx: { readonly tainted: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>; | |
| oninput?: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void; | |
| onresult?: (ctx: { | |
| readonly success: boolean; | |
| readonly result?: RemoteForm<Input, Output>["result"]; | |
| readonly issues?: RemoteFormIssue[]; | |
| readonly error?: unknown; | |
| }) => Awaitable<void>; | |
| onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => Awaitable<void>; | |
| } | |
| export interface ConfiguredForm<Input extends RemoteFormInput, Output> { | |
| (): { | |
| form: Omit<RemoteForm<Input, Output>, "for">; | |
| attrs: { | |
| [attachment: symbol]: (node: HTMLFormElement) => void; | |
| method: "POST"; | |
| action: string; | |
| onsubmit: ( | |
| ev: Event & { | |
| currentTarget: EventTarget & HTMLFormElement; | |
| } | |
| ) => Promise<void>; | |
| oninput: ( | |
| ev: Event & { | |
| currentTarget: EventTarget & HTMLFormElement; | |
| } | |
| ) => void; | |
| }; | |
| result: Output | undefined; | |
| dirty: boolean; | |
| submitting: boolean; | |
| }; | |
| } | |
| export function configureForm<Input extends RemoteFormInput, Output>( | |
| form: RemoteForm<Input, Output>, | |
| options = () => ({}) as RemoteFormOptions<Input, Output> | |
| ): ConfiguredForm<Input, Output> { | |
| let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form); | |
| let initial = $state.raw($state.snapshot(options().data)); | |
| let submitting = $state.raw(false); | |
| type Fields = RemoteFormFields<unknown>; | |
| function setup(config: RemoteFormOptions<Input, Output>) { | |
| initial = $state.snapshot(config?.data); | |
| configuredForm = form.for((config.key ?? config.data?.id ?? v7()) as ExtractId<Input>); | |
| if (config?.schema) { | |
| configuredForm = configuredForm.preflight(config.schema as unknown as StandardSchemaV1<Input, unknown>); | |
| } | |
| if (config.data) { | |
| (configuredForm.fields as Fields).set(config.data); | |
| } | |
| } | |
| setup(options()); | |
| let hydrated = false; | |
| $effect(() => { | |
| const config = untrack(options); | |
| const updateOn = config.resetOn ?? (untrack(() => config.data) ? "data" : "url"); | |
| if (updateOn === "data") void options().data; | |
| else void page.url; | |
| if (!hydrated) return void (hydrated = true); | |
| untrack(() => setup(config)); | |
| }); | |
| let dirty = $derived(!deepEqual(initial, $state.snapshot(configuredForm.fields.value()))); | |
| const result = $derived(configuredForm.result); | |
| const issues = $derived(configuredForm.fields.issues()); | |
| const allIssues = () => (configuredForm.fields as Fields).allIssues(); | |
| let lastIssues = $state.raw<RemoteFormIssue[] | undefined>(allIssues()); | |
| const debouncedValidate = debounce(validate, 300); | |
| async function validate() { | |
| const config = options(); | |
| await configuredForm.validate({ includeUntouched: true, preflightOnly: true }); | |
| const issues = allIssues(); | |
| if (issues && config?.onissues && !deepEqual(lastIssues, issues)) config.onissues({ issues }); | |
| if (issues?.length) lastIssues = issues; | |
| } | |
| async function focusInvalid(formEl: HTMLFormElement) { | |
| await tick(); | |
| const issues = allIssues(); | |
| if (issues?.length) lastIssues = issues; | |
| else return; | |
| const invalid = formEl.querySelector(":is(input, select, textarea):not(.hidden, [type=hidden], :disabled)[aria-invalid]") as | |
| | HTMLInputElement | |
| | HTMLSelectElement | |
| | HTMLTextAreaElement | |
| | null; | |
| invalid?.focus(); | |
| } | |
| onMount(() => { | |
| const config = options(); | |
| if (config?.initialErrors) validate(); | |
| }); | |
| beforeNavigate((ev) => { | |
| const config = options(); | |
| if (!config?.navBlockMessage) return; | |
| if ((dirty || issues) && !confirm(config.navBlockMessage)) { | |
| return ev.cancel(); | |
| } | |
| }); | |
| return () => ({ | |
| form: configuredForm, | |
| attrs: { | |
| ...configuredForm.enhance(async ({ submit, form: formEl, data }) => { | |
| const config = options(); | |
| const bf = !config.onsubmit || (await config.onsubmit({ tainted: dirty, form: formEl, data })); | |
| if (!bf) return; | |
| submitting = true; | |
| const wasDirty = dirty; | |
| try { | |
| dirty = false; | |
| await submit(); | |
| const issues = allIssues(); | |
| const success = !issues?.length; | |
| config.onresult?.({ success, result: configuredForm.result, issues }); | |
| if (success) { | |
| successToast(`${(configuredForm.fields as Fields).name?.value() || "Form"} saved successfully`); | |
| } else { | |
| dirty = wasDirty; | |
| await focusInvalid(formEl); | |
| config.onissues?.({ issues }); | |
| } | |
| } catch (error) { | |
| unknownErrorToast(error || "Oh no! Something went wrong"); | |
| config.onresult?.({ success: false, error }); | |
| dirty = wasDirty; | |
| } finally { | |
| submitting = false; | |
| } | |
| }), | |
| onsubmit: (ev) => focusInvalid(ev.currentTarget), | |
| oninput: (ev) => { | |
| const config = options(); | |
| if (lastIssues) debouncedValidate.call(); | |
| config.oninput?.(ev); | |
| } | |
| }, | |
| initial, | |
| result, | |
| dirty, | |
| submitting | |
| }); | |
| } | |
| export function successToast(message: string) { | |
| toast.success("Success", { | |
| description: message, | |
| classes: { | |
| description: "text-white!" | |
| } | |
| }); | |
| } | |
| export function errorToast(message: string) { | |
| toast.error("Error", { | |
| description: message, | |
| classes: { | |
| description: "text-white!" | |
| }, | |
| duration: Duration.toMillis("30 seconds") | |
| }); | |
| } | |
| export function unknownErrorToast(error: unknown) { | |
| errorToast(unknownErrorMessage(error)); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment