import { context, type WithRefOpts } from '../../provider'; import { createAttrs } from '$uix/morfo'; import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { type ActiveProps } from '$libs/reactive'; import { Soma } from '../../core/soma.svelte'; import { FORM_LANGS } from './langs'; import type { Form } from '$libs/forms'; import type { StandardSchemaV1 } from '$libs/standard-schema'; // ── Contract ───────────────────────────────────────────────────────────────── import { formMorfo } from '../../../morfo/components/form'; const attrs = createAttrs(formMorfo); import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; // ── Root provider ──────────────────────────────────────────────────────────── export interface FormProviderOpts extends WithRefOpts, ActiveProps<{ form: TForm; schema: StandardSchemaV1 | undefined; noValidate: boolean; focusFirstError: boolean; }> {} export class FormProvider { readonly opts: FormProviderOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; readonly runtime: SomaRuntime; static readonly ctx = context('Form'); static get(): FormProvider | undefined { return this.ctx.getOr(undefined) as FormProvider | undefined; } static require(): FormProvider { return this.ctx.get(); } static create(opts: FormProviderOpts) { return new FormProvider(opts); } private constructor(opts: FormProviderOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(formMorfo, { props: { isPending: () => this.isPending, dirty: () => this.isDirty, touched: () => this.isTouched, invalid: () => this.isInvalid && this.isSubmitted, submitted: () => this.isSubmitted } }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: FormProvider.ctx, syncAttrs: true }); } get form(): TForm { return this.opts.form.current; } get schema(): StandardSchemaV1 | undefined { return this.opts.schema.current; } get issues() { return this.form.issues; } readonly isPending = $derived.by(() => this.form.isPending); readonly isValid = $derived.by(() => this.form.isValid); readonly isDirty = $derived.by(() => this.form.isDirty); readonly isTouched = $derived.by(() => this.form.isTouched); readonly submitCount = $derived.by(() => this.form.submitCount); readonly isSubmitted = $derived.by(() => this.form.submitCount > 0); readonly isInvalid = $derived.by(() => !this.form.isValid); /** Resolve the element id for a registered field (for focus / scroll-to). */ getFieldElementId(name: string): string | undefined { const state = this.form.getFieldState(name); if (!state) return undefined; // Convention: Field.Provider uses `{formId}-field-{name}` for its control id. const base = this.opts.id.current; return `${base}-field-${name}`; } /** Focus the first invalid field after an invalid submit. */ focusFirstInvalid(): void { if (!this.opts.focusFirstError.current) return; const name = this.form.firstInvalidField; if (!name) return; // Prefer a control inside the Field.Provider — fall back to the Field root. const root = this.opts.ref?.current; if (!root) return; const field = root.querySelector(`[data-field][data-field-name="${name}"]`); if (!field) return; const focusable = field.querySelector( 'input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])' ) ?? field; this.soma.dom.focus(focusable); } readonly snippetProps = $derived.by(() => ({ isPending: this.isPending, isValid: this.isValid, isDirty: this.isDirty, isTouched: this.isTouched, submitCount: this.submitCount })); /** Handler wired into the `
`'s `onsubmit`. */ readonly handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); const ok = await this.form.submit(e); const target = this.opts.ref?.current; // `signal-warn-invalid` is `untilFix` (book §6.2): clear any prior // warning before deciding whether to re-emit (failed) or skip // (succeeded). The clear IS the fix when the form now validates. const provider = this.runtime.partRef('provider'); if (provider) this.runtime.clearTarget(provider); if (!ok) { // Resolve the error-summary text from the existing FORM_LANGS // catalog so the live region (a11ySemantic.requiresLiveRegion) // announces it. Singular vs plural by # of invalid fields. const invalidFieldCount = Object.keys(this.form.issues).length; const message = invalidFieldCount <= 1 ? this.soma.langs.ts(FORM_LANGS.ERROR_SUMMARY_SINGLE) : this.soma.langs.t(FORM_LANGS.ERROR_SUMMARY_TITLE, { count: String(invalidFieldCount) }); void this.runtime.trigger('signal-warn-invalid', { ...(target ? { fallbackTarget: target } : {}), message }); this.focusFirstInvalid(); return; } void this.runtime.trigger( 'commit-submit', target ? { fallbackTarget: target } : undefined ); }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, novalidate: this.opts.noValidate.current || undefined, onsubmit: this.handleSubmit } as const) ); } // ── Submit button ──────────────────────────────────────────────────────────── export interface FormSubmitOpts extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {} export class FormSubmitProvider { readonly opts: FormSubmitOpts; readonly runtimePart: SomaRuntimePart; static create(opts: FormSubmitOpts) { return new FormSubmitProvider(opts); } readonly provider: FormProvider; private constructor(opts: FormSubmitOpts) { this.opts = opts; this.provider = FormProvider.require(); this.runtimePart = this.provider.runtime.part('submit', { id: opts.id, ref: opts.ref, owner: this }); } readonly resolvedLabel = $derived.by( () => this.opts.ariaLabel.current ?? this.provider.soma.langs.ts(FORM_LANGS.SUBMIT) ?? 'Submit' ); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, type: 'submit' as const, 'aria-label': this.resolvedLabel, 'aria-busy': boolToStr(this.provider.isPending), disabled: this.provider.isPending || undefined, 'data-pending': boolToEmptyStrOrUndef(this.provider.isPending) } as const) ); } // ── Reset button ───────────────────────────────────────────────────────────── export interface FormResetOpts extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined; disableWhenPristine: boolean }> {} export class FormResetProvider { readonly opts: FormResetOpts; readonly runtimePart: SomaRuntimePart; static create(opts: FormResetOpts) { return new FormResetProvider(opts); } readonly provider: FormProvider; private constructor(opts: FormResetOpts) { this.opts = opts; this.provider = FormProvider.require(); this.runtimePart = this.provider.runtime.part('reset', { id: opts.id, ref: opts.ref, owner: this }); } readonly resolvedLabel = $derived.by( () => this.opts.ariaLabel.current ?? this.provider.soma.langs.ts(FORM_LANGS.RESET) ?? 'Reset' ); readonly onclick = () => { this.provider.form.reset(); const target = this.provider.opts.ref?.current; // Reset wipes the form state — outstanding warn signals are no // longer applicable. Clear before reset so the UI doesn't briefly // show a stale warning on the freshly reset form. const provider = this.provider.runtime.partRef('provider'); if (provider) this.provider.runtime.clearTarget(provider); void this.provider.runtime.trigger( 'commit-reset', target ? { fallbackTarget: target } : undefined ); }; readonly isDisabled = $derived.by( () => this.provider.isPending || (this.opts.disableWhenPristine.current && !this.provider.isDirty) ); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, type: 'button' as const, 'aria-label': this.resolvedLabel, disabled: this.isDisabled || undefined, 'data-dirty': boolToEmptyStrOrUndef(this.provider.isDirty), onclick: this.onclick } as const) ); } // ── Error summary ──────────────────────────────────────────────────────────── export interface FormErrorSummaryOpts extends WithRefOpts, ActiveProps<{ hideUntilSubmitted: boolean }> {} export class FormErrorSummaryProvider { readonly opts: FormErrorSummaryOpts; readonly runtimePart: SomaRuntimePart; static create(opts: FormErrorSummaryOpts) { return new FormErrorSummaryProvider(opts); } readonly provider: FormProvider; private constructor(opts: FormErrorSummaryOpts) { this.opts = opts; this.provider = FormProvider.require(); this.runtimePart = this.provider.runtime.part('error-summary', { id: opts.id, ref: opts.ref, owner: this }); } readonly formErrors: string[] = $derived.by(() => this.provider.form.errors[''] ?? []); readonly fieldErrors = $derived.by(() => { const out: Array<{ field: string; label?: string; message: string }> = []; const all = this.provider.form.errors; for (const field in all) { if (field === '') continue; for (const message of all[field] ?? []) { out.push({ field, message }); } } return out; }); readonly count: number = $derived.by(() => this.formErrors.length + this.fieldErrors.length); readonly isHidden = $derived.by( () => (this.opts.hideUntilSubmitted.current && this.provider.submitCount === 0) || this.count === 0 ); readonly snippetProps = $derived.by(() => ({ formErrors: this.formErrors, fieldErrors: this.fieldErrors, count: this.count })); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'alert' as const, 'aria-live': 'polite' as const, hidden: this.isHidden || undefined, 'data-hidden': boolToEmptyStrOrUndef(this.isHidden) } as const) ); } export { attrs as formAttrs };