Add Effect v4 support, Fast Refresh tooling, and revamped docs #56
+119
-54
@@ -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 PropertyKey[], unknown, string>
|
||||
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 (
|
||||
<label>
|
||||
{props.label}
|
||||
<input
|
||||
type={props.type}
|
||||
value={input.value}
|
||||
disabled={isCommitting}
|
||||
aria-invalid={issues.length > 0}
|
||||
onChange={(event) =>
|
||||
input.setValue(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
{isValidating && <small>Validating...</small>}
|
||||
{issues.map((issue, index) => (
|
||||
<small key={index}>{issue.message}</small>
|
||||
))}
|
||||
</label>
|
||||
)
|
||||
},
|
||||
return (
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => 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<TextField.RootProps, "form">,
|
||||
Form.useInput.Options {
|
||||
readonly form: Form.Form<P, A, string, ER, EW>
|
||||
}
|
||||
|
||||
export type Signature = <
|
||||
P extends readonly PropertyKey[],
|
||||
A,
|
||||
ER,
|
||||
EW,
|
||||
>(
|
||||
props: Props<P, A, ER, EW>,
|
||||
) => 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 (
|
||||
<Flex direction="column" gap="1">
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, ["form", "debounce"])}
|
||||
>
|
||||
{isValidating && (
|
||||
<TextField.Slot side="right">
|
||||
<Spinner />
|
||||
</TextField.Slot>
|
||||
)}
|
||||
{props.children}
|
||||
</TextField.Root>
|
||||
|
||||
{Option.match(Array.head(issues), {
|
||||
onSome: (issue) => (
|
||||
<Callout.Root>
|
||||
<Callout.Text>{issue.message}</Callout.Text>
|
||||
</Callout.Root>
|
||||
),
|
||||
onNone: () => null,
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldFormInputView.Signature>(),
|
||||
)
|
||||
```
|
||||
|
||||
## The common Form model
|
||||
|
||||
Both root implementations and every focused subform implement `Form.Form`.
|
||||
|
||||
Reference in New Issue
Block a user