@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.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(LevelEntries, {
338
- parts,
339
- form,
340
- fields,
341
- layout,
342
- path: void 0
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
- const Form = (props) => /* @__PURE__ */ jsx(ConfiguredForm, {
450
- ...props,
451
- binding,
452
- config
453
- });
454
- return Form;
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
- ...extension.widgets
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 `fieldParts` gives the control of a field, for code that has no
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
- const wasEditedAndLeft = isBlurred && isDirty;
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
- const id = fieldId(field.form.formId, field.name);
657
- const descriptionId = `${id}${DESCRIPTION_SUFFIX}`;
658
- const errorId = `${id}${ERROR_SUFFIX}`;
659
- const isErrorDue = isErrorDueFor(field);
660
- const errors = errorsOf(field);
661
- const isErrorNamed = isErrorDue && errors.length > 0;
662
- const hasDescription = options.description !== void 0 && options.description !== "";
663
- return {
664
- control: {
665
- id,
666
- name: field.name,
667
- onBlur: field.handleBlur,
668
- "aria-invalid": isErrorDue || void 0,
669
- "aria-describedby": idrefsOf([hasDescription ? descriptionId : void 0, isErrorNamed ? errorId : void 0]),
670
- "aria-errormessage": isErrorNamed ? errorId : void 0
671
- },
672
- descriptionId,
673
- errorId,
674
- isErrorDue,
675
- errors
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