@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 +21 -0
- package/README.md +95 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +232 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +229 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|