@pyreon/form 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { Signal, Computed } from '@pyreon/reactivity'
2
+
3
+ export type ValidationError = string | undefined
4
+
5
+ /**
6
+ * A reactive value that can be read by calling it.
7
+ * Both `Signal<T>` and `Computed<T>` satisfy this interface.
8
+ */
9
+ export type Accessor<T> = Signal<T> | Computed<T>
10
+
11
+ /**
12
+ * Field validator function. Receives the field value and all current form values
13
+ * for cross-field validation.
14
+ */
15
+ export type ValidateFn<T, TValues = Record<string, unknown>> = (
16
+ value: T,
17
+ allValues: TValues,
18
+ ) => ValidationError | Promise<ValidationError>
19
+
20
+ export type SchemaValidateFn<TValues> = (
21
+ values: TValues,
22
+ ) =>
23
+ | Partial<Record<keyof TValues, ValidationError>>
24
+ | Promise<Partial<Record<keyof TValues, ValidationError>>>
25
+
26
+ export interface FieldState<T = unknown> {
27
+ /** Current field value. */
28
+ value: Signal<T>
29
+ /** Field error message (undefined if no error). */
30
+ error: Signal<ValidationError>
31
+ /** Whether the field has been blurred at least once. */
32
+ touched: Signal<boolean>
33
+ /** Whether the field value differs from its initial value. */
34
+ dirty: Signal<boolean>
35
+ /** Set the field value. */
36
+ setValue: (value: T) => void
37
+ /** Mark the field as touched (typically on blur). */
38
+ setTouched: () => void
39
+ /** Reset the field to its initial value and clear error/touched/dirty. */
40
+ reset: () => void
41
+ }
42
+
43
+ /** Props returned by `register()` for binding an input element. */
44
+ export interface FieldRegisterProps<T> {
45
+ value: Signal<T>
46
+ onInput: (e: Event) => void
47
+ onBlur: () => void
48
+ checked?: Accessor<boolean>
49
+ }
50
+
51
+ export interface FormState<TValues extends Record<string, unknown>> {
52
+ /** Individual field states keyed by field name. */
53
+ fields: { [K in keyof TValues]: FieldState<TValues[K]> }
54
+ /** Whether the form is currently being submitted. */
55
+ isSubmitting: Signal<boolean>
56
+ /** Whether async validation is currently running. */
57
+ isValidating: Signal<boolean>
58
+ /** Whether any field has an error (computed — read-only). */
59
+ isValid: Accessor<boolean>
60
+ /** Whether any field value differs from its initial value (computed — read-only). */
61
+ isDirty: Accessor<boolean>
62
+ /** Number of times the form has been submitted. */
63
+ submitCount: Signal<number>
64
+ /** Error thrown by onSubmit (undefined if no error). */
65
+ submitError: Signal<unknown>
66
+ /** All current form values as a plain object. */
67
+ values: () => TValues
68
+ /** All current errors as a record. */
69
+ errors: () => Partial<Record<keyof TValues, ValidationError>>
70
+ /** Set a single field's value. */
71
+ setFieldValue: <K extends keyof TValues>(field: K, value: TValues[K]) => void
72
+ /** Set a single field's error (e.g. from server-side validation). */
73
+ setFieldError: (field: keyof TValues, error: ValidationError) => void
74
+ /** Set multiple field errors at once (e.g. from server-side validation). */
75
+ setErrors: (errors: Partial<Record<keyof TValues, ValidationError>>) => void
76
+ /** Clear all field errors. */
77
+ clearErrors: () => void
78
+ /** Reset a single field to its initial value. */
79
+ resetField: (field: keyof TValues) => void
80
+ /**
81
+ * Returns props for binding an input element to a field.
82
+ * For text/select: includes `value` signal, `onInput`, and `onBlur`.
83
+ * For checkboxes: pass `{ type: 'checkbox' }` to also get a `checked` signal.
84
+ * For numbers: pass `{ type: 'number' }` to use `valueAsNumber` on input.
85
+ */
86
+ register: <K extends keyof TValues & string>(
87
+ field: K,
88
+ options?: { type?: 'checkbox' | 'number' },
89
+ ) => FieldRegisterProps<TValues[K]>
90
+ /**
91
+ * Submit handler — runs validation, then calls onSubmit if valid.
92
+ * Can be called directly or as a form event handler (calls preventDefault).
93
+ */
94
+ handleSubmit: (e?: Event) => Promise<void>
95
+ /** Reset all fields to initial values. */
96
+ reset: () => void
97
+ /** Validate all fields and return whether the form is valid. */
98
+ validate: () => Promise<boolean>
99
+ }
100
+
101
+ export interface UseFormOptions<TValues extends Record<string, unknown>> {
102
+ /** Initial values for each field. */
103
+ initialValues: TValues
104
+ /** Called with validated values on successful submit. */
105
+ onSubmit: (values: TValues) => void | Promise<void>
106
+ /** Per-field validators. Receives field value and all form values. */
107
+ validators?: Partial<{
108
+ [K in keyof TValues]: ValidateFn<TValues[K], TValues>
109
+ }>
110
+ /** Schema-level validator (runs after field validators). */
111
+ schema?: SchemaValidateFn<TValues>
112
+ /** When to validate: 'blur' (default), 'change', or 'submit'. */
113
+ validateOn?: 'blur' | 'change' | 'submit'
114
+ /** Debounce delay in ms for validators (useful for async validators). */
115
+ debounceMs?: number
116
+ }
@@ -0,0 +1,117 @@
1
+ import { signal, computed } from '@pyreon/reactivity'
2
+ import type { Signal, Computed } from '@pyreon/reactivity'
3
+
4
+ export interface FieldArrayItem<T> {
5
+ /** Stable key for keyed rendering. */
6
+ key: number
7
+ /** Reactive value for this item. */
8
+ value: Signal<T>
9
+ }
10
+
11
+ export interface UseFieldArrayResult<T> {
12
+ /** Reactive list of items with stable keys. */
13
+ items: Signal<FieldArrayItem<T>[]>
14
+ /** Number of items. */
15
+ length: Computed<number>
16
+ /** Append a new item to the end. */
17
+ append: (value: T) => void
18
+ /** Prepend a new item to the start. */
19
+ prepend: (value: T) => void
20
+ /** Insert an item at the given index. */
21
+ insert: (index: number, value: T) => void
22
+ /** Remove the item at the given index. */
23
+ remove: (index: number) => void
24
+ /** Update the value of an item at the given index. */
25
+ update: (index: number, value: T) => void
26
+ /** Move an item from one index to another. */
27
+ move: (from: number, to: number) => void
28
+ /** Swap two items by index. */
29
+ swap: (indexA: number, indexB: number) => void
30
+ /** Replace all items. */
31
+ replace: (values: T[]) => void
32
+ /** Get all current values as a plain array. */
33
+ values: () => T[]
34
+ }
35
+
36
+ /**
37
+ * Manage a dynamic array of form fields with stable keys.
38
+ *
39
+ * @example
40
+ * const tags = useFieldArray<string>([])
41
+ * tags.append('typescript')
42
+ * tags.append('pyreon')
43
+ * // tags.items() — array of { key, value } for keyed rendering
44
+ */
45
+ export function useFieldArray<T>(initial: T[] = []): UseFieldArrayResult<T> {
46
+ let nextKey = 0
47
+ const makeItem = (value: T): FieldArrayItem<T> => ({
48
+ key: nextKey++,
49
+ value: signal(value),
50
+ })
51
+
52
+ const items = signal<FieldArrayItem<T>[]>(initial.map(makeItem))
53
+ const length = computed(() => items().length)
54
+
55
+ return {
56
+ items,
57
+ length,
58
+
59
+ append(value: T) {
60
+ items.update((arr) => [...arr, makeItem(value)])
61
+ },
62
+
63
+ prepend(value: T) {
64
+ items.update((arr) => [makeItem(value), ...arr])
65
+ },
66
+
67
+ insert(index: number, value: T) {
68
+ items.update((arr) => {
69
+ const next = [...arr]
70
+ next.splice(index, 0, makeItem(value))
71
+ return next
72
+ })
73
+ },
74
+
75
+ remove(index: number) {
76
+ items.update((arr) => arr.filter((_, i) => i !== index))
77
+ },
78
+
79
+ update(index: number, value: T) {
80
+ const current = items.peek()
81
+ const item = current[index]
82
+ if (item) {
83
+ item.value.set(value)
84
+ }
85
+ },
86
+
87
+ move(from: number, to: number) {
88
+ items.update((arr) => {
89
+ const next = [...arr]
90
+ const [item] = next.splice(from, 1)
91
+ if (item) next.splice(to, 0, item)
92
+ return next
93
+ })
94
+ },
95
+
96
+ swap(indexA: number, indexB: number) {
97
+ items.update((arr) => {
98
+ const next = [...arr]
99
+ const a = next[indexA]
100
+ const b = next[indexB]
101
+ if (a && b) {
102
+ next[indexA] = b
103
+ next[indexB] = a
104
+ }
105
+ return next
106
+ })
107
+ },
108
+
109
+ replace(values: T[]) {
110
+ items.set(values.map(makeItem))
111
+ },
112
+
113
+ values() {
114
+ return items.peek().map((item) => item.value.peek())
115
+ },
116
+ }
117
+ }
@@ -0,0 +1,69 @@
1
+ import { computed } from '@pyreon/reactivity'
2
+ import type { Signal, Computed } from '@pyreon/reactivity'
3
+ import type {
4
+ FieldState,
5
+ FormState,
6
+ ValidationError,
7
+ FieldRegisterProps,
8
+ } from './types'
9
+
10
+ export interface UseFieldResult<T> {
11
+ /** Current field value (reactive signal). */
12
+ value: Signal<T>
13
+ /** Field error message (reactive signal). */
14
+ error: Signal<ValidationError>
15
+ /** Whether the field has been touched (reactive signal). */
16
+ touched: Signal<boolean>
17
+ /** Whether the field value differs from initial (reactive signal). */
18
+ dirty: Signal<boolean>
19
+ /** Set the field value. */
20
+ setValue: (value: T) => void
21
+ /** Mark the field as touched. */
22
+ setTouched: () => void
23
+ /** Reset the field to its initial value. */
24
+ reset: () => void
25
+ /** Register props for input binding. */
26
+ register: (opts?: { type?: 'checkbox' }) => FieldRegisterProps<T>
27
+ /** Whether the field has an error (computed). */
28
+ hasError: Computed<boolean>
29
+ /** Whether the field should show its error (touched + has error). */
30
+ showError: Computed<boolean>
31
+ }
32
+
33
+ /**
34
+ * Extract a single field's state and helpers from a form instance.
35
+ * Useful for building isolated field components.
36
+ *
37
+ * @example
38
+ * function EmailField({ form }: { form: FormState<{ email: string }> }) {
39
+ * const field = useField(form, 'email')
40
+ * return (
41
+ * <>
42
+ * <input {...field.register()} />
43
+ * {field.showError() && <span>{field.error()}</span>}
44
+ * </>
45
+ * )
46
+ * }
47
+ */
48
+ export function useField<
49
+ TValues extends Record<string, unknown>,
50
+ K extends keyof TValues & string,
51
+ >(form: FormState<TValues>, name: K): UseFieldResult<TValues[K]> {
52
+ const fieldState: FieldState<TValues[K]> = form.fields[name]
53
+
54
+ const hasError = computed(() => fieldState.error() !== undefined)
55
+ const showError = computed(() => fieldState.touched() && hasError())
56
+
57
+ return {
58
+ value: fieldState.value,
59
+ error: fieldState.error,
60
+ touched: fieldState.touched,
61
+ dirty: fieldState.dirty,
62
+ setValue: fieldState.setValue,
63
+ setTouched: fieldState.setTouched,
64
+ reset: fieldState.reset,
65
+ register: (opts?) => form.register(name, opts),
66
+ hasError,
67
+ showError,
68
+ }
69
+ }
@@ -0,0 +1,74 @@
1
+ import { computed } from '@pyreon/reactivity'
2
+ import type { Computed } from '@pyreon/reactivity'
3
+ import type { FormState, ValidationError } from './types'
4
+
5
+ export interface FormStateSummary<TValues extends Record<string, unknown>> {
6
+ isSubmitting: boolean
7
+ isValidating: boolean
8
+ isValid: boolean
9
+ isDirty: boolean
10
+ submitCount: number
11
+ submitError: unknown
12
+ touchedFields: Partial<Record<keyof TValues, boolean>>
13
+ dirtyFields: Partial<Record<keyof TValues, boolean>>
14
+ errors: Partial<Record<keyof TValues, ValidationError>>
15
+ }
16
+
17
+ /**
18
+ * Subscribe to the full form state as a single computed signal.
19
+ * Useful for rendering form-level UI (submit button disabled state,
20
+ * error summaries, progress indicators).
21
+ *
22
+ * @example
23
+ * const state = useFormState(form)
24
+ * // state() => { isSubmitting, isValid, isDirty, errors, ... }
25
+ *
26
+ * @example
27
+ * // Use a selector for fine-grained reactivity
28
+ * const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting)
29
+ */
30
+ export function useFormState<TValues extends Record<string, unknown>>(
31
+ form: FormState<TValues>,
32
+ ): Computed<FormStateSummary<TValues>>
33
+
34
+ export function useFormState<TValues extends Record<string, unknown>, R>(
35
+ form: FormState<TValues>,
36
+ selector: (state: FormStateSummary<TValues>) => R,
37
+ ): Computed<R>
38
+
39
+ export function useFormState<TValues extends Record<string, unknown>, R>(
40
+ form: FormState<TValues>,
41
+ selector?: (state: FormStateSummary<TValues>) => R,
42
+ ): Computed<FormStateSummary<TValues>> | Computed<R> {
43
+ const buildSummary = (): FormStateSummary<TValues> => {
44
+ const touchedFields = {} as Partial<Record<keyof TValues, boolean>>
45
+ const dirtyFields = {} as Partial<Record<keyof TValues, boolean>>
46
+ const errors = {} as Partial<Record<keyof TValues, ValidationError>>
47
+
48
+ for (const key of Object.keys(form.fields) as (keyof TValues & string)[]) {
49
+ const field = form.fields[key]
50
+ if (field.touched()) touchedFields[key] = true
51
+ if (field.dirty()) dirtyFields[key] = true
52
+ const err = field.error()
53
+ if (err !== undefined) errors[key] = err
54
+ }
55
+
56
+ return {
57
+ isSubmitting: form.isSubmitting(),
58
+ isValidating: form.isValidating(),
59
+ isValid: form.isValid(),
60
+ isDirty: form.isDirty(),
61
+ submitCount: form.submitCount(),
62
+ submitError: form.submitError(),
63
+ touchedFields,
64
+ dirtyFields,
65
+ errors,
66
+ }
67
+ }
68
+
69
+ if (selector) {
70
+ return computed(() => selector(buildSummary()))
71
+ }
72
+
73
+ return computed(buildSummary)
74
+ }