{"name":"form","files":[{"path":"ui/form.svelte.ts","content":"import type { Attachment } from 'svelte/attachments';\n\n/** One message per field name. `form` is for the whole form, shown above the button. */\nexport type Errors = Record<string, string | undefined>;\n\ntype Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;\ntype Check = Exclude<keyof ValidityState, 'valid'>;\n\ninterface Options {\n\t/** Your wording for the browser's own checks, per field: { email: { valueMissing: 'Enter your email.' } }. */\n\tmessages?: Record<string, Partial<Record<Check, string>>>;\n\t/** Checks the browser can't do (across fields, custom controls). Return a message per failing field. */\n\tvalidate?: (data: FormData) => Errors;\n\t/**\n\t * Runs once every check passes. Return errors (mapped from your API's error codes) to show them;\n\t * a throw shows a generic message, never the raw error text.\n\t */\n\tonsubmit: (data: FormData) => Promise<Errors | void> | Errors | void;\n\t/** Shown when onsubmit throws (network down, server error). */\n\tfailed?: string;\n}\n\n/**\n * Forms with the browser's own constraint validation (required, type, min, pattern...) and no library.\n * Errors appear when a field is left or the form is sent, and clear as soon as they're fixed.\n * Without JavaScript, the browser's built-in validation still runs.\n */\nexport function createForm(options: Options) {\n\tlet errors = $state<Errors>({});\n\tlet submitting = $state(false);\n\tlet form: HTMLFormElement | undefined;\n\n\tconst fields = () =>\n\t\t[...(form?.elements ?? [])].filter(\n\t\t\t(e): e is Field => 'validity' in e && !!(e as Field).name && !(e as Field).disabled\n\t\t);\n\n\tfunction messageFor(field: Field, custom: Errors): string | undefined {\n\t\tif (custom[field.name]) return custom[field.name];\n\t\tif (field.validity.valid) return undefined;\n\t\tconst failed = (Object.keys(options.messages?.[field.name] ?? {}) as Check[]).find(\n\t\t\t(k) => field.validity[k]\n\t\t);\n\t\treturn (failed && options.messages?.[field.name]?.[failed]) || field.validationMessage;\n\t}\n\n\t/** Checks the named fields (all when omitted) and returns true when they pass. */\n\tfunction check(names?: string[]): boolean {\n\t\tconst custom = options.validate?.(new FormData(form)) ?? {};\n\t\tconst next: Errors = { ...errors, form: undefined };\n\t\tlet ok = true;\n\t\tfor (const f of fields()) {\n\t\t\tif (names && !names.includes(f.name)) continue;\n\t\t\tnext[f.name] = messageFor(f, custom);\n\t\t\tif (next[f.name]) ok = false;\n\t\t}\n\t\t// Custom errors for fields the browser can't see (hidden inputs behind custom controls).\n\t\tfor (const [name, msg] of Object.entries(custom))\n\t\t\tif (!names || names.includes(name)) {\n\t\t\t\tif (!fields().some((f) => f.name === name)) next[name] = msg;\n\t\t\t\tif (msg) ok = false;\n\t\t\t}\n\t\terrors = next;\n\t\treturn ok;\n\t}\n\n\tfunction focusFirstError() {\n\t\tconst first = fields().find((f) => errors[f.name]);\n\t\tfirst?.focus();\n\t}\n\n\tasync function submit(e: SubmitEvent) {\n\t\te.preventDefault();\n\t\tif (submitting) return;\n\t\tif (!check()) return focusFirstError();\n\t\tsubmitting = true;\n\t\ttry {\n\t\t\tconst result = await options.onsubmit(new FormData(form));\n\t\t\tif (result) {\n\t\t\t\terrors = result;\n\t\t\t\tfocusFirstError();\n\t\t\t}\n\t\t} catch {\n\t\t\terrors = {\n\t\t\t\tform: options.failed ?? \"Couldn't send the form. Check your connection and try again.\"\n\t\t\t};\n\t\t} finally {\n\t\t\tsubmitting = false;\n\t\t}\n\t}\n\n\tconst attach: Attachment<HTMLFormElement> = (el) => {\n\t\tform = el;\n\t\t// We show the messages; the browser's own bubbles would cover them.\n\t\tel.noValidate = true;\n\t\t// Checked when left after a change, not while typing (no \"invalid email\" after one letter),\n\t\t// and not when merely tabbed past: an untouched empty field waits for the submit.\n\t\t// eslint-disable-next-line svelte/prefer-svelte-reactivity -- tracks touched fields for blur checks; nothing renders from it\n\t\tconst changed = new Set<string>();\n\t\tconst left = (e: Event) => {\n\t\t\tconst name = (e.target as Field).name;\n\t\t\tif (name && changed.has(name)) check([name]);\n\t\t};\n\t\t// Once a field shows an error, it clears the moment it's fixed.\n\t\tconst typed = (e: Event) => {\n\t\t\tconst name = (e.target as Field).name;\n\t\t\tif (!name) return;\n\t\t\tchanged.add(name);\n\t\t\tif (errors[name]) check([name]);\n\t\t};\n\t\t// A reset button clears the messages along with the values.\n\t\tconst reset = () => {\n\t\t\terrors = {};\n\t\t\tchanged.clear();\n\t\t};\n\t\tel.addEventListener('focusout', left);\n\t\tel.addEventListener('input', typed);\n\t\tel.addEventListener('change', typed);\n\t\tel.addEventListener('submit', submit);\n\t\tel.addEventListener('reset', reset);\n\t\treturn () => {\n\t\t\tel.removeEventListener('focusout', left);\n\t\t\tel.removeEventListener('input', typed);\n\t\t\tel.removeEventListener('change', typed);\n\t\t\tel.removeEventListener('submit', submit);\n\t\t\tel.removeEventListener('reset', reset);\n\t\t\tform = undefined;\n\t\t};\n\t};\n\n\treturn {\n\t\tattach,\n\t\tget errors() {\n\t\t\treturn errors;\n\t\t},\n\t\tget submitting() {\n\t\t\treturn submitting;\n\t\t}\n\t};\n}\n"}],"dependencies":{}}