diff --git a/.changeset/build-config-cleanup.md b/.changeset/build-config-cleanup.md
new file mode 100644
index 0000000..0755f3c
--- /dev/null
+++ b/.changeset/build-config-cleanup.md
@@ -0,0 +1,10 @@
+---
+"@ilokesto/form": patch
+---
+
+Build config cleanup: enable sourcemaps, preserve JSDoc in declarations, remove dead vite build config, remove redundant .npmignore.
+
+- `tsconfig.json`: `sourceMap: true` — consumers can now debug into library source.
+- `tsconfig.json`: `removeComments: false` — Korean JSDoc preserved in `.d.ts` files, visible in IDE tooltips.
+- `vite.config.ts`: removed dead `build.lib` block (production build uses `tsc`, not `vite build`). File now clearly scoped to vitest config only.
+- Removed `.npmignore` — `package.json` `files` field already controls published files; `.npmignore` was redundant.
\ No newline at end of file
diff --git a/.changeset/core-unit-tests.md b/.changeset/core-unit-tests.md
new file mode 100644
index 0000000..7de1f94
--- /dev/null
+++ b/.changeset/core-unit-tests.md
@@ -0,0 +1,11 @@
+---
+"@ilokesto/form": patch
+---
+
+Add dedicated unit tests for core modules.
+
+- `test/core/ValueHelper.test.ts` — covers path-based get/set and values reconstruction.
+- `test/core/FormPath.test.ts` — covers path ↔ key conversion and path normalization.
+- `test/core/FormStateInitializer.test.ts` — covers defaultValues → FormState initialization.
+- `test/core/FormArrayMutationPlanner.test.ts` — covers push, insert, remove, move, swap, and replace planning.
+- `test/core/FormArrayRebaser.test.ts` — covers field metadata rebase and arrayKeys updates.
diff --git a/.changeset/svelte-use-field.md b/.changeset/svelte-use-field.md
new file mode 100644
index 0000000..8769846
--- /dev/null
+++ b/.changeset/svelte-use-field.md
@@ -0,0 +1,13 @@
+---
+"@ilokesto/form": patch
+---
+
+Add `useField` to the Svelte adapter for API parity with React/Vue/Solid.
+
+- `useForm(form).useField(options)` returns `{ props, value, setValue, errors, dirty, touched }` matching the shape of the React, Vue, and Solid `useField` hooks.
+- `props` is a Svelte `use:`-compatible register action bound to the field's `RegisterOptions`, so `` works directly.
+- `value`, `errors`, `dirty`, and `touched` read the latest field state from the core form on every access, staying reactive through the existing `form.subscribe` flow.
+- `setValue(value)` updates the field programmatically with `{ source: 'program' }`.
+- Field-local `schema` in `RegisterOptions` is supported via the existing register action, with cleanup on action destroy restoring the form-level schema.
+
+Resolves #17
\ No newline at end of file
diff --git a/.npmignore b/.npmignore
deleted file mode 100644
index ce53855..0000000
--- a/.npmignore
+++ /dev/null
@@ -1,9 +0,0 @@
-.DS_Store
-.git*
-node_modules/
-src/
-vite.config.ts
-tsconfig.json
-stats.html
-.omx
-.examplesdocs/
diff --git a/src/svelte/index.ts b/src/svelte/index.ts
index b272f44..86a4cd2 100644
--- a/src/svelte/index.ts
+++ b/src/svelte/index.ts
@@ -1,4 +1,5 @@
export { useForm } from './useForm';
export type {
RegisterOptions,
+ SvelteFieldReturn,
} from './types';
diff --git a/src/svelte/types.ts b/src/svelte/types.ts
index afdabdf..b9a9df2 100644
--- a/src/svelte/types.ts
+++ b/src/svelte/types.ts
@@ -1,6 +1,6 @@
import type { Action } from 'svelte/action';
import type { Readable } from 'svelte/store';
-import type { Form } from '../core/index';
+import type { Form, FormError } from '../core/index';
import type { FormStateSummary } from '../adapters/FormStateSummary';
import type { RegisterOptions, SubmitHandler, SubmitInvalidHandler, SubmitValidHandler } from '../adapters/dom';
@@ -8,11 +8,29 @@ export type { RegisterOptions } from '../adapters/dom';
export type SvelteRegisterAction = Action;
+/** 한 field의 binding action, value, meta, setter를 함께 제공한다. */
+export type SvelteFieldReturn = {
+ /** ``에 바로 전달할 수 있는, options가 고정된 register action이다. */
+ readonly props: SvelteRegisterAction;
+ /** 현재 field value다. */
+ readonly value: unknown;
+ /** field value를 programmatic하게 갱신한다. */
+ setValue(value: unknown): void;
+ /** 현재 field에 붙어 있는 validation errors다. */
+ readonly errors: FormError[];
+ /** 현재 값이 initial value와 다른지 여부다. */
+ readonly dirty: boolean;
+ /** field가 한 번 이상 blur 되었는지 여부다. */
+ readonly touched: boolean;
+};
+
/** `useForm(form)`이 반환하는 Svelte action 중심 surface다. */
export type SvelteForm = {
form: Form;
/** ``처럼 사용하는 Svelte action이다. */
register: SvelteRegisterAction;
+ /** 한 field의 binding, value, meta, setter를 함께 반환한다. */
+ useField(options: RegisterOptions): SvelteFieldReturn;
/** form-wide aggregate state를 Svelte readable store로 반환한다. */
useFormState(): Readable>;
/** submit event를 막고 core submit 흐름을 실행하는 handler factory다. */
diff --git a/src/svelte/useField.ts b/src/svelte/useField.ts
new file mode 100644
index 0000000..3073739
--- /dev/null
+++ b/src/svelte/useField.ts
@@ -0,0 +1,35 @@
+import type { Form } from '../core/index';
+import { getFieldState } from '../adapters/dom';
+import type { RegisterOptions } from '../adapters/dom';
+import { createRegisterAction } from './RegisterAction';
+import type { SvelteFieldReturn, SvelteRegisterAction } from './types';
+
+/** 한 field의 binding action, reactive value/meta, setter를 함께 반환한다. */
+export function useFieldWithForm(form: Form, options: RegisterOptions): SvelteFieldReturn {
+ const props = createBoundRegisterAction(form, options);
+
+ return {
+ props,
+ get value() {
+ return getFieldState(form, form.getState(), options.name).value;
+ },
+ setValue(value: unknown) {
+ form.setValue(options.name, value, { source: 'program' });
+ },
+ get errors() {
+ return [...getFieldState(form, form.getState(), options.name).errors];
+ },
+ get dirty() {
+ return getFieldState(form, form.getState(), options.name).dirty;
+ },
+ get touched() {
+ return getFieldState(form, form.getState(), options.name).touched;
+ },
+ };
+}
+
+function createBoundRegisterAction(form: Form, options: RegisterOptions): SvelteRegisterAction {
+ const register = createRegisterAction(form);
+
+ return (node, overrideOptions) => register(node, overrideOptions ?? options);
+}
\ No newline at end of file
diff --git a/src/svelte/useForm.ts b/src/svelte/useForm.ts
index 885e429..ac2ebd0 100644
--- a/src/svelte/useForm.ts
+++ b/src/svelte/useForm.ts
@@ -3,6 +3,7 @@ import { createFormFromOptions, isFormInstance, type FormInput } from '../adapte
import type { CreateFormOptions, Form } from '../core/index';
import type { SvelteForm } from './types';
import { createRegisterAction } from './RegisterAction';
+import { useFieldWithForm } from './useField';
import { useFormStateWithForm } from './useFormState';
/** Svelte action surface를 core Form 인스턴스에 바인딩한다. */
@@ -13,6 +14,9 @@ export function useForm(input: FormInput): SvelteForm
return {
form,
register: createRegisterAction(form),
+ useField(options) {
+ return useFieldWithForm(form, options);
+ },
useFormState() {
return useFormStateWithForm(form);
},
diff --git a/test/core/FormArrayMutationPlanner.test.ts b/test/core/FormArrayMutationPlanner.test.ts
new file mode 100644
index 0000000..cee3c4c
--- /dev/null
+++ b/test/core/FormArrayMutationPlanner.test.ts
@@ -0,0 +1,89 @@
+import { test, expect } from 'vitest';
+
+import { FormArrayMutationPlanner } from '../../src/core/array/FormArrayMutationPlanner';
+
+const planner = new FormArrayMutationPlanner();
+
+test('push adds a new item at the end', () => {
+ const mutation = planner.push(['a', 'b'], ['k0', 'k1'], 'c', 'k2');
+
+ expect(mutation.values).toEqual(['a', 'b', 'c']);
+ expect(mutation.keys).toEqual(['k0', 'k1', 'k2']);
+ expect(mutation.mapPreviousIndex(0)).toBe(0);
+ expect(mutation.mapPreviousIndex(1)).toBe(1);
+ expect(mutation.mapPreviousIndex(2)).toBeUndefined();
+});
+
+test('insert places a new item at the requested index', () => {
+ const mutation = planner.insert(['a', 'b'], ['k0', 'k1'], 1, 'X', 'k2');
+
+ expect(mutation.values).toEqual(['a', 'X', 'b']);
+ expect(mutation.keys).toEqual(['k0', 'k2', 'k1']);
+ expect(mutation.mapPreviousIndex(0)).toBe(0);
+ expect(mutation.mapPreviousIndex(1)).toBe(2);
+ expect(mutation.mapPreviousIndex(2)).toBeUndefined();
+});
+
+test('insert clamps index to array bounds', () => {
+ const insertAtStart = planner.insert(['a', 'b'], ['k0', 'k1'], -1, 'X', 'k2');
+ const insertAtEnd = planner.insert(['a', 'b'], ['k0', 'k1'], 10, 'X', 'k2');
+
+ expect(insertAtStart.values).toEqual(['X', 'a', 'b']);
+ expect(insertAtEnd.values).toEqual(['a', 'b', 'X']);
+});
+
+test('remove drops item and maps remaining items', () => {
+ const mutation = planner.remove(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 1);
+
+ expect(mutation?.values).toEqual(['a', 'c']);
+ expect(mutation?.keys).toEqual(['k0', 'k2']);
+ expect(mutation?.mapPreviousIndex(0)).toBe(0);
+ expect(mutation?.mapPreviousIndex(1)).toBeUndefined();
+ expect(mutation?.mapPreviousIndex(2)).toBe(1);
+});
+
+test('remove returns undefined for invalid index', () => {
+ expect(planner.remove(['a', 'b'], ['k0', 'k1'], -1)).toBeUndefined();
+ expect(planner.remove(['a', 'b'], ['k0', 'k1'], 2)).toBeUndefined();
+});
+
+test('move shifts item from one index to another', () => {
+ const mutation = planner.move(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 1, 0);
+
+ expect(mutation?.values).toEqual(['b', 'a', 'c']);
+ expect(mutation?.keys).toEqual(['k1', 'k0', 'k2']);
+ expect(mutation?.mapPreviousIndex(0)).toBe(1);
+ expect(mutation?.mapPreviousIndex(1)).toBe(0);
+ expect(mutation?.mapPreviousIndex(2)).toBe(2);
+});
+
+test('move returns undefined for invalid or same index', () => {
+ expect(planner.move(['a', 'b'], ['k0', 'k1'], -1, 0)).toBeUndefined();
+ expect(planner.move(['a', 'b'], ['k0', 'k1'], 0, 2)).toBeUndefined();
+ expect(planner.move(['a', 'b'], ['k0', 'k1'], 0, 0)).toBeUndefined();
+});
+
+test('swap exchanges two item positions', () => {
+ const mutation = planner.swap(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 0, 2);
+
+ expect(mutation?.values).toEqual(['c', 'b', 'a']);
+ expect(mutation?.keys).toEqual(['k2', 'k1', 'k0']);
+ expect(mutation?.mapPreviousIndex(0)).toBe(2);
+ expect(mutation?.mapPreviousIndex(1)).toBe(1);
+ expect(mutation?.mapPreviousIndex(2)).toBe(0);
+});
+
+test('swap returns undefined for invalid or same index', () => {
+ expect(planner.swap(['a', 'b'], ['k0', 'k1'], -1, 1)).toBeUndefined();
+ expect(planner.swap(['a', 'b'], ['k0', 'k1'], 0, 2)).toBeUndefined();
+ expect(planner.swap(['a', 'b'], ['k0', 'k1'], 1, 1)).toBeUndefined();
+});
+
+test('replace returns new values and keys without previous mapping', () => {
+ const mutation = planner.replace(['x', 'y'], ['new0', 'new1']);
+
+ expect(mutation.values).toEqual(['x', 'y']);
+ expect(mutation.keys).toEqual(['new0', 'new1']);
+ expect(mutation.mapPreviousIndex(0)).toBeUndefined();
+ expect(mutation.mapPreviousIndex(1)).toBeUndefined();
+});
diff --git a/test/core/FormArrayRebaser.test.ts b/test/core/FormArrayRebaser.test.ts
new file mode 100644
index 0000000..93b6247
--- /dev/null
+++ b/test/core/FormArrayRebaser.test.ts
@@ -0,0 +1,75 @@
+import { test, expect } from 'vitest';
+
+import { FormArrayRebaser } from '../../src/core/array/FormArrayRebaser';
+import { FormArrayMutationPlanner } from '../../src/core/array/FormArrayMutationPlanner';
+import { FormStateStore } from '../../src/core/state/FormStateStore';
+import { FormStateInitializer } from '../../src/core/state/FormStateInitializer';
+import { FormPath } from '../../src/core/path/FormPath';
+import { ValueHelper } from '../../src/core/value/ValueHelper';
+
+test('rebase preserves non-array fields', () => {
+ const store = new FormStateStore({ email: 'a@example.com', items: [{ name: 'x' }] });
+ const planner = new FormArrayMutationPlanner();
+ const mutation = planner.push([{ name: 'x' }], ['k0'], { name: 'y' }, 'k1');
+
+ const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex);
+
+ expect(nextState.fields[FormPath.pathToKey(['email'])]).toMatchObject({ value: 'a@example.com' });
+});
+
+test('rebase moves child metadata to new index', () => {
+ const store = new FormStateStore({ items: [{ name: 'a' }, { name: 'b' }] });
+ const previousKey = FormPath.pathToKey(['items', 1, 'name']);
+ store.focusField(previousKey);
+ store.touchField(previousKey);
+ store.setErrorsByKey(previousKey, [{ message: 'Keep me' }]);
+
+ const planner = new FormArrayMutationPlanner();
+ const mutation = planner.move(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], 1, 0);
+
+ const nextState = FormArrayRebaser.rebase(store, ['items'], mutation!.values, mutation!.keys, mutation!.mapPreviousIndex);
+
+ const movedField = nextState.fields[FormPath.pathToKey(['items', 0, 'name'])];
+ expect(movedField.touched).toBe(true);
+ expect(movedField.errors).toEqual([{ message: 'Keep me' }]);
+ expect(movedField.isFocused).toBe(true);
+});
+
+test('rebase drops metadata for removed items', () => {
+ const store = new FormStateStore({ items: [{ name: 'a' }, { name: 'b' }] });
+ const previousKey = FormPath.pathToKey(['items', 1, 'name']);
+ store.setErrorsByKey(previousKey, [{ message: 'Drop me' }]);
+
+ const planner = new FormArrayMutationPlanner();
+ const mutation = planner.remove(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], 1);
+
+ const nextState = FormArrayRebaser.rebase(store, ['items'], mutation!.values, mutation!.keys, mutation!.mapPreviousIndex);
+
+ expect(nextState.fields[FormPath.pathToKey(['items', 0, 'name'])]).toBeDefined();
+ expect(nextState.fields[FormPath.pathToKey(['items', 1, 'name'])]).toBeUndefined();
+});
+
+test('rebase updates arrayKeys', () => {
+ const store = new FormStateStore({ items: [{ name: 'a' }] });
+ const planner = new FormArrayMutationPlanner();
+ const mutation = planner.replace([{ name: 'x' }, { name: 'y' }], ['new0', 'new1']);
+
+ const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex);
+
+ expect(nextState.arrayKeys[FormPath.pathToKey(['items'])]).toEqual(['new0', 'new1']);
+});
+
+test('rebase preserves submit state from the original state', () => {
+ const store = new FormStateStore({ items: [{ name: 'a' }] });
+ store.beginSubmit();
+ store.completeSubmit(true);
+
+ const planner = new FormArrayMutationPlanner();
+ const mutation = planner.push(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], { name: 'b' }, 'new');
+
+ const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex);
+
+ expect(nextState.submitCount).toBe(1);
+ expect(nextState.isSubmitted).toBe(true);
+ expect(nextState.isSubmitSuccessful).toBe(true);
+});
diff --git a/test/core/FormPath.test.ts b/test/core/FormPath.test.ts
new file mode 100644
index 0000000..04a0e5e
--- /dev/null
+++ b/test/core/FormPath.test.ts
@@ -0,0 +1,56 @@
+import { test, expect } from 'vitest';
+
+import { FormPath } from '../../src/core/path/FormPath';
+
+test('pathToKey returns root key for empty path', () => {
+ expect(FormPath.pathToKey([])).toBe('$');
+});
+
+test('pathToKey returns JSON string for single segment path', () => {
+ expect(FormPath.pathToKey(['email'])).toBe('["email"]');
+});
+
+test('pathToKey returns JSON string for multi segment path', () => {
+ expect(FormPath.pathToKey(['user', 'name'])).toBe('["user","name"]');
+});
+
+test('pathToKey handles array index segments', () => {
+ expect(FormPath.pathToKey(['items', 0, 'name'])).toBe('["items",0,"name"]');
+});
+
+test('keyToPath round-trips paths through JSON string', () => {
+ const paths = [
+ [],
+ ['email'],
+ ['user', 'name'],
+ ['items', 0, 'name'],
+ ];
+
+ paths.forEach(path => {
+ expect(FormPath.keyToPath(FormPath.pathToKey(path))).toEqual(path);
+ });
+});
+
+test('keyToPath converts root key to empty path', () => {
+ expect(FormPath.keyToPath('$')).toEqual([]);
+});
+
+test('pathInputToKey converts string path to single segment key', () => {
+ expect(FormPath.pathInputToKey('user.name')).toBe('["user.name"]');
+});
+
+test('pathInputToKey converts tuple path to key', () => {
+ expect(FormPath.pathInputToKey(['user', 'name'])).toBe('["user","name"]');
+});
+
+test('toFieldPath converts string to single segment tuple', () => {
+ expect(FormPath.toFieldPath('user.name')).toEqual(['user.name']);
+});
+
+test('toFieldPath passes tuple path through', () => {
+ expect(FormPath.toFieldPath(['user', 'name'])).toEqual(['user', 'name']);
+});
+
+test('path helper returns segments as tuple', () => {
+ expect(FormPath.path('user', 'name')).toEqual(['user', 'name']);
+});
diff --git a/test/core/FormStateInitializer.test.ts b/test/core/FormStateInitializer.test.ts
new file mode 100644
index 0000000..86484ac
--- /dev/null
+++ b/test/core/FormStateInitializer.test.ts
@@ -0,0 +1,63 @@
+import { test, expect } from 'vitest';
+
+import { FormStateInitializer } from '../../src/core/state/FormStateInitializer';
+import { FormPath } from '../../src/core/path/FormPath';
+
+test('initialize with primitive values creates leaf fields', () => {
+ const state = FormStateInitializer.initialize({ name: 'Ada', count: 42, active: true });
+
+ expect(state.fields[FormPath.pathToKey(['name'])]).toEqual({
+ value: 'Ada',
+ errors: [],
+ touched: false,
+ dirty: false,
+ modified: false,
+ isFocused: false,
+ });
+ expect(state.fields[FormPath.pathToKey(['count'])]).toMatchObject({ value: 42 });
+ expect(state.fields[FormPath.pathToKey(['active'])]).toMatchObject({ value: true });
+ expect(state.arrayKeys).toEqual({});
+});
+
+test('initialize with nested objects creates nested leaf fields', () => {
+ const state = FormStateInitializer.initialize({
+ user: { name: 'Ada', profile: { role: 'admin' } },
+ });
+
+ expect(state.fields[FormPath.pathToKey(['user', 'name'])]).toMatchObject({ value: 'Ada' });
+ expect(state.fields[FormPath.pathToKey(['user', 'profile', 'role'])]).toMatchObject({ value: 'admin' });
+ expect(state.arrayKeys).toEqual({});
+});
+
+test('initialize with arrays generates arrayKeys and nested fields', () => {
+ const state = FormStateInitializer.initialize({
+ items: [{ name: 'a' }, { name: 'b' }],
+ });
+
+ expect(state.arrayKeys[FormPath.pathToKey(['items'])]).toEqual(['initial-0', 'initial-1']);
+ expect(state.fields[FormPath.pathToKey(['items', 0, 'name'])]).toMatchObject({ value: 'a' });
+ expect(state.fields[FormPath.pathToKey(['items', 1, 'name'])]).toMatchObject({ value: 'b' });
+});
+
+test('initialize with mixed null and undefined preserves leaf values', () => {
+ const state = FormStateInitializer.initialize({
+ maybeNull: null,
+ maybeUndefined: undefined,
+ nested: { maybeNull: null },
+ });
+
+ expect(state.fields[FormPath.pathToKey(['maybeNull'])]).toMatchObject({ value: null });
+ expect(state.fields[FormPath.pathToKey(['maybeUndefined'])]).toMatchObject({ value: undefined });
+ expect(state.fields[FormPath.pathToKey(['nested', 'maybeNull'])]).toMatchObject({ value: null });
+});
+
+test('initialize records defaultValues and default submit state', () => {
+ const defaultValues = { name: 'Ada' };
+ const state = FormStateInitializer.initialize(defaultValues);
+
+ expect(state.defaultValues).toEqual(defaultValues);
+ expect(state.submitCount).toBe(0);
+ expect(state.isSubmitting).toBe(false);
+ expect(state.isSubmitted).toBe(false);
+ expect(state.isSubmitSuccessful).toBe(false);
+});
diff --git a/test/core/ValueHelper.test.ts b/test/core/ValueHelper.test.ts
new file mode 100644
index 0000000..a6b9d8a
--- /dev/null
+++ b/test/core/ValueHelper.test.ts
@@ -0,0 +1,79 @@
+import { test, expect } from 'vitest';
+
+import { ValueHelper } from '../../src/core/value/ValueHelper';
+import { FormPath } from '../../src/core/path/FormPath';
+import { FormStateInitializer } from '../../src/core/state/FormStateInitializer';
+
+test('getValueAtPath reads nested object values', () => {
+ const source = { user: { name: 'Ada', role: 'admin' } };
+
+ expect(ValueHelper.getValueAtPath(source, ['user', 'name'])).toBe('Ada');
+ expect(ValueHelper.getValueAtPath(source, ['user', 'role'])).toBe('admin');
+});
+
+test('getValueAtPath reads array values', () => {
+ const source = { items: ['a', 'b', 'c'] };
+
+ expect(ValueHelper.getValueAtPath(source, ['items', 0])).toBe('a');
+ expect(ValueHelper.getValueAtPath(source, ['items', 2])).toBe('c');
+});
+
+test('getValueAtPath returns undefined for null or undefined root', () => {
+ expect(ValueHelper.getValueAtPath(null, ['value'])).toBeUndefined();
+ expect(ValueHelper.getValueAtPath(undefined, ['value'])).toBeUndefined();
+});
+
+test('getValueAtPath returns undefined for missing or primitive path', () => {
+ const source = { user: { name: 'Ada' } };
+
+ expect(ValueHelper.getValueAtPath(source, ['user', 'age'])).toBeUndefined();
+ expect(ValueHelper.getValueAtPath(source, ['user', 'name', 'extra'])).toBeUndefined();
+ expect(ValueHelper.getValueAtPath(source, ['unknown', 'path'])).toBeUndefined();
+});
+
+test('setValueAtPath writes nested object values immutably', () => {
+ const source = { user: { name: 'Ada' } };
+ const next = ValueHelper.setValueAtPath(source, ['user', 'name'], 'Grace');
+
+ expect(next).toEqual({ user: { name: 'Grace' } });
+ expect(source).toEqual({ user: { name: 'Ada' } });
+});
+
+test('setValueAtPath writes array values immutably', () => {
+ const source = { items: ['a', 'b', 'c'] };
+ const next = ValueHelper.setValueAtPath(source, ['items', 1], 'B');
+
+ expect(next).toEqual({ items: ['a', 'B', 'c'] });
+ expect(source).toEqual({ items: ['a', 'b', 'c'] });
+});
+
+test('setValueAtPath creates intermediate containers', () => {
+ const source = {};
+ const nextObjectContainer = ValueHelper.setValueAtPath(source, ['user', 'name'], 'Ada');
+ const nextArrayContainer = ValueHelper.setValueAtPath(source, ['items', 0], 'a');
+
+ expect(nextObjectContainer).toEqual({ user: { name: 'Ada' } });
+ expect(nextArrayContainer).toEqual({ items: ['a'] });
+});
+
+test('setValueAtPath replaces root when path is empty', () => {
+ const source = { user: { name: 'Ada' } };
+ const next = ValueHelper.setValueAtPath(source, [], { user: { name: 'Grace' } });
+
+ expect(next).toEqual({ user: { name: 'Grace' } });
+});
+
+test('getValuesFromFields reconstructs nested values from flat fields', () => {
+ const state = FormStateInitializer.initialize({
+ user: { name: 'Ada', role: 'admin' },
+ items: ['a', 'b'],
+ });
+ const fieldPaths = Object.fromEntries(
+ Object.keys(state.fields).map(key => [key, FormPath.keyToPath(key)]),
+ );
+
+ expect(ValueHelper.getValuesFromFields(state, fieldPaths)).toEqual({
+ user: { name: 'Ada', role: 'admin' },
+ items: ['a', 'b'],
+ });
+});
diff --git a/test/svelte.test.ts b/test/svelte.test.ts
index e4d2467..52b4448 100644
--- a/test/svelte.test.ts
+++ b/test/svelte.test.ts
@@ -162,3 +162,102 @@ test('Svelte field-local schema overrides form-level schema while action is aliv
await form.trigger('email');
expect(form.getFieldState('email').errors.map(error => error.message)).toEqual(['Form-level error']);
});
+
+test('Svelte useField returns props, value, setValue, errors, dirty, touched', () => {
+ const form = new CreateForm({ defaultValues: { email: '' } });
+ const { useField } = useForm(form);
+ const field = useField({ name: 'email' });
+
+ expect(field.value).toBe('');
+ expect(field.errors).toEqual([]);
+ expect(field.dirty).toBe(false);
+ expect(field.touched).toBe(false);
+
+ field.setValue('ada@example.com');
+ expect(field.value).toBe('ada@example.com');
+ expect(form.getValue('email')).toBe('ada@example.com');
+ expect(field.dirty).toBe(true);
+
+ form.setErrors('email', [{ message: 'Required' }]);
+ expect(field.errors.map(error => error.message)).toEqual(['Required']);
+
+ void form.blur('email');
+ expect(field.touched).toBe(true);
+});
+
+test('Svelte useField props action binds input and syncs value', () => {
+ const form = new CreateForm({ defaultValues: { email: '' } });
+ const { useField } = useForm(form);
+ const field = useField({ name: 'email' });
+ const input = document.createElement('input');
+ const action = field.props(input, { name: 'email' });
+
+ expect(input.name).toBe('email');
+
+ input.value = 'grace@example.com';
+ input.dispatchEvent(new InputEvent('input', { bubbles: true }));
+
+ expect(field.value).toBe('grace@example.com');
+ expect(form.getValue('email')).toBe('grace@example.com');
+
+ action?.destroy?.();
+});
+
+test('Svelte useField with field-local schema overrides form-level schema while action is alive', async () => {
+ const form = new CreateForm({
+ defaultValues: { email: '' },
+ validateOn: ['blur', 'submit'],
+ schema: standardSchema(() => ({
+ issues: [{ message: 'Form-level error', path: ['email'] }],
+ })),
+ });
+ const emailSchema = standardSchema((value: string) => {
+ if (value.includes('@')) {
+ return { value };
+ }
+
+ return { issues: [{ message: 'Field-level error' }] };
+ });
+ const { useField } = useForm(form);
+ const field = useField({ name: 'email', schema: emailSchema });
+ const input = document.createElement('input');
+ const action = field.props(input, { name: 'email', schema: emailSchema });
+
+ await form.blur('email');
+ expect(field.errors.map(error => error.message)).toEqual(['Field-level error']);
+
+ action?.destroy?.();
+ await form.trigger('email');
+ expect(form.getFieldState('email').errors.map(error => error.message)).toEqual(['Form-level error']);
+});
+
+test('Svelte useField schema cleanup restores form-level schema after action destroy', async () => {
+ const form = new CreateForm({
+ defaultValues: { email: '' },
+ validateOn: ['blur', 'submit'],
+ schema: standardSchema(() => ({
+ issues: [{ message: 'Form-level error', path: ['email'] }],
+ })),
+ });
+ const emailSchema = standardSchema((value: string) => {
+ if (value.includes('@')) {
+ return { value };
+ }
+
+ return { issues: [{ message: 'Field-level error' }] };
+ });
+ const { useField } = useForm(form);
+ const field = useField({ name: 'email', schema: emailSchema });
+
+ expect(field.errors).toEqual([]);
+
+ const input = document.createElement('input');
+ const action = field.props(input, { name: 'email', schema: emailSchema });
+
+ await form.blur('email');
+ expect(field.errors.map(error => error.message)).toEqual(['Field-level error']);
+
+ action?.destroy?.();
+ await form.trigger('email');
+ expect(field.errors.map(error => error.message)).toEqual(['Form-level error']);
+});
diff --git a/tsconfig.json b/tsconfig.json
index c7c5c97..7318521 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,7 @@
"declaration": true,
"declarationDir": "dist",
"emitDeclarationOnly": false,
+ "sourceMap": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
@@ -13,7 +14,7 @@
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
- "removeComments": true,
+ "removeComments": false,
"types": ["node"]
},
"include": ["src"]
diff --git a/vite.config.ts b/vite.config.ts
index 09d21a4..033e83b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,22 +1,9 @@
import { defineConfig } from 'vite';
+// This config is used by vitest only. Production builds use `tsc` (see package.json `build` script).
+// The `build.lib` block was removed because it was dead code — `pnpm build` runs `rm -rf dist && tsc`, not `vite build`.
export default defineConfig({
- build: {
- lib: {
- entry: {
- index: 'src/index.ts',
- 'react/index': 'src/react/index.ts',
- 'vue/index': 'src/vue/index.ts',
- 'solid/index': 'src/solid/index.ts',
- 'svelte/index': 'src/svelte/index.ts',
- },
- formats: ['es'],
- fileName: (_format, entryName) => `${entryName}.js`,
- },
- rollupOptions: {
- external: ['@ilokesto/store', 'immer', 'react', 'vue', 'solid-js', 'svelte', 'svelte/action', 'svelte/store'],
- },
- sourcemap: false,
- emptyOutDir: false,
+ test: {
+ environment: 'jsdom',
},
-});
+});
\ No newline at end of file