You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/form/form-provider.svelte.ts

327 lines
10 KiB

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<TForm extends Form = Form>
extends
WithRefOpts,
ActiveProps<{
form: TForm;
schema: StandardSchemaV1 | undefined;
noValidate: boolean;
focusFirstError: boolean;
}> {}
export class FormProvider<TForm extends Form = Form> {
readonly opts: FormProviderOpts<TForm>;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<FormProvider>('Form');
static get(): FormProvider | undefined {
return this.ctx.getOr(undefined) as FormProvider | undefined;
}
static require(): FormProvider {
return this.ctx.get();
}
static create<TForm extends Form = Form>(opts: FormProviderOpts<TForm>) {
return new FormProvider<TForm>(opts);
}
private constructor(opts: FormProviderOpts<TForm>) {
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<HTMLElement>(`[data-field][data-field-name="${name}"]`);
if (!field) return;
const focusable =
field.querySelector<HTMLElement>(
'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 `<form>`'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 };

Powered by TurnKey Linux.