@asnewyla/form 0.2.0

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 XD
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 ADDED
@@ -0,0 +1,95 @@
1
+ # @asnewyla/form
2
+
3
+ Form wrapper with real client-side validation, an accessible error summary,
4
+ and native `<form>` submission semantics.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @asnewyla/form @asnewyla/input
10
+ ```
11
+
12
+ `FormFieldInput` renders `@asnewyla/input`'s `Input` under the hood, so its
13
+ stylesheet also needs importing once, anywhere in your app:
14
+
15
+ ```ts
16
+ import '@asnewyla/input/styles.css';
17
+ ```
18
+
19
+ `@asnewyla/form` ships no CSS of its own — every field's appearance comes
20
+ from `@asnewyla/input`.
21
+
22
+ ## Usage
23
+
24
+ ```tsx
25
+ import { Form, FormFieldInput } from '@asnewyla/form';
26
+
27
+ <Form onSubmit={(values) => console.log(values)}>
28
+ <FormFieldInput label="Email" name="email" required pattern="^\S+@\S+$" />
29
+ <FormFieldInput label="Password" name="password" required minLength={8} />
30
+ <button type="submit">Submit</button>
31
+ </Form>
32
+ ```
33
+
34
+ `Form` validates on blur and on submit, blocks `onSubmit` while any field is
35
+ invalid, and reads submitted values via native `FormData` — there's no
36
+ duplicated value state to keep in sync.
37
+
38
+ ### `FormFieldInput`
39
+
40
+ Use `FormFieldInput`, not a plain `Input`, for any field you want `Form` to
41
+ register, validate, and collect. It accepts every `Input` prop plus a
42
+ required `name` and a small validation-rule vocabulary:
43
+
44
+ | Prop | Type | Notes |
45
+ |---|---|---|
46
+ | `name` | `string` (**required**) | Native `name`, also the key in `onSubmit`'s payload |
47
+ | `required` | `boolean \| string` | A string overrides the default error message |
48
+ | `pattern` | `string` | Regex source text, matching the native `<input pattern>` attribute |
49
+ | `minLength` / `maxLength` | `number` | |
50
+ | `min` / `max` | `string \| number` | Matching the native `<input min>`/`<input max>` attributes |
51
+ | `validate` | `(value: string) => string \| undefined` | Custom sync validator, runs after every other rule passes |
52
+
53
+ Rules run in that order — `required` → `pattern` → `minLength` → `maxLength`
54
+ → `min` → `max` → `validate` — and the first one that fails wins; only one
55
+ error message shows per field.
56
+
57
+ Used outside a `Form`, `FormFieldInput` still renders correctly — it's a
58
+ normal `Input` with no ancestor to register with, and the native attributes
59
+ (`required`, `pattern`, etc.) still reach the underlying `<input>`.
60
+
61
+ A plain `Input` (not `FormFieldInput`) placed inside a `Form` is never
62
+ registered or validated, but its value is still collected into `onSubmit`'s
63
+ payload if it has a `name` — `Form` reads values via native `FormData`,
64
+ which picks up any named form control regardless of registration. This is
65
+ intentional, native `<form>` behavior, not a gap `FormFieldInput` needs to
66
+ close.
67
+
68
+ ### Error display
69
+
70
+ Both an inline message and a summary show for an invalid field, not either:
71
+
72
+ - Each `FormFieldInput` shows its own inline error, linked via
73
+ `aria-describedby` — the same as a plain `Input`.
74
+ - `Form` additionally renders an error summary once a submit attempt has
75
+ failed: `role="alert"`, one link per invalid field pointing at that
76
+ field's real `id`, and focus moved to the summary on every failed submit.
77
+ Clicking a link moves focus to the corresponding field. The summary stays
78
+ in sync with corrections made afterward (including via blur) and
79
+ disappears once no errors remain — it doesn't require resubmitting to
80
+ update or go away.
81
+
82
+ The summary only appears after a submit has actually been attempted — a
83
+ field that's invalid purely from blur-driven validation (nobody has tried
84
+ to submit yet) shows its own inline error only.
85
+
86
+ ### Props
87
+
88
+ | Prop | Type |
89
+ |---|---|
90
+ | `children` | `React.ReactNode` (**required**) |
91
+ | `onSubmit` | `(values: Record<string, string>) => void` (**required**) |
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,19 @@
1
+ import * as react from 'react';
2
+ import { InputProps } from '@asnewyla/input';
3
+
4
+ type FormProps = {
5
+ children: React.ReactNode;
6
+ onSubmit: (values: Record<string, string>) => void;
7
+ };
8
+ declare function Form({ children, onSubmit }: FormProps): react.JSX.Element;
9
+
10
+ type FormFieldInputProps = Omit<InputProps, 'name'> & {
11
+ name: string;
12
+ validate?: (value: string) => string | undefined;
13
+ };
14
+ declare const FormFieldInput: react.ForwardRefExoticComponent<Omit<InputProps, "name"> & {
15
+ name: string;
16
+ validate?: (value: string) => string | undefined;
17
+ } & react.RefAttributes<HTMLInputElement>>;
18
+
19
+ export { Form, FormFieldInput, type FormFieldInputProps, type FormProps };
@@ -0,0 +1,19 @@
1
+ import * as react from 'react';
2
+ import { InputProps } from '@asnewyla/input';
3
+
4
+ type FormProps = {
5
+ children: React.ReactNode;
6
+ onSubmit: (values: Record<string, string>) => void;
7
+ };
8
+ declare function Form({ children, onSubmit }: FormProps): react.JSX.Element;
9
+
10
+ type FormFieldInputProps = Omit<InputProps, 'name'> & {
11
+ name: string;
12
+ validate?: (value: string) => string | undefined;
13
+ };
14
+ declare const FormFieldInput: react.ForwardRefExoticComponent<Omit<InputProps, "name"> & {
15
+ name: string;
16
+ validate?: (value: string) => string | undefined;
17
+ } & react.RefAttributes<HTMLInputElement>>;
18
+
19
+ export { Form, FormFieldInput, type FormFieldInputProps, type FormProps };
package/dist/index.js ADDED
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ var input = require('@asnewyla/input');
4
+ var react = require('react');
5
+ var reactDom = require('react-dom');
6
+ var jsxRuntime = require('react/jsx-runtime');
7
+
8
+ // src/components/Form.tsx
9
+
10
+ // src/components/validation.ts
11
+ function validateValue(value, rules) {
12
+ if (!rules) return void 0;
13
+ if (rules.required && value.trim() === "") {
14
+ return typeof rules.required === "string" ? rules.required : "This field is required";
15
+ }
16
+ if (rules.pattern && value !== "" && !new RegExp(rules.pattern).test(value)) {
17
+ return "Invalid format";
18
+ }
19
+ if (rules.minLength !== void 0 && value.length < rules.minLength) {
20
+ return `Must be at least ${rules.minLength} characters`;
21
+ }
22
+ if (rules.maxLength !== void 0 && value.length > rules.maxLength) {
23
+ return `Must be at most ${rules.maxLength} characters`;
24
+ }
25
+ if (rules.min !== void 0 && value !== "" && Number(value) < Number(rules.min)) {
26
+ return `Must be at least ${rules.min}`;
27
+ }
28
+ if (rules.max !== void 0 && value !== "" && Number(value) > Number(rules.max)) {
29
+ return `Must be at most ${rules.max}`;
30
+ }
31
+ if (rules.validate) {
32
+ return rules.validate(value);
33
+ }
34
+ return void 0;
35
+ }
36
+
37
+ // src/components/useFieldRegistry.ts
38
+ function useFieldRegistry(formRef) {
39
+ const fieldsRef = react.useRef(/* @__PURE__ */ new Map());
40
+ const [errors, setErrors] = react.useState({});
41
+ const registerField = react.useCallback(
42
+ (name, registration) => {
43
+ fieldsRef.current.set(name, registration);
44
+ },
45
+ []
46
+ );
47
+ const unregisterField = react.useCallback((name) => {
48
+ fieldsRef.current.delete(name);
49
+ setErrors((prev) => {
50
+ if (!(name in prev)) return prev;
51
+ const next = { ...prev };
52
+ delete next[name];
53
+ return next;
54
+ });
55
+ }, []);
56
+ const getFieldValue = react.useCallback(
57
+ (name) => {
58
+ if (!formRef.current) return "";
59
+ return String(new FormData(formRef.current).get(name) ?? "");
60
+ },
61
+ [formRef]
62
+ );
63
+ const validateField = react.useCallback(
64
+ (name) => {
65
+ const field = fieldsRef.current.get(name);
66
+ const error = validateValue(getFieldValue(name), field?.rules);
67
+ setErrors((prev) => ({ ...prev, [name]: error }));
68
+ return error;
69
+ },
70
+ [getFieldValue]
71
+ );
72
+ const fieldNames = react.useCallback(
73
+ () => Array.from(fieldsRef.current.keys()),
74
+ []
75
+ );
76
+ const invalidFields = react.useCallback(() => {
77
+ const result = [];
78
+ for (const [name, message] of Object.entries(errors)) {
79
+ if (!message) continue;
80
+ const id = fieldsRef.current.get(name)?.ref.current?.id;
81
+ if (!id) continue;
82
+ result.push({ name, id, message });
83
+ }
84
+ return result;
85
+ }, [errors]);
86
+ return {
87
+ errors,
88
+ registerField,
89
+ unregisterField,
90
+ validateField,
91
+ fieldNames,
92
+ invalidFields
93
+ };
94
+ }
95
+ function Form({ children, onSubmit }) {
96
+ const formRef = react.useRef(null);
97
+ const summaryRef = react.useRef(null);
98
+ const {
99
+ errors,
100
+ registerField,
101
+ unregisterField,
102
+ validateField,
103
+ fieldNames,
104
+ invalidFields
105
+ } = useFieldRegistry(formRef);
106
+ const [hasAttemptedSubmit, setHasAttemptedSubmit] = react.useState(false);
107
+ function handleSubmit(event) {
108
+ event.preventDefault();
109
+ let hasError = false;
110
+ reactDom.flushSync(() => {
111
+ for (const name of fieldNames()) {
112
+ if (validateField(name)) hasError = true;
113
+ }
114
+ setHasAttemptedSubmit(true);
115
+ });
116
+ if (hasError) {
117
+ summaryRef.current?.focus();
118
+ return;
119
+ }
120
+ const values = {};
121
+ for (const [key, value] of new FormData(event.currentTarget).entries()) {
122
+ values[key] = String(value);
123
+ }
124
+ onSubmit(values);
125
+ }
126
+ const invalid = hasAttemptedSubmit ? invalidFields() : [];
127
+ const contextValue = react.useMemo(
128
+ () => ({ registerField, unregisterField, validateField, errors }),
129
+ [registerField, unregisterField, validateField, errors]
130
+ );
131
+ return /* @__PURE__ */ jsxRuntime.jsxs("form", { ref: formRef, onSubmit: handleSubmit, noValidate: true, children: [
132
+ invalid.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ErrorSummary, { ref: summaryRef, invalidFields: invalid }),
133
+ /* @__PURE__ */ jsxRuntime.jsx(input.FormFieldProvider, { value: contextValue, children })
134
+ ] });
135
+ }
136
+ var ErrorSummary = react.forwardRef(
137
+ function ErrorSummary2({ invalidFields }, ref) {
138
+ return /* @__PURE__ */ jsxRuntime.jsxs(
139
+ "div",
140
+ {
141
+ className: "xd-form-error-summary",
142
+ role: "alert",
143
+ tabIndex: -1,
144
+ ref,
145
+ children: [
146
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "xd-form-error-summary-heading", children: "There is a problem" }),
147
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { children: invalidFields.map(({ name, id, message }) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(
148
+ "a",
149
+ {
150
+ href: `#${id}`,
151
+ onClick: (event) => {
152
+ event.preventDefault();
153
+ document.getElementById(id)?.focus();
154
+ },
155
+ children: message
156
+ }
157
+ ) }, name)) })
158
+ ]
159
+ }
160
+ );
161
+ }
162
+ );
163
+ function FormFieldInputInner({
164
+ name,
165
+ required,
166
+ pattern,
167
+ minLength,
168
+ maxLength,
169
+ min,
170
+ max,
171
+ validate,
172
+ error,
173
+ onBlur,
174
+ ...rest
175
+ }, ref) {
176
+ const formField = input.useFormFieldContext();
177
+ const inputRef = react.useRef(null);
178
+ const rules = {
179
+ required,
180
+ pattern,
181
+ minLength,
182
+ maxLength,
183
+ min,
184
+ max,
185
+ validate
186
+ };
187
+ react.useEffect(() => {
188
+ if (!formField) return;
189
+ formField.registerField(name, { ref: inputRef, rules });
190
+ return () => formField.unregisterField(name);
191
+ }, [formField?.registerField, formField?.unregisterField, name]);
192
+ const setRefs = (node) => {
193
+ inputRef.current = node;
194
+ if (typeof ref === "function") {
195
+ ref(node);
196
+ } else if (ref) {
197
+ ref.current = node;
198
+ }
199
+ };
200
+ function handleBlur(event) {
201
+ if (formField) {
202
+ formField.validateField(name);
203
+ }
204
+ onBlur?.(event);
205
+ }
206
+ const formContextError = formField ? formField.errors[name] : void 0;
207
+ const formFieldError = error ?? formContextError;
208
+ return /* @__PURE__ */ jsxRuntime.jsx(
209
+ input.Input,
210
+ {
211
+ name,
212
+ required,
213
+ pattern,
214
+ minLength,
215
+ maxLength,
216
+ min,
217
+ max,
218
+ ...rest,
219
+ error: formFieldError,
220
+ onBlur: handleBlur,
221
+ ref: setRefs
222
+ }
223
+ );
224
+ }
225
+ var FormFieldInput = react.forwardRef(
226
+ FormFieldInputInner
227
+ );
228
+
229
+ exports.Form = Form;
230
+ exports.FormFieldInput = FormFieldInput;
231
+ //# sourceMappingURL=index.js.map
232
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/validation.ts","../src/components/useFieldRegistry.ts","../src/components/Form.tsx","../src/components/FormFieldInput.tsx"],"names":["useRef","useState","useCallback","flushSync","useMemo","jsx","FormFieldProvider","forwardRef","ErrorSummary","jsxs","useFormFieldContext","useEffect","Input"],"mappings":"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","file":"index.js","sourcesContent":["import type { ValidationRules } from '@asnewyla/input';\r\n\r\nexport function validateValue(\r\n value: string,\r\n rules?: ValidationRules,\r\n): string | undefined {\r\n if (!rules) return undefined;\r\n\r\n if (rules.required && value.trim() === '') {\r\n return typeof rules.required === 'string'\r\n ? rules.required\r\n : 'This field is required';\r\n }\r\n if (rules.pattern && value !== '' && !new RegExp(rules.pattern).test(value)) {\r\n return 'Invalid format';\r\n }\r\n if (rules.minLength !== undefined && value.length < rules.minLength) {\r\n return `Must be at least ${rules.minLength} characters`;\r\n }\r\n if (rules.maxLength !== undefined && value.length > rules.maxLength) {\r\n return `Must be at most ${rules.maxLength} characters`;\r\n }\r\n if (\r\n rules.min !== undefined &&\r\n value !== '' &&\r\n Number(value) < Number(rules.min)\r\n ) {\r\n return `Must be at least ${rules.min}`;\r\n }\r\n if (\r\n rules.max !== undefined &&\r\n value !== '' &&\r\n Number(value) > Number(rules.max)\r\n ) {\r\n return `Must be at most ${rules.max}`;\r\n }\r\n if (rules.validate) {\r\n return rules.validate(value);\r\n }\r\n return undefined;\r\n}\r\n","import type { FieldRegistration } from '@asnewyla/input';\r\nimport { type RefObject, useCallback, useRef, useState } from 'react';\r\nimport { validateValue } from './validation';\r\n\r\nexport interface InvalidField {\r\n name: string;\r\n id: string;\r\n message: string;\r\n}\r\n\r\nexport interface FieldRegistry {\r\n errors: Record<string, string | undefined>;\r\n registerField: (name: string, registration: FieldRegistration) => void;\r\n unregisterField: (name: string) => void;\r\n validateField: (name: string) => string | undefined;\r\n fieldNames: () => string[];\r\n invalidFields: () => InvalidField[];\r\n}\r\n\r\nexport function useFieldRegistry(\r\n formRef: RefObject<HTMLFormElement>,\r\n): FieldRegistry {\r\n const fieldsRef = useRef<Map<string, FieldRegistration>>(new Map());\r\n const [errors, setErrors] = useState<Record<string, string | undefined>>({});\r\n\r\n const registerField = useCallback(\r\n (name: string, registration: FieldRegistration) => {\r\n fieldsRef.current.set(name, registration);\r\n },\r\n [],\r\n );\r\n\r\n const unregisterField = useCallback((name: string) => {\r\n fieldsRef.current.delete(name);\r\n setErrors((prev) => {\r\n if (!(name in prev)) return prev;\r\n const next = { ...prev };\r\n delete next[name];\r\n return next;\r\n });\r\n }, []);\r\n\r\n const getFieldValue = useCallback(\r\n (name: string): string => {\r\n if (!formRef.current) return '';\r\n return String(new FormData(formRef.current).get(name) ?? '');\r\n },\r\n [formRef],\r\n );\r\n\r\n const validateField = useCallback(\r\n (name: string) => {\r\n const field = fieldsRef.current.get(name);\r\n const error = validateValue(getFieldValue(name), field?.rules);\r\n setErrors((prev) => ({ ...prev, [name]: error }));\r\n return error;\r\n },\r\n [getFieldValue],\r\n );\r\n\r\n const fieldNames = useCallback(\r\n () => Array.from(fieldsRef.current.keys()),\r\n [],\r\n );\r\n\r\n const invalidFields = useCallback((): InvalidField[] => {\r\n const result: InvalidField[] = [];\r\n for (const [name, message] of Object.entries(errors)) {\r\n if (!message) continue;\r\n const id = fieldsRef.current.get(name)?.ref.current?.id;\r\n if (!id) continue;\r\n result.push({ name, id, message });\r\n }\r\n return result;\r\n }, [errors]);\r\n\r\n return {\r\n errors,\r\n registerField,\r\n unregisterField,\r\n validateField,\r\n fieldNames,\r\n invalidFields,\r\n };\r\n}\r\n","import { FormFieldProvider } from '@asnewyla/input';\r\nimport { forwardRef, useMemo, useRef, useState } from 'react';\r\nimport { flushSync } from 'react-dom';\r\nimport { type InvalidField, useFieldRegistry } from './useFieldRegistry';\r\n\r\nexport type FormProps = {\r\n children: React.ReactNode;\r\n onSubmit: (values: Record<string, string>) => void;\r\n};\r\n\r\nexport function Form({ children, onSubmit }: FormProps) {\r\n const formRef = useRef<HTMLFormElement>(null);\r\n const summaryRef = useRef<HTMLDivElement>(null);\r\n const {\r\n errors,\r\n registerField,\r\n unregisterField,\r\n validateField,\r\n fieldNames,\r\n invalidFields,\r\n } = useFieldRegistry(formRef);\r\n\r\n const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);\r\n\r\n function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\r\n event.preventDefault();\r\n\r\n let hasError = false;\r\n // flushSync commits the validation state synchronously, so the\r\n // summary is already in the DOM by the time we call .focus() below.\r\n flushSync(() => {\r\n for (const name of fieldNames()) {\r\n if (validateField(name)) hasError = true;\r\n }\r\n setHasAttemptedSubmit(true);\r\n });\r\n if (hasError) {\r\n summaryRef.current?.focus();\r\n return;\r\n }\r\n\r\n const values: Record<string, string> = {};\r\n for (const [key, value] of new FormData(event.currentTarget).entries()) {\r\n values[key] = String(value);\r\n }\r\n onSubmit(values);\r\n }\r\n\r\n const invalid: InvalidField[] = hasAttemptedSubmit ? invalidFields() : [];\r\n\r\n const contextValue = useMemo(\r\n () => ({ registerField, unregisterField, validateField, errors }),\r\n [registerField, unregisterField, validateField, errors],\r\n );\r\n\r\n return (\r\n <form ref={formRef} onSubmit={handleSubmit} noValidate>\r\n {invalid.length > 0 && (\r\n <ErrorSummary ref={summaryRef} invalidFields={invalid} />\r\n )}\r\n <FormFieldProvider value={contextValue}>{children}</FormFieldProvider>\r\n </form>\r\n );\r\n}\r\n\r\ntype ErrorSummaryProps = {\r\n invalidFields: InvalidField[];\r\n};\r\n\r\nconst ErrorSummary = forwardRef<HTMLDivElement, ErrorSummaryProps>(\r\n function ErrorSummary({ invalidFields }, ref) {\r\n return (\r\n <div\r\n className=\"xd-form-error-summary\"\r\n role=\"alert\"\r\n tabIndex={-1}\r\n ref={ref}\r\n >\r\n <p className=\"xd-form-error-summary-heading\">There is a problem</p>\r\n <ul>\r\n {invalidFields.map(({ name, id, message }) => (\r\n <li key={name}>\r\n <a\r\n href={`#${id}`}\r\n onClick={(event) => {\r\n event.preventDefault();\r\n document.getElementById(id)?.focus();\r\n }}\r\n >\r\n {message}\r\n </a>\r\n </li>\r\n ))}\r\n </ul>\r\n </div>\r\n );\r\n },\r\n);\r\n","import {\r\n Input,\r\n type InputProps,\r\n type ValidationRules,\r\n useFormFieldContext,\r\n} from '@asnewyla/input';\r\nimport { type ForwardedRef, forwardRef, useEffect, useRef } from 'react';\r\n\r\nexport type FormFieldInputProps = Omit<InputProps, 'name'> & {\r\n name: string;\r\n validate?: (value: string) => string | undefined;\r\n};\r\n\r\nfunction FormFieldInputInner(\r\n {\r\n name,\r\n required,\r\n pattern,\r\n minLength,\r\n maxLength,\r\n min,\r\n max,\r\n validate,\r\n error,\r\n onBlur,\r\n ...rest\r\n }: FormFieldInputProps,\r\n ref: ForwardedRef<HTMLInputElement>,\r\n) {\r\n // useFormFieldContext() returns null when there's no ancestor\r\n // FormFieldProvider — that's what makes FormFieldInput a no-op outside a\r\n // Form (still a perfectly usable Input, just without registration).\r\n const formField = useFormFieldContext();\r\n\r\n // A stable ref we always own, so registerField always gets a real node —\r\n // separate from `ref`, which is whatever (or nothing) the consumer passed.\r\n const inputRef = useRef<HTMLInputElement | null>(null);\r\n\r\n const rules: ValidationRules = {\r\n required,\r\n pattern,\r\n minLength,\r\n maxLength,\r\n min,\r\n max,\r\n validate,\r\n };\r\n\r\n // Deliberately depends on formField?.registerField/unregisterField, not\r\n // on `formField` itself — Form's context value changes identity whenever\r\n // its `errors` state updates (that's correct: this component needs to\r\n // re-render to read the new error). But if this effect depended on the\r\n // whole object, every validation would also re-trigger registration:\r\n // cleanup would call the *previous* render's unregisterField, deleting\r\n // the field and wiping the error validateField just set, in the same\r\n // tick it was written. Depending on the two functions directly (stable\r\n // via Form's useCallback) keeps registration decoupled from errors\r\n // changing.\r\n // Also deliberately not depending on `rules` — it's a fresh object every\r\n // render, so registration happens once per mount (or when formField/name\r\n // change), not on every rule-prop tweak. Rule props are effectively fixed\r\n // at mount for v0.x; no test or use case yet needs them reactive.\r\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally narrowed deps — see comment above\r\n useEffect(() => {\r\n if (!formField) return;\r\n formField.registerField(name, { ref: inputRef, rules });\r\n return () => formField.unregisterField(name);\r\n }, [formField?.registerField, formField?.unregisterField, name]);\r\n\r\n // Merges `inputRef` (ours, for registration) with `ref` (the consumer's\r\n // forwarded one, if any) so both end up pointing at the same DOM node —\r\n // neither one silently loses the element to the other.\r\n const setRefs = (node: HTMLInputElement | null) => {\r\n inputRef.current = node;\r\n if (typeof ref === 'function') {\r\n ref(node);\r\n } else if (ref) {\r\n ref.current = node;\r\n }\r\n };\r\n\r\n function handleBlur(event: React.FocusEvent<HTMLInputElement>) {\r\n if (formField) {\r\n formField.validateField(name);\r\n }\r\n onBlur?.(event);\r\n }\r\n\r\n const formContextError = formField ? formField.errors[name] : undefined;\r\n const formFieldError = error ?? formContextError;\r\n\r\n return (\r\n <Input\r\n name={name}\r\n required={required}\r\n pattern={pattern}\r\n minLength={minLength}\r\n maxLength={maxLength}\r\n min={min}\r\n max={max}\r\n {...rest}\r\n error={formFieldError}\r\n onBlur={handleBlur}\r\n ref={setRefs}\r\n />\r\n );\r\n}\r\n\r\nexport const FormFieldInput = forwardRef<HTMLInputElement, FormFieldInputProps>(\r\n FormFieldInputInner,\r\n);\r\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,229 @@
1
+ import { useFormFieldContext, Input, FormFieldProvider } from '@asnewyla/input';
2
+ import { forwardRef, useRef, useEffect, useState, useMemo, useCallback } from 'react';
3
+ import { flushSync } from 'react-dom';
4
+ import { jsxs, jsx } from 'react/jsx-runtime';
5
+
6
+ // src/components/Form.tsx
7
+
8
+ // src/components/validation.ts
9
+ function validateValue(value, rules) {
10
+ if (!rules) return void 0;
11
+ if (rules.required && value.trim() === "") {
12
+ return typeof rules.required === "string" ? rules.required : "This field is required";
13
+ }
14
+ if (rules.pattern && value !== "" && !new RegExp(rules.pattern).test(value)) {
15
+ return "Invalid format";
16
+ }
17
+ if (rules.minLength !== void 0 && value.length < rules.minLength) {
18
+ return `Must be at least ${rules.minLength} characters`;
19
+ }
20
+ if (rules.maxLength !== void 0 && value.length > rules.maxLength) {
21
+ return `Must be at most ${rules.maxLength} characters`;
22
+ }
23
+ if (rules.min !== void 0 && value !== "" && Number(value) < Number(rules.min)) {
24
+ return `Must be at least ${rules.min}`;
25
+ }
26
+ if (rules.max !== void 0 && value !== "" && Number(value) > Number(rules.max)) {
27
+ return `Must be at most ${rules.max}`;
28
+ }
29
+ if (rules.validate) {
30
+ return rules.validate(value);
31
+ }
32
+ return void 0;
33
+ }
34
+
35
+ // src/components/useFieldRegistry.ts
36
+ function useFieldRegistry(formRef) {
37
+ const fieldsRef = useRef(/* @__PURE__ */ new Map());
38
+ const [errors, setErrors] = useState({});
39
+ const registerField = useCallback(
40
+ (name, registration) => {
41
+ fieldsRef.current.set(name, registration);
42
+ },
43
+ []
44
+ );
45
+ const unregisterField = useCallback((name) => {
46
+ fieldsRef.current.delete(name);
47
+ setErrors((prev) => {
48
+ if (!(name in prev)) return prev;
49
+ const next = { ...prev };
50
+ delete next[name];
51
+ return next;
52
+ });
53
+ }, []);
54
+ const getFieldValue = useCallback(
55
+ (name) => {
56
+ if (!formRef.current) return "";
57
+ return String(new FormData(formRef.current).get(name) ?? "");
58
+ },
59
+ [formRef]
60
+ );
61
+ const validateField = useCallback(
62
+ (name) => {
63
+ const field = fieldsRef.current.get(name);
64
+ const error = validateValue(getFieldValue(name), field?.rules);
65
+ setErrors((prev) => ({ ...prev, [name]: error }));
66
+ return error;
67
+ },
68
+ [getFieldValue]
69
+ );
70
+ const fieldNames = useCallback(
71
+ () => Array.from(fieldsRef.current.keys()),
72
+ []
73
+ );
74
+ const invalidFields = useCallback(() => {
75
+ const result = [];
76
+ for (const [name, message] of Object.entries(errors)) {
77
+ if (!message) continue;
78
+ const id = fieldsRef.current.get(name)?.ref.current?.id;
79
+ if (!id) continue;
80
+ result.push({ name, id, message });
81
+ }
82
+ return result;
83
+ }, [errors]);
84
+ return {
85
+ errors,
86
+ registerField,
87
+ unregisterField,
88
+ validateField,
89
+ fieldNames,
90
+ invalidFields
91
+ };
92
+ }
93
+ function Form({ children, onSubmit }) {
94
+ const formRef = useRef(null);
95
+ const summaryRef = useRef(null);
96
+ const {
97
+ errors,
98
+ registerField,
99
+ unregisterField,
100
+ validateField,
101
+ fieldNames,
102
+ invalidFields
103
+ } = useFieldRegistry(formRef);
104
+ const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
105
+ function handleSubmit(event) {
106
+ event.preventDefault();
107
+ let hasError = false;
108
+ flushSync(() => {
109
+ for (const name of fieldNames()) {
110
+ if (validateField(name)) hasError = true;
111
+ }
112
+ setHasAttemptedSubmit(true);
113
+ });
114
+ if (hasError) {
115
+ summaryRef.current?.focus();
116
+ return;
117
+ }
118
+ const values = {};
119
+ for (const [key, value] of new FormData(event.currentTarget).entries()) {
120
+ values[key] = String(value);
121
+ }
122
+ onSubmit(values);
123
+ }
124
+ const invalid = hasAttemptedSubmit ? invalidFields() : [];
125
+ const contextValue = useMemo(
126
+ () => ({ registerField, unregisterField, validateField, errors }),
127
+ [registerField, unregisterField, validateField, errors]
128
+ );
129
+ return /* @__PURE__ */ jsxs("form", { ref: formRef, onSubmit: handleSubmit, noValidate: true, children: [
130
+ invalid.length > 0 && /* @__PURE__ */ jsx(ErrorSummary, { ref: summaryRef, invalidFields: invalid }),
131
+ /* @__PURE__ */ jsx(FormFieldProvider, { value: contextValue, children })
132
+ ] });
133
+ }
134
+ var ErrorSummary = forwardRef(
135
+ function ErrorSummary2({ invalidFields }, ref) {
136
+ return /* @__PURE__ */ jsxs(
137
+ "div",
138
+ {
139
+ className: "xd-form-error-summary",
140
+ role: "alert",
141
+ tabIndex: -1,
142
+ ref,
143
+ children: [
144
+ /* @__PURE__ */ jsx("p", { className: "xd-form-error-summary-heading", children: "There is a problem" }),
145
+ /* @__PURE__ */ jsx("ul", { children: invalidFields.map(({ name, id, message }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
146
+ "a",
147
+ {
148
+ href: `#${id}`,
149
+ onClick: (event) => {
150
+ event.preventDefault();
151
+ document.getElementById(id)?.focus();
152
+ },
153
+ children: message
154
+ }
155
+ ) }, name)) })
156
+ ]
157
+ }
158
+ );
159
+ }
160
+ );
161
+ function FormFieldInputInner({
162
+ name,
163
+ required,
164
+ pattern,
165
+ minLength,
166
+ maxLength,
167
+ min,
168
+ max,
169
+ validate,
170
+ error,
171
+ onBlur,
172
+ ...rest
173
+ }, ref) {
174
+ const formField = useFormFieldContext();
175
+ const inputRef = useRef(null);
176
+ const rules = {
177
+ required,
178
+ pattern,
179
+ minLength,
180
+ maxLength,
181
+ min,
182
+ max,
183
+ validate
184
+ };
185
+ useEffect(() => {
186
+ if (!formField) return;
187
+ formField.registerField(name, { ref: inputRef, rules });
188
+ return () => formField.unregisterField(name);
189
+ }, [formField?.registerField, formField?.unregisterField, name]);
190
+ const setRefs = (node) => {
191
+ inputRef.current = node;
192
+ if (typeof ref === "function") {
193
+ ref(node);
194
+ } else if (ref) {
195
+ ref.current = node;
196
+ }
197
+ };
198
+ function handleBlur(event) {
199
+ if (formField) {
200
+ formField.validateField(name);
201
+ }
202
+ onBlur?.(event);
203
+ }
204
+ const formContextError = formField ? formField.errors[name] : void 0;
205
+ const formFieldError = error ?? formContextError;
206
+ return /* @__PURE__ */ jsx(
207
+ Input,
208
+ {
209
+ name,
210
+ required,
211
+ pattern,
212
+ minLength,
213
+ maxLength,
214
+ min,
215
+ max,
216
+ ...rest,
217
+ error: formFieldError,
218
+ onBlur: handleBlur,
219
+ ref: setRefs
220
+ }
221
+ );
222
+ }
223
+ var FormFieldInput = forwardRef(
224
+ FormFieldInputInner
225
+ );
226
+
227
+ export { Form, FormFieldInput };
228
+ //# sourceMappingURL=index.mjs.map
229
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/validation.ts","../src/components/useFieldRegistry.ts","../src/components/Form.tsx","../src/components/FormFieldInput.tsx"],"names":["useRef","useState","ErrorSummary","jsx","forwardRef"],"mappings":"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","file":"index.mjs","sourcesContent":["import type { ValidationRules } from '@asnewyla/input';\r\n\r\nexport function validateValue(\r\n value: string,\r\n rules?: ValidationRules,\r\n): string | undefined {\r\n if (!rules) return undefined;\r\n\r\n if (rules.required && value.trim() === '') {\r\n return typeof rules.required === 'string'\r\n ? rules.required\r\n : 'This field is required';\r\n }\r\n if (rules.pattern && value !== '' && !new RegExp(rules.pattern).test(value)) {\r\n return 'Invalid format';\r\n }\r\n if (rules.minLength !== undefined && value.length < rules.minLength) {\r\n return `Must be at least ${rules.minLength} characters`;\r\n }\r\n if (rules.maxLength !== undefined && value.length > rules.maxLength) {\r\n return `Must be at most ${rules.maxLength} characters`;\r\n }\r\n if (\r\n rules.min !== undefined &&\r\n value !== '' &&\r\n Number(value) < Number(rules.min)\r\n ) {\r\n return `Must be at least ${rules.min}`;\r\n }\r\n if (\r\n rules.max !== undefined &&\r\n value !== '' &&\r\n Number(value) > Number(rules.max)\r\n ) {\r\n return `Must be at most ${rules.max}`;\r\n }\r\n if (rules.validate) {\r\n return rules.validate(value);\r\n }\r\n return undefined;\r\n}\r\n","import type { FieldRegistration } from '@asnewyla/input';\r\nimport { type RefObject, useCallback, useRef, useState } from 'react';\r\nimport { validateValue } from './validation';\r\n\r\nexport interface InvalidField {\r\n name: string;\r\n id: string;\r\n message: string;\r\n}\r\n\r\nexport interface FieldRegistry {\r\n errors: Record<string, string | undefined>;\r\n registerField: (name: string, registration: FieldRegistration) => void;\r\n unregisterField: (name: string) => void;\r\n validateField: (name: string) => string | undefined;\r\n fieldNames: () => string[];\r\n invalidFields: () => InvalidField[];\r\n}\r\n\r\nexport function useFieldRegistry(\r\n formRef: RefObject<HTMLFormElement>,\r\n): FieldRegistry {\r\n const fieldsRef = useRef<Map<string, FieldRegistration>>(new Map());\r\n const [errors, setErrors] = useState<Record<string, string | undefined>>({});\r\n\r\n const registerField = useCallback(\r\n (name: string, registration: FieldRegistration) => {\r\n fieldsRef.current.set(name, registration);\r\n },\r\n [],\r\n );\r\n\r\n const unregisterField = useCallback((name: string) => {\r\n fieldsRef.current.delete(name);\r\n setErrors((prev) => {\r\n if (!(name in prev)) return prev;\r\n const next = { ...prev };\r\n delete next[name];\r\n return next;\r\n });\r\n }, []);\r\n\r\n const getFieldValue = useCallback(\r\n (name: string): string => {\r\n if (!formRef.current) return '';\r\n return String(new FormData(formRef.current).get(name) ?? '');\r\n },\r\n [formRef],\r\n );\r\n\r\n const validateField = useCallback(\r\n (name: string) => {\r\n const field = fieldsRef.current.get(name);\r\n const error = validateValue(getFieldValue(name), field?.rules);\r\n setErrors((prev) => ({ ...prev, [name]: error }));\r\n return error;\r\n },\r\n [getFieldValue],\r\n );\r\n\r\n const fieldNames = useCallback(\r\n () => Array.from(fieldsRef.current.keys()),\r\n [],\r\n );\r\n\r\n const invalidFields = useCallback((): InvalidField[] => {\r\n const result: InvalidField[] = [];\r\n for (const [name, message] of Object.entries(errors)) {\r\n if (!message) continue;\r\n const id = fieldsRef.current.get(name)?.ref.current?.id;\r\n if (!id) continue;\r\n result.push({ name, id, message });\r\n }\r\n return result;\r\n }, [errors]);\r\n\r\n return {\r\n errors,\r\n registerField,\r\n unregisterField,\r\n validateField,\r\n fieldNames,\r\n invalidFields,\r\n };\r\n}\r\n","import { FormFieldProvider } from '@asnewyla/input';\r\nimport { forwardRef, useMemo, useRef, useState } from 'react';\r\nimport { flushSync } from 'react-dom';\r\nimport { type InvalidField, useFieldRegistry } from './useFieldRegistry';\r\n\r\nexport type FormProps = {\r\n children: React.ReactNode;\r\n onSubmit: (values: Record<string, string>) => void;\r\n};\r\n\r\nexport function Form({ children, onSubmit }: FormProps) {\r\n const formRef = useRef<HTMLFormElement>(null);\r\n const summaryRef = useRef<HTMLDivElement>(null);\r\n const {\r\n errors,\r\n registerField,\r\n unregisterField,\r\n validateField,\r\n fieldNames,\r\n invalidFields,\r\n } = useFieldRegistry(formRef);\r\n\r\n const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);\r\n\r\n function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\r\n event.preventDefault();\r\n\r\n let hasError = false;\r\n // flushSync commits the validation state synchronously, so the\r\n // summary is already in the DOM by the time we call .focus() below.\r\n flushSync(() => {\r\n for (const name of fieldNames()) {\r\n if (validateField(name)) hasError = true;\r\n }\r\n setHasAttemptedSubmit(true);\r\n });\r\n if (hasError) {\r\n summaryRef.current?.focus();\r\n return;\r\n }\r\n\r\n const values: Record<string, string> = {};\r\n for (const [key, value] of new FormData(event.currentTarget).entries()) {\r\n values[key] = String(value);\r\n }\r\n onSubmit(values);\r\n }\r\n\r\n const invalid: InvalidField[] = hasAttemptedSubmit ? invalidFields() : [];\r\n\r\n const contextValue = useMemo(\r\n () => ({ registerField, unregisterField, validateField, errors }),\r\n [registerField, unregisterField, validateField, errors],\r\n );\r\n\r\n return (\r\n <form ref={formRef} onSubmit={handleSubmit} noValidate>\r\n {invalid.length > 0 && (\r\n <ErrorSummary ref={summaryRef} invalidFields={invalid} />\r\n )}\r\n <FormFieldProvider value={contextValue}>{children}</FormFieldProvider>\r\n </form>\r\n );\r\n}\r\n\r\ntype ErrorSummaryProps = {\r\n invalidFields: InvalidField[];\r\n};\r\n\r\nconst ErrorSummary = forwardRef<HTMLDivElement, ErrorSummaryProps>(\r\n function ErrorSummary({ invalidFields }, ref) {\r\n return (\r\n <div\r\n className=\"xd-form-error-summary\"\r\n role=\"alert\"\r\n tabIndex={-1}\r\n ref={ref}\r\n >\r\n <p className=\"xd-form-error-summary-heading\">There is a problem</p>\r\n <ul>\r\n {invalidFields.map(({ name, id, message }) => (\r\n <li key={name}>\r\n <a\r\n href={`#${id}`}\r\n onClick={(event) => {\r\n event.preventDefault();\r\n document.getElementById(id)?.focus();\r\n }}\r\n >\r\n {message}\r\n </a>\r\n </li>\r\n ))}\r\n </ul>\r\n </div>\r\n );\r\n },\r\n);\r\n","import {\r\n Input,\r\n type InputProps,\r\n type ValidationRules,\r\n useFormFieldContext,\r\n} from '@asnewyla/input';\r\nimport { type ForwardedRef, forwardRef, useEffect, useRef } from 'react';\r\n\r\nexport type FormFieldInputProps = Omit<InputProps, 'name'> & {\r\n name: string;\r\n validate?: (value: string) => string | undefined;\r\n};\r\n\r\nfunction FormFieldInputInner(\r\n {\r\n name,\r\n required,\r\n pattern,\r\n minLength,\r\n maxLength,\r\n min,\r\n max,\r\n validate,\r\n error,\r\n onBlur,\r\n ...rest\r\n }: FormFieldInputProps,\r\n ref: ForwardedRef<HTMLInputElement>,\r\n) {\r\n // useFormFieldContext() returns null when there's no ancestor\r\n // FormFieldProvider — that's what makes FormFieldInput a no-op outside a\r\n // Form (still a perfectly usable Input, just without registration).\r\n const formField = useFormFieldContext();\r\n\r\n // A stable ref we always own, so registerField always gets a real node —\r\n // separate from `ref`, which is whatever (or nothing) the consumer passed.\r\n const inputRef = useRef<HTMLInputElement | null>(null);\r\n\r\n const rules: ValidationRules = {\r\n required,\r\n pattern,\r\n minLength,\r\n maxLength,\r\n min,\r\n max,\r\n validate,\r\n };\r\n\r\n // Deliberately depends on formField?.registerField/unregisterField, not\r\n // on `formField` itself — Form's context value changes identity whenever\r\n // its `errors` state updates (that's correct: this component needs to\r\n // re-render to read the new error). But if this effect depended on the\r\n // whole object, every validation would also re-trigger registration:\r\n // cleanup would call the *previous* render's unregisterField, deleting\r\n // the field and wiping the error validateField just set, in the same\r\n // tick it was written. Depending on the two functions directly (stable\r\n // via Form's useCallback) keeps registration decoupled from errors\r\n // changing.\r\n // Also deliberately not depending on `rules` — it's a fresh object every\r\n // render, so registration happens once per mount (or when formField/name\r\n // change), not on every rule-prop tweak. Rule props are effectively fixed\r\n // at mount for v0.x; no test or use case yet needs them reactive.\r\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally narrowed deps — see comment above\r\n useEffect(() => {\r\n if (!formField) return;\r\n formField.registerField(name, { ref: inputRef, rules });\r\n return () => formField.unregisterField(name);\r\n }, [formField?.registerField, formField?.unregisterField, name]);\r\n\r\n // Merges `inputRef` (ours, for registration) with `ref` (the consumer's\r\n // forwarded one, if any) so both end up pointing at the same DOM node —\r\n // neither one silently loses the element to the other.\r\n const setRefs = (node: HTMLInputElement | null) => {\r\n inputRef.current = node;\r\n if (typeof ref === 'function') {\r\n ref(node);\r\n } else if (ref) {\r\n ref.current = node;\r\n }\r\n };\r\n\r\n function handleBlur(event: React.FocusEvent<HTMLInputElement>) {\r\n if (formField) {\r\n formField.validateField(name);\r\n }\r\n onBlur?.(event);\r\n }\r\n\r\n const formContextError = formField ? formField.errors[name] : undefined;\r\n const formFieldError = error ?? formContextError;\r\n\r\n return (\r\n <Input\r\n name={name}\r\n required={required}\r\n pattern={pattern}\r\n minLength={minLength}\r\n maxLength={maxLength}\r\n min={min}\r\n max={max}\r\n {...rest}\r\n error={formFieldError}\r\n onBlur={handleBlur}\r\n ref={setRefs}\r\n />\r\n );\r\n}\r\n\r\nexport const FormFieldInput = forwardRef<HTMLInputElement, FormFieldInputProps>(\r\n FormFieldInputInner,\r\n);\r\n"]}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@asnewyla/form",
3
+ "version": "0.2.0",
4
+ "description": "Form wrapper with real validation and an accessible error summary",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/xavierDelaFuente/xd-components.git",
9
+ "directory": "packages/form"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "main": "dist/index.js",
15
+ "module": "dist/index.mjs",
16
+ "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "import": "./dist/index.mjs",
20
+ "require": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "keywords": [
28
+ "react",
29
+ "form",
30
+ "validation",
31
+ "accessible"
32
+ ],
33
+ "peerDependencies": {
34
+ "react": "^18.0.0 || ^19.0.0",
35
+ "react-dom": "^18.0.0 || ^19.0.0"
36
+ },
37
+ "dependencies": {
38
+ "@asnewyla/input": "0.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@testing-library/jest-dom": "^6.4.0",
42
+ "@testing-library/react": "^16.0.0",
43
+ "@testing-library/user-event": "^14.5.0",
44
+ "@vitest/coverage-v8": "^2.0.0",
45
+ "jsdom": "^24.0.0",
46
+ "react": "^18.3.0",
47
+ "react-dom": "^18.3.0",
48
+ "tsup": "^8.1.0",
49
+ "typescript": "^5.5.0",
50
+ "vitest": "^2.0.0"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "test:coverage": "vitest run --coverage",
57
+ "type-check": "tsc --noEmit"
58
+ }
59
+ }