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