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