From d319ec6b66ccbce9dc0681eeea2088c359e9005e Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 21:24:09 +0200 Subject: [PATCH] Move form core to forms lib --- src/libs/forms/form-core.svelte.test.ts | 140 ++++++++++++++++++ .../form => libs/forms}/form-core.svelte.ts | 2 +- src/libs/forms/index.ts | 11 ++ .../form/components/form-auto-fields.svelte | 2 +- .../components/form/components/form.svelte | 2 +- src/uix/soma/components/form/exports.ts | 6 +- .../components/form/form-provider.svelte.ts | 4 +- .../soma/components/form/standard-schema.ts | 84 ----------- src/uix/soma/components/form/types.ts | 6 +- 9 files changed, 162 insertions(+), 95 deletions(-) create mode 100644 src/libs/forms/form-core.svelte.test.ts rename src/{uix/soma/components/form => libs/forms}/form-core.svelte.ts (99%) create mode 100644 src/libs/forms/index.ts delete mode 100644 src/uix/soma/components/form/standard-schema.ts diff --git a/src/libs/forms/form-core.svelte.test.ts b/src/libs/forms/form-core.svelte.test.ts new file mode 100644 index 000000000..cc0b4f269 --- /dev/null +++ b/src/libs/forms/form-core.svelte.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { flushSync } from 'svelte'; + +import type { StandardSchemaV1 } from '$libs/standard-schema'; +import { createForm, type CreateFormOpts } from './form-core.svelte'; + +type LoginValues = { + email: string; +}; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function loginSchema(): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate(value) { + const values = value as LoginValues; + if (values.email.includes('@')) return { value: values }; + return { + issues: [ + { + message: 'Invalid email', + path: ['email'] + } + ] + }; + } + } + }; +} + +function createLoginForm( + patch: Partial | undefined, LoginValues>> = {} +) { + return createForm({ + defaults: { email: '' }, + ...patch + }); +} + +describe('createForm', () => { + it('tracks registered field dirty and touched state and resets in place', () => { + const { result: form, cleanup } = withEffectRoot(() => createLoginForm()); + form.registerField('email', {}); + + expect(form.getFieldState('email')).toEqual({ + touched: false, + dirty: false, + errors: [], + isInvalid: false + }); + + form.values.email = 'ada@example.com'; + form.setFieldTouched('email', true); + flushSync(); + + expect(form.isDirty).toBe(true); + expect(form.isTouched).toBe(true); + expect(form.getFieldState('email')?.dirty).toBe(true); + expect(form.getFieldState('email')?.touched).toBe(true); + + form.reset(); + flushSync(); + + expect(form.values.email).toBe(''); + expect(form.isDirty).toBe(false); + expect(form.isTouched).toBe(false); + expect(form.submitCount).toBe(0); + + cleanup(); + }); + + it('groups Standard Schema issues by field path', () => { + const { result: form, cleanup } = withEffectRoot(() => + createLoginForm({ schema: loginSchema() }) + ); + form.registerField('email', {}); + + form.validate(); + flushSync(); + + expect(form.errors).toEqual({ email: ['Invalid email'] }); + expect(form.issues.email?.[0]?.message).toBe('Invalid email'); + expect(form.firstInvalidField).toBe('email'); + expect(form.isValid).toBe(false); + + form.values.email = 'ada@example.com'; + form.validate(); + flushSync(); + + expect(form.errors).toEqual({}); + expect(form.firstInvalidField).toBeUndefined(); + expect(form.isValid).toBe(true); + + cleanup(); + }); + + it('marks pending while an async submit handler is running', async () => { + let release!: () => void; + const submitted = vi.fn(); + const { result: form, cleanup } = withEffectRoot(() => + createLoginForm({ + defaults: { email: 'ada@example.com' }, + schema: loginSchema(), + onValidSubmit: async (values) => { + submitted(values); + await new Promise((resolve) => { + release = resolve; + }); + } + }) + ); + + const promise = form.submit(new SubmitEvent('submit')); + flushSync(); + + expect(form.isPending).toBe(true); + expect(form.submitCount).toBe(1); + expect(submitted).toHaveBeenCalledWith(form.values); + + release(); + await promise; + flushSync(); + + expect(form.isPending).toBe(false); + expect(form.errors).toEqual({}); + + cleanup(); + }); +}); diff --git a/src/uix/soma/components/form/form-core.svelte.ts b/src/libs/forms/form-core.svelte.ts similarity index 99% rename from src/uix/soma/components/form/form-core.svelte.ts rename to src/libs/forms/form-core.svelte.ts index 6acf92651..6c3f3ec3e 100644 --- a/src/uix/soma/components/form/form-core.svelte.ts +++ b/src/libs/forms/form-core.svelte.ts @@ -14,7 +14,7 @@ import { untrack } from 'svelte'; import { SvelteMap } from 'svelte/reactivity'; -import { type StandardSchemaV1, isPromiseLike } from './standard-schema'; +import { type StandardSchemaV1, isPromiseLike } from '$libs/standard-schema'; // ── Public types ───────────────────────────────────────────────────────────── diff --git a/src/libs/forms/index.ts b/src/libs/forms/index.ts new file mode 100644 index 000000000..904b4edcc --- /dev/null +++ b/src/libs/forms/index.ts @@ -0,0 +1,11 @@ +export { createForm } from './form-core.svelte'; +export type { + CreateFormOpts, + FieldState, + Form, + FormErrors, + FormIssue, + FormIssues, + SubmitFailureReason, + ValidationBehaviour +} from './form-core.svelte'; diff --git a/src/uix/soma/components/form/components/form-auto-fields.svelte b/src/uix/soma/components/form/components/form-auto-fields.svelte index 82b31aa06..54cd9d67f 100644 --- a/src/uix/soma/components/form/components/form-auto-fields.svelte +++ b/src/uix/soma/components/form/components/form-auto-fields.svelte @@ -24,7 +24,7 @@ import { createAttrs } from '$uix/morfo'; import { formMorfo } from '../../../../morfo/components/form'; import { FormProvider } from '../form-provider.svelte'; - import type { FormIssue } from '../form-core.svelte'; + import type { FormIssue } from '$libs/forms'; import FormAutoFields from './form-auto-fields.svelte'; type Path = Array; diff --git a/src/uix/soma/components/form/components/form.svelte b/src/uix/soma/components/form/components/form.svelte index 2f500a302..4e5f03df9 100644 --- a/src/uix/soma/components/form/components/form.svelte +++ b/src/uix/soma/components/form/components/form.svelte @@ -7,7 +7,7 @@ import { mergeProps } from '../../../props'; import { createId } from '../../../id'; import { FormProvider } from '../form-provider.svelte'; - import { createForm, type Form, type FormErrors } from '../form-core.svelte'; + import { createForm, type Form, type FormErrors } from '$libs/forms'; import type { FormProviderProps } from '../types'; const uid = $props.id(); diff --git a/src/uix/soma/components/form/exports.ts b/src/uix/soma/components/form/exports.ts index 604ab0720..758c82d07 100644 --- a/src/uix/soma/components/form/exports.ts +++ b/src/uix/soma/components/form/exports.ts @@ -1,7 +1,7 @@ // Core: runes-native form state + Standard Schema integration -export { createForm } from './form-core.svelte'; -export type { Form, FormErrors, FieldState, CreateFormOpts, SubmitFailureReason } from './form-core.svelte'; -export type { StandardSchemaV1 } from './standard-schema'; +export { createForm } from '$libs/forms'; +export type { Form, FormErrors, FieldState, CreateFormOpts, SubmitFailureReason } from '$libs/forms'; +export type { StandardSchemaV1 } from '$libs/standard-schema'; // UI primitives export { default as Provider } from './components/form.svelte'; diff --git a/src/uix/soma/components/form/form-provider.svelte.ts b/src/uix/soma/components/form/form-provider.svelte.ts index c00148d94..735a20911 100644 --- a/src/uix/soma/components/form/form-provider.svelte.ts +++ b/src/uix/soma/components/form/form-provider.svelte.ts @@ -4,8 +4,8 @@ import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { type ActiveProps } from '$libs/reactive'; import { Soma } from '../../core/soma.svelte'; import { FORM_LANGS } from './langs'; -import type { StandardSchemaV1 } from './standard-schema'; -import type { Form } from './form-core.svelte'; +import type { Form } from '$libs/forms'; +import type { StandardSchemaV1 } from '$libs/standard-schema'; // ── Contract ───────────────────────────────────────────────────────────────── diff --git a/src/uix/soma/components/form/standard-schema.ts b/src/uix/soma/components/form/standard-schema.ts deleted file mode 100644 index 502dfc001..000000000 --- a/src/uix/soma/components/form/standard-schema.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Standard Schema v1 — minimal inline port of the specification. - * - * - * - * A thin type + helper layer that lets `soma/Form` accept any validation - * library that implements the `"~standard"` interface: Zod, Valibot, ArkType, - * Effect/Schema, Yup (via adapter), etc. No runtime dependency on any specific - * schema library. - * - * The consumer imports their schema from their library of choice and passes - * it to `createForm({ schema })`. Validation is invoked via - * `schema["~standard"].validate(values)`. - */ - -// ── Types ──────────────────────────────────────────────────────────────────── - -export interface StandardSchemaV1 { - readonly '~standard': StandardSchemaV1.Props; -} - -export namespace StandardSchemaV1 { - export interface Props { - /** Version number of the standard. */ - readonly version: 1; - /** Vendor name (e.g. `"zod"`, `"valibot"`, `"arktype"`). */ - readonly vendor: string; - /** Validates a value. May return a `Promise` for async schemas. */ - readonly validate: (value: unknown) => Result | Promise>; - /** Type metadata — compile-time only, undefined at runtime. */ - readonly types?: Types; - } - - export type Result = SuccessResult | FailureResult; - - export interface SuccessResult { - readonly value: Output; - readonly issues?: undefined; - } - - export interface FailureResult { - readonly issues: ReadonlyArray; - } - - export interface Issue { - readonly message: string; - readonly path?: ReadonlyArray; - } - - export interface PathSegment { - readonly key: PropertyKey; - } - - export interface Types { - readonly input: Input; - readonly output: Output; - } - - export type InferInput = NonNullable< - S['~standard']['types'] - >['input']; - - export type InferOutput = NonNullable< - S['~standard']['types'] - >['output']; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** `true` when the value's first segment is a `PathSegment` object. */ -function isPathSegment(v: PropertyKey | StandardSchemaV1.PathSegment): v is StandardSchemaV1.PathSegment { - return typeof v === 'object' && v !== null && 'key' in v; -} - -/** Normalize an issue's `path` to a string array of keys. */ -export function pathKeys(issue: StandardSchemaV1.Issue): string[] { - if (!issue.path) return []; - return issue.path.map((p) => String(isPathSegment(p) ? p.key : p)); -} - -/** `true` when the validate call returned a Promise. */ -export function isPromiseLike(v: T | Promise): v is Promise { - return typeof (v as { then?: unknown })?.then === 'function'; -} diff --git a/src/uix/soma/components/form/types.ts b/src/uix/soma/components/form/types.ts index 2faa4425a..8e325ec1d 100644 --- a/src/uix/soma/components/form/types.ts +++ b/src/uix/soma/components/form/types.ts @@ -4,8 +4,8 @@ import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types'; -import type { Form, FormErrors, ValidationBehaviour } from './form-core.svelte'; -import type { StandardSchemaV1 } from './standard-schema'; +import type { Form, FormErrors, ValidationBehaviour } from '$libs/forms'; +import type { StandardSchemaV1 } from '$libs/standard-schema'; // ── Snippet props ────────────────────────────────────────────────────────── @@ -132,4 +132,4 @@ export type { FieldState, CreateFormOpts, ValidationBehaviour -} from './form-core.svelte'; +} from '$libs/forms';