@formdef/core 0.1.2 → 0.3.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/dist/index.cjs +203 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -6
- package/dist/index.d.ts +65 -6
- package/dist/index.js +204 -45
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,11 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let _tanstack_react_form = require("@tanstack/react-form");
|
|
3
3
|
let react = require("react");
|
|
4
4
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
5
|
+
// src/widget/declares-empty-value.ts
|
|
6
|
+
/** Whether `widget` declares its empty value, the value it holds while nothing is entered. */
|
|
7
|
+
function declaresEmptyValue(widget) {
|
|
8
|
+
return Object.hasOwn(widget, "emptyValue");
|
|
9
|
+
}
|
|
5
10
|
// src/render/array-entry.tsx
|
|
6
11
|
const removeLabelOf = (removeLabel, item, index) => typeof removeLabel === "function" ? removeLabel(item, index) : String(removeLabel);
|
|
7
12
|
const itemControlsOf = (options, value) => {
|
|
@@ -103,6 +108,17 @@ const Derived = ({ form, entry, children }) => {
|
|
|
103
108
|
}
|
|
104
109
|
});
|
|
105
110
|
};
|
|
111
|
+
// src/render/path-pattern.ts
|
|
112
|
+
const ITEM_INDEX = /\[\d+\]/g;
|
|
113
|
+
const ANY_ITEM = "[]";
|
|
114
|
+
/** The path with every array index left open: `contacts[2].name` becomes `contacts[].name`. */
|
|
115
|
+
function pathPattern(path) {
|
|
116
|
+
return path.replaceAll(ITEM_INDEX, ANY_ITEM);
|
|
117
|
+
}
|
|
118
|
+
// src/render/required-paths.ts
|
|
119
|
+
const NO_REQUIRED_PATHS = /* @__PURE__ */ new Set();
|
|
120
|
+
/** The path patterns of the required fields of the level being rendered. */
|
|
121
|
+
const RequiredPaths = (0, react.createContext)(NO_REQUIRED_PATHS);
|
|
106
122
|
const OWN_ENTRY_KEY_SET = /* @__PURE__ */ new Set([
|
|
107
123
|
"widget",
|
|
108
124
|
"props",
|
|
@@ -161,13 +177,16 @@ const Item = ({ parts, form, entry, path, index, extra }) => {
|
|
|
161
177
|
};
|
|
162
178
|
const FieldContent = ({ parts, form, entry, path, props, extra }) => {
|
|
163
179
|
const { own, fieldOptions } = entry;
|
|
180
|
+
const requiredPaths = (0, react.use)(RequiredPaths);
|
|
164
181
|
if (typeof own.widget === "string") {
|
|
165
182
|
const Widget = byName(parts.widgets, own.widget);
|
|
166
183
|
if (Widget === void 0) throw new Error(`formdef: field "${path}" names the unregistered widget "${own.widget}".`);
|
|
184
|
+
const found = declaresEmptyValue(Widget) ? { required: requiredPaths.has(pathPattern(path)) } : void 0;
|
|
167
185
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(form.AppField, {
|
|
168
186
|
...fieldOptions,
|
|
169
187
|
name: path,
|
|
170
188
|
children: (field) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Widget, {
|
|
189
|
+
...found,
|
|
171
190
|
...props,
|
|
172
191
|
...extra,
|
|
173
192
|
field
|
|
@@ -333,15 +352,75 @@ const LevelEntries = ({ layout, ...level }) => {
|
|
|
333
352
|
* into sections and rows and places form components and inline entries. A leaf renders as its
|
|
334
353
|
* widget, an object as its own level, a custom entry through its `render`, an array through the
|
|
335
354
|
* registered `structure.arrayFrame` or the bare default. An object entry with TanStack options of
|
|
336
|
-
* its own also mounts a field at its path, so its validators run.
|
|
355
|
+
* its own also mounts a field at its path, so its validators run. A `Fields` nested in a form finds
|
|
356
|
+
* no required fields of its own, since its paths may be relative to a field group.
|
|
337
357
|
*/
|
|
338
|
-
const Fields = ({ parts, form, fields, layout }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
358
|
+
const Fields = ({ parts, form, fields, layout, requiredPaths }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequiredPaths, {
|
|
359
|
+
value: requiredPaths ?? NO_REQUIRED_PATHS,
|
|
360
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LevelEntries, {
|
|
361
|
+
parts,
|
|
362
|
+
form,
|
|
363
|
+
fields,
|
|
364
|
+
layout,
|
|
365
|
+
path: void 0
|
|
366
|
+
})
|
|
344
367
|
});
|
|
368
|
+
// src/form/find-required-paths.ts
|
|
369
|
+
const NONE = { paths: /* @__PURE__ */ new Set() };
|
|
370
|
+
const ASYNC_SCHEMA = "formdef: the schema validates asynchronously, so no field is found to be required; pass required in props.";
|
|
371
|
+
const UNSETTLED_SCHEMA = "formdef: the schema did not validate empty values synchronously, so no field is found to be required; pass required in props.";
|
|
372
|
+
const THROWING_SCHEMA = "formdef: the schema threw on empty values, so no field is found to be required";
|
|
373
|
+
const FIRST_ITEM = 0;
|
|
374
|
+
const isStandardSchema = (schema) => typeof schema === "object" && schema !== null && "~standard" in schema;
|
|
375
|
+
const emptyLevel = (fields, path, empty) => Object.fromEntries(Object.entries(fields).map(([key, entry]) => [key, emptyEntry(entry, childPath(path, key), empty)]));
|
|
376
|
+
function emptyEntry(entry, path, empty) {
|
|
377
|
+
const { own } = splitEntry(entry);
|
|
378
|
+
if (typeof own.widget === "string") {
|
|
379
|
+
const widget = byName(empty.widgets, own.widget);
|
|
380
|
+
if (widget === void 0 || !declaresEmptyValue(widget)) return;
|
|
381
|
+
empty.declared.add(pathPattern(path));
|
|
382
|
+
return widget.emptyValue;
|
|
383
|
+
}
|
|
384
|
+
if (typeof own.item === "object" && own.item !== null) return [emptyEntry(own.item, childPath(path, FIRST_ITEM), empty)];
|
|
385
|
+
if (typeof own.fields === "object" && own.fields !== null) return emptyLevel(own.fields, path, empty);
|
|
386
|
+
}
|
|
387
|
+
const issuePath = (issue) => pathPattern((issue.path ?? []).reduce((parent, segment) => {
|
|
388
|
+
const key = typeof segment === "object" ? segment.key : segment;
|
|
389
|
+
return childPath(parent, typeof key === "number" ? key : String(key));
|
|
390
|
+
}, void 0) ?? "");
|
|
391
|
+
const parentOf = (path) => path.slice(0, Math.max(path.lastIndexOf("."), path.lastIndexOf("[")));
|
|
392
|
+
const leafOf = (path, leaves) => {
|
|
393
|
+
for (let candidate = path; candidate !== ""; candidate = parentOf(candidate)) if (leaves.has(candidate)) return candidate;
|
|
394
|
+
};
|
|
395
|
+
const ignoreRejection = () => void 0;
|
|
396
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
397
|
+
/**
|
|
398
|
+
* Finds the required fields of a config: validates `schema` once, synchronously, with every leaf
|
|
399
|
+
* holding its widget's empty value and every array one such item. A leaf whose widget declares an
|
|
400
|
+
* empty value and whose path receives an issue is required. A schema the config validates
|
|
401
|
+
* asynchronously is not run, so its asynchronous checks send nothing.
|
|
402
|
+
*/
|
|
403
|
+
function findRequiredPaths({ schema, validatesAsync, fields, widgets }) {
|
|
404
|
+
if (!isStandardSchema(schema)) return NONE;
|
|
405
|
+
if (validatesAsync) return { problem: ASYNC_SCHEMA };
|
|
406
|
+
const empty = {
|
|
407
|
+
widgets,
|
|
408
|
+
declared: /* @__PURE__ */ new Set()
|
|
409
|
+
};
|
|
410
|
+
const values = emptyLevel(fields, void 0, empty);
|
|
411
|
+
let result;
|
|
412
|
+
try {
|
|
413
|
+
result = schema["~standard"].validate(values);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
return { problem: `${THROWING_SCHEMA}: ${errorText(error)}` };
|
|
416
|
+
}
|
|
417
|
+
if (result instanceof Promise) {
|
|
418
|
+
result.then(void 0, ignoreRejection);
|
|
419
|
+
return { problem: UNSETTLED_SCHEMA };
|
|
420
|
+
}
|
|
421
|
+
const leaves = (result.issues ?? []).map((issue) => leafOf(issuePath(issue), empty.declared));
|
|
422
|
+
return { paths: new Set(leaves.filter((leaf) => leaf !== void 0)) };
|
|
423
|
+
}
|
|
345
424
|
// src/form/blur-then-change.ts
|
|
346
425
|
const isSchemaDue = ({ event, form }) => {
|
|
347
426
|
if (event.type === "blur" || event.type === "submit") return true;
|
|
@@ -416,7 +495,7 @@ function formOptionsFor({ config, options }) {
|
|
|
416
495
|
}
|
|
417
496
|
// src/form/create-form.tsx
|
|
418
497
|
const MISSING_DEFAULT_VALUES = "formdef: the form has no default values; set defaultValues in its config or in its options.";
|
|
419
|
-
const ConfiguredForm = ({ binding, config, options, ...formProps }) => {
|
|
498
|
+
const ConfiguredForm = ({ binding, config, requiredPaths, options, ...formProps }) => {
|
|
420
499
|
const formOptions = formOptionsFor({
|
|
421
500
|
config,
|
|
422
501
|
options
|
|
@@ -428,7 +507,8 @@ const ConfiguredForm = ({ binding, config, options, ...formProps }) => {
|
|
|
428
507
|
parts: binding.parts,
|
|
429
508
|
form,
|
|
430
509
|
fields: config.fields,
|
|
431
|
-
layout: config.layout
|
|
510
|
+
layout: config.layout,
|
|
511
|
+
requiredPaths
|
|
432
512
|
});
|
|
433
513
|
const handleSubmit = (event) => {
|
|
434
514
|
event.preventDefault();
|
|
@@ -442,17 +522,36 @@ const ConfiguredForm = ({ binding, config, options, ...formProps }) => {
|
|
|
442
522
|
children: Body === void 0 ? content : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Body, { children: content })
|
|
443
523
|
}) });
|
|
444
524
|
};
|
|
525
|
+
const isAsyncValidation = (validation) => typeof validation === "object" && validation !== null && "async" in validation ? validation.async === true : false;
|
|
526
|
+
const requiredPathsOf = (binding, config) => {
|
|
527
|
+
const result = findRequiredPaths({
|
|
528
|
+
schema: config.schema,
|
|
529
|
+
validatesAsync: isAsyncValidation(config.validation),
|
|
530
|
+
fields: config.fields ?? {},
|
|
531
|
+
widgets: binding.parts.widgets
|
|
532
|
+
});
|
|
533
|
+
if ("problem" in result) {
|
|
534
|
+
warnOnce(config, result.problem);
|
|
535
|
+
return /* @__PURE__ */ new Set();
|
|
536
|
+
}
|
|
537
|
+
return result.paths;
|
|
538
|
+
};
|
|
445
539
|
/**
|
|
446
540
|
* Turns a form config into a form component bound to `binding`: TanStack's form with the merged
|
|
447
541
|
* options, a `<form>` element that submits through it, the registered body and the config's fields.
|
|
542
|
+
* When it first renders, it finds the config's required fields once.
|
|
448
543
|
*/
|
|
449
544
|
function createForm(binding, config) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
binding,
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
545
|
+
let requiredPaths;
|
|
546
|
+
return (props) => {
|
|
547
|
+
requiredPaths ??= requiredPathsOf(binding, config);
|
|
548
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfiguredForm, {
|
|
549
|
+
...props,
|
|
550
|
+
binding,
|
|
551
|
+
config,
|
|
552
|
+
requiredPaths
|
|
553
|
+
});
|
|
554
|
+
};
|
|
456
555
|
}
|
|
457
556
|
// src/registry/create-registry.ts
|
|
458
557
|
function buildRegistry(parts) {
|
|
@@ -463,13 +562,14 @@ function buildRegistry(parts) {
|
|
|
463
562
|
formComponents: parts.formComponents
|
|
464
563
|
});
|
|
465
564
|
function extendRegistry(extension) {
|
|
565
|
+
const addedWidgets = extension.widgets ?? {};
|
|
466
566
|
const addedStructure = extension.structure ?? {};
|
|
467
567
|
return buildRegistry({
|
|
468
568
|
fieldContext: parts.fieldContext,
|
|
469
569
|
formContext: parts.formContext,
|
|
470
570
|
widgets: {
|
|
471
571
|
...parts.widgets,
|
|
472
|
-
...
|
|
572
|
+
...addedWidgets
|
|
473
573
|
},
|
|
474
574
|
formComponents: {
|
|
475
575
|
...parts.formComponents,
|
|
@@ -513,7 +613,8 @@ function buildRegistry(parts) {
|
|
|
513
613
|
* @param config - The contexts and what the registry holds.
|
|
514
614
|
* @param config.fieldContext - The field context from `createFormHookContexts()`.
|
|
515
615
|
* @param config.formContext - The form context from `createFormHookContexts()`.
|
|
516
|
-
* @param config.widgets - The widgets by name.
|
|
616
|
+
* @param config.widgets - The widgets by name. A widget's static `emptyValue`, the value it holds
|
|
617
|
+
* while nothing is entered, must be of the value type it serves.
|
|
517
618
|
* @param config.formComponents - The form components by name.
|
|
518
619
|
* @param config.structure - The components that render the structure slots.
|
|
519
620
|
* @defaultValue `config.formComponents` and `config.structure`: none registered.
|
|
@@ -582,7 +683,7 @@ function errorsOf(field) {
|
|
|
582
683
|
const PATH_SEPARATORS = /[.[\]]+/g;
|
|
583
684
|
const SELECTOR_SAFE_SEPARATOR = "__";
|
|
584
685
|
/**
|
|
585
|
-
* Builds the DOM id that `
|
|
686
|
+
* Builds the DOM id that `useFieldParts` gives the control of a field, for code that has no
|
|
586
687
|
* field at hand — an error summary above the form that links to each invalid field, for example.
|
|
587
688
|
*
|
|
588
689
|
* The id is the form's id, a hyphen, and the field's path with every run of dots and brackets
|
|
@@ -609,17 +710,42 @@ function fieldId(formId, path) {
|
|
|
609
710
|
const DESCRIPTION_SUFFIX = "-description";
|
|
610
711
|
const ERROR_SUFFIX = "-error";
|
|
611
712
|
const IDREF_SEPARATOR = " ";
|
|
612
|
-
function isErrorDueFor(field) {
|
|
713
|
+
function isErrorDueFor(field, wasSubmitted) {
|
|
613
714
|
const { isBlurred, isDirty, isValid } = field.state.meta;
|
|
614
|
-
|
|
615
|
-
const wasSubmitted = field.form.state.submissionAttempts > 0;
|
|
616
|
-
return !isValid && (wasEditedAndLeft || wasSubmitted);
|
|
715
|
+
return !isValid && (isBlurred && isDirty || wasSubmitted);
|
|
617
716
|
}
|
|
618
717
|
function idrefsOf(ids) {
|
|
619
718
|
const present = ids.filter((id) => id !== void 0);
|
|
620
719
|
return present.length > 0 ? present.join(IDREF_SEPARATOR) : void 0;
|
|
621
720
|
}
|
|
622
721
|
/**
|
|
722
|
+
* The parts of `field` with the submit state passed in, so the result depends on nothing the
|
|
723
|
+
* caller cannot see.
|
|
724
|
+
*/
|
|
725
|
+
function fieldPartsFor(field, options) {
|
|
726
|
+
const id = fieldId(field.form.formId, field.name);
|
|
727
|
+
const descriptionId = `${id}${DESCRIPTION_SUFFIX}`;
|
|
728
|
+
const errorId = `${id}${ERROR_SUFFIX}`;
|
|
729
|
+
const isErrorDue = isErrorDueFor(field, options.wasSubmitted);
|
|
730
|
+
const errors = errorsOf(field);
|
|
731
|
+
const isErrorNamed = isErrorDue && errors.length > 0;
|
|
732
|
+
const hasDescription = options.description !== void 0 && options.description !== "";
|
|
733
|
+
return {
|
|
734
|
+
control: {
|
|
735
|
+
id,
|
|
736
|
+
name: field.name,
|
|
737
|
+
onBlur: field.handleBlur,
|
|
738
|
+
"aria-invalid": isErrorDue || void 0,
|
|
739
|
+
"aria-describedby": idrefsOf([hasDescription ? descriptionId : void 0, isErrorNamed ? errorId : void 0]),
|
|
740
|
+
"aria-errormessage": isErrorNamed ? errorId : void 0
|
|
741
|
+
},
|
|
742
|
+
descriptionId,
|
|
743
|
+
errorId,
|
|
744
|
+
isErrorDue,
|
|
745
|
+
errors
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
623
749
|
* Wires a widget's control to its field: its id and name, the blur report, and the aria
|
|
624
750
|
* attributes that tie it to its description and its errors.
|
|
625
751
|
*
|
|
@@ -628,6 +754,11 @@ function idrefsOf(ids) {
|
|
|
628
754
|
* points at the error element through `aria-describedby` and `aria-errormessage`. An id appears in
|
|
629
755
|
* them only when its element is rendered.
|
|
630
756
|
*
|
|
757
|
+
* `fieldParts` reads whether the form was submitted without subscribing to it, so a widget whose
|
|
758
|
+
* field state does not change on a submit keeps its error hidden until it renders again.
|
|
759
|
+
*
|
|
760
|
+
* @deprecated Use {@link useFieldParts}, which subscribes to the submit and renders the widget
|
|
761
|
+
* again.
|
|
631
762
|
* @typeParam TValue - The value type of the field.
|
|
632
763
|
* @param field - The field the widget renders.
|
|
633
764
|
* @param options - What the widget renders besides the control.
|
|
@@ -654,31 +785,60 @@ function idrefsOf(ids) {
|
|
|
654
785
|
* ```
|
|
655
786
|
*/
|
|
656
787
|
function fieldParts(field, options = {}) {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
788
|
+
return fieldPartsFor(field, {
|
|
789
|
+
...options,
|
|
790
|
+
wasSubmitted: field.form.state.submissionAttempts > 0
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
// src/widget/use-field-parts.ts
|
|
794
|
+
/**
|
|
795
|
+
* Wires a widget's control to its field: its id and name, the blur report, and the aria
|
|
796
|
+
* attributes that tie it to its description and its errors.
|
|
797
|
+
*
|
|
798
|
+
* An error is due once the field is invalid and was edited and left, or once the form was
|
|
799
|
+
* submitted. While one is due, `aria-invalid` is `true`; while one with text is due, the control
|
|
800
|
+
* points at the error element through `aria-describedby` and `aria-errormessage`. An id appears in
|
|
801
|
+
* them only when its element is rendered.
|
|
802
|
+
*
|
|
803
|
+
* The hook subscribes to whether the form was submitted, so a submit renders the widget again even
|
|
804
|
+
* when its field state does not change, as for a field that was left before without an edit.
|
|
805
|
+
*
|
|
806
|
+
* @typeParam TValue - The value type of the field.
|
|
807
|
+
* @param field - The field the widget renders.
|
|
808
|
+
* @param options - What the widget renders besides the control.
|
|
809
|
+
* @param options.description - The description the widget renders below its label, if any. Only
|
|
810
|
+
* whether it is a non-empty string is read.
|
|
811
|
+
* @defaultValue `options.description`: `undefined`, no description is rendered.
|
|
812
|
+
* @returns The props for the control, the ids for the description and error elements, whether an
|
|
813
|
+
* error is due, and the error texts.
|
|
814
|
+
*
|
|
815
|
+
* @example
|
|
816
|
+
* ```tsx
|
|
817
|
+
* const TextField = ({ field, label, description }: FieldOf<string> & { label: string; description?: string }) => {
|
|
818
|
+
* const { control, descriptionId, errorId, isErrorDue, errors } = useFieldParts(field, { description })
|
|
819
|
+
*
|
|
820
|
+
* return (
|
|
821
|
+
* <div>
|
|
822
|
+
* <label htmlFor={control.id}>{label}</label>
|
|
823
|
+
* {description && <p id={descriptionId}>{description}</p>}
|
|
824
|
+
* <input {...control} value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} />
|
|
825
|
+
* {isErrorDue && errors.length > 0 && <p id={errorId}>{errors.join(' ')}</p>}
|
|
826
|
+
* </div>
|
|
827
|
+
* )
|
|
828
|
+
* }
|
|
829
|
+
* ```
|
|
830
|
+
*/
|
|
831
|
+
function useFieldParts(field, options = {}) {
|
|
832
|
+
const wasSubmitted = (0, _tanstack_react_form.useSelector)(field.form.store, (state) => state.submissionAttempts > 0);
|
|
833
|
+
return fieldPartsFor(field, {
|
|
834
|
+
...options,
|
|
835
|
+
wasSubmitted
|
|
836
|
+
});
|
|
678
837
|
}
|
|
679
838
|
exports.createRegistry = createRegistry;
|
|
680
839
|
exports.errorsOf = errorsOf;
|
|
681
840
|
exports.fieldId = fieldId;
|
|
682
841
|
exports.fieldParts = fieldParts;
|
|
842
|
+
exports.useFieldParts = useFieldParts;
|
|
683
843
|
|
|
684
844
|
//# sourceMappingURL=index.cjs.map
|