@playfast/reform-forms 0.1.0 → 1.1.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.
@@ -0,0 +1,166 @@
1
+ import { Function as Fn, Option, Record } from 'effect'
2
+ import {
3
+ getNestedValue,
4
+ moveAt,
5
+ recalculateDirtyPaths,
6
+ replaceAt,
7
+ setNestedValue,
8
+ } from './path'
9
+ import type { FormState } from './formTypes'
10
+
11
+ const arrayKeyCounter = { current: 0 }
12
+ const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
13
+
14
+ export const arrayKeysFor = (source: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
15
+ const out: Record<string, ReadonlyArray<string>> = {}
16
+ const visit = (node: KeyVisitNode): void => {
17
+ if (Array.isArray(node.current)) {
18
+ out[node.path] = node.current.map(() => makeArrayKey())
19
+ node.current.forEach((element, index) => visit({ current: element, path: `${node.path}[${index}]` }))
20
+ return
21
+ }
22
+ if (node.current !== null && typeof node.current === 'object') {
23
+ Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(([key, child]) =>
24
+ visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
25
+ )
26
+ }
27
+ }
28
+ visit({ current: source, path })
29
+ return out
30
+ }
31
+
32
+ export const initialState = <Values>(initial: Values): FormState<Values> => ({
33
+ values: initial,
34
+ initialValues: initial,
35
+ touched: {},
36
+ errors: {},
37
+ dirtyPaths: [],
38
+ submitCount: 0,
39
+ validationCount: 0,
40
+ lastSubmittedValues: Option.none(),
41
+ arrayKeys: arrayKeysFor(initial),
42
+ })
43
+
44
+ export const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
45
+ ({ ...touched, [path]: true })
46
+
47
+ interface ArrayLookup {
48
+ readonly source: unknown
49
+ readonly path: string
50
+ }
51
+
52
+ interface KeyVisitNode {
53
+ readonly current: unknown
54
+ readonly path: string
55
+ }
56
+
57
+ interface PathInput<Values> {
58
+ readonly state: FormState<Values>
59
+ readonly path: string
60
+ }
61
+
62
+ interface SetPathInput<Values> extends PathInput<Values> {
63
+ readonly value: unknown
64
+ }
65
+
66
+ interface RemovePathInput<Values> extends PathInput<Values> {
67
+ readonly index: number
68
+ }
69
+
70
+ interface MovePathInput<Values> extends PathInput<Values> {
71
+ readonly from: number
72
+ readonly to: number
73
+ }
74
+
75
+ interface SwapPathInput<Values> extends PathInput<Values> {
76
+ readonly first: number
77
+ readonly second: number
78
+ }
79
+
80
+ const withValues = <Values>(state: FormState<Values>, nextValues: Values): FormState<Values> => ({
81
+ ...state,
82
+ values: nextValues,
83
+ dirtyPaths: recalculateDirtyPaths(state.initialValues, nextValues),
84
+ })
85
+
86
+ export const currentArray = (input: ArrayLookup): ReadonlyArray<unknown> => {
87
+ const current = getNestedValue(input.source, input.path)
88
+ return Array.isArray(current) ? current : []
89
+ }
90
+
91
+ const updateKeys = (
92
+ state: FormState<unknown>,
93
+ path: string,
94
+ transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
95
+ ): Readonly<Record<string, ReadonlyArray<string>>> => {
96
+ const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
97
+ return { ...state.arrayKeys, [path]: transform(existing) }
98
+ }
99
+
100
+ export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
101
+ withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
102
+
103
+ export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
104
+ const elements = currentArray({ source: input.state.values, path: input.path })
105
+ const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
106
+ return {
107
+ ...withValues(input.state, nextValues),
108
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
109
+ }
110
+ }
111
+
112
+ export const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<Values> => {
113
+ const elements = currentArray({ source: input.state.values, path: input.path })
114
+ if (input.index < 0 || input.index >= elements.length) {
115
+ return input.state
116
+ }
117
+ const nextValues = setNestedValue(
118
+ input.state.values,
119
+ input.path,
120
+ elements.filter((_, position) => position !== input.index),
121
+ )
122
+ return {
123
+ ...withValues(input.state, nextValues),
124
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
125
+ keys.filter((_, position) => position !== input.index),
126
+ ),
127
+ }
128
+ }
129
+
130
+ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> => {
131
+ const elements = currentArray({ source: input.state.values, path: input.path })
132
+ const next = moveAt(elements, input.from, input.to)
133
+ if (next === elements) {
134
+ return input.state
135
+ }
136
+ const nextValues = setNestedValue(input.state.values, input.path, next)
137
+ return {
138
+ ...withValues(input.state, nextValues),
139
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
140
+ }
141
+ }
142
+
143
+ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> => {
144
+ const elements = currentArray({ source: input.state.values, path: input.path })
145
+ if (
146
+ input.first < 0 ||
147
+ input.second < 0 ||
148
+ input.first >= elements.length ||
149
+ input.second >= elements.length ||
150
+ input.first === input.second
151
+ ) {
152
+ return input.state
153
+ }
154
+ const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first])
155
+ const nextValues = setNestedValue(input.state.values, input.path, swapped)
156
+ return {
157
+ ...withValues(input.state, nextValues),
158
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
159
+ replaceAt(
160
+ replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
161
+ input.second,
162
+ keys[input.first] ?? makeArrayKey(),
163
+ ),
164
+ ),
165
+ }
166
+ }
@@ -0,0 +1,211 @@
1
+ import { Context, Effect, Option, Schema as S } from 'effect'
2
+ import type { AnySource, Event, Reducer, Source, State } from '@playfast/reform'
3
+ import type {
4
+ ArrayItem,
5
+ ArrayPath,
6
+ FieldPath,
7
+ PathValue,
8
+ VariantPath,
9
+ VariantValue,
10
+ } from './path'
11
+ import type { FormError, FormErrors } from './validation'
12
+
13
+ export type AnyValue = S.Schema.Type<S.Schema.Any>
14
+
15
+ // Wire-serialized UI knobs: ExternalApi postfix allows optional fields (Option would break the contract).
16
+ export interface FieldLimitationsExternalApi<A = unknown> {
17
+ readonly required?: boolean
18
+ readonly disabled?: boolean
19
+ readonly readonly?: boolean
20
+ readonly visible?: boolean
21
+ readonly min?: number
22
+ readonly max?: number
23
+ readonly minLength?: number
24
+ readonly maxLength?: number
25
+ readonly minItems?: number
26
+ readonly maxItems?: number
27
+ readonly options?: ReadonlyArray<A>
28
+ readonly meta?: Readonly<Record<string, unknown>>
29
+ }
30
+
31
+ export type FieldLimitations<A = unknown> = FieldLimitationsExternalApi<A>
32
+
33
+ export type Limitations = Readonly<Record<string, FieldLimitations>>
34
+
35
+ export interface FormState<Values> {
36
+ readonly values: Values
37
+ readonly initialValues: Values
38
+ readonly touched: Readonly<Record<string, boolean>>
39
+ readonly errors: FormErrors
40
+ readonly dirtyPaths: ReadonlyArray<string>
41
+ readonly submitCount: number
42
+ readonly validationCount: number
43
+ readonly lastSubmittedValues: Option.Option<Values>
44
+ readonly arrayKeys: Readonly<Record<string, ReadonlyArray<string>>>
45
+ }
46
+
47
+ export interface FieldBinding<A> {
48
+ readonly path: string
49
+ readonly value: A
50
+ readonly set: (value: A | ((prev: A) => A)) => void
51
+ readonly blur: () => void
52
+ readonly error: Option.Option<string>
53
+ readonly dirty: boolean
54
+ readonly touched: boolean
55
+ readonly validating: boolean
56
+ readonly limitations: FieldLimitations<A>
57
+ }
58
+
59
+ export interface ArrayItemView<Item> {
60
+ readonly key: string
61
+ readonly index: number
62
+ readonly value: Item
63
+ readonly remove: () => void
64
+ readonly move: (to: number) => void
65
+ }
66
+
67
+ export interface ArrayBinding<Item> {
68
+ readonly path: string
69
+ readonly items: ReadonlyArray<ArrayItemView<Item>>
70
+ readonly append: (value?: Item) => void
71
+ readonly remove: (index: number) => void
72
+ readonly move: (from: number, to: number) => void
73
+ readonly swap: (a: number, b: number) => void
74
+ readonly limitations: FieldLimitations<ReadonlyArray<Item>>
75
+ }
76
+
77
+ export interface FormView<Values, Inputs = {}> {
78
+ readonly values: Values
79
+ readonly inputs: Inputs
80
+ readonly errors: FormErrors
81
+ readonly dirty: boolean
82
+ readonly canSubmit: boolean
83
+ readonly submitCount: number
84
+ readonly validationCount: number
85
+ readonly lastSubmittedValues: Option.Option<Values>
86
+ readonly submit: () => void
87
+ readonly reset: () => void
88
+ readonly validate: () => void
89
+ readonly field: <P extends FieldPath<Values>>(path: P) => FieldBinding<PathValue<Values, P>>
90
+ readonly array: <P extends ArrayPath<Values>>(path: P) => ArrayBinding<ArrayItem<Values, P>>
91
+ readonly variantValue: <P extends VariantPath<Values>>(path: P) => VariantValue<Values, P>
92
+ }
93
+
94
+ export type InputRecord = Readonly<Record<string, AnySource>>
95
+
96
+ // Structural `S['name']`: Source value param is invariant, so `Source<_, unknown>` erases keys to never.
97
+ export type SourceName<S extends AnySource> = S['name']
98
+ export type SourceValue<S> = S extends Source<string, infer A> ? A : never
99
+
100
+ export type InputsObject<Inputs extends InputRecord> = {
101
+ readonly [K in keyof Inputs as SourceName<Inputs[K]>]: SourceValue<Inputs[K]>
102
+ }
103
+
104
+ export type InputStores<Inputs extends InputRecord> = {
105
+ [K in keyof Inputs]: Inputs[K] extends { readonly store: Context.Tag<infer Service, AnyValue> }
106
+ ? Service
107
+ : never
108
+ }[keyof Inputs]
109
+
110
+ export type ValuesOfSchema<S extends S.Schema.Any> = S.Schema.Encoded<S>
111
+ export type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
112
+
113
+ export interface RuntimeConfig<Values, Decoded, Inputs> {
114
+ readonly initial: Values
115
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
116
+ readonly limit?: (ctx: {
117
+ readonly values: Values
118
+ readonly inputs: Inputs
119
+ }) => Limitations
120
+ readonly validate: (ctx: {
121
+ readonly values: Values
122
+ readonly decoded: Decoded
123
+ readonly inputs: Inputs
124
+ }) => Effect.Effect<ReadonlyArray<FormError>, never, never>
125
+ readonly submit: (ctx: {
126
+ readonly values: Values
127
+ readonly decoded: Decoded
128
+ readonly inputs: Inputs
129
+ }) => Effect.Effect<void, unknown, never>
130
+ }
131
+
132
+ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
133
+ readonly initial: Values
134
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
135
+ readonly limit?: (ctx: {
136
+ readonly values: Values
137
+ readonly inputs: Inputs
138
+ }) => Limitations
139
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
140
+ readonly validate?: (ctx: {
141
+ readonly values: Values
142
+ readonly decoded: Decoded
143
+ readonly inputs: Inputs
144
+ }) =>
145
+ | void
146
+ | FormError
147
+ | ReadonlyArray<FormError>
148
+ | Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
149
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
150
+ readonly submit?: (ctx: {
151
+ readonly values: Values
152
+ readonly decoded: Decoded
153
+ readonly inputs: Inputs
154
+ }) => void | Effect.Effect<unknown, unknown, R>
155
+ }
156
+
157
+ export interface FormManifest<N extends string, Schema extends S.Schema.Any, Inputs extends InputRecord> {
158
+ readonly kind: 'Form'
159
+ readonly name: N
160
+ readonly schema: Schema
161
+ readonly inputs: Inputs
162
+ }
163
+
164
+ export interface FormEvents {
165
+ readonly set: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
166
+ readonly blur: Event.EventClass<string, { readonly path: string }>
167
+ readonly reset: Event.EventClass<string, {}>
168
+ readonly validationFinished: Event.EventClass<string, { readonly errors: FormErrors }>
169
+ readonly submitAttempted: Event.EventClass<string, {}>
170
+ readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
171
+ readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
172
+ readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
173
+ readonly move: Event.EventClass<string, { readonly path: string; readonly from: number; readonly to: number }>
174
+ readonly swap: Event.EventClass<string, { readonly path: string; readonly a: number; readonly b: number }>
175
+ }
176
+
177
+ export interface FormClass<
178
+ N extends string,
179
+ Schema extends S.Schema.Any,
180
+ Inputs extends InputRecord,
181
+ > {
182
+ new (): {}
183
+ readonly manifest: FormManifest<N, Schema, Inputs>
184
+ readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
185
+ readonly config: Context.Tag<
186
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
187
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
188
+ >
189
+ readonly events: FormEvents
190
+ readonly reducer: Reducer.StateReducerClass<
191
+ State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>,
192
+ ReadonlyArray<Event.AnyEvent>
193
+ >
194
+ }
195
+
196
+ // `any` inputs wildcard: config tag is invariant via InputsObject; InputRecord would reject concrete forms.
197
+ export type AnyForm = FormClass<string, S.Schema.Any, AnyValue>
198
+
199
+ export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
200
+ ? ValuesOfSchema<Schema>
201
+ : never
202
+
203
+ export type Decoded<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
204
+ ? DecodedOfSchema<Schema>
205
+ : never
206
+
207
+ export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any, infer Inputs>
208
+ ? InputsObject<Inputs>
209
+ : never
210
+
211
+ export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
@@ -0,0 +1,202 @@
1
+ import {
2
+ Effect,
3
+ Either,
4
+ Function as Fn,
5
+ Option,
6
+ Record,
7
+ Runtime,
8
+ Schema as S,
9
+ } from 'effect'
10
+ import {
11
+ Bus,
12
+ CurrentTracker,
13
+ Event,
14
+ type Store,
15
+ type Trigger,
16
+ } from '@playfast/reform'
17
+ import type { AnySource } from '@playfast/reform'
18
+ import { currentArray } from './formState'
19
+ import { getNestedValue, isPathOrParentDirty } from './path'
20
+ import type { ArrayItem, ArrayPath, FieldPath, PathValue } from './path'
21
+ import { errorsToRecord, firstError, routeParseError } from './validation'
22
+ import type {
23
+ AnyForm,
24
+ ArrayBinding,
25
+ Decoded,
26
+ FieldBinding,
27
+ FieldLimitations,
28
+ FormState,
29
+ InputRecord,
30
+ Inputs,
31
+ InputsObject,
32
+ InputStores,
33
+ Limitations,
34
+ RuntimeConfig,
35
+ SourceValue,
36
+ Values,
37
+ View,
38
+ } from './formTypes'
39
+
40
+ const readInput = <Source extends AnySource>(
41
+ source: Source,
42
+ ): Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>> =>
43
+ Fn.unsafeCoerce(
44
+ Effect.gen(function* () {
45
+ const store = yield* source.store
46
+ const tracker = yield* Effect.serviceOption(CurrentTracker)
47
+ if (Option.isSome(tracker)) {
48
+ tracker.value.add(store)
49
+ }
50
+ return store.getSnapshot()
51
+ }),
52
+ )
53
+
54
+ const readInputs = <Inputs extends InputRecord>(
55
+ inputs: Inputs,
56
+ ): Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>> =>
57
+ Fn.unsafeCoerce(
58
+ Effect.gen(function* () {
59
+ const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
60
+ Effect.map(readInput(source), (snapshot) => [source.name, snapshot] as const),
61
+ )
62
+ return Record.fromEntries(pairs)
63
+ }),
64
+ )
65
+
66
+ const limitationsFor = <A>(
67
+ limitations: Limitations,
68
+ path: string,
69
+ ): FieldLimitations<A> =>
70
+ Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
71
+
72
+ const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
73
+ eventTrigger
74
+
75
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view: the explicit R (Store | RuntimeConfig | Bus | InputStores) must be preserved verbatim; Effect.fn would re-infer requirements off the erased AnyForm wildcard
76
+ export const view = <F extends AnyForm>(
77
+ form: F,
78
+ ): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
79
+ Effect.gen(function* () {
80
+ const state = yield* form.state
81
+ const runtimeConfig = yield* form.config
82
+ // AnyForm erases inputs to `any`; restate at concrete F so R keeps declared input stores.
83
+ const inputs: Inputs<F> = Fn.unsafeCoerce(
84
+ yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
85
+ )
86
+ const limitations = Option.getOrElse(
87
+ Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
88
+ () => ({}),
89
+ )
90
+ const setField = yield* trigger(form.events.set.trigger)
91
+ const blurField = yield* trigger(form.events.blur.trigger)
92
+ const reset = yield* trigger(form.events.reset.trigger)
93
+ const append = yield* trigger(form.events.append.trigger)
94
+ const remove = yield* trigger(form.events.remove.trigger)
95
+ const move = yield* trigger(form.events.move.trigger)
96
+ const swap = yield* trigger(form.events.swap.trigger)
97
+ const runtime = yield* Effect.runtime<Bus>()
98
+
99
+ const schema: S.Schema<Decoded<F>, Values<F>, never> = Fn.unsafeCoerce(form.manifest.schema)
100
+ const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
101
+ const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
102
+
103
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- local closure whose inferred R must flow into the parent view gen; Effect.fn would force an explicit Return annotation over the erased AnyForm requirements
104
+ const runValidation = (publishSubmitAttempt: boolean) =>
105
+ Effect.gen(function* () {
106
+ if (publishSubmitAttempt) {
107
+ yield* Event.dispatch(form.events.submitAttempted, {})
108
+ }
109
+ const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
110
+ if (Either.isLeft(decoded)) {
111
+ yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
112
+ return Option.none<Decoded<F>>()
113
+ }
114
+ const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
115
+ const errors = errorsToRecord(custom)
116
+ yield* Event.dispatch(form.events.validationFinished, { errors })
117
+ return Record.keys(errors).length === 0
118
+ ? Option.some(Fn.unsafeCoerce<unknown, Decoded<F>>(decoded.right))
119
+ : Option.none<Decoded<F>>()
120
+ })
121
+
122
+ const submit = () => {
123
+ Runtime.runFork(runtime)(
124
+ Effect.gen(function* () {
125
+ const decoded = yield* runValidation(true)
126
+ if (Option.isNone(decoded)) {
127
+ return
128
+ }
129
+ yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
130
+ yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
131
+ }).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
132
+ )
133
+ }
134
+
135
+ const validate = () => {
136
+ Runtime.runFork(runtime)(runValidation(false))
137
+ }
138
+
139
+ const field = <P extends FieldPath<Values<F>>>(path: P): FieldBinding<PathValue<Values<F>, P>> => {
140
+ const fieldValue: PathValue<Values<F>, P> = Fn.unsafeCoerce(getNestedValue(state.values, path))
141
+ return {
142
+ path,
143
+ value: fieldValue,
144
+ set: (next) => {
145
+ if (typeof next === 'function') {
146
+ const updater = Fn.unsafeCoerce<typeof next, (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>>(
147
+ next,
148
+ )
149
+ setField({ path, value: updater(fieldValue) })
150
+ return
151
+ }
152
+ setField({ path, value: next })
153
+ },
154
+ blur: () => blurField({ path }),
155
+ error: firstError(state.errors, path),
156
+ dirty: isPathOrParentDirty(state.dirtyPaths, path),
157
+ touched: state.touched[path] === true,
158
+ validating: false,
159
+ limitations: limitationsFor<PathValue<Values<F>, P>>(limitations, path),
160
+ }
161
+ }
162
+
163
+ const array = <P extends ArrayPath<Values<F>>>(path: P): ArrayBinding<ArrayItem<Values<F>, P>> => {
164
+ const arrayValues: ReadonlyArray<ArrayItem<Values<F>, P>> = Fn.unsafeCoerce(
165
+ currentArray({ source: state.values, path }),
166
+ )
167
+ const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
168
+ return {
169
+ path,
170
+ items: arrayValues.map((element, index) => ({
171
+ value: element,
172
+ index,
173
+ key: keys[index] ?? `${path}-${index}`,
174
+ remove: () => remove({ path, index }),
175
+ move: (to) => move({ path, from: index, to }),
176
+ })),
177
+ append: (element) => append({ path, value: element }),
178
+ remove: (index) => remove({ path, index }),
179
+ move: (from, to) => move({ path, from, to }),
180
+ swap: (first, second) => swap({ path, a: first, b: second }),
181
+ limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
182
+ }
183
+ }
184
+
185
+ const formView: View<F> = Fn.unsafeCoerce({
186
+ values: state.values,
187
+ inputs,
188
+ errors: state.errors,
189
+ dirty: state.dirtyPaths.length > 0,
190
+ canSubmit,
191
+ submitCount: state.submitCount,
192
+ validationCount: state.validationCount,
193
+ lastSubmittedValues: state.lastSubmittedValues,
194
+ submit,
195
+ reset: () => reset({}),
196
+ validate,
197
+ field,
198
+ array,
199
+ variantValue: (path: string) => Fn.unsafeCoerce(getNestedValue(state.values, path)),
200
+ })
201
+ return formView
202
+ })
package/src/path.ts CHANGED
@@ -5,8 +5,7 @@ import { sort as sortArray } from 'effect/Array'
5
5
  const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g
6
6
  const NO_INDEX = -1
7
7
 
8
- // `null` is a genuine leaf of arbitrary form values (JSON-shaped user data); it
9
- // is matched here to stop path recursion at primitive leaves.
8
+ // JSON null is a real form-value leaf stop path recursion here.
10
9
  export type Primitive =
11
10
  | string
12
11
  | number
@@ -83,8 +82,6 @@ export type VariantCase<V, Tag extends string> = Extract<V, { readonly _tag: Tag
83
82
 
84
83
  export type VariantBody<V, Tag extends string> = Omit<VariantCase<V, Tag>, '_tag'>
85
84
 
86
- // Typed view over an arbitrary object for string-keyed traversal of unknown form
87
- // values. `unsafeCoerce` is the Effect-blessed identity coercion (no `as`).
88
85
  const asRecord = (source: unknown): Record<string, unknown> => Fn.unsafeCoerce(source)
89
86
 
90
87
  export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string =>