@behivetech/forms.form-field-managed 0.0.0-stage → 0.1.4
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/README.md +126 -2
- package/dist/index.cjs +50 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +25 -0
- package/docs.json +77 -0
- package/form-field-managed.example.tsx +83 -0
- package/form-field-managed.tsx +105 -0
- package/index.ts +5 -0
- package/package.json +61 -3
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FormFieldManaged
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`FormField` with its react-hook-form wiring already done — the control's
|
|
4
|
+
value/onChange/onBlur/ref, the label association, and the error message.
|
|
5
|
+
|
|
6
|
+
Use plain [`FormField`](../form-field) when you are managing state yourself;
|
|
7
|
+
use this when the field belongs to a react-hook-form form.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```jsx
|
|
12
|
+
import { FormFieldManaged } from "@behivetech/forms.form-field-managed";
|
|
13
|
+
import { Form, useForm } from "@behivetech/forms.form-provider";
|
|
14
|
+
import { TextField } from "@behivetech/atoms.text-field";
|
|
15
|
+
|
|
16
|
+
const methods = useForm({ defaultValues: { email: "" } });
|
|
17
|
+
const save = (values) => alert(JSON.stringify(values));
|
|
18
|
+
|
|
19
|
+
<Form methods={methods} onSubmit={save}>
|
|
20
|
+
<FormFieldManaged
|
|
21
|
+
name="email"
|
|
22
|
+
label="Email"
|
|
23
|
+
rules={{ required: "Email is required" }}
|
|
24
|
+
>
|
|
25
|
+
{(field) => <TextField {...field} />}
|
|
26
|
+
</FormFieldManaged>
|
|
27
|
+
</Form>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The error message comes from the field's own validation state — there is no
|
|
31
|
+
`error` prop to pass, and no need to read `formState.errors`. `label`, `hint`,
|
|
32
|
+
`required` and `className` pass through to `FormField`.
|
|
33
|
+
|
|
34
|
+
`control` is optional; the field resolves it from the surrounding `Form` /
|
|
35
|
+
`FormProvider` context.
|
|
36
|
+
|
|
37
|
+
## Typing in TypeScript
|
|
38
|
+
|
|
39
|
+
`field.value` is typed as the field `name` points at, so it spreads onto a
|
|
40
|
+
control without a cast. Getting that narrowing depends on how the generics are
|
|
41
|
+
supplied.
|
|
42
|
+
|
|
43
|
+
Passing `control` infers both type parameters and needs no annotation:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
function Example() {
|
|
47
|
+
const methods = useForm({ defaultValues: { email: "" } });
|
|
48
|
+
return (
|
|
49
|
+
<Form
|
|
50
|
+
methods={methods}
|
|
51
|
+
onSubmit={(values) => alert(JSON.stringify(values))}
|
|
52
|
+
>
|
|
53
|
+
<FormFieldManaged control={methods.control} name="email" label="Email">
|
|
54
|
+
{(field) => <TextField {...field} />}
|
|
55
|
+
</FormFieldManaged>
|
|
56
|
+
</Form>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Taking `control` from context instead means naming the field type too:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
type Values = { email: string };
|
|
65
|
+
|
|
66
|
+
function Example() {
|
|
67
|
+
const methods = useForm<Values>({ defaultValues: { email: "" } });
|
|
68
|
+
return (
|
|
69
|
+
<Form
|
|
70
|
+
methods={methods}
|
|
71
|
+
onSubmit={(values) => alert(JSON.stringify(values))}
|
|
72
|
+
>
|
|
73
|
+
<FormFieldManaged<Values, "email"> name="email" label="Email">
|
|
74
|
+
{(field) => <TextField {...field} />}
|
|
75
|
+
</FormFieldManaged>
|
|
76
|
+
</Form>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Supplying only the first type argument — `<FormFieldManaged<Values> name="email">`
|
|
82
|
+
— does **not** narrow. TypeScript stops inferring the remaining parameters once
|
|
83
|
+
any is given explicitly, so `TName` falls back to its default and `field.value`
|
|
84
|
+
widens to a union of every field in the form. Prefer one of the two forms above.
|
|
85
|
+
|
|
86
|
+
## Why a render prop
|
|
87
|
+
|
|
88
|
+
The one thing that genuinely differs between controls is the prop they report
|
|
89
|
+
changes on. Native inputs take the field as-is:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
(field) => <TextField {...field} />
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Radix-backed controls (`Select`, `Checkbox`, `Toggle`) report through
|
|
96
|
+
`onValueChange` / `onCheckedChange`, so the caller maps that one prop:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
(field) => (
|
|
100
|
+
<Select
|
|
101
|
+
id={field.id}
|
|
102
|
+
name={field.name}
|
|
103
|
+
options={roles}
|
|
104
|
+
value={field.value}
|
|
105
|
+
onValueChange={field.onChange}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Making that visible is deliberate — it is the difference that matters, and it
|
|
111
|
+
cannot be typed if hidden behind a clone.
|
|
112
|
+
|
|
113
|
+
## Why not `register`
|
|
114
|
+
|
|
115
|
+
`register()` returns an uncontrolled _native input_ contract
|
|
116
|
+
(`{name, onChange, onBlur, ref}`). It works on `TextField` and `Textarea`,
|
|
117
|
+
which render real `<input>` / `<textarea>` elements.
|
|
118
|
+
|
|
119
|
+
It does not work on `Checkbox`, `Toggle` or `Select`. Those are Radix-backed:
|
|
120
|
+
the ref would land on the trigger `<button>`, whose `value` is always `""`, and
|
|
121
|
+
Radix reports changes through `onCheckedChange` / `onValueChange`, never
|
|
122
|
+
`onChange`. The field would silently submit its default value with no type or
|
|
123
|
+
runtime error. `FormFieldManaged` uses `useController`, which works for all of
|
|
124
|
+
them.
|
|
125
|
+
|
|
126
|
+
The render prop also receives the field's state as a second argument
|
|
127
|
+
(`(field, fieldState) => …`) if you need `invalid`, `isDirty` or `isTouched`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
FormFieldManaged: () => FormFieldManaged
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// form-field-managed.tsx
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_forms = require("@behivetech/forms.form-field");
|
|
30
|
+
var import_forms2 = require("@behivetech/forms.form-provider");
|
|
31
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
32
|
+
var FormFieldManaged = ({
|
|
33
|
+
name,
|
|
34
|
+
control,
|
|
35
|
+
rules,
|
|
36
|
+
children,
|
|
37
|
+
...fieldProps
|
|
38
|
+
}) => {
|
|
39
|
+
const id = (0, import_react.useId)();
|
|
40
|
+
const { field, fieldState } = (0, import_forms2.useController)({
|
|
41
|
+
name,
|
|
42
|
+
control,
|
|
43
|
+
rules
|
|
44
|
+
});
|
|
45
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_forms.FormField, { ...fieldProps, htmlFor: id, error: fieldState.error?.message, children: children({ ...field, id }, fieldState) });
|
|
46
|
+
};
|
|
47
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
48
|
+
0 && (module.exports = {
|
|
49
|
+
FormFieldManaged
|
|
50
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { FormFieldProps } from '@behivetech/forms.form-field';
|
|
4
|
+
import { FieldValues, FieldPath, Control, RegisterOptions, ControllerRenderProps, ControllerFieldState } from '@behivetech/forms.form-provider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the render prop receives: the wired field plus its `id`.
|
|
8
|
+
*
|
|
9
|
+
* `TName` is threaded through to `ControllerRenderProps` rather than left at its
|
|
10
|
+
* default. Without it `value` widens to the union of every field type in the
|
|
11
|
+
* form, so a call site spreading onto a `TextField` has to assert
|
|
12
|
+
* `value as string` — an assertion that keeps compiling if the field later
|
|
13
|
+
* changes type, which is the mistake the generic exists to catch.
|
|
14
|
+
*/
|
|
15
|
+
type ManagedField<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = ControllerRenderProps<TFieldValues, TName> & {
|
|
16
|
+
/** Matches the label's `htmlFor`; spread onto the control or pass explicitly */
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
interface FormFieldManagedProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> extends Omit<FormFieldProps, "children" | "error" | "htmlFor"> {
|
|
20
|
+
/** Field path in the form values; infers the field's value type */
|
|
21
|
+
name: TName;
|
|
22
|
+
/** Form control; falls back to the surrounding `useFormContext()` when omitted */
|
|
23
|
+
control?: Control<TFieldValues>;
|
|
24
|
+
/** Validation rules passed through to react-hook-form */
|
|
25
|
+
rules?: RegisterOptions<TFieldValues, TName>;
|
|
26
|
+
/** Renders the control from the wired field */
|
|
27
|
+
children: (field: ManagedField<TFieldValues, TName>, fieldState: ControllerFieldState) => ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `FormField` with its react-hook-form wiring already done: the control's
|
|
31
|
+
* value/onChange/onBlur/ref, the label association, and the error message.
|
|
32
|
+
*
|
|
33
|
+
* Uses `useController` rather than `register` because `register` only works on
|
|
34
|
+
* controls that render a native input. `TextField` and `Textarea` do;
|
|
35
|
+
* `Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land
|
|
36
|
+
* on the trigger button and the change callback is `onCheckedChange` /
|
|
37
|
+
* `onValueChange`, so `register` binds nothing and the field silently keeps its
|
|
38
|
+
* default. `useController` works for all of them.
|
|
39
|
+
*
|
|
40
|
+
* The control is supplied through a render prop rather than cloned, because the
|
|
41
|
+
* prop a control listens on is the one thing that genuinely differs between
|
|
42
|
+
* them — worth seeing at the call site, and impossible to type if hidden.
|
|
43
|
+
*
|
|
44
|
+
* Pass `control` and both type parameters infer, so `field.value` is the type
|
|
45
|
+
* of that one field and spreads onto a control without a cast:
|
|
46
|
+
*
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <FormFieldManaged control={methods.control} name="email" label="Email">
|
|
49
|
+
* {(field) => <TextField {...field} />}
|
|
50
|
+
* </FormFieldManaged>
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Taking `control` from context instead means naming both parameters. Supplying
|
|
54
|
+
* only the first — `<FormFieldManaged<Values> name="email">` — leaves `TName` at
|
|
55
|
+
* its default and widens `field.value` to a union of every field in the form,
|
|
56
|
+
* because TypeScript stops inferring the rest once any type argument is given
|
|
57
|
+
* explicitly:
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* <FormFieldManaged<Values, "email"> name="email" label="Email">
|
|
61
|
+
* {(field) => <TextField {...field} />}
|
|
62
|
+
* </FormFieldManaged>
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
declare const FormFieldManaged: <TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>>({ name, control, rules, children, ...fieldProps }: FormFieldManagedProps<TFieldValues, TName>) => react.JSX.Element;
|
|
66
|
+
|
|
67
|
+
export { FormFieldManaged, type FormFieldManagedProps, type ManagedField };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// form-field-managed.tsx
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { FormField } from "@behivetech/forms.form-field";
|
|
4
|
+
import {
|
|
5
|
+
useController
|
|
6
|
+
} from "@behivetech/forms.form-provider";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
var FormFieldManaged = ({
|
|
9
|
+
name,
|
|
10
|
+
control,
|
|
11
|
+
rules,
|
|
12
|
+
children,
|
|
13
|
+
...fieldProps
|
|
14
|
+
}) => {
|
|
15
|
+
const id = useId();
|
|
16
|
+
const { field, fieldState } = useController({
|
|
17
|
+
name,
|
|
18
|
+
control,
|
|
19
|
+
rules
|
|
20
|
+
});
|
|
21
|
+
return /* @__PURE__ */ jsx(FormField, { ...fieldProps, htmlFor: id, error: fieldState.error?.message, children: children({ ...field, id }, fieldState) });
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
FormFieldManaged
|
|
25
|
+
};
|
package/docs.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://github.com/behivetech/bht-component-registry/docs.schema.json",
|
|
3
|
+
"version": 2,
|
|
4
|
+
"generatedAt": "2026-10-20T05:50:11.164Z",
|
|
5
|
+
"docs": [
|
|
6
|
+
{
|
|
7
|
+
"displayName": "FormFieldManaged",
|
|
8
|
+
"description": "`FormField` with its react-hook-form wiring already done: the control's\nvalue/onChange/onBlur/ref, the label association, and the error message.\n\nUses `useController` rather than `register` because `register` only works on\ncontrols that render a native input. `TextField` and `Textarea` do;\n`Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land\non the trigger button and the change callback is `onCheckedChange` /\n`onValueChange`, so `register` binds nothing and the field silently keeps its\ndefault. `useController` works for all of them.\n\nThe control is supplied through a render prop rather than cloned, because the\nprop a control listens on is the one thing that genuinely differs between\nthem — worth seeing at the call site, and impossible to type if hidden.\n\nPass `control` and both type parameters infer, so `field.value` is the type\nof that one field and spreads onto a control without a cast:\n\n```tsx\n<FormFieldManaged control={methods.control} name=\"email\" label=\"Email\">\n {(field) => <TextField {...field} />}\n</FormFieldManaged>\n```\n\nTaking `control` from context instead means naming both parameters. Supplying\nonly the first — `<FormFieldManaged<Values> name=\"email\">` — leaves `TName` at\nits default and widens `field.value` to a union of every field in the form,\nbecause TypeScript stops inferring the rest once any type argument is given\nexplicitly:\n\n```tsx\n<FormFieldManaged<Values, \"email\"> name=\"email\" label=\"Email\">\n {(field) => <TextField {...field} />}\n</FormFieldManaged>\n```",
|
|
9
|
+
"extendsNative": true,
|
|
10
|
+
"props": [
|
|
11
|
+
{
|
|
12
|
+
"name": "children",
|
|
13
|
+
"type": "(field: ManagedField<TFieldValues, TName>, fieldState: ControllerFieldState) => ReactNode",
|
|
14
|
+
"required": true,
|
|
15
|
+
"defaultValue": null,
|
|
16
|
+
"description": "Renders the control from the wired field"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "name",
|
|
20
|
+
"type": "string",
|
|
21
|
+
"required": true,
|
|
22
|
+
"defaultValue": null,
|
|
23
|
+
"description": "Field path in the form values; infers the field's value type"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "className",
|
|
27
|
+
"type": "string",
|
|
28
|
+
"required": false,
|
|
29
|
+
"defaultValue": null,
|
|
30
|
+
"description": "Additional class names to merge with the component root element"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "control",
|
|
34
|
+
"type": "Control<TFieldValues>",
|
|
35
|
+
"required": false,
|
|
36
|
+
"defaultValue": null,
|
|
37
|
+
"description": "Form control; falls back to the surrounding `useFormContext()` when omitted"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "hint",
|
|
41
|
+
"type": "ReactNode",
|
|
42
|
+
"required": false,
|
|
43
|
+
"defaultValue": null,
|
|
44
|
+
"description": "Supporting hint text; hidden while `error` is present"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "label",
|
|
48
|
+
"type": "ReactNode",
|
|
49
|
+
"required": false,
|
|
50
|
+
"defaultValue": null,
|
|
51
|
+
"description": "Field label"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "required",
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"required": false,
|
|
57
|
+
"defaultValue": null,
|
|
58
|
+
"description": "Shows a required marker next to the label"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "rules",
|
|
62
|
+
"type": "RegisterOptions<TFieldValues, TName>",
|
|
63
|
+
"required": false,
|
|
64
|
+
"defaultValue": null,
|
|
65
|
+
"description": "Validation rules passed through to react-hook-form"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"displayName": "PartialArgsStillWiden",
|
|
71
|
+
"description": "Supplying only `TFieldValues` leaves `TName` at its default, so `value` stays\nthe union — TypeScript stops inferring the remaining parameters as soon as\none is given explicitly. This is a language rule, not something the component\ncan type its way around, which is why the docstring steers callers to\n`control` or to naming both arguments.\n\nThe expected error is the guard: if a future TypeScript infers partial type\narguments, this line stops erroring and the comment above needs revisiting.",
|
|
72
|
+
"extendsNative": false,
|
|
73
|
+
"props": []
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"functions": []
|
|
77
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Button } from "@behivetech/atoms.button";
|
|
2
|
+
import { Select } from "@behivetech/atoms.select";
|
|
3
|
+
import { TextField } from "@behivetech/atoms.text-field";
|
|
4
|
+
import { Toggle } from "@behivetech/atoms.toggle";
|
|
5
|
+
import { Form, useForm } from "@behivetech/forms.form-provider";
|
|
6
|
+
import { FormFieldManaged } from "./form-field-managed.js";
|
|
7
|
+
|
|
8
|
+
interface DemoValues {
|
|
9
|
+
email: string;
|
|
10
|
+
role: string;
|
|
11
|
+
active: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ROLES = [
|
|
15
|
+
{ label: "Admin", value: "admin" },
|
|
16
|
+
{ label: "Editor", value: "editor" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One managed form covering both integration shapes: a native control, where
|
|
21
|
+
* the field spreads straight on, and two Radix controls, where the caller maps
|
|
22
|
+
* the one prop that differs.
|
|
23
|
+
*
|
|
24
|
+
* Each field passes `control` rather than naming type parameters. That is the
|
|
25
|
+
* shape worth copying: it infers both parameters, so `field.value` is the type
|
|
26
|
+
* of that one field and needs no cast. Reading `control` from context instead
|
|
27
|
+
* works too, but then the field type has to be named — `<FormFieldManaged<
|
|
28
|
+
* DemoValues, "email">` — because supplying one type argument stops TypeScript
|
|
29
|
+
* inferring the other.
|
|
30
|
+
*/
|
|
31
|
+
export const ManagedForm = () => {
|
|
32
|
+
const methods = useForm<DemoValues>({
|
|
33
|
+
defaultValues: { email: "", role: "", active: false },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Form methods={methods} onSubmit={(values) => console.log(values)}>
|
|
38
|
+
<FormFieldManaged
|
|
39
|
+
control={methods.control}
|
|
40
|
+
name="email"
|
|
41
|
+
label="Email"
|
|
42
|
+
hint="We only use this for sign-in"
|
|
43
|
+
required
|
|
44
|
+
rules={{ required: "Email is required" }}
|
|
45
|
+
>
|
|
46
|
+
{(field) => <TextField {...field} type="email" />}
|
|
47
|
+
</FormFieldManaged>
|
|
48
|
+
|
|
49
|
+
<FormFieldManaged
|
|
50
|
+
control={methods.control}
|
|
51
|
+
name="role"
|
|
52
|
+
label="Role"
|
|
53
|
+
rules={{ required: "Pick a role" }}
|
|
54
|
+
>
|
|
55
|
+
{(field) => (
|
|
56
|
+
<Select
|
|
57
|
+
id={field.id}
|
|
58
|
+
name={field.name}
|
|
59
|
+
options={ROLES}
|
|
60
|
+
value={field.value}
|
|
61
|
+
onValueChange={field.onChange}
|
|
62
|
+
placeholder="Pick one"
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</FormFieldManaged>
|
|
66
|
+
|
|
67
|
+
<FormFieldManaged control={methods.control} name="active" label="Active">
|
|
68
|
+
{(field) => (
|
|
69
|
+
<Toggle
|
|
70
|
+
id={field.id}
|
|
71
|
+
name={field.name}
|
|
72
|
+
checked={field.value}
|
|
73
|
+
onCheckedChange={field.onChange}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
</FormFieldManaged>
|
|
77
|
+
|
|
78
|
+
<Button type="submit" variant="primary" size="sm">
|
|
79
|
+
Save
|
|
80
|
+
</Button>
|
|
81
|
+
</Form>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type ReactNode, useId } from "react";
|
|
2
|
+
import { FormField, type FormFieldProps } from "@behivetech/forms.form-field";
|
|
3
|
+
import {
|
|
4
|
+
useController,
|
|
5
|
+
type Control,
|
|
6
|
+
type ControllerFieldState,
|
|
7
|
+
type ControllerRenderProps,
|
|
8
|
+
type FieldPath,
|
|
9
|
+
type FieldValues,
|
|
10
|
+
type RegisterOptions,
|
|
11
|
+
} from "@behivetech/forms.form-provider";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the render prop receives: the wired field plus its `id`.
|
|
15
|
+
*
|
|
16
|
+
* `TName` is threaded through to `ControllerRenderProps` rather than left at its
|
|
17
|
+
* default. Without it `value` widens to the union of every field type in the
|
|
18
|
+
* form, so a call site spreading onto a `TextField` has to assert
|
|
19
|
+
* `value as string` — an assertion that keeps compiling if the field later
|
|
20
|
+
* changes type, which is the mistake the generic exists to catch.
|
|
21
|
+
*/
|
|
22
|
+
export type ManagedField<
|
|
23
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
24
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
25
|
+
> = ControllerRenderProps<TFieldValues, TName> & {
|
|
26
|
+
/** Matches the label's `htmlFor`; spread onto the control or pass explicitly */
|
|
27
|
+
id: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface FormFieldManagedProps<
|
|
31
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
32
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
33
|
+
> extends Omit<FormFieldProps, "children" | "error" | "htmlFor"> {
|
|
34
|
+
/** Field path in the form values; infers the field's value type */
|
|
35
|
+
name: TName;
|
|
36
|
+
/** Form control; falls back to the surrounding `useFormContext()` when omitted */
|
|
37
|
+
control?: Control<TFieldValues>;
|
|
38
|
+
/** Validation rules passed through to react-hook-form */
|
|
39
|
+
rules?: RegisterOptions<TFieldValues, TName>;
|
|
40
|
+
/** Renders the control from the wired field */
|
|
41
|
+
children: (
|
|
42
|
+
field: ManagedField<TFieldValues, TName>,
|
|
43
|
+
fieldState: ControllerFieldState,
|
|
44
|
+
) => ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `FormField` with its react-hook-form wiring already done: the control's
|
|
49
|
+
* value/onChange/onBlur/ref, the label association, and the error message.
|
|
50
|
+
*
|
|
51
|
+
* Uses `useController` rather than `register` because `register` only works on
|
|
52
|
+
* controls that render a native input. `TextField` and `Textarea` do;
|
|
53
|
+
* `Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land
|
|
54
|
+
* on the trigger button and the change callback is `onCheckedChange` /
|
|
55
|
+
* `onValueChange`, so `register` binds nothing and the field silently keeps its
|
|
56
|
+
* default. `useController` works for all of them.
|
|
57
|
+
*
|
|
58
|
+
* The control is supplied through a render prop rather than cloned, because the
|
|
59
|
+
* prop a control listens on is the one thing that genuinely differs between
|
|
60
|
+
* them — worth seeing at the call site, and impossible to type if hidden.
|
|
61
|
+
*
|
|
62
|
+
* Pass `control` and both type parameters infer, so `field.value` is the type
|
|
63
|
+
* of that one field and spreads onto a control without a cast:
|
|
64
|
+
*
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <FormFieldManaged control={methods.control} name="email" label="Email">
|
|
67
|
+
* {(field) => <TextField {...field} />}
|
|
68
|
+
* </FormFieldManaged>
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* Taking `control` from context instead means naming both parameters. Supplying
|
|
72
|
+
* only the first — `<FormFieldManaged<Values> name="email">` — leaves `TName` at
|
|
73
|
+
* its default and widens `field.value` to a union of every field in the form,
|
|
74
|
+
* because TypeScript stops inferring the rest once any type argument is given
|
|
75
|
+
* explicitly:
|
|
76
|
+
*
|
|
77
|
+
* ```tsx
|
|
78
|
+
* <FormFieldManaged<Values, "email"> name="email" label="Email">
|
|
79
|
+
* {(field) => <TextField {...field} />}
|
|
80
|
+
* </FormFieldManaged>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export const FormFieldManaged = <
|
|
84
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
85
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
86
|
+
>({
|
|
87
|
+
name,
|
|
88
|
+
control,
|
|
89
|
+
rules,
|
|
90
|
+
children,
|
|
91
|
+
...fieldProps
|
|
92
|
+
}: FormFieldManagedProps<TFieldValues, TName>) => {
|
|
93
|
+
const id = useId();
|
|
94
|
+
const { field, fieldState } = useController<TFieldValues, TName>({
|
|
95
|
+
name,
|
|
96
|
+
control,
|
|
97
|
+
rules,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<FormField {...fieldProps} htmlFor={id} error={fieldState.error?.message}>
|
|
102
|
+
{children({ ...field, id }, fieldState)}
|
|
103
|
+
</FormField>
|
|
104
|
+
);
|
|
105
|
+
};
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@behivetech/forms.form-field-managed",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"index.ts",
|
|
18
|
+
"form-field-managed.tsx",
|
|
19
|
+
"form-field-managed.module.scss",
|
|
20
|
+
"*.example.tsx",
|
|
21
|
+
"docs.json"
|
|
22
|
+
],
|
|
23
|
+
"registry": {
|
|
24
|
+
"status": "stable",
|
|
25
|
+
"tags": []
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@behivetech/forms.form-field": "0.3.2",
|
|
29
|
+
"@behivetech/forms.form-provider": "0.1.4"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^19.2.7",
|
|
33
|
+
"react-dom": "^19.2.7"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "19.2.17",
|
|
37
|
+
"@types/react-dom": "19.2.3",
|
|
38
|
+
"eslint": "^9.39.5",
|
|
39
|
+
"tsup": "^8.5.1",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"vitest": "^4.1.10",
|
|
42
|
+
"@behivetech/atoms.text-field": "0.3.2",
|
|
43
|
+
"@behivetech/atoms.toggle": "0.4.2",
|
|
44
|
+
"@bht-component-registry/env": "0.1.0",
|
|
45
|
+
"@behivetech/atoms.select": "0.4.2"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/behivetech/bht-galaxy.git",
|
|
50
|
+
"directory": "registry/behivetech/forms/form-field-managed"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"registry": "https://registry.npmjs.org",
|
|
54
|
+
"access": "public",
|
|
55
|
+
"provenance": true
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsup && bht-registry-docgen",
|
|
59
|
+
"dev": "tsup --watch",
|
|
60
|
+
"lint": "eslint . --max-warnings 0",
|
|
61
|
+
"check-types": "tsc --noEmit",
|
|
62
|
+
"test": "vitest run"
|
|
63
|
+
}
|
|
6
64
|
}
|