+119
-54
@@ -283,9 +283,14 @@ the schema defines both directions and the form keeps them synchronized.
|
|||||||
|
|
||||||
## Focus into subforms
|
## Focus into subforms
|
||||||
|
|
||||||
After creating either root implementation, focus it along the schema's
|
The root `MutationForm` or `LensForm` represents the complete schema. UI
|
||||||
structure. Focusing an object can produce a nested subform, not only a leaf
|
components usually need a smaller form representing one field, so they do not
|
||||||
field:
|
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
|
```tsx
|
||||||
const displayNameField = Form.focusObjectOn(form, "displayName")
|
const displayNameField = Form.focusObjectOn(form, "displayName")
|
||||||
@@ -295,19 +300,22 @@ const contactForm = Form.focusObjectOn(form, "contact")
|
|||||||
const emailField = Form.focusObjectOn(contactForm, "email")
|
const emailField = Form.focusObjectOn(contactForm, "email")
|
||||||
```
|
```
|
||||||
|
|
||||||
`contactForm` can be passed to a component responsible for the whole contact
|
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
||||||
section. `emailField` can be passed to a single input. Each focused form narrows
|
component responsible for that field. Focusing can also be repeated:
|
||||||
all of the following to its path:
|
`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.
|
- `encodedValue`: the UI-facing value that can be edited.
|
||||||
- `value`: the decoded value as an `Option`.
|
- `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
|
Validation and commit state remain connected to the root form. An input can
|
||||||
`isValidating`, `canCommit`, and `isCommitting` without separately coordinating
|
show its own issues while still observing the root form's `isValidating`,
|
||||||
with its parent.
|
`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
|
```tsx
|
||||||
const property = Form.focusObjectOn(form, "contact")
|
const property = Form.focusObjectOn(form, "contact")
|
||||||
@@ -316,63 +324,120 @@ const tupleMember = Form.focusTupleAt(tupleForm, 1)
|
|||||||
const chunkItem = Form.focusChunkAt(chunkForm, 0)
|
const chunkItem = Form.focusChunkAt(chunkForm, 0)
|
||||||
```
|
```
|
||||||
|
|
||||||
Because issue paths come from the schema, validation messages follow the same
|
Schema issue paths are focused at the same time. A component receiving
|
||||||
focus operation. A component receiving `emailField` does not need to search the
|
`emailField` gets its email issues directly instead of searching through every
|
||||||
root issue collection for `contact.email` errors.
|
issue on the root form.
|
||||||
|
|
||||||
## Bind a subform to an input
|
## Bind a subform to an input
|
||||||
|
|
||||||
`Form.useInput` connects a focused form to a controlled component. It returns a
|
`Form.useInput` is the liaison between an Effect form and a React controlled
|
||||||
React-style `{ value, setValue }` pair backed by the form's `encodedValue` Lens.
|
component. It reads the subform's encoded value and returns the familiar React
|
||||||
An optional debounce delays schema validation while the user types.
|
`{ value, setValue }` interface. Calling `setValue` updates the form, which in
|
||||||
|
turn runs the schema pipeline and updates validation state.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Component, Form, View } from "effect-view"
|
const input = yield* Form.useInput(emailField, {
|
||||||
|
debounce: "250 millis",
|
||||||
|
})
|
||||||
|
|
||||||
const TextInputView = Component.make("TextInput")(
|
return (
|
||||||
function* (props: {
|
<input
|
||||||
readonly form: Form.Form<readonly PropertyKey[], unknown, string>
|
value={input.value}
|
||||||
readonly label: string
|
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||||
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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The same input component works with a field focused from a `MutationForm` or a
|
The debounce is useful for text inputs, which can otherwise update the form and
|
||||||
`LensForm`; it only depends on the common `Form` interface.
|
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
|
Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
|
||||||
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||||
it suitable for an optional input that the user can toggle on and off.
|
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
|
## The common Form model
|
||||||
|
|
||||||
Both root implementations and every focused subform implement `Form.Form`.
|
Both root implementations and every focused subform implement `Form.Form`.
|
||||||
|
|||||||
Reference in New Issue
Block a user