Move form core to forms lib

active-uix
dev 5 months ago
parent 02c1fb43ab
commit d319ec6b66

@ -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<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function loginSchema(): StandardSchemaV1<LoginValues> {
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<CreateFormOpts<StandardSchemaV1<LoginValues> | 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<void>((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();
});
});

@ -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 ─────────────────────────────────────────────────────────────

@ -0,0 +1,11 @@
export { createForm } from './form-core.svelte';
export type {
CreateFormOpts,
FieldState,
Form,
FormErrors,
FormIssue,
FormIssues,
SubmitFailureReason,
ValidationBehaviour
} from './form-core.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<string | number>;

@ -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();

@ -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';

@ -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 ─────────────────────────────────────────────────────────────────

@ -1,84 +0,0 @@
/**
* Standard Schema v1 — minimal inline port of the specification.
*
* <https://standardschema.dev/>
*
* 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<Input = unknown, Output = Input> {
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}
export namespace StandardSchemaV1 {
export interface Props<Input = unknown, Output = Input> {
/** 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<Result>` for async schemas. */
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
/** Type metadata — compile-time only, undefined at runtime. */
readonly types?: Types<Input, Output>;
}
export type Result<Output> = SuccessResult<Output> | FailureResult;
export interface SuccessResult<Output> {
readonly value: Output;
readonly issues?: undefined;
}
export interface FailureResult {
readonly issues: ReadonlyArray<Issue>;
}
export interface Issue {
readonly message: string;
readonly path?: ReadonlyArray<PropertyKey | PathSegment>;
}
export interface PathSegment {
readonly key: PropertyKey;
}
export interface Types<Input = unknown, Output = Input> {
readonly input: Input;
readonly output: Output;
}
export type InferInput<S extends StandardSchemaV1> = NonNullable<
S['~standard']['types']
>['input'];
export type InferOutput<S extends StandardSchemaV1> = 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<T>(v: T | Promise<T>): v is Promise<T> {
return typeof (v as { then?: unknown })?.then === 'function';
}

@ -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';

Loading…
Cancel
Save

Powered by TurnKey Linux.