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.
327 lines
10 KiB
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 };
|