diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index 06cb4b1..115300b 100644 --- a/packages/docs/docs/forms.md +++ b/packages/docs/docs/forms.md @@ -283,9 +283,14 @@ the schema defines both directions and the form keeps them synchronized. ## Focus into subforms -After creating either root implementation, focus it along the schema's -structure. Focusing an object can produce a nested subform, not only a leaf -field: +The root `MutationForm` or `LensForm` represents the complete schema. UI +components usually need a smaller form representing one field, so they do not +have to receive or understand the entire root form. **Focusing** creates that +smaller form. + +Every root form and every focused subform implements the same `Form.Form` +interface. There is no separate field abstraction: an individual field is +simply a `Form` focused on that part of its parent. ```tsx const displayNameField = Form.focusObjectOn(form, "displayName") @@ -295,19 +300,22 @@ const contactForm = Form.focusObjectOn(form, "contact") const emailField = Form.focusObjectOn(contactForm, "email") ``` -`contactForm` can be passed to a component responsible for the whole contact -section. `emailField` can be passed to a single input. Each focused form narrows -all of the following to its path: +`displayNameField`, `ageField`, and `emailField` can each be passed to the input +component responsible for that field. Focusing can also be repeated: +`contactForm` represents the complete contact section, and `emailField` +represents its email field. + +A focused form exposes only the relevant: - `encodedValue`: the UI-facing value that can be edited. - `value`: the decoded value as an `Option`. -- `issues`: only schema issues at or below the focused path. +- `issues`: the schema issues for that subform. -Commit state is intentionally shared with the root. A nested component can use -`isValidating`, `canCommit`, and `isCommitting` without separately coordinating -with its parent. +Validation and commit state remain connected to the root form. An input can +show its own issues while still observing the root form's `isValidating`, +`canCommit`, and `isCommitting` state. -The focusing helpers mirror the kind of data being traversed: +Use the focusing helper that matches the value being represented: ```tsx const property = Form.focusObjectOn(form, "contact") @@ -316,63 +324,120 @@ const tupleMember = Form.focusTupleAt(tupleForm, 1) const chunkItem = Form.focusChunkAt(chunkForm, 0) ``` -Because issue paths come from the schema, validation messages follow the same -focus operation. A component receiving `emailField` does not need to search the -root issue collection for `contact.email` errors. +Schema issue paths are focused at the same time. A component receiving +`emailField` gets its email issues directly instead of searching through every +issue on the root form. ## Bind a subform to an input -`Form.useInput` connects a focused form to a controlled component. It returns a -React-style `{ value, setValue }` pair backed by the form's `encodedValue` Lens. -An optional debounce delays schema validation while the user types. +`Form.useInput` is the liaison between an Effect form and a React controlled +component. It reads the subform's encoded value and returns the familiar React +`{ value, setValue }` interface. Calling `setValue` updates the form, which in +turn runs the schema pipeline and updates validation state. ```tsx -import { Component, Form, View } from "effect-view" +const input = yield* Form.useInput(emailField, { + debounce: "250 millis", +}) -const TextInputView = Component.make("TextInput")( - function* (props: { - readonly form: Form.Form - readonly label: string - readonly type?: "text" | "email" | "number" - }) { - const input = yield* Form.useInput(props.form, { - debounce: "250 millis", - }) - const [issues, isValidating, isCommitting] = yield* View.useAll([ - props.form.issues, - props.form.isValidating, - props.form.isCommitting, - ]) - - return ( - - ) - }, +return ( + input.setValue(event.currentTarget.value)} + /> ) ``` -The same input component works with a field focused from a `MutationForm` or a -`LensForm`; it only depends on the common `Form` interface. +The debounce is useful for text inputs, which can otherwise update the form and +run schema validation on every keystroke. The displayed value still updates +immediately; propagation to the form waits until the user pauses typing. Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It returns `value` and `setValue` together with `enabled` and `setEnabled`, making it suitable for an optional input that the user can toggle on and off. +`useInput` and `useOptionalInput` can be used directly, but they are primarily +building blocks for your own reusable input components. A component library +can wrap them to consistently handle labels, validation issues, validating +indicators, disabled state, accessibility, styling, and a standard debounce. +Because both hooks accept a `Form.Form`, the same input components work with +subforms from both `MutationForm` and `LensForm`. + +```tsx title="TextFieldFormInputView.tsx" +import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" +import { Array, Option, Struct } from "effect" +import { Component, Form, View } from "effect-view" +import type * as React from "react" + +export declare namespace TextFieldFormInputView { + export interface Props< + out P extends readonly PropertyKey[], + A, + ER, + EW, + > extends Omit, + Form.useInput.Options { + readonly form: Form.Form + } + + export type Signature = < + P extends readonly PropertyKey[], + A, + ER, + EW, + >( + props: Props, + ) => React.ReactNode +} + +export const TextFieldFormInputView = Component.make( + "TextFieldFormInputView", +)(function* ( + props: TextFieldFormInputView.Props< + readonly PropertyKey[], + any, + any, + any + >, +) { + const input = yield* Form.useInput(props.form, props) + const [issues, isValidating, isCommitting] = yield* View.useAll([ + props.form.issues, + props.form.isValidating, + props.form.isCommitting, + ]) + + return ( + + input.setValue(event.target.value)} + disabled={isCommitting} + {...Struct.omit(props, ["form", "debounce"])} + > + {isValidating && ( + + + + )} + {props.children} + + + {Option.match(Array.head(issues), { + onSome: (issue) => ( + + {issue.message} + + ), + onNone: () => null, + })} + + ) +}).pipe( + Component.withSignature(), +) +``` + ## The common Form model Both root implementations and every focused subform implement `Form.Form`.