Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/build-config-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions .changeset/core-unit-tests.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .changeset/svelte-use-field.md
Original file line number Diff line number Diff line change
@@ -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 `<input use:field.props />` 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
9 changes: 0 additions & 9 deletions .npmignore

This file was deleted.

1 change: 1 addition & 0 deletions src/svelte/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useForm } from './useForm';
export type {
RegisterOptions,
SvelteFieldReturn,
} from './types';
20 changes: 19 additions & 1 deletion src/svelte/types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
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';

export type { RegisterOptions } from '../adapters/dom';

export type SvelteRegisterAction = Action<HTMLElement, RegisterOptions>;

/** 한 field의 binding action, value, meta, setter를 함께 제공한다. */
export type SvelteFieldReturn = {
/** `<input use:props />`에 바로 전달할 수 있는, 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<TValues> = {
form: Form<TValues>;
/** `<input use:register={{ name: 'email' }} />`처럼 사용하는 Svelte action이다. */
register: SvelteRegisterAction;
/** 한 field의 binding, value, meta, setter를 함께 반환한다. */
useField(options: RegisterOptions): SvelteFieldReturn;
/** form-wide aggregate state를 Svelte readable store로 반환한다. */
useFormState(): Readable<FormStateSummary<TValues>>;
/** submit event를 막고 core submit 흐름을 실행하는 handler factory다. */
Expand Down
35 changes: 35 additions & 0 deletions src/svelte/useField.ts
Original file line number Diff line number Diff line change
@@ -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<TValues>(form: Form<TValues>, 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<TValues>(form: Form<TValues>, options: RegisterOptions): SvelteRegisterAction {
const register = createRegisterAction(form);

return (node, overrideOptions) => register(node, overrideOptions ?? options);
}
4 changes: 4 additions & 0 deletions src/svelte/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 인스턴스에 바인딩한다. */
Expand All @@ -13,6 +14,9 @@ export function useForm<TValues>(input: FormInput<TValues>): SvelteForm<TValues>
return {
form,
register: createRegisterAction(form),
useField(options) {
return useFieldWithForm(form, options);
},
useFormState() {
return useFormStateWithForm(form);
},
Expand Down
89 changes: 89 additions & 0 deletions test/core/FormArrayMutationPlanner.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
75 changes: 75 additions & 0 deletions test/core/FormArrayRebaser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
56 changes: 56 additions & 0 deletions test/core/FormPath.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
Loading
Loading