@kitr/forms 0.0.0-stage → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @kitr/forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Forms.** Build forms with validation, and show server errors next to the right field.
4
+
5
+ ```sh
6
+ pnpm kitr add forms
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/forms`.
10
+
11
+ Built on react-hook-form.
12
+
13
+ Read the guide at https://kitr.dev/packages/forms.
14
+
15
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,96 @@
1
+ import { FieldPath, FieldValues, Form as RhfForm, FormProps as RhfFormProps, UseFormProps, UseFormReturn } from "react-hook-form";
2
+ import { StandardSchemaV1 } from "@kitr/core";
3
+ import { ComponentProps, ReactNode } from "react";
4
+ export * from "react-hook-form";
5
+ /** Props of `<Form>`. Every other prop is passed to the `<form>` element. */
6
+ interface FormProps<TIn extends FieldValues, TOut extends FieldValues> extends Omit<ComponentProps<'form'>, 'onSubmit' | 'onError'> {
7
+ /** The object returned by `useSchemaForm` or React Hook Form's `useForm`. */
8
+ form: UseFormReturn<TIn, unknown, TOut>;
9
+ /** Runs with validated values. A thrown KitrError with `fieldErrors` lands on fields. */
10
+ onSubmit: (values: TOut) => unknown | Promise<unknown>;
11
+ /** Runs after `onSubmit` resolves, with its return value and the submitted values. */
12
+ onSuccess?: (result: unknown, values: TOut) => void;
13
+ /** Runs after a thrown error has been mapped onto the fields; receives it as a `KitrError`. */
14
+ onError?: (error: unknown) => void;
15
+ /** Reset to default values after a successful submit. */
16
+ resetOnSuccess?: boolean;
17
+ /** The fields, buttons and anything else inside the form. */
18
+ children: ReactNode;
19
+ }
20
+ /**
21
+ * The kitr glue around React Hook Form: submit handling with pending state,
22
+ * server validation errors mapped onto fields, and a root error for everything else.
23
+ */
24
+ export declare function Form<TIn extends FieldValues, TOut extends FieldValues>({ form, onSubmit, onSuccess, onError, resetOnSuccess, children, ...rest }: FormProps<TIn, TOut>): import("react").JSX.Element;
25
+ /** `pending` is true while `onSubmit` runs. Use it on the submit button. */
26
+ export declare function useFormStatus(): {
27
+ pending: boolean;
28
+ isValid: boolean;
29
+ };
30
+ /** The form-level error from the server (anything not mapped to a field). */
31
+ export declare function useRootError(): string | undefined;
32
+ /** Props of `<FormField>`: which field it wraps and the text around the input. */
33
+ interface FormFieldProps<T extends FieldValues> {
34
+ /** The field path, dot-separated for nested fields (`'lines.0.quantity'`). */
35
+ name: FieldPath<T>;
36
+ /** Content of the `<label>`, which points at the input's generated id. */
37
+ label?: ReactNode;
38
+ /** Help text under the input, linked to it with `aria-describedby`. */
39
+ description?: ReactNode;
40
+ /** The input, or a function that receives `{ id, name, invalid, describedBy }` and renders it. */
41
+ children: ReactNode | ((field: {
42
+ id: string;
43
+ name: string;
44
+ invalid: boolean;
45
+ describedBy: string;
46
+ }) => ReactNode);
47
+ /** Class for the wrapper `<div data-kitr="field">`. */
48
+ className?: string;
49
+ }
50
+ /**
51
+ * Headless field wrapper: generates ids, wires `aria-describedby` and
52
+ * `aria-invalid`, shows the field's error. Style it with your own classes
53
+ * or pull the registry's `form` component for a styled version.
54
+ */
55
+ export declare function FormField<T extends FieldValues>({ name, label, description, children, className }: FormFieldProps<T>): import("react").JSX.Element;
56
+ /** Props to spread on the input inside a `<FormField>`: `{...useFieldProps()}` plus `form.register(name)`. */
57
+ export declare function useFieldProps(): {
58
+ onChange: import("react-hook-form").ChangeHandler;
59
+ onBlur: import("react-hook-form").ChangeHandler;
60
+ ref: import("react-hook-form").RefCallBack;
61
+ name: string;
62
+ min?: string | number;
63
+ max?: string | number;
64
+ maxLength?: number;
65
+ minLength?: number;
66
+ pattern?: string;
67
+ required?: boolean;
68
+ disabled?: boolean;
69
+ id: string;
70
+ 'aria-invalid': boolean | undefined;
71
+ 'aria-describedby': string | undefined;
72
+ };
73
+ /** Convenience: an `<input>` already registered to the surrounding field. */
74
+ export declare function FieldInput(props: Omit<ComponentProps<'input'>, 'name' | 'id'>): import("react").JSX.Element;
75
+ /**
76
+ * A `type="submit"` button that is disabled and `aria-busy` while the form submits, and shows
77
+ * `pendingText` in that time. A `disabled` prop is combined with the pending state, never replaced.
78
+ */
79
+ export declare function SubmitButton({ children, pendingText, disabled, ...props }: ComponentProps<'button'> & {
80
+ pendingText?: ReactNode;
81
+ }): import("react").JSX.Element;
82
+ /**
83
+ * Map kitr's `fieldErrors` onto React Hook Form fields. Unknown fields and
84
+ * `_root` go to `root.server`. Returns true if the error was a validation error.
85
+ */
86
+ export declare function applyServerErrors<T extends FieldValues>(form: UseFormReturn<T>, error: unknown): boolean;
87
+ /** Every React Hook Form `useForm` option except `resolver`, which `useSchemaForm` sets from the schema. */
88
+ export type SchemaFormOptions<TIn extends FieldValues, TOut> = Omit<UseFormProps<TIn, unknown, TOut>, 'resolver'>;
89
+ /**
90
+ * `useForm` with a Standard Schema resolver (zod, valibot, arktype). The full
91
+ * React Hook Form API is on the returned object; nothing is wrapped.
92
+ */
93
+ export declare function useSchemaForm<TIn extends FieldValues, TOut extends FieldValues = TIn>(schema: StandardSchemaV1<TIn, TOut>, options?: SchemaFormOptions<TIn, TOut>): UseFormReturn<TIn, unknown, TOut>;
94
+ /** Alias kept for people who think in zod. */
95
+ export declare const useZodForm: typeof useSchemaForm;
96
+ export { type FormFieldProps, type FormProps, RhfForm, type RhfFormProps };
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ import { Form as RhfForm, FormProvider, useForm, useFormContext } from "react-hook-form";
2
+ import { isKitrError, toKitrError } from "@kitr/core";
3
+ import { createContext, useContext, useId, useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
6
+ export * from "react-hook-form";
7
+ // src/server-errors.ts
8
+ /**
9
+ * Map kitr's `fieldErrors` onto React Hook Form fields. Unknown fields and
10
+ * `_root` go to `root.server`. Returns true if the error was a validation error.
11
+ */
12
+ function applyServerErrors(form, error) {
13
+ const err = toKitrError(error);
14
+ const fieldErrors = err.fieldErrors;
15
+ const known = new Set(Object.keys(form.getValues() ?? {}));
16
+ let applied = false;
17
+ if (fieldErrors) for (const [field, messages] of Object.entries(fieldErrors)) {
18
+ const first = messages[0] ?? "Invalid";
19
+ const top = field.split(".")[0] ?? field;
20
+ if (field !== "_root" && (known.has(top) || known.size === 0)) {
21
+ form.setError(field, {
22
+ type: "server",
23
+ message: first
24
+ });
25
+ applied = true;
26
+ } else form.setError("root.server", {
27
+ type: "server",
28
+ message: first
29
+ });
30
+ }
31
+ if (!applied && !fieldErrors) form.setError("root.server", {
32
+ type: "server",
33
+ message: err.message
34
+ });
35
+ return isKitrError(error) && err.code === "VALIDATION";
36
+ }
37
+ // src/form.tsx
38
+ /**
39
+ * The kitr glue around React Hook Form: submit handling with pending state,
40
+ * server validation errors mapped onto fields, and a root error for everything else.
41
+ */
42
+ function Form({ form, onSubmit, onSuccess, onError, resetOnSuccess, children, ...rest }) {
43
+ const [pending, setPending] = useState(false);
44
+ const submit = form.handleSubmit(async (values) => {
45
+ setPending(true);
46
+ form.clearErrors("root.server");
47
+ try {
48
+ const result = await onSubmit(values);
49
+ onSuccess?.(result, values);
50
+ if (resetOnSuccess) form.reset();
51
+ } catch (error) {
52
+ applyServerErrors(form, error);
53
+ onError?.(toKitrError(error));
54
+ } finally {
55
+ setPending(false);
56
+ }
57
+ });
58
+ return /* @__PURE__ */ jsx(FormProvider, {
59
+ ...form,
60
+ children: /* @__PURE__ */ jsx(FormStatusContext.Provider, {
61
+ value: { pending },
62
+ children: /* @__PURE__ */ jsx("form", {
63
+ noValidate: true,
64
+ ...rest,
65
+ onSubmit: submit,
66
+ children
67
+ })
68
+ })
69
+ });
70
+ }
71
+ const FormStatusContext = createContext({ pending: false });
72
+ /** `pending` is true while `onSubmit` runs. Use it on the submit button. */
73
+ function useFormStatus() {
74
+ const { pending } = useContext(FormStatusContext);
75
+ const form = useFormContext();
76
+ return {
77
+ pending: pending || form.formState.isSubmitting,
78
+ isValid: form.formState.isValid
79
+ };
80
+ }
81
+ /** The form-level error from the server (anything not mapped to a field). */
82
+ function useRootError() {
83
+ return useFormContext().formState.errors?.root?.server?.message;
84
+ }
85
+ const FieldContext = createContext(null);
86
+ /**
87
+ * Headless field wrapper: generates ids, wires `aria-describedby` and
88
+ * `aria-invalid`, shows the field's error. Style it with your own classes
89
+ * or pull the registry's `form` component for a styled version.
90
+ */
91
+ function FormField({ name, label, description, children, className }) {
92
+ const form = useFormContext();
93
+ const id = useId();
94
+ const error = form.getFieldState(name, form.formState).error?.message ?? void 0;
95
+ const describedBy = [description ? `${id}-description` : null, error ? `${id}-error` : null].filter(Boolean).join(" ");
96
+ const value = {
97
+ name,
98
+ id,
99
+ error,
100
+ describedBy
101
+ };
102
+ return /* @__PURE__ */ jsx(FieldContext.Provider, {
103
+ value,
104
+ children: /* @__PURE__ */ jsxs("div", {
105
+ className,
106
+ "data-kitr": "field",
107
+ "data-invalid": error ? "" : void 0,
108
+ children: [
109
+ label ? /* @__PURE__ */ jsx("label", {
110
+ htmlFor: id,
111
+ "data-kitr": "label",
112
+ children: label
113
+ }) : null,
114
+ typeof children === "function" ? children({
115
+ id,
116
+ name,
117
+ invalid: !!error,
118
+ describedBy
119
+ }) : children,
120
+ description ? /* @__PURE__ */ jsx("p", {
121
+ id: `${id}-description`,
122
+ "data-kitr": "description",
123
+ children: description
124
+ }) : null,
125
+ error ? /* @__PURE__ */ jsx("p", {
126
+ id: `${id}-error`,
127
+ role: "alert",
128
+ "data-kitr": "field-error",
129
+ children: error
130
+ }) : null
131
+ ]
132
+ })
133
+ });
134
+ }
135
+ /** Props to spread on the input inside a `<FormField>`: `{...useFieldProps()}` plus `form.register(name)`. */
136
+ function useFieldProps() {
137
+ const ctx = useContext(FieldContext);
138
+ if (!ctx) throw new Error("[@kitr/forms] useFieldProps() must be used inside <FormField>.");
139
+ const form = useFormContext();
140
+ return {
141
+ id: ctx.id,
142
+ "aria-invalid": ctx.error ? true : void 0,
143
+ "aria-describedby": ctx.describedBy || void 0,
144
+ ...form.register(ctx.name)
145
+ };
146
+ }
147
+ /** Convenience: an `<input>` already registered to the surrounding field. */
148
+ function FieldInput(props) {
149
+ const fieldProps = useFieldProps();
150
+ return /* @__PURE__ */ jsx("input", {
151
+ ...props,
152
+ ...fieldProps
153
+ });
154
+ }
155
+ /**
156
+ * A `type="submit"` button that is disabled and `aria-busy` while the form submits, and shows
157
+ * `pendingText` in that time. A `disabled` prop is combined with the pending state, never replaced.
158
+ */
159
+ function SubmitButton({ children, pendingText, disabled, ...props }) {
160
+ const { pending } = useFormStatus();
161
+ return /* @__PURE__ */ jsx("button", {
162
+ type: "submit",
163
+ ...props,
164
+ disabled: pending || disabled,
165
+ "aria-busy": pending,
166
+ children: pending && pendingText ? pendingText : children
167
+ });
168
+ }
169
+ // src/use-schema-form.ts
170
+ /**
171
+ * `useForm` with a Standard Schema resolver (zod, valibot, arktype). The full
172
+ * React Hook Form API is on the returned object; nothing is wrapped.
173
+ */
174
+ function useSchemaForm(schema, options = {}) {
175
+ return useForm({
176
+ mode: "onTouched",
177
+ ...options,
178
+ resolver: standardSchemaResolver(schema)
179
+ });
180
+ }
181
+ /** Alias kept for people who think in zod. */
182
+ const useZodForm = useSchemaForm;
183
+ export { FieldInput, Form, FormField, RhfForm, SubmitButton, applyServerErrors, useFieldProps, useFormStatus, useRootError, useSchemaForm, useZodForm };
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** `kitr add forms`: React Hook Form with Standard Schema resolvers, `<Form>` with server-error mapping. */
2
+ declare const _default: {
3
+ readonly name: "forms";
4
+ readonly client: true;
5
+ readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
6
+ readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
7
+ readonly generators: {
8
+ readonly form: {
9
+ readonly description: "Create a form component in app/components/forms";
10
+ readonly definition: "A component that owns one schema-validated form and its submit.";
11
+ readonly target: (name: string, project: import("@kitr/core/package").ProjectInfo) => {
12
+ file: string;
13
+ vars: Record<string, string>;
14
+ };
15
+ readonly stub: ({ name }: import("@kitr/core/package").GeneratorVars) => string;
16
+ };
17
+ };
18
+ readonly wraps: {
19
+ readonly 'react-hook-form': "@kitr/forms";
20
+ };
21
+ readonly eject: {
22
+ readonly '@kitr/forms': "react-hook-form + @hookform/resolvers";
23
+ };
24
+ readonly vite: {
25
+ readonly optimizeDeps: ["react-hook-form", "@hookform/resolvers/standard-schema"];
26
+ };
27
+ };
28
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,49 @@
1
+ import { definePackage, generatorFile, naming } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ /** `kitr add forms`: React Hook Form with Standard Schema resolvers, `<Form>` with server-error mapping. */
4
+ var kitr_default = definePackage({
5
+ name: "forms",
6
+ client: true,
7
+ add(ctx) {
8
+ ctx.deps.add("react-hook-form", "@hookform/resolvers", "zod");
9
+ ctx.agents.add("forms", `- Forms: \`const form = useSchemaForm(schema, { defaultValues })\` then \`<Form form={form} onSubmit={...}>\` from \`@kitr/forms\` (or the styled copy in \`app/components/ui/form.tsx\`).\n- Throw or reject with a KitrError carrying \`fieldErrors\` and the fields show the messages; anything else lands in \`useRootError()\`.`);
10
+ },
11
+ remove(ctx) {
12
+ ctx.deps.remove("react-hook-form", "@hookform/resolvers");
13
+ ctx.agents.remove("forms");
14
+ },
15
+ generators: { form: {
16
+ description: "Create a form component in app/components/forms",
17
+ definition: "A component that owns one schema-validated form and its submit.",
18
+ target: (name, project) => generatorFile(project, "components", "forms", name, {
19
+ suffix: "-form",
20
+ ext: ".tsx"
21
+ }),
22
+ stub: ({ name }) => `import { useSchemaForm } from '@kitr/forms'
23
+ import { z } from 'zod'
24
+ import { FieldInput, Form, FormField, FormMessage, SubmitButton } from '#components/ui/form'
25
+
26
+ export const ${naming.camel(name)}Schema = z.object({
27
+ name: z.string().min(2, 'Name must be at least 2 characters'),
28
+ })
29
+ export type ${naming.pascal(name)}Values = z.infer<typeof ${naming.camel(name)}Schema>
30
+
31
+ export function ${naming.pascal(name)}Form({ defaultValues, onSubmit }: { defaultValues?: Partial<${naming.pascal(name)}Values>; onSubmit: (values: ${naming.pascal(name)}Values) => Promise<unknown> }) {
32
+ const form = useSchemaForm(${naming.camel(name)}Schema, { defaultValues: { name: '', ...defaultValues } })
33
+ return (
34
+ <Form form={form} onSubmit={onSubmit} className="grid gap-4">
35
+ <FormMessage />
36
+ <FormField name="name" label="Name">
37
+ <FieldInput autoComplete="off" />
38
+ </FormField>
39
+ <SubmitButton>Save</SubmitButton>
40
+ </Form>
41
+ )
42
+ }
43
+ `
44
+ } },
45
+ wraps: { "react-hook-form": "@kitr/forms" },
46
+ eject: { "@kitr/forms": "react-hook-form + @hookform/resolvers" },
47
+ vite: { optimizeDeps: ["react-hook-form", "@hookform/resolvers/standard-schema"] }
48
+ });
49
+ export { kitr_default as default };
package/docs/api.md ADDED
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: API reference
3
+ description: Every export of @kitr/forms.
4
+ order: 2
5
+ ---
6
+
7
+ # API reference
8
+
9
+ `@kitr/forms` re-exports everything from `react-hook-form` (its `Form` as `RhfForm`) and adds the following.
10
+
11
+ ## useSchemaForm
12
+
13
+ ```ts
14
+ function useSchemaForm<TIn, TOut = TIn>(
15
+ schema: StandardSchemaV1<TIn, TOut>,
16
+ options?: Omit<UseFormProps<TIn, unknown, TOut>, 'resolver'>,
17
+ ): UseFormReturn<TIn, unknown, TOut>
18
+ ```
19
+
20
+ `useForm` with a Standard Schema resolver and `mode: 'onTouched'`. Every React Hook Form option is accepted. `useZodForm` is an alias.
21
+
22
+ ## Form
23
+
24
+ ```tsx
25
+ <Form form={form} onSubmit={fn} onSuccess? onError? resetOnSuccess? {...formAttributes}>
26
+ ```
27
+
28
+ | Prop | Type | Description |
29
+ | --- | --- | --- |
30
+ | `form` | `UseFormReturn` | From `useSchemaForm` or `useForm`. |
31
+ | `onSubmit` | `(values: TOut) => unknown` | Runs with validated values. Throw or reject to report errors. |
32
+ | `onSuccess` | `(result, values) => void` | Runs after `onSubmit` resolves. |
33
+ | `onError` | `(error: KitrError) => void` | Runs after errors are applied to fields. |
34
+ | `resetOnSuccess` | `boolean` | Reset to default values after success. |
35
+
36
+ Any other prop goes to the `<form>` element, which always has `noValidate`.
37
+
38
+ ## FormField
39
+
40
+ ```tsx
41
+ <FormField name="email" label="Email" description="We never share it." className="...">
42
+ {children | ({ id, name, invalid, describedBy }) => ReactNode}
43
+ </FormField>
44
+ ```
45
+
46
+ Renders a wrapper with `data-kitr="field"` (and `data-invalid` when it has an error), a `<label>`, the description and the error message with `role="alert"`. It is headless: style it with your own classes or the `data-kitr` attributes.
47
+
48
+ ## useFieldProps and FieldInput
49
+
50
+ ```ts
51
+ useFieldProps(): { id, 'aria-invalid', 'aria-describedby', name, onChange, onBlur, ref }
52
+ ```
53
+
54
+ Props for the input inside the nearest `<FormField>`, including `form.register(name)`. `<FieldInput {...inputProps} />` is an `<input>` with these applied.
55
+
56
+ ## SubmitButton
57
+
58
+ ```tsx
59
+ <SubmitButton pendingText="Saving…">Save</SubmitButton>
60
+ ```
61
+
62
+ A `type="submit"` button, disabled with `aria-busy` while the form submits.
63
+
64
+ ## useFormStatus and useRootError
65
+
66
+ ```ts
67
+ useFormStatus(): { pending: boolean; isValid: boolean }
68
+ useRootError(): string | undefined
69
+ ```
70
+
71
+ ## applyServerErrors
72
+
73
+ ```ts
74
+ function applyServerErrors(form: UseFormReturn, error: unknown): boolean
75
+ ```
76
+
77
+ Maps a `KitrError`'s `fieldErrors` onto fields and everything else to `root.server`. `<Form>` calls it for you; use it when submitting another way. Returns `true` for validation errors.