({
return Unknown field type: {type}
;
}
+ // Spread rather than re-listing each prop: the set is fixed by core's
+ // FIELD_RENDERER_PROP_KEYS contract, and a hand-maintained list here is
+ // exactly how `placeholder`, `min`, `max`, `step`, `accept` and `multiple`
+ // came to be silently dropped on their way to the renderer.
return React.createElement(Renderer, {
...extraProps,
- value,
+ ...(rendererProps as FieldRendererProps),
onValueChange: onChange,
onBlur,
- label,
- options,
- className,
- description,
- disabled,
- readOnly,
- required,
- touched,
- dirty,
- error,
- id,
- ariaInvalid,
- ariaDescribedBy,
- ariaRequired,
});
};
diff --git a/packages/react/src/components/FieldGroupInput.tsx b/packages/react/src/components/FieldGroupInput.tsx
index aa2dc58..c219efd 100644
--- a/packages/react/src/components/FieldGroupInput.tsx
+++ b/packages/react/src/components/FieldGroupInput.tsx
@@ -3,15 +3,22 @@ import {
canRemoveGroupItem,
createGroupItem,
FieldDescription,
+ indexGroupPathMap,
Properties,
} from '@dynamic-field-kit/core';
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import MultiFieldInput from './MultiFieldInput';
+/** Shared so an untouched item's `touched` prop keeps a stable identity. */
+const EMPTY_TOUCHED: Record = Object.freeze({});
+
interface Props {
fieldDescription: FieldDescription;
items: Properties[];
rootData?: Properties;
+ errors?: Record;
+ touched?: Record;
+ onBlurField?: (key: string) => void;
onChange: (items: Properties[]) => void;
}
@@ -19,6 +26,9 @@ const FieldGroupInput = ({
fieldDescription,
items,
rootData,
+ errors,
+ touched,
+ onBlurField,
onChange,
}: Props) => {
const {
@@ -36,6 +46,15 @@ const FieldGroupInput = ({
const removeText = removeLabel ?? 'Remove';
const groupName = label ?? fieldDescription.name;
+ const errorsByItem = useMemo(
+ () => indexGroupPathMap(errors, fieldDescription.name),
+ [errors, fieldDescription.name],
+ );
+ const touchedByItem = useMemo(
+ () => indexGroupPathMap(touched, fieldDescription.name),
+ [touched, fieldDescription.name],
+ );
+
const handleItemChange = useCallback(
(index: number, next: Properties) => {
const nextItems = items.slice();
@@ -75,6 +94,19 @@ const FieldGroupInput = ({
fieldDescriptions={fields}
properties={item}
rootData={rootData}
+ errors={errorsByItem?.[index]}
+ // An item with no touched keys still has to receive a map, or
+ // the nested input reads `undefined` as "uncontrolled" and starts
+ // tracking touched on its own - which then survives the owner
+ // clearing the map. The constant keeps the prop identity stable.
+ touched={
+ touched === undefined
+ ? undefined
+ : (touchedByItem?.[index] ?? EMPTY_TOUCHED)
+ }
+ onBlurField={(key) =>
+ onBlurField?.(`${fieldDescription.name}[${index}].${key}`)
+ }
onChange={(next) => handleItemChange(index, next)}
/>
diff --git a/packages/react/src/components/FieldInput.tsx b/packages/react/src/components/FieldInput.tsx
index 01a1c92..729cb66 100644
--- a/packages/react/src/components/FieldInput.tsx
+++ b/packages/react/src/components/FieldInput.tsx
@@ -1,8 +1,6 @@
import {
- resolveDisabled,
- resolveOptions,
- resolveReadOnly,
- validateField,
+ buildFieldRendererProps,
+ makeFieldId,
FieldDescription,
Properties,
} from '@dynamic-field-kit/core';
@@ -14,8 +12,12 @@ interface Props {
fieldDescription: FieldDescription;
renderInfos: Properties;
rootData?: Properties;
+ /** Per-form-instance id namespace; see core's `makeFieldId`. */
+ idPrefix?: string;
touched?: boolean;
+ touchedMap?: Record;
dirty?: boolean;
+ errors?: Record;
onBlurField?: (key: string) => void;
onValueChangeField: (value: unknown, key: string) => void;
}
@@ -24,13 +26,15 @@ const FieldInputInner = ({
fieldDescription,
renderInfos,
rootData,
+ idPrefix = 'dfk-field',
touched,
+ touchedMap,
dirty,
+ errors,
onBlurField,
onValueChangeField,
}: Props) => {
- const { name, type, label, className, description, props, fields, required } =
- fieldDescription;
+ const { name, fields } = fieldDescription;
// Stable per-field handler so DynamicInput's memoization isn't defeated
// by a freshly-allocated closure on every parent render.
@@ -54,46 +58,28 @@ const FieldInputInner = ({
fieldDescription={fieldDescription}
items={items}
rootData={rootData}
+ errors={errors}
+ touched={touchedMap}
+ onBlurField={onBlurField}
onChange={handleChange}
/>
);
}
- const effectiveDisabled = resolveDisabled(
+ const rendererProps = buildFieldRendererProps({
fieldDescription,
- renderInfos,
+ data: renderInfos,
rootData,
- );
- const readOnly = resolveReadOnly(fieldDescription, renderInfos, rootData);
- const resolvedOptionsList = resolveOptions(
- fieldDescription,
- renderInfos,
- rootData,
- );
- const errors = effectiveDisabled
- ? []
- : validateField(fieldDescription, renderInfos[name], renderInfos, rootData);
- const error = errors.length > 0 ? errors : undefined;
- const fieldId = `dfk-field-${name}`;
+ id: makeFieldId(fieldDescription, idPrefix),
+ touched,
+ dirty,
+ validationErrors: errors === undefined ? undefined : (errors[name] ?? []),
+ });
return (
@@ -110,8 +96,11 @@ const FieldInput = /* @__PURE__ */ React.memo(FieldInputInner, (prev, next) => {
prev.onValueChangeField === next.onValueChangeField &&
prev.onBlurField === next.onBlurField &&
prev.rootData === next.rootData &&
+ prev.idPrefix === next.idPrefix &&
prev.touched === next.touched &&
+ prev.touchedMap === next.touchedMap &&
prev.dirty === next.dirty &&
+ prev.errors === next.errors &&
prev.renderInfos[name] === next.renderInfos[name]
);
});
diff --git a/packages/react/src/components/MultiFieldInput.tsx b/packages/react/src/components/MultiFieldInput.tsx
index 96894e5..14f4cca 100644
--- a/packages/react/src/components/MultiFieldInput.tsx
+++ b/packages/react/src/components/MultiFieldInput.tsx
@@ -1,155 +1,293 @@
-import {
- applyComputedValues,
- validateFields,
- FieldDescription,
- Properties,
- type ValidationResult,
-} from '@dynamic-field-kit/core';
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-
-import { layoutRegistry, LayoutConfig } from '../layout';
-import FieldInput from './FieldInput';
-
-interface Props {
- fieldDescriptions: FieldDescription[];
- properties?: Properties;
- onChange?: (data: Properties) => void;
- layout?: LayoutConfig;
- /**
- * Top-level form data, threaded down through repeatable groups so a nested
- * field's `appearCondition`/`computeValue` can read the root form. Omitted at
- * the top level, where the form's own data is the root.
- */
- rootData?: Properties;
- /**
- * Called with the recursive validation result ({ valid, errors }) on every
- * change. On the top-level component this covers the whole form (groups
- * included).
- */
- onValidityChange?: (result: ValidationResult) => void;
- /**
- * Called with a field's name when it loses focus. Touched state is still
- * tracked internally either way; this is the hook for driving an external
- * form store - pass `useDynamicForm`'s `handleBlur` to get its `touched`
- * map and `validateOnBlur` behaviour.
- */
- onBlurField?: (fieldName: string) => void;
-}
-
-function resolveLayout(layout?: LayoutConfig) {
- if (!layout) {
- return { type: 'column', config: {} };
- }
- if (typeof layout === 'string') {
- return { type: layout, config: {} };
- }
- return { type: layout.type, config: layout };
-}
-
-const MultiFieldInput = ({
- fieldDescriptions,
- properties,
- onChange,
- layout,
- rootData,
- onValidityChange,
- onBlurField,
-}: Props) => {
- const [data, setData] = useState({});
- const [touchedFields, setTouchedFields] = useState>(
- {},
- );
- const initialPropertiesRef = useRef(properties ?? {});
-
- useEffect(() => {
- if (properties) {
- setData(applyComputedValues(fieldDescriptions, properties, rootData));
- }
- // Only re-run when `properties` itself changes; recomputing on every
- // fieldDescriptions identity change would fight user edits mid-session.
- }, [properties]);
-
- // The root data seen by this level: the prop when nested in a group, else
- // this form's own data at the top level.
- const effectiveRoot = rootData ?? data;
-
- const visibleFields = useMemo(
- () =>
- fieldDescriptions.filter(
- (f) => !f.appearCondition || f.appearCondition(data, effectiveRoot),
- ),
- [fieldDescriptions, data, effectiveRoot],
- );
-
- // Keep the latest data/onChange in refs so handleValueChangeField can stay
- // referentially stable (empty deps) without defeating FieldInput's memoization,
- // and without calling onChange from inside a setState updater (must stay pure).
- const dataRef = useRef(data);
- dataRef.current = data;
- const onChangeRef = useRef(onChange);
- onChangeRef.current = onChange;
- const fieldDescriptionsRef = useRef(fieldDescriptions);
- fieldDescriptionsRef.current = fieldDescriptions;
- const rootDataRef = useRef(rootData);
- rootDataRef.current = rootData;
- const onValidityChangeRef = useRef(onValidityChange);
- onValidityChangeRef.current = onValidityChange;
- const onBlurFieldRef = useRef(onBlurField);
- onBlurFieldRef.current = onBlurField;
-
- useEffect(() => {
- onValidityChangeRef.current?.(
- validateFields(fieldDescriptions, data, rootData),
- );
- }, [data, fieldDescriptions, rootData]);
-
- const handleValueChangeField = useCallback((value: unknown, key: string) => {
- const merged = { ...dataRef.current, [key]: value };
- const next = applyComputedValues(
- fieldDescriptionsRef.current,
- merged,
- rootDataRef.current,
- );
- dataRef.current = next;
- setData(next);
- onChangeRef.current?.(next);
- }, []);
-
- const handleBlurField = useCallback((key: string) => {
- setTouchedFields((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
- onBlurFieldRef.current?.(key);
- }, []);
-
- const { type, config } = resolveLayout(layout);
-
- const Layout = layoutRegistry.get(type);
-
- if (!Layout) {
- throw new Error(`Unknown layout: ${type}`);
- }
-
- return (
-
- {visibleFields.map((f) => (
-
- ))}
-
- );
-};
-
-export default MultiFieldInput;
+import {
+ applyComputedValues,
+ isFieldGroup,
+ validateFields,
+ FieldDescription,
+ Properties,
+ type ValidationResult,
+} from '@dynamic-field-kit/core';
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { layoutRegistry, LayoutConfig } from '../layout';
+import FieldInput from './FieldInput';
+
+/**
+ * The slice of `useDynamicForm`'s result `MultiFieldInput` needs to drive
+ * itself. Structural, so the hook result can be passed straight in.
+ */
+export interface DynamicFormBinding {
+ data: Properties;
+ errors: Record;
+ touched: Record;
+ handleChange: (data: Properties) => void;
+ handleBlur: (fieldName: string) => void;
+}
+
+/** Imperative handle exposed on a `MultiFieldInput` ref. */
+export interface MultiFieldInputHandle {
+ /**
+ * Clears the internally tracked touched state. Only meaningful in
+ * uncontrolled mode - when `touched` is passed as a prop, resetting the form
+ * store (e.g. `useDynamicForm().reset()`) already clears it.
+ */
+ resetTouched: () => void;
+ setFieldTouched: (fieldName: string, isTouched?: boolean) => void;
+ /** The touched map currently in effect, controlled or internal. */
+ getTouched: () => Record;
+}
+
+interface Props {
+ fieldDescriptions: FieldDescription[];
+ properties?: Properties;
+ onChange?: (data: Properties) => void;
+ layout?: LayoutConfig;
+ /**
+ * Namespace for generated field ids: a field renders with
+ * `${idPrefix}-${name}`. Defaults to a value unique to this component
+ * instance, so two forms containing the same field name do not emit
+ * duplicate DOM ids. Pass a fixed string to pin ids (`idPrefix="dfk-field"`
+ * restores the pre-1.6 ids), or set `FieldDescription.id` per field.
+ */
+ idPrefix?: string;
+ /**
+ * Top-level form data, threaded down through repeatable groups so a nested
+ * field's `appearCondition`/`computeValue` can read the root form. Omitted at
+ * the top level, where the form's own data is the root.
+ */
+ rootData?: Properties;
+ /**
+ * Called with the recursive validation result ({ valid, errors }) on every
+ * change. On the top-level component this covers the whole form (groups
+ * included).
+ */
+ onValidityChange?: (result: ValidationResult) => void;
+ /**
+ * Called with a field's name when it loses focus. Touched state is still
+ * tracked internally either way; this is the hook for driving an external
+ * form store - pass `useDynamicForm`'s `handleBlur` to get its `touched`
+ * map and `validateOnBlur` behaviour.
+ */
+ onBlurField?: (fieldName: string) => void;
+ /**
+ * Controlled touched map. When provided it is the single source of truth and
+ * the internal tracker is bypassed entirely, so `useDynamicForm().touched`
+ * (updated by `setFieldTouched`, `touchAll`, `handleSubmit` and cleared by
+ * `reset`) is what renderers actually see. Omit it to keep the internal,
+ * blur-only tracker.
+ */
+ touched?: Record;
+ /** Controlled validation errors. Empty means no renderer error. */
+ errors?: Record;
+ /** Fires with the next touched map whenever a field is blurred. */
+ onTouchedChange?: (touched: Record) => void;
+ /**
+ * Shorthand that wires `properties`, `onChange`, `onBlurField` and `touched`
+ * from a `useDynamicForm` result in one prop. Individually passed props win
+ * over the ones derived from here.
+ */
+ form?: DynamicFormBinding;
+}
+
+function resolveLayout(layout?: LayoutConfig) {
+ if (!layout) {
+ return { type: 'column', config: {} };
+ }
+ if (typeof layout === 'string') {
+ return { type: layout, config: {} };
+ }
+ return { type: layout.type, config: layout };
+}
+
+const MultiFieldInputInner = (
+ {
+ fieldDescriptions,
+ properties,
+ onChange,
+ layout,
+ idPrefix,
+ rootData,
+ onValidityChange,
+ onBlurField,
+ touched: touchedProp,
+ errors: errorsProp,
+ onTouchedChange,
+ form,
+ }: Props,
+ ref: React.Ref,
+) => {
+ // Explicit props take precedence over the `form` shorthand, so a caller can
+ // pass `form` and still override one wire.
+ const effectiveProperties = properties ?? form?.data;
+ const effectiveOnChange = onChange ?? form?.handleChange;
+ const effectiveOnBlurField = onBlurField ?? form?.handleBlur;
+ const controlledTouched = touchedProp ?? form?.touched;
+ const effectiveErrors = errorsProp ?? form?.errors;
+
+ const [data, setData] = useState({});
+ const [internalTouched, setInternalTouched] = useState<
+ Record
+ >({});
+ const initialPropertiesRef = useRef(effectiveProperties ?? {});
+
+ // Unique per component instance. `useId` is SSR-safe (server and client
+ // agree), unlike a module-level counter. Its delimiters vary by React version
+ // (':r1:' on 18, '_r_1_' on 19) and are legal in an id attribute but break
+ // CSS selectors, so keep only the alphanumeric core.
+ const autoId = useId().replace(/[^a-zA-Z0-9]/g, '');
+ const effectiveIdPrefix = idPrefix ?? `dfk-${autoId}`;
+
+ // Controlled when a touched map is supplied; otherwise fall back to the
+ // internal blur-only tracker.
+ const isTouchedControlled = controlledTouched !== undefined;
+ const effectiveTouched = controlledTouched ?? internalTouched;
+
+ useEffect(() => {
+ if (effectiveProperties) {
+ setData(
+ applyComputedValues(fieldDescriptions, effectiveProperties, rootData),
+ );
+ }
+ // Only re-run when `properties` itself changes; recomputing on every
+ // fieldDescriptions identity change would fight user edits mid-session.
+ }, [effectiveProperties]);
+
+ // The root data seen by this level: the prop when nested in a group, else
+ // this form's own data at the top level.
+ const effectiveRoot = rootData ?? data;
+
+ const visibleFields = useMemo(
+ () =>
+ fieldDescriptions.filter(
+ (f) => !f.appearCondition || f.appearCondition(data, effectiveRoot),
+ ),
+ [fieldDescriptions, data, effectiveRoot],
+ );
+
+ // Keep the latest data/onChange in refs so handleValueChangeField can stay
+ // referentially stable (empty deps) without defeating FieldInput's memoization,
+ // and without calling onChange from inside a setState updater (must stay pure).
+ const dataRef = useRef(data);
+ dataRef.current = data;
+ const onChangeRef = useRef(effectiveOnChange);
+ onChangeRef.current = effectiveOnChange;
+ const fieldDescriptionsRef = useRef(fieldDescriptions);
+ fieldDescriptionsRef.current = fieldDescriptions;
+ const rootDataRef = useRef(rootData);
+ rootDataRef.current = rootData;
+ const onValidityChangeRef = useRef(onValidityChange);
+ onValidityChangeRef.current = onValidityChange;
+ const onBlurFieldRef = useRef(effectiveOnBlurField);
+ onBlurFieldRef.current = effectiveOnBlurField;
+ const onTouchedChangeRef = useRef(onTouchedChange);
+ onTouchedChangeRef.current = onTouchedChange;
+ const isTouchedControlledRef = useRef(isTouchedControlled);
+ isTouchedControlledRef.current = isTouchedControlled;
+ const effectiveTouchedRef = useRef(effectiveTouched);
+ effectiveTouchedRef.current = effectiveTouched;
+
+ useEffect(() => {
+ onValidityChangeRef.current?.(
+ validateFields(fieldDescriptions, data, rootData),
+ );
+ }, [data, fieldDescriptions, rootData]);
+
+ const handleValueChangeField = useCallback((value: unknown, key: string) => {
+ const merged = { ...dataRef.current, [key]: value };
+ const next = applyComputedValues(
+ fieldDescriptionsRef.current,
+ merged,
+ rootDataRef.current,
+ );
+ dataRef.current = next;
+ setData(next);
+ onChangeRef.current?.(next);
+ }, []);
+
+ const markTouched = useCallback((key: string, isTouched: boolean) => {
+ const current = effectiveTouchedRef.current;
+ if (Boolean(current[key]) === isTouched) {
+ return;
+ }
+ const next = { ...current, [key]: isTouched };
+ // In controlled mode the owner holds the map; only report the change.
+ if (!isTouchedControlledRef.current) {
+ effectiveTouchedRef.current = next;
+ setInternalTouched(next);
+ }
+ onTouchedChangeRef.current?.(next);
+ }, []);
+
+ const handleBlurField = useCallback(
+ (key: string) => {
+ markTouched(key, true);
+ onBlurFieldRef.current?.(key);
+ },
+ [markTouched],
+ );
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetTouched: () => {
+ if (!isTouchedControlledRef.current) {
+ effectiveTouchedRef.current = {};
+ }
+ setInternalTouched({});
+ },
+ setFieldTouched: (fieldName: string, isTouched = true) =>
+ markTouched(fieldName, isTouched),
+ getTouched: () => effectiveTouchedRef.current,
+ }),
+ [markTouched],
+ );
+
+ const { type, config } = resolveLayout(layout);
+
+ const Layout = layoutRegistry.get(type);
+
+ if (!Layout) {
+ throw new Error(`Unknown layout: ${type}`);
+ }
+
+ return (
+
+ {visibleFields.map((f) => (
+
+ ))}
+
+ );
+};
+
+// forwardRef rather than a plain `ref` prop: the package's React peer range
+// starts at 18, where ref-as-prop does not exist yet.
+const MultiFieldInput = /* @__PURE__ */ React.forwardRef<
+ MultiFieldInputHandle,
+ Props
+>(MultiFieldInputInner);
+
+MultiFieldInput.displayName = 'MultiFieldInput';
+
+export default MultiFieldInput;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 224eb6d..da76f1e 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -6,8 +6,16 @@ export { layoutRegistry } from './layout';
export { default as DynamicInput } from './components/DynamicInput';
export { default as FieldInput } from './components/FieldInput';
export { default as MultiFieldInput } from './components/MultiFieldInput';
+export type {
+ MultiFieldInputHandle,
+ DynamicFormBinding,
+} from './components/MultiFieldInput';
export { DynamicFormDevTools } from './components/DynamicFormDevTools';
export { useDynamicForm } from './useDynamicForm';
+export type {
+ UseDynamicFormOptions,
+ UseDynamicFormResult,
+} from './useDynamicForm';
export { defaultRenderersMap, getDefaultRenderer } from './defaultRenderers';
export {
@@ -28,15 +36,21 @@ export {
type FieldTypeKey,
type FieldDescription,
type FieldRendererProps,
+ buildFieldRendererProps,
+ makeFieldId,
+ FIELD_RENDERER_PROP_KEYS,
} from '@dynamic-field-kit/core';
export {
validateField,
validateFieldAsync,
validateFields,
validateFieldsAsync,
+ collectFieldPaths,
+ indexGroupPathMap,
resolveDisabled,
resolveReadOnly,
resolveOptions,
validators,
type ValidationResult,
+ type ValidationContext,
} from '@dynamic-field-kit/core';
diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts
index 01ae8b1..cd8eaa9 100644
--- a/packages/react/src/useDynamicForm.ts
+++ b/packages/react/src/useDynamicForm.ts
@@ -1,10 +1,13 @@
import {
applyComputedValues,
+ collectFieldPaths,
FieldDescription,
Properties,
+ type ValidationResult,
validateFields,
+ validateFieldsAsync,
} from '@dynamic-field-kit/core';
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
export interface UseDynamicFormOptions {
fields: FieldDescription[];
@@ -17,6 +20,9 @@ export interface UseDynamicFormResult {
data: Properties;
errors: Record;
isValid: boolean;
+ isValidating: boolean;
+ isValidationComplete: boolean;
+ validationStatus: ValidationResult['status'];
isDirty: boolean;
isSubmitting: boolean;
isSubmitted: boolean;
@@ -24,16 +30,56 @@ export interface UseDynamicFormResult {
setData: React.Dispatch>;
setFieldValue: (name: string, value: unknown) => void;
setFieldTouched: (name: string, isTouched?: boolean) => void;
+ /** Replaces the whole touched map. */
+ setTouched: React.Dispatch>>;
+ /**
+ * Marks every field touched at once. `handleSubmit` calls this for you, so
+ * an invalid submit surfaces errors on fields the user never focused - pass
+ * `touched` into `MultiFieldInput` for it to take effect.
+ */
+ touchAll: () => void;
+ /** Clears the touched map without touching data, errors or dirty state. */
+ resetTouched: () => void;
handleChange: (newData: Properties) => void;
handleBlur: (fieldName: string) => void;
reset: (newValues?: Properties) => void;
validate: () => boolean;
+ /** Validate all fields and await Promise-based rules. */
+ validateAsync: () => Promise;
handleSubmit: (
onValid: (data: Properties) => void | Promise,
onInvalid?: (errors: Record) => void,
) => (e?: React.FormEvent) => Promise;
}
+function sameStringMap(
+ left: Record,
+ right: Record,
+): boolean {
+ const keys = Object.keys(left);
+ return (
+ keys.length === Object.keys(right).length &&
+ keys.every(
+ (key) =>
+ left[key]?.length === right[key]?.length &&
+ left[key]?.every((value, index) => value === right[key]?.[index]),
+ )
+ );
+}
+
+function sameValidationResult(
+ left: ValidationResult,
+ right: ValidationResult,
+): boolean {
+ return (
+ left.valid === right.valid &&
+ left.complete === right.complete &&
+ left.status === right.status &&
+ (left.pending ?? []).join('\0') === (right.pending ?? []).join('\0') &&
+ sameStringMap(left.errors, right.errors)
+ );
+}
+
export function useDynamicForm({
fields,
initialValues = {},
@@ -48,25 +94,93 @@ export function useDynamicForm({
const [touched, setTouched] = useState>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
+ // Seeded from the initial data rather than from an effect: an effect never
+ // runs on the server, so a server-rendered form would ship `isValid: true`
+ // for an empty required field and never correct it. The initialiser runs
+ // once, unlike the useMemo this replaced, which ran on every render.
+ const [validationResult, setValidationResult] = useState(
+ () => validateFields(fields, data),
+ );
+ const [isValidating, setIsValidating] = useState(false);
+ const validationRunRef = useRef(0);
+ const validationAbortRef = useRef(undefined);
+ // A submit gets its own run counter and controller. Typing aborts the live
+ // validation run, and a submit must not be collateral damage of that.
+ const submitRunRef = useRef(0);
+ const submitAbortRef = useRef(undefined);
+ const dataRef = useRef(data);
+ dataRef.current = data;
+
+ const commitSyncResult = useCallback((res: ValidationResult) => {
+ setValidationResult((previous) =>
+ sameValidationResult(previous, res) ? previous : res,
+ );
+ return res.valid;
+ }, []);
+
+ // Keeps the result in step with later data changes. The seed above covers
+ // the first render (including the server's); from here on validators run
+ // after commit, never from a useMemo that re-runs on every render.
+ useEffect(() => {
+ commitSyncResult(validateFields(fields, data));
+ }, [fields, data, commitSyncResult]);
+
+ useEffect(
+ () => () => {
+ validationAbortRef.current?.abort();
+ submitAbortRef.current?.abort();
+ },
+ [],
+ );
const validate = useCallback(() => {
const res = validateFields(fields, data);
setErrors(res.errors);
- return res.valid;
+ return commitSyncResult(res);
+ }, [fields, data, commitSyncResult]);
+
+ const validateAsync = useCallback(async () => {
+ const run = ++validationRunRef.current;
+ validationAbortRef.current?.abort();
+ const controller = new AbortController();
+ validationAbortRef.current = controller;
+ const snapshot = data;
+ setIsValidating(true);
+ try {
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ signal: controller.signal,
+ });
+ if (run !== validationRunRef.current || dataRef.current !== snapshot) {
+ return res.valid;
+ }
+ setErrors(res.errors);
+ setValidationResult(res);
+ return res.valid;
+ } finally {
+ if (run === validationRunRef.current) {
+ setIsValidating(false);
+ }
+ }
}, [fields, data]);
const handleChange = useCallback(
(newData: Properties) => {
const next = applyComputedValues(fields, newData);
setData(next);
+ dataRef.current = next;
setIsDirty(true);
+ validationAbortRef.current?.abort();
+ validationRunRef.current += 1;
+ setIsValidating(false);
+
+ const res = validateFields(fields, next);
+ commitSyncResult(res);
if (validateOnChange) {
- const res = validateFields(fields, next);
setErrors(res.errors);
}
},
- [fields, validateOnChange],
+ [fields, validateOnChange, commitSyncResult],
);
const setFieldValue = useCallback(
@@ -80,15 +194,26 @@ export function useDynamicForm({
setTouched((prev) => ({ ...prev, [name]: isTouched }));
}, []);
+ const touchAll = useCallback(() => {
+ setTouched(
+ Object.fromEntries(
+ collectFieldPaths(fields, data).map((path) => [path, true] as const),
+ ),
+ );
+ }, [fields, data]);
+
+ const resetTouched = useCallback(() => setTouched({}), []);
+
const handleBlur = useCallback(
(fieldName: string) => {
setFieldTouched(fieldName, true);
if (validateOnBlur) {
const res = validateFields(fields, data);
setErrors(res.errors);
+ commitSyncResult(res);
}
},
- [fields, data, validateOnBlur, setFieldTouched],
+ [fields, data, validateOnBlur, setFieldTouched, commitSyncResult],
);
const reset = useCallback(
@@ -96,11 +221,15 @@ export function useDynamicForm({
const seed = newValues ?? initialValues;
const next = applyComputedValues(fields, seed);
setData(next);
+ dataRef.current = next;
setErrors({});
setIsDirty(false);
setTouched({});
setIsSubmitting(false);
setIsSubmitted(false);
+ validationAbortRef.current?.abort();
+ validationRunRef.current += 1;
+ setIsValidating(false);
},
[fields, initialValues],
);
@@ -115,28 +244,71 @@ export function useDynamicForm({
e.preventDefault();
}
setIsSubmitting(true);
+ const submitRun = ++submitRunRef.current;
try {
- const res = validateFields(fields, data);
- setErrors(res.errors);
+ // Touch everything before validating: a submit is the user asserting
+ // the form is finished, so a field they never focused should still
+ // show its error. Without this, submitting an untouched form appears
+ // to do nothing at all.
+ touchAll();
+ // A submit handler is already async, so use one async-capable pass.
+ // Sync hooks still run once; Promise-based hooks are awaited instead
+ // of being invoked once for detection and a second time for results.
+ const run = ++validationRunRef.current;
+ // Cancel any live run so its (older) result cannot land on top of
+ // this one, but validate under a controller of the submit's own.
+ validationAbortRef.current?.abort();
+ submitAbortRef.current?.abort();
+ const controller = new AbortController();
+ submitAbortRef.current = controller;
+ const snapshot = data;
+ setIsValidating(true);
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ signal: controller.signal,
+ });
+ if (submitRun !== submitRunRef.current) {
+ return;
+ }
+ // Editing during the submit does not cancel it - the user submitted
+ // this snapshot and is owed an answer for it. What the form *shows*
+ // still has to describe the data on screen, so when it moved on, the
+ // displayed state is re-derived instead of showing the old pass.
+ if (
+ dataRef.current === snapshot &&
+ run === validationRunRef.current
+ ) {
+ setErrors(res.errors);
+ setValidationResult(res);
+ } else {
+ const live = validateFields(fields, dataRef.current);
+ setErrors(live.errors);
+ commitSyncResult(live);
+ }
setIsSubmitted(true);
if (res.valid) {
- await onValid(data);
+ await onValid(snapshot);
} else if (onInvalid) {
onInvalid(res.errors);
}
} finally {
+ if (submitRun === submitRunRef.current) {
+ setIsValidating(false);
+ }
setIsSubmitting(false);
}
},
- [fields, data],
+ [fields, data, touchAll, commitSyncResult],
);
- const isValid = Object.keys(errors).length === 0;
-
return {
data,
errors,
- isValid,
+ isValid: validationResult.valid,
+ isValidating,
+ // Matches Vue and Angular: a run still in flight is not complete, whatever
+ // the last finished pass concluded.
+ isValidationComplete: validationResult.complete && !isValidating,
+ validationStatus: isValidating ? 'pending' : validationResult.status,
isDirty,
isSubmitting,
isSubmitted,
@@ -144,10 +316,14 @@ export function useDynamicForm({
setData,
setFieldValue,
setFieldTouched,
+ setTouched,
+ touchAll,
+ resetTouched,
handleChange,
handleBlur,
reset,
validate,
+ validateAsync,
handleSubmit,
};
}
diff --git a/packages/react/test/MultiFieldInputBlur.test.tsx b/packages/react/test/MultiFieldInputBlur.test.tsx
index 0631b31..5f076d0 100644
--- a/packages/react/test/MultiFieldInputBlur.test.tsx
+++ b/packages/react/test/MultiFieldInputBlur.test.tsx
@@ -19,20 +19,28 @@ const fields: FieldDescription[] = [
describe('MultiFieldInput blur reporting', () => {
beforeEach(() => {
- fieldRegistry.register('text', ({ value, onValueChange, onBlur, id }) => (
- onValueChange?.(e.target.value)}
- onBlur={onBlur}
- />
- ));
+ fieldRegistry.register(
+ 'text',
+ ({ value, onValueChange, onBlur, id, touched }) => (
+ onValueChange?.(e.target.value)}
+ onBlur={onBlur}
+ />
+ ),
+ );
});
it('reports which field was blurred', () => {
const onBlurField = vi.fn();
render(
- ,
+ ,
);
fireEvent.blur(screen.getByTestId('dfk-field-second'));
@@ -43,7 +51,11 @@ describe('MultiFieldInput blur reporting', () => {
it('reports each field separately', () => {
const onBlurField = vi.fn();
render(
- ,
+ ,
);
fireEvent.blur(screen.getByTestId('dfk-field-first'));
@@ -57,8 +69,67 @@ describe('MultiFieldInput blur reporting', () => {
it('still tracks touched internally when no handler is passed', () => {
expect(() => {
- render();
+ render(
+ ,
+ );
fireEvent.blur(screen.getByTestId('dfk-field-first'));
}).not.toThrow();
});
+
+ it('reports the full path for a field inside a repeatable group', () => {
+ const onBlurField = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.blur(screen.getByRole('textbox'));
+ expect(onBlurField).toHaveBeenCalledWith('contacts[0].email');
+ });
+
+ it('lets the owner clear touched inside a repeatable group', () => {
+ // An item with no touched keys must still count as controlled, or the
+ // nested MultiFieldInput falls back to its own internal tracker and keeps
+ // showing touched after the owner resets the map.
+ const Harness = () => {
+ const [touched, setTouched] = React.useState>({});
+ return (
+ <>
+
+
+ setTouched((prev) => ({ ...prev, [key]: true }))
+ }
+ />
+ >
+ );
+ };
+
+ render();
+ const input = screen.getByRole('textbox');
+
+ fireEvent.blur(input);
+ expect(input.dataset.touched).toBe('true');
+
+ fireEvent.click(screen.getByText('reset'));
+ expect(input.dataset.touched).toBe('false');
+ });
});
diff --git a/packages/react/test/reportedIssues.test.tsx b/packages/react/test/reportedIssues.test.tsx
new file mode 100644
index 0000000..906c833
--- /dev/null
+++ b/packages/react/test/reportedIssues.test.tsx
@@ -0,0 +1,323 @@
+/**
+ * Regression tests for the issues reported against 1.5.1:
+ * 1. two forms rendering the same field name emitted duplicate DOM ids
+ * 2. submitting a never-touched form showed no errors
+ * 3. `form.reset()` could not clear MultiFieldInput's touched state
+ * 4. `FieldDescription.placeholder` never reached the renderer
+ * 5. renderer props differed between adapters
+ */
+import type { FieldDescription } from '@dynamic-field-kit/core';
+import { fireEvent, render, screen } from '@testing-library/react';
+import React, { useRef } from 'react';
+import { beforeEach, describe, expect, it } from 'vitest';
+import MultiFieldInput, {
+ type MultiFieldInputHandle,
+} from '../src/components/MultiFieldInput';
+import { fieldRegistry } from '../src/fieldRegistry';
+import { useDynamicForm } from '../src/useDynamicForm';
+import '../src/layout/defaultLayouts';
+
+declare module '@dynamic-field-kit/core' {
+ interface FieldTypeMap {
+ text: string;
+ }
+}
+
+/** Mirrors what an app renderer does: show the error only once touched. */
+const TextRenderer = ({
+ id,
+ value,
+ onValueChange,
+ onBlur,
+ label,
+ placeholder,
+ touched,
+ error,
+}: {
+ id?: string;
+ value?: unknown;
+ onValueChange?: (v: unknown) => void;
+ onBlur?: () => void;
+ label?: string;
+ placeholder?: string;
+ touched?: boolean;
+ error?: string | string[];
+}) => (
+
+);
+
+beforeEach(() => {
+ fieldRegistry.register('text', TextRenderer as never);
+});
+
+describe('issue 1: duplicate field ids across form instances', () => {
+ const fields: FieldDescription[] = [
+ { name: 'title', type: 'text', label: 'Title' },
+ ];
+
+ function ids(container: HTMLElement) {
+ return Array.from(container.querySelectorAll('input')).map((i) => i.id);
+ }
+
+ it('gives two forms sharing a field name different ids', () => {
+ const { container } = render(
+ <>
+
+
+ >,
+ );
+
+ const [first, second] = ids(container);
+ expect(first).toBeTruthy();
+ expect(first).not.toBe(second);
+ });
+
+ it('produces ids that are usable as CSS selectors', () => {
+ const { container } = render(
+ ,
+ );
+
+ const [id] = ids(container);
+ expect(id).toMatch(/^[A-Za-z][\w-]*$/);
+ expect(container.querySelector(`#${id}`)).not.toBeNull();
+ });
+
+ it('honours an explicit idPrefix, restoring the old stable ids', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(ids(container)).toEqual(['dfk-field-title']);
+ });
+
+ it('lets a field pin its own id', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(ids(container)).toEqual(['login-title']);
+ });
+});
+
+describe('issue 2: submitting an untouched form surfaces errors', () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ label: 'Username',
+ required: true,
+ validate: (v) => (v ? undefined : 'Required'),
+ },
+ ];
+
+ function Form() {
+ const form = useDynamicForm({ fields, initialValues: { username: '' } });
+ return (
+
+ );
+ }
+
+ it('shows the error without the user ever focusing the field', async () => {
+ render();
+
+ expect(screen.queryByRole('alert')).toBeNull();
+
+ fireEvent.click(screen.getByText('Submit'));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Required');
+ });
+
+ it('touchAll marks every field touched', () => {
+ let seen: Record = {};
+ function Probe() {
+ const form = useDynamicForm({ fields });
+ seen = form.touched;
+ return ;
+ }
+ render();
+
+ expect(seen).toEqual({});
+ fireEvent.click(screen.getByText('touch'));
+ expect(seen).toEqual({ username: true });
+ });
+});
+
+describe('issue 3: resetting clears touched', () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ label: 'Username',
+ validate: (v) => (v ? undefined : 'Required'),
+ },
+ ];
+
+ it('clears the error after reset when touched is controlled', () => {
+ function Form() {
+ const form = useDynamicForm({ fields, initialValues: { username: '' } });
+ return (
+ <>
+
+
+ >
+ );
+ }
+ render();
+
+ fireEvent.blur(screen.getByLabelText('Username'));
+ expect(screen.getByRole('alert')).toHaveTextContent('Required');
+
+ fireEvent.click(screen.getByText('reset'));
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('exposes resetTouched on a ref for the uncontrolled mode', () => {
+ function Form() {
+ const ref = useRef(null);
+ return (
+ <>
+
+
+ >
+ );
+ }
+ render();
+
+ fireEvent.blur(screen.getByLabelText('Username'));
+ expect(screen.getByRole('alert')).toHaveTextContent('Required');
+
+ fireEvent.click(screen.getByText('reset'));
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+});
+
+describe('issue 5: form errors are the renderer source of truth', () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ label: 'Username',
+ validate: () => 'Required',
+ },
+ ];
+
+ it('does not show a live error before the form store records it', () => {
+ function Form() {
+ const form = useDynamicForm({ fields });
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+ render();
+
+ fireEvent.click(screen.getByText('touch'));
+ expect(screen.queryByRole('alert')).toBeNull();
+
+ fireEvent.click(screen.getByText('validate'));
+ expect(screen.getByRole('alert')).toHaveTextContent('Required');
+ });
+});
+
+describe('issue 4: FieldDescription props reach the renderer', () => {
+ it('forwards placeholder declared at the top level', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Username')).toHaveAttribute(
+ 'placeholder',
+ 'Type your name',
+ );
+ });
+
+ it('forwards the numeric/file props the contract declares', () => {
+ const received: Record[] = [];
+ fieldRegistry.register('text', ((p: Record) => {
+ received.push(p);
+ return ;
+ }) as never);
+
+ render(
+ ,
+ );
+
+ expect(received[0]).toMatchObject({
+ min: 1,
+ max: 9,
+ step: 2,
+ accept: '.png',
+ multiple: true,
+ required: true,
+ });
+ });
+});
+
+describe('issue 1, worst case: repeatable groups', () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'contacts',
+ type: 'text',
+ label: 'Contacts',
+ fields: [{ name: 'email', type: 'text', label: 'Email' }],
+ },
+ ];
+
+ it('gives every group item its own id for the same field name', () => {
+ const { container } = render(
+ ,
+ );
+
+ const ids = Array.from(container.querySelectorAll('input')).map(
+ (i) => i.id,
+ );
+ expect(ids).toHaveLength(3);
+ expect(new Set(ids).size).toBe(3);
+ });
+});
diff --git a/packages/react/test/useDynamicFormAsync.test.tsx b/packages/react/test/useDynamicFormAsync.test.tsx
new file mode 100644
index 0000000..42acef9
--- /dev/null
+++ b/packages/react/test/useDynamicFormAsync.test.tsx
@@ -0,0 +1,311 @@
+/**
+ * The two DX gaps the 1.5.1 report raised alongside the bugs:
+ * - `isValid` was derived from the lazily-populated `errors` state, so a form
+ * with an empty required field reported `isValid: true` until something
+ * happened to validate it. Anyone using it to disable a submit button got
+ * the wrong answer exactly when it mattered - at first paint.
+ * - a `validate` hook returning a Promise was treated as valid on the sync
+ * submit path, so an async rule could not block a submit at all.
+ */
+import type { FieldDescription } from '@dynamic-field-kit/core';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import React from 'react';
+import { renderToString } from 'react-dom/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useDynamicForm } from '../src/useDynamicForm';
+
+const required: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ required: true,
+ validate: (v) => (v ? undefined : 'Required'),
+ },
+];
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+describe('isValid reflects the data, not the last validation run', () => {
+ it('is already false on a server render, where effects never run', () => {
+ // renderHook flushes effects before the assertion, so an effect-seeded
+ // result looks correct there. Server rendering is where it shows: the
+ // markup ships with a submit button that never disables.
+ const Probe = () => {
+ const { isValid } = useDynamicForm({
+ fields: required,
+ initialValues: { username: '' },
+ });
+ return {String(isValid)};
+ };
+
+ expect(renderToString()).toContain('>false<');
+ });
+
+ it('is false at first render for an empty required field', () => {
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields: required, initialValues: { username: '' } }),
+ );
+
+ expect(result.current.isValid).toBe(false);
+ });
+
+ it('flips to true as soon as the data is valid, with no validate() call', () => {
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields: required, initialValues: { username: '' } }),
+ );
+
+ act(() => result.current.setFieldValue('username', 'ada'));
+
+ expect(result.current.isValid).toBe(true);
+ });
+
+ it('leaves `errors` lazy - it still only fills in on validate/blur/submit', () => {
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields: required, initialValues: { username: '' } }),
+ );
+
+ expect(result.current.isValid).toBe(false);
+ expect(result.current.errors).toEqual({});
+
+ act(() => {
+ result.current.validate();
+ });
+
+ expect(result.current.errors).toEqual({ username: ['Required'] });
+ });
+
+ it('ignores a field that is hidden or disabled, as validateFields does', () => {
+ const hidden: FieldDescription[] = [
+ { ...required[0], appearCondition: () => false },
+ ];
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields: hidden, initialValues: { username: '' } }),
+ );
+
+ expect(result.current.isValid).toBe(true);
+ });
+});
+
+describe('handleSubmit awaits async validators instead of ignoring them', () => {
+ const asyncFields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ validate: async (v) => (v === 'taken' ? 'Already taken' : undefined),
+ },
+ ];
+
+ it('blocks the submit when an async rule fails', async () => {
+ const onValid = vi.fn();
+ const onInvalid = vi.fn();
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: asyncFields,
+ initialValues: { username: 'taken' },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleSubmit(onValid, onInvalid)();
+ });
+
+ expect(onValid).not.toHaveBeenCalled();
+ expect(onInvalid).toHaveBeenCalledWith({ username: ['Already taken'] });
+ expect(result.current.errors).toEqual({ username: ['Already taken'] });
+ });
+
+ it('lets the submit through when the async rule passes', async () => {
+ const onValid = vi.fn();
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: asyncFields,
+ initialValues: { username: 'free' },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleSubmit(onValid)();
+ });
+
+ expect(onValid).toHaveBeenCalledWith({ username: 'free' });
+ expect(result.current.errors).toEqual({});
+ });
+
+ it('does not run a second pass when every validator is sync', async () => {
+ const validate = vi.fn((v: unknown) => (v ? undefined : 'Required'));
+ const syncFields: FieldDescription[] = [
+ { name: 'a', type: 'text', validate },
+ ];
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: syncFields,
+ initialValues: { a: 'x' },
+ }),
+ );
+ validate.mockClear();
+
+ await act(async () => {
+ await result.current.handleSubmit(() => {})();
+ });
+
+ expect(validate).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears isSubmitting once the async pass settles', async () => {
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: asyncFields,
+ initialValues: { username: 'taken' },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleSubmit(() => {})();
+ });
+
+ await waitFor(() => expect(result.current.isSubmitting).toBe(false));
+ expect(result.current.isSubmitted).toBe(true);
+ });
+});
+
+describe('a submit already in flight', () => {
+ it('still completes when the user edits a field while it runs', async () => {
+ // The remote check is slow, the user tabs into another field meanwhile.
+ // The submit must still resolve against the data that was submitted -
+ // otherwise the button just re-enables and nothing tells the user why.
+ let release: ((value: string | undefined) => void) | undefined;
+ const slow: FieldDescription[] = [
+ { name: 'username', type: 'text' },
+ {
+ name: 'code',
+ type: 'text',
+ // Declared async so only the submit's async pass invokes it - the live
+ // sync pass skips it, which keeps `release` pointing at this one run.
+ validationMode: 'async',
+ validate: () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ },
+ ];
+ const onValid = vi.fn();
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: slow,
+ initialValues: { username: 'a', code: '1' },
+ }),
+ );
+
+ let submitted: Promise | undefined;
+ act(() => {
+ submitted = result.current.handleSubmit(onValid)();
+ });
+ act(() => {
+ result.current.handleChange({ username: 'ab', code: '1' });
+ });
+
+ await act(async () => {
+ release?.(undefined);
+ await submitted;
+ });
+
+ expect(onValid).toHaveBeenCalledTimes(1);
+ expect(onValid).toHaveBeenCalledWith({ username: 'a', code: '1' });
+ expect(result.current.isSubmitted).toBe(true);
+ expect(result.current.isSubmitting).toBe(false);
+ });
+});
+
+describe('validateAsync', () => {
+ it('resolves async rules that validate() cannot', async () => {
+ const asyncFields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ validate: async () => 'Always fails',
+ },
+ ];
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields: asyncFields, initialValues: { username: 'x' } }),
+ );
+
+ let sync: boolean | undefined;
+ act(() => {
+ sync = result.current.validate();
+ });
+ expect(sync).toBe(true);
+ expect(result.current.errors).toEqual({});
+
+ let asyncValid: boolean | undefined;
+ await act(async () => {
+ asyncValid = await result.current.validateAsync();
+ });
+
+ expect(asyncValid).toBe(false);
+ expect(result.current.errors).toEqual({ username: ['Always fails'] });
+ });
+
+ it('ignores a stale result when a newer validation finishes first', async () => {
+ const releases = new Map void>();
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ validationMode: 'async',
+ validate: (value) =>
+ new Promise((resolve) => {
+ releases.set(String(value), resolve);
+ }),
+ },
+ ];
+ const { result } = renderHook(() =>
+ useDynamicForm({ fields, initialValues: { username: 'old' } }),
+ );
+
+ let oldRun!: Promise;
+ let newRun!: Promise;
+ act(() => {
+ oldRun = result.current.validateAsync();
+ });
+ act(() => result.current.setFieldValue('username', 'new'));
+ act(() => {
+ newRun = result.current.validateAsync();
+ });
+ expect(result.current.isValidating).toBe(true);
+
+ await act(async () => {
+ releases.get('new')?.();
+ await newRun;
+ releases.get('old')?.('Stale error');
+ await oldRun;
+ });
+
+ expect(result.current.errors).toEqual({});
+ expect(result.current.isValidating).toBe(false);
+ });
+});
+
+describe('nested touched paths', () => {
+ it('touchAll expands every existing repeatable group item', () => {
+ const { result } = renderHook(() =>
+ useDynamicForm({
+ fields: [
+ {
+ name: 'contacts',
+ type: 'text',
+ fields: [{ name: 'email', type: 'text' }],
+ },
+ ],
+ initialValues: { contacts: [{ email: '' }, { email: '' }] },
+ }),
+ );
+
+ act(() => result.current.touchAll());
+ expect(result.current.touched).toEqual({
+ 'contacts[0].email': true,
+ 'contacts[1].email': true,
+ });
+ });
+});
diff --git a/packages/react/test/validation.test.tsx b/packages/react/test/validation.test.tsx
index c44f172..cff4021 100644
--- a/packages/react/test/validation.test.tsx
+++ b/packages/react/test/validation.test.tsx
@@ -103,6 +103,11 @@ describe('React validation wiring', () => {
/>,
registryWithText(),
);
- expect(onValidity).toHaveBeenLastCalledWith({ valid: true, errors: {} });
+ expect(onValidity).toHaveBeenLastCalledWith({
+ valid: true,
+ errors: {},
+ complete: true,
+ status: 'valid',
+ });
});
});
diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.mts
similarity index 88%
rename from packages/react/vitest.config.ts
rename to packages/react/vitest.config.mts
index aab7284..2e328ae 100644
--- a/packages/react/vitest.config.ts
+++ b/packages/react/vitest.config.mts
@@ -14,7 +14,7 @@ export default defineConfig({
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['**/*.d.ts', '**/dist/**'],
- // Coverage floor — fails the run when coverage drops below these numbers.
+ // Coverage floor - fails the run when coverage drops below these numbers.
thresholds: {
statements: 85,
branches: 75,
diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md
index 8586ae1..836b3d0 100644
--- a/packages/vue/CHANGELOG.md
+++ b/packages/vue/CHANGELOG.md
@@ -1,5 +1,195 @@
# @dynamic-field-kit/vue
+## 1.6.0
+
+### Minor Changes
+
+- Cancellable, status-aware async validation; touched state that reaches inside repeatable groups; Angular 21 and TypeScript 5.9 support; and peer ranges corrected to the versions that actually work, proven at both ends in CI.
+- 67e4eec: Give every adapter one renderer-prop contract, unique field ids, and a touched
+ state the form store can actually drive.
+
+ Five things went wrong at once for anyone building a real form on 1.5.1, and
+ four of them share a cause: `FieldRendererProps` was a type nobody enforced.
+ Each adapter hand-wrote the object it handed the registered renderer, and the
+ three lists drifted. React dropped `placeholder`, `min`, `max`, `step`,
+ `accept` and `multiple`. Vue dropped `required`, `id`, `dirty` and the aria
+ flags. Angular dropped `touched`, `dirty` and `id` — so an Angular renderer had
+ no way to know whether a field had been touched, and "only show the error once
+ the user leaves the field" had to be rebuilt by hand. Setting
+ `placeholder` on a `FieldDescription` therefore did nothing at all on React and
+ Vue: no error, no warning, the value simply vanished. Core now owns the list as
+ `FIELD_RENDERER_PROP_KEYS` and builds the bag once in
+ `buildFieldRendererProps`, which all three adapters call, and
+ `scripts/check-renderer-prop-parity.js` fails the build if an adapter stops
+ forwarding one. The single deliberate deviation is Vue's `class` in place of
+ `className`: forwarding `className` lets it fall through to a renderer's root
+ element, where Vue assigns `el.className` and wipes the class the renderer set
+ on itself.
+
+ Field ids were `dfk-field-${name}`, derived from the field name alone. Two
+ forms holding a field of the same name — a create form beside an edit form, the
+ most ordinary layout there is — emitted the same DOM id twice, which is invalid
+ HTML and leaves every `label[for]` pointing at two inputs. Ids are now
+ namespaced per `MultiFieldInput` instance (React `useId`, so it is SSR-safe;
+ Vue's instance uid; a counter on Angular). Set `idPrefix` to pin them —
+ `idPrefix="dfk-field"` reproduces the old ids exactly — or give a single field
+ its own id with the new `FieldDescription.id`.
+
+ Touched state had two independent trackers that never met: the one in
+ `useDynamicForm`, and a private one inside `MultiFieldInput` that only blur
+ could set and that was the one renderers actually saw. So
+ `setFieldTouched` in an `onInvalid` handler changed nothing visible, submitting
+ a form nobody had focused showed no errors at all (the button looked broken),
+ and `reset()` could not clear the touched state a previous submit had left
+ behind. `MultiFieldInput` now accepts `touched` as a controlled prop —
+ `useDynamicForm` becomes the single source of truth for it, exactly as
+ `properties`/`onChange` already were for data — plus `onTouchedChange`, and a
+ `form` shorthand (React and Vue) that wires data, change, blur and touched in
+ one prop. `handleSubmit` marks every field touched before validating, and the
+ new `touchAll()`/`resetTouched()` sit alongside it. Omit `touched` and the old
+ internal tracker still runs, so nothing breaks; for that mode a ref
+ (`resetTouched()` on React and Vue, a public method on Angular) can clear it
+ without remounting the component.
+
+ The only behaviour change to watch for is the generated ids. Anything pinned to
+ a literal `dfk-field-*` id in CSS or a test needs either `idPrefix="dfk-field"`
+ or a per-field `id`. Angular's `MultiFieldInput` also loses four undocumented
+ template helpers — `getResolvedOptions`, `getDisabled`, `getReadOnly` and
+ `getError` — which its own template no longer calls now that `FieldInput`
+ resolves all of it through core. Keeping a second copy of that logic beside the
+ shared one is how the adapters drifted apart to begin with; the equivalents are
+ `resolveOptions`, `resolveDisabled`, `resolveReadOnly` and `validateField`,
+ already re-exported from this package.
+
+ Form validity now reflects current data immediately instead of merely checking
+ the lazily populated `errors` map. The error map remains lazy for display, and
+ passing a form binding (or the new controlled `errors` input) makes that same
+ map the renderer's source of truth, removing the previous timing mismatch.
+
+ Promise-based validators are no longer silently accepted on submit.
+ `validateFields` reports unresolved field names in `pending`; every framework
+ form helper uses one async-capable validation pass before dispatching submit
+ callbacks. React, Vue and Angular also expose
+ `validateAsync()` for explicit pre-submit checks. Live `isValid` remains a
+ synchronous answer because a property/computed/signal cannot await.
+
+ The new UI-kit recipes show complete touched/error wiring for Ant Design,
+ Vuetify and Angular Material.
+
+- 5e0b08f: Make async validation answerable: a status you can act on, runs that cancel
+ cleanly, and touched state that reaches inside repeatable groups.
+
+ `ValidationResult` gains `complete` and `status` (`'valid' | 'invalid' |
+'pending'`). Combining `valid` with `pending` was the only way to tell "nothing
+ is wrong" from "nothing is wrong _yet_", and everyone got it wrong the same
+ way — a `valid: true` with async rules still in flight reads as a green light.
+ `status` is the single answer; `complete` says whether every applicable
+ validator finished. Both are always present on a result the library returns, so
+ reading them needs no fallback; code that constructs a `ValidationResult` by
+ hand (a mock, a wrapper typed to return one) has to supply them.
+
+ `FieldDescription.validationMode: 'async'` declares a validator that returns a
+ Promise without the `async` keyword, which detection cannot see. Declaring it
+ keeps the synchronous pass from invoking the validator at all — and, unlike
+ detection, it is an explicit opt-in, so the dev warning about a field the live
+ pass cannot check stays quiet for it.
+
+ `validateFieldsAsync` now takes a `ValidationContext` and forwards its
+ `AbortSignal` to every validator, runs independent validators in parallel
+ instead of awaiting them one after another, skips validators once the signal is
+ aborted, and reports an aborted run as `complete: false` / `status: 'pending'`.
+ A validator that honours the signal the conventional way — rejecting with an
+ `AbortError` — no longer rejects the caller's `handleSubmit`; an error that is
+ not an abort still propagates.
+
+ Each adapter's form helper exposes `isValidating`, `isValidationComplete` and
+ `validationStatus`, and applies latest-run-wins: typing cancels an in-flight
+ live validation so a stale result cannot overwrite a newer one. A submit is not
+ collateral damage of that — it validates the snapshot the user submitted under
+ a controller of its own, so editing a field mid-flight no longer leaves the
+ form with the submit silently dropped, no `onValid`/`onInvalid`, and a button
+ that just re-enables.
+
+ `touchAll()` now expands to the concrete leaf paths that exist in the data
+ (`contacts[0].email`, not `contacts`) via the new `collectFieldPaths`, skipping
+ fields validation itself skips — hidden by `appearCondition`, or disabled.
+ Repeatable group items receive `touched` and report blur with their full path,
+ so a UI kit that only shows an error once a field is touched now works inside a
+ group. An item with no touched keys still receives a map rather than
+ `undefined`, which previously flipped the nested input into tracking touched by
+ itself and left it stale after the owner cleared the map. The new
+ `indexGroupPathMap` is what indexes those maps by item, exported so a custom
+ renderer can do the same without filtering the whole map per item.
+
+ React's `isValid` is now seeded from the initial data instead of from an
+ effect. An effect never runs on the server, so a server-rendered form shipped
+ `isValid: true` for an empty required field and never corrected it — a submit
+ button rendered enabled and stayed that way.
+
+ `@dynamic-field-kit/angular`'s `types` entry pointed at `dist/index.d.ts`,
+ which is not where its type declarations are emitted any more; it and the
+ `exports` block now point at the file that actually ships, so TypeScript
+ consumers resolve the package's types again.
+
+- eec9386: Correct the peer ranges to the ones that actually work, and prove both ends of
+ each in CI.
+
+ `@dynamic-field-kit/angular` declared `@angular/core` and `@angular/common` as
+ `>=14 <22`, but the form store is built on `signal` and `computed`, which
+ Angular introduced in **16**. On 14 or 15 npm accepted the install and the
+ package then failed on import - the manifest promised something it could not
+ do. The range is now `>=16 <22`, so the same install is refused up front.
+
+ `@dynamic-field-kit/vue` moves from `vue ^3.0.0` to `^3.2.0`.
+ `useDynamicForm` now aborts an in-flight validation when the owning effect
+ scope is disposed, using `getCurrentScope` / `onScopeDispose` - both Vue 3.2.
+ Without this an unmounted form held its request open until the response came
+ back. If you are on Vue 3.0 or 3.1, stay on 1.5.x; nothing else in the package
+ ever required 3.2, but nothing tested below it either.
+
+ Both ranges are now verified rather than asserted:
+ `scripts/verify-vue-peer-range.js` server-renders the packed tarballs under Vue
+ 3.2 and the newest 3.x, and `scripts/verify-angular-peer-range.js` installs
+ them against Angular 16 and 21 and checks the package imports, its components
+ evaluate and it shares one registry with core. Both run in the CI verify job,
+ next to the React one that has existed since 1.5.0. A render is out of reach
+ for Angular - the published fesm2022 needs the CLI's linker to instantiate a
+ component - but import-and-wire is the level that breaks across majors, which
+ is exactly how a floor of 14 survived years of `signal()`.
+
+ The three adapters now re-export `collectFieldPaths`, `indexGroupPathMap` and
+ the `ValidationContext` type from core, so typing a validator's `context`
+ argument no longer means importing `@dynamic-field-kit/core` alongside the
+ adapter.
+
+ `@angular/platform-browser-dynamic`, which Angular 21 deprecates, is gone from
+ the package's devDependencies and from the demo app, which never used it - the
+ test setup now initialises through `@angular/platform-browser/testing`.
+
+## 1.5.1
+
+### Patch Changes
+
+- Findable on npm: every package now carries real search keywords, and homepage points at the live demo instead of the README the npm page already renders.
+- b22a6a1: Make the packages findable on npm, and point `homepage` at something worth
+ landing on.
+
+ The keyword lists were three entries long and two of those were the package's
+ own name — nobody searches `dynamic-field-kit/core`. npm ranks search partly on
+ keywords, so in practice these packages could only be found by someone who
+ already knew what they were called. The repository has carried the right
+ vocabulary as GitHub topics all along (`dynamic-forms`, `form-builder`,
+ `form-engine`, `form-validation`, `schema-driven`, `headless`, and the three
+ framework names); npm simply never saw any of it. Each package now carries that
+ vocabulary plus the terms its own users would type, including the schema
+ libraries it actually adapts — zod, yup, valibot and Standard Schema. Not JSON
+ Schema, which it does not support.
+
+ `homepage` pointed at the package's README on GitHub, which is the same text
+ npm already renders on the package page from the shipped README. It now points
+ at the live demo instead, where the forms actually run. The source stays one
+ click away in `repository`.
+
## 1.5.0
### Minor Changes
diff --git a/packages/vue/README.md b/packages/vue/README.md
index 128707d..e0be5b7 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -2,6 +2,12 @@
Vue 3 adapter for `@dynamic-field-kit/core`.
+Requires **Vue 3.2 or newer** (`peerDependencies: vue ^3.2.0`). The floor is
+3.2 because `useDynamicForm` uses `getCurrentScope` / `onScopeDispose` to
+abort an in-flight validation when the owning scope goes away.
+`scripts/verify-vue-peer-range.js` renders the packed tarballs under 3.2 and
+the newest 3.x in CI, so the range is proven rather than asserted.
+
This package provides Vue components that render `FieldDescription[]` and resolve field renderers through the shared registry used by `dynamic-field-kit`.
Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ — tabs for the
@@ -39,16 +45,27 @@ both packages:
- `validateField` / `validateFieldAsync` — one field, returns `string[]`
- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult`
+- `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`)
+- `indexGroupPathMap` — index an error or touched map by repeatable-group item
- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options
- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …)
-- `ValidationResult`
-
-`useDynamicForm` validates **synchronously** via `validateFields`, including on
-submit. Fields whose `validate` hook returns a Promise are treated as valid on
-that path, so run async rules through `validateFieldsAsync` yourself. See the
+- `ValidationResult` / `ValidationContext`
+
+`useDynamicForm` keeps live validation synchronous - a validator declared or
+detected as async is never invoked on that path. Its `handleSubmit` runs one
+async-capable pass, and `validateAsync()` is there when you need that answer
+before submit. Runs are latest-wins: typing aborts the live run in flight, so a
+stale result cannot overwrite a newer one, and a submit validates the snapshot
+it was given under a controller of its own, so editing mid-submit no longer
+cancels it. Declare a Promise-returning validator with
+`validationMode: 'async'` and read `context.signal` (the fourth argument) to
+cancel the request itself. See the
[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation)
for the full rules.
+For a complete UI integration, see the
+[Vuetify recipe](../../docs/ui-kit-recipes.md#vue--vuetify).
+
Default layouts are registered automatically when you import the package root.
Built-in layouts:
@@ -141,35 +158,84 @@ const onSubmit = form.handleSubmit((data) => save(data));
```
+`:form` is shorthand for five state/callback props, and is the recommended wiring:
+
+```vue
+
+```
+
+Passing `touched` and `errors` makes the form store the renderer's source of
+truth. `handleSubmit` marks
+every field touched before validating, so a renderer that gates its error on
+`touched` shows it even for fields the user never focused. `reset()` clears
+touched the same way. Individually passed props win over the ones `form`
+derives.
+
| Member | Description |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| `data` | `Ref` of the form data, with `computeValue` fields applied |
| `errors` | `Ref>`, keyed like `validateFields` |
-| `isValid` / `isDirty` | `computed` / `Ref` |
+| `isValid` / `isDirty` | Live synchronous validity (`computed`) / changed state (`Ref`) |
+| `isValidating` | An async validation pass is in flight |
+| `isValidationComplete` | Every applicable validator finished and none is in flight |
+| `validationStatus` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
| `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted |
| `touched` | Fields that have been blurred |
| `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` |
| `setFieldValue(name, value)` | Change one field |
| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
| `setFieldTouched(name, value?)` | Set touched explicitly |
+| `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
+| `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
| `validate()` | Validate now, returns a boolean |
+| `validateAsync()` | Validate now, awaiting Promise-based rules |
| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
| `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches |
`onBlurField` is what connects `handleBlur` — and therefore `touched` and
`validateOnBlur` — to the rendered form.
+Leave `touched` off and `MultiFieldInput` falls back to tracking it internally
+from blur alone, as it always did. In that mode nothing outside the component
+can clear it, so it exposes `resetTouched()`, `setFieldTouched(name, value?)`
+and `getTouched()` on its instance:
+
+```vue
+
+
+```
+
+## Field ids
+
+Each field renders with ``id={`${idPrefix}-${name}`}``, where `idPrefix`
+defaults to a value unique to the `MultiFieldInput` instance. Two forms
+containing a field of the same name therefore no longer emit the same DOM id
+twice.
+
+```vue
+
+
+```
+
+For one field, set `id` on its `FieldDescription`; it wins over the prefix.
+
## Default renderers
`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` ·
diff --git a/packages/vue/package.json b/packages/vue/package.json
index e47b5f5..bd7c165 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@dynamic-field-kit/vue",
- "version": "1.5.0",
+ "version": "1.6.0",
"description": "Vue 3 renderer for dynamic-field-kit",
"license": "MIT",
"type": "module",
@@ -24,10 +24,10 @@
],
"peerDependencies": {
"@dynamic-field-kit/core": "^1.3.0",
- "vue": "^3.0.0"
+ "vue": "^3.2.0"
},
"devDependencies": {
- "@dynamic-field-kit/core": "^1.5.0",
+ "@dynamic-field-kit/core": "^1.6.0",
"@vitest/coverage-v8": "^4.1.11",
"@vue/test-utils": "^2.5.0",
"jsdom": "^29.1.1",
@@ -64,11 +64,27 @@
"directory": "packages/vue"
},
"keywords": [
- "dynamic-field",
- "dynamic-field-kit",
- "dynamic-field-kit/vue"
+ "vue",
+ "vue3",
+ "vue-forms",
+ "vue-form",
+ "form-state",
+ "dynamic-forms",
+ "dynamic-form",
+ "form-builder",
+ "form-engine",
+ "form-validation",
+ "schema-driven",
+ "headless",
+ "forms",
+ "typescript",
+ "zod",
+ "yup",
+ "valibot",
+ "standard-schema",
+ "dynamic-field-kit"
],
- "homepage": "https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/vue#readme",
+ "homepage": "https://vannt-dev.github.io/dynamic-field-kit/",
"bugs": {
"url": "https://github.com/vannt-dev/dynamic-field-kit/issues"
}
diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts
index 0b7ec6d..4441ea0 100644
--- a/packages/vue/src/components/DynamicInput.ts
+++ b/packages/vue/src/components/DynamicInput.ts
@@ -6,6 +6,11 @@ import { useFieldRegistry } from '../fieldRegistryContext';
const DynamicInput = /* @__PURE__ */ defineComponent({
name: 'DynamicInput',
+ // Every key of core's FIELD_RENDERER_PROP_KEYS must be declared here, or Vue
+ // treats it as a fallthrough attribute instead of a prop and the renderer
+ // never sees it as one. This list drifting from the contract is how
+ // `placeholder`, `required`, `id`, `dirty` and the aria flags used to go
+ // missing on the Vue adapter only.
props: {
type: {
type: String as unknown as PropType,
@@ -23,12 +28,24 @@ const DynamicInput = /* @__PURE__ */ defineComponent({
type: Function as PropType<() => void>,
default: undefined,
},
+ label: {
+ type: String,
+ default: undefined,
+ },
+ placeholder: {
+ type: String,
+ default: undefined,
+ },
+ required: {
+ type: Boolean,
+ default: undefined,
+ },
touched: {
type: Boolean,
default: undefined,
},
- label: {
- type: String,
+ dirty: {
+ type: Boolean,
default: undefined,
},
options: {
@@ -55,6 +72,42 @@ const DynamicInput = /* @__PURE__ */ defineComponent({
type: [String, Array] as PropType,
default: undefined,
},
+ id: {
+ type: String,
+ default: undefined,
+ },
+ ariaInvalid: {
+ type: Boolean,
+ default: undefined,
+ },
+ ariaDescribedBy: {
+ type: String,
+ default: undefined,
+ },
+ ariaRequired: {
+ type: Boolean,
+ default: undefined,
+ },
+ min: {
+ type: [Number, String] as PropType,
+ default: undefined,
+ },
+ max: {
+ type: [Number, String] as PropType,
+ default: undefined,
+ },
+ step: {
+ type: [Number, String] as PropType,
+ default: undefined,
+ },
+ accept: {
+ type: String,
+ default: undefined,
+ },
+ multiple: {
+ type: Boolean,
+ default: undefined,
+ },
// Extra, framework-agnostic props forwarded verbatim to the renderer.
extraProps: {
type: Object as PropType,
@@ -76,16 +129,38 @@ const DynamicInput = /* @__PURE__ */ defineComponent({
return h(Renderer.value, {
...props.extraProps,
value: props.value,
+ // Both spellings: `onUpdate:value` is the Vue idiom the bundled
+ // renderers use, `onValueChange` is the name core's FieldRendererProps
+ // declares, so a renderer ported from React keeps working.
'onUpdate:value': props.onChange,
+ onValueChange: props.onChange,
onBlur: props.onBlur,
- touched: props.touched,
label: props.label,
+ placeholder: props.placeholder,
+ required: props.required,
+ touched: props.touched,
+ dirty: props.dirty,
+ error: props.error,
options: props.options,
+ // Vue's name for the contract's `className`, and the one intentional
+ // deviation from it. Forwarding `className` as well is not an option:
+ // a renderer that does not declare it lets the key fall through to its
+ // root element, where Vue's patchDOMProp assigns `el.className` - with
+ // an undefined value that becomes `''` and wipes the class the
+ // renderer set on itself.
class: props.className,
description: props.description,
disabled: props.disabled,
readOnly: props.readOnly,
- error: props.error,
+ id: props.id,
+ ariaInvalid: props.ariaInvalid,
+ ariaDescribedBy: props.ariaDescribedBy,
+ ariaRequired: props.ariaRequired,
+ min: props.min,
+ max: props.max,
+ step: props.step,
+ accept: props.accept,
+ multiple: props.multiple,
});
};
},
diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts
index 4810924..389b9fd 100644
--- a/packages/vue/src/components/FieldInput.ts
+++ b/packages/vue/src/components/FieldInput.ts
@@ -1,8 +1,6 @@
import {
- resolveDisabled,
- resolveOptions,
- resolveReadOnly,
- validateField,
+ buildFieldRendererProps,
+ makeFieldId,
FieldDescription,
Properties,
} from '@dynamic-field-kit/core';
@@ -24,6 +22,11 @@ const FieldInput = /* @__PURE__ */ defineComponent({
type: Object as PropType,
default: undefined,
},
+ /** Per-form-instance id namespace; see core's `makeFieldId`. */
+ idPrefix: {
+ type: String,
+ default: 'dfk-field',
+ },
onValueChangeField: {
type: Function as PropType<(value: unknown, key: string) => void>,
required: true,
@@ -36,61 +39,32 @@ const FieldInput = /* @__PURE__ */ defineComponent({
type: Boolean,
default: undefined,
},
+ dirty: {
+ type: Boolean,
+ default: undefined,
+ },
+ errors: {
+ type: Object as PropType>,
+ default: undefined,
+ },
},
setup(props) {
return () => {
- const {
- name,
- type,
- label,
- className,
- description,
- required,
- props: extraProps,
- } = props.fieldDescription;
+ const { name } = props.fieldDescription;
- const disabled = resolveDisabled(
- props.fieldDescription,
- props.renderInfos,
- props.rootData,
- );
- const readOnly = resolveReadOnly(
- props.fieldDescription,
- props.renderInfos,
- props.rootData,
- );
- const resolvedOptions = resolveOptions(
- props.fieldDescription,
- props.renderInfos,
- props.rootData,
- );
- const errors = disabled
- ? []
- : validateField(
- props.fieldDescription,
- props.renderInfos[name],
- props.renderInfos,
- props.rootData,
- );
- const errorList = errors.length > 0 ? errors : undefined;
- const fieldId = `dfk-field-${name}`;
+ const rendererProps = buildFieldRendererProps({
+ fieldDescription: props.fieldDescription,
+ data: props.renderInfos,
+ rootData: props.rootData,
+ id: makeFieldId(props.fieldDescription, props.idPrefix),
+ touched: props.touched,
+ dirty: props.dirty,
+ validationErrors:
+ props.errors === undefined ? undefined : (props.errors[name] ?? []),
+ });
return h(DynamicInput, {
- id: fieldId,
- type,
- label,
- value: props.renderInfos[name],
- options: resolvedOptions,
- className,
- description,
- disabled,
- readOnly,
- required,
- error: errorList,
- ariaInvalid: Boolean(errorList),
- ariaRequired: Boolean(required),
- extraProps,
- touched: props.touched,
+ ...rendererProps,
onChange: (v: unknown) => props.onValueChangeField(v, name),
onBlur: () => props.onBlurField?.(name),
});
diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts
index c6ce272..7c6654b 100644
--- a/packages/vue/src/components/MultiFieldInput.ts
+++ b/packages/vue/src/components/MultiFieldInput.ts
@@ -3,17 +3,39 @@ import {
canAddGroupItem,
canRemoveGroupItem,
createGroupItem,
+ indexGroupPathMap,
validateFields,
FieldDescription,
Properties,
} from '@dynamic-field-kit/core';
import type { ValidationResult } from '@dynamic-field-kit/core';
-import type { Component } from 'vue';
-import { computed, defineComponent, h, PropType, reactive, watch } from 'vue';
+import type { Component, Ref } from 'vue';
+import {
+ computed,
+ defineComponent,
+ getCurrentInstance,
+ h,
+ PropType,
+ reactive,
+ unref,
+ watch,
+} from 'vue';
import { layoutRegistry } from '../layout';
import { LayoutConfig } from '../types/layout';
import FieldInput from './FieldInput';
+/**
+ * The slice of `useDynamicForm`'s result `MultiFieldInput` needs to drive
+ * itself. Its members are refs, so they are unwrapped on read.
+ */
+export interface DynamicFormBinding {
+ data: Ref | Properties;
+ errors: Ref> | Record;
+ touched: Ref> | Record;
+ handleChange: (data: Properties) => void;
+ handleBlur: (fieldName: string) => void;
+}
+
function resolveLayout(layout?: LayoutConfig) {
if (!layout) {
return { type: 'column', config: {} };
@@ -45,6 +67,9 @@ function selfRef(): Component {
return MultiFieldInput;
}
+/** Shared so an untouched item keeps a stable touched-map identity. */
+const EMPTY_TOUCHED: Record = Object.freeze({});
+
// Repeatable field groups render a nested MultiFieldInput per item, so this
// component renders itself recursively (see renderGroupField below) rather
// than delegating to a separate group component. A separate file importing
@@ -58,9 +83,11 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
type: Array as PropType,
required: true,
},
+ // Left undefined rather than `{}` when unset so the `form` shorthand can
+ // tell "no properties passed" from "passed an empty object".
properties: {
type: Object as PropType,
- default: () => ({}),
+ default: undefined,
},
onChange: {
type: Function as PropType<(data: Properties) => void>,
@@ -89,22 +116,138 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
type: Function as PropType<(fieldName: string) => void>,
default: undefined,
},
+ // Namespace for generated field ids: a field renders with
+ // `${idPrefix}-${name}`. Defaults to a value unique to this component
+ // instance, so two forms containing the same field name do not emit
+ // duplicate DOM ids. Pass a fixed string to pin ids
+ // (`idPrefix="dfk-field"` restores the pre-1.6 ids), or set
+ // `FieldDescription.id` per field.
+ idPrefix: {
+ type: String,
+ default: undefined,
+ },
+ // Controlled touched map. When provided it is the single source of truth
+ // and the internal tracker is bypassed, so `useDynamicForm().touched`
+ // (updated by setFieldTouched/touchAll/handleSubmit, cleared by reset) is
+ // what renderers actually see. Omit it to keep the internal, blur-only
+ // tracker.
+ touched: {
+ type: Object as PropType>,
+ default: undefined,
+ },
+ // Controlled error map. When supplied (directly or through `form`), it is
+ // the renderer's source of truth instead of live per-field validation.
+ errors: {
+ type: Object as PropType>,
+ default: undefined,
+ },
+ // Fires with the next touched map whenever a field is blurred.
+ onTouchedChange: {
+ type: Function as PropType<(touched: Record) => void>,
+ default: undefined,
+ },
+ // Shorthand wiring `properties`, `onChange`, `onBlurField` and `touched`
+ // from a `useDynamicForm` result in one prop. Individually passed props
+ // win over the ones derived from here.
+ form: {
+ type: Object as PropType,
+ default: undefined,
+ },
},
- setup(props) {
+ setup(props, { expose }) {
// A single reactive object, mutated in place (never reassigned), so Vue's
// per-property dependency tracking lets each FieldInput re-render only
// when the specific key it reads actually changes.
const data = reactive({});
const touchedFields = reactive>({});
+ // Unique per component instance, so two forms rendering the same field
+ // name no longer emit duplicate DOM ids.
+ const instanceUid = getCurrentInstance()?.uid ?? 0;
+ const effectiveIdPrefix = computed(
+ () => props.idPrefix ?? `dfk-${instanceUid}`,
+ );
+
+ // Explicit props take precedence over the `form` shorthand, so a caller
+ // can pass `form` and still override one wire.
+ const effectiveProperties = computed(() =>
+ props.properties !== undefined
+ ? props.properties
+ : props.form
+ ? unref(props.form.data)
+ : undefined,
+ );
+ const controlledTouched = computed | undefined>(
+ () =>
+ props.touched !== undefined
+ ? props.touched
+ : props.form
+ ? unref(props.form.touched)
+ : undefined,
+ );
+ const effectiveTouched = computed>(
+ () => controlledTouched.value ?? touchedFields,
+ );
+ const effectiveErrors = computed | undefined>(
+ () =>
+ props.errors !== undefined
+ ? props.errors
+ : props.form
+ ? unref(props.form.errors)
+ : undefined,
+ );
+ const emitChange = (next: Properties) => {
+ props.onChange?.(next);
+ if (!props.onChange) {
+ props.form?.handleChange(next);
+ }
+ };
+
+ // Snapshot of the values this form opened with, for the `dirty` flag.
+ const initialProperties: Properties = {
+ ...(effectiveProperties.value ?? {}),
+ };
+
function handleBlurField(key: string) {
- touchedFields[key] = true;
- props.onBlurField?.(key);
+ if (controlledTouched.value === undefined) {
+ touchedFields[key] = true;
+ }
+ props.onTouchedChange?.({ ...effectiveTouched.value, [key]: true });
+ if (props.onBlurField) {
+ props.onBlurField(key);
+ } else {
+ props.form?.handleBlur(key);
+ }
+ }
+
+ /**
+ * Clears the internally tracked touched state. Only meaningful in
+ * uncontrolled mode - when `touched` is passed, resetting the form store
+ * (e.g. `useDynamicForm().reset()`) already clears it.
+ */
+ function resetTouched() {
+ Object.keys(touchedFields).forEach((key) => delete touchedFields[key]);
}
+ function setFieldTouched(fieldName: string, isTouched = true) {
+ if (controlledTouched.value === undefined) {
+ touchedFields[fieldName] = isTouched;
+ }
+ props.onTouchedChange?.({
+ ...effectiveTouched.value,
+ [fieldName]: isTouched,
+ });
+ }
+
+ expose({
+ resetTouched,
+ setFieldTouched,
+ getTouched: () => effectiveTouched.value,
+ });
+
watch(
- () => props.properties,
+ () => effectiveProperties.value,
(newProps) => {
Object.keys(data).forEach((key) => delete data[key]);
if (newProps) {
@@ -150,7 +293,7 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
data,
applyComputedValues(props.fieldDescriptions, next, props.rootData),
);
- props.onChange?.({ ...data });
+ emitChange({ ...data });
};
const handleValueChange = (value: unknown, key: string) => {
@@ -200,6 +343,23 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
? ((item[field.keyField] as string | number) ?? index)
: index;
+ const errorsByGroup = computed(() =>
+ Object.fromEntries(
+ props.fieldDescriptions.map((field) => [
+ field.name,
+ indexGroupPathMap(effectiveErrors.value, field.name),
+ ]),
+ ),
+ );
+ const touchedByGroup = computed(() =>
+ Object.fromEntries(
+ props.fieldDescriptions.map((field) => [
+ field.name,
+ indexGroupPathMap(effectiveTouched.value, field.name),
+ ]),
+ ),
+ );
+
const renderGroupField = (field: FieldDescription) => {
const items = getItems(field);
const fields = field.fields ?? [];
@@ -222,6 +382,18 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
fieldDescriptions: fields,
properties: item,
rootData: props.rootData ?? data,
+ errors: errorsByGroup.value[field.name]?.[index],
+ // An item with no touched keys still has to receive a map,
+ // or the nested input reads `undefined` as "uncontrolled"
+ // and starts tracking touched on its own - which then
+ // survives the owner clearing the map.
+ touched:
+ controlledTouched.value === undefined
+ ? undefined
+ : (touchedByGroup.value[field.name]?.[index] ??
+ EMPTY_TOUCHED),
+ onBlurField: (key: string) =>
+ handleBlurField(`${field.name}[${index}].${key}`),
onChange: (next: Properties) =>
handleGroupItemChange(field, index, next),
}),
@@ -265,7 +437,10 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({
fieldDescription: f,
renderInfos: data,
rootData: props.rootData ?? data,
- touched: Boolean(touchedFields[f.name]),
+ idPrefix: effectiveIdPrefix.value,
+ touched: Boolean(effectiveTouched.value[f.name]),
+ errors: effectiveErrors.value,
+ dirty: data[f.name] !== initialProperties[f.name],
onValueChangeField: handleValueChange,
onBlurField: handleBlurField,
}),
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index 384f3db..cd7e25e 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -5,6 +5,7 @@ import './layout/responsiveLayout';
export { default as DynamicInput } from './components/DynamicInput';
export { default as FieldInput } from './components/FieldInput';
export { default as MultiFieldInput } from './components/MultiFieldInput';
+export type { DynamicFormBinding } from './components/MultiFieldInput';
export { defaultRenderersMap, getDefaultRenderer } from './defaultRenderers';
export {
@@ -23,6 +24,8 @@ export {
validateFieldAsync,
validateFields,
validateFieldsAsync,
+ collectFieldPaths,
+ indexGroupPathMap,
resolveDisabled,
resolveReadOnly,
resolveOptions,
@@ -33,6 +36,10 @@ export {
type FieldTypeMap,
type Properties,
type ValidationResult,
+ type ValidationContext,
+ buildFieldRendererProps,
+ makeFieldId,
+ FIELD_RENDERER_PROP_KEYS,
} from '@dynamic-field-kit/core';
export type { LayoutConfig } from './types/layout';
diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts
index e63ff3b..0d59e23 100644
--- a/packages/vue/src/useDynamicForm.ts
+++ b/packages/vue/src/useDynamicForm.ts
@@ -1,10 +1,13 @@
import {
applyComputedValues,
+ collectFieldPaths,
FieldDescription,
Properties,
+ type ValidationResult,
validateFields,
+ validateFieldsAsync,
} from '@dynamic-field-kit/core';
-import { computed, ref } from 'vue';
+import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
export interface UseDynamicFormOptions {
fields: FieldDescription[];
@@ -25,22 +28,82 @@ export function useDynamicForm({
const touched = ref>({});
const isSubmitting = ref(false);
const isSubmitted = ref(false);
+ const initialValidation = validateFields(fields, data.value);
+ const validationResult = ref(initialValidation);
+ const isValidating = ref(false);
+ let validationRun = 0;
+ let validationController: AbortController | undefined;
+ // A submit gets its own run counter and controller. Typing aborts the live
+ // validation run, and a submit must not be collateral damage of that.
+ let submitRun = 0;
+ let submitController: AbortController | undefined;
- const isValid = computed(() => Object.keys(errors.value).length === 0);
+ // Cancel whatever is still in flight when the owning component (or effect
+ // scope) goes away, so an unmounted form stops holding a request open. The
+ // guard is for calling this composable outside a scope, which the tests do
+ // and which onScopeDispose would otherwise warn about.
+ if (getCurrentScope()) {
+ onScopeDispose(() => {
+ validationController?.abort();
+ submitController?.abort();
+ });
+ }
+
+ const isValid = computed(() => validationResult.value.valid);
+ const isValidationComplete = computed(
+ () => validationResult.value.complete && !isValidating.value,
+ );
+ const validationStatus = computed(() =>
+ isValidating.value ? 'pending' : validationResult.value.status,
+ );
+
+ function commitSyncResult(res: ValidationResult) {
+ validationResult.value = res;
+ return res.valid;
+ }
function validate() {
const res = validateFields(fields, data.value);
errors.value = res.errors;
- return res.valid;
+ return commitSyncResult(res);
+ }
+
+ async function validateAsync() {
+ const run = ++validationRun;
+ validationController?.abort();
+ const controller = new AbortController();
+ validationController = controller;
+ const snapshot = data.value;
+ isValidating.value = true;
+ try {
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ signal: controller.signal,
+ });
+ if (run !== validationRun || data.value !== snapshot) {
+ return res.valid;
+ }
+ errors.value = res.errors;
+ validationResult.value = res;
+ return res.valid;
+ } finally {
+ if (run === validationRun) {
+ isValidating.value = false;
+ }
+ }
}
function handleChange(newData: Properties) {
const next = applyComputedValues(fields, newData);
data.value = next;
isDirty.value = true;
+ validationController?.abort();
+ validationRun += 1;
+ isValidating.value = false;
+
+ const res = validateFields(fields, next);
+ commitSyncResult(res);
if (validateOnChange) {
- const res = validateFields(fields, next);
errors.value = res.errors;
}
}
@@ -53,11 +116,30 @@ export function useDynamicForm({
touched.value = { ...touched.value, [name]: isTouched };
}
+ /**
+ * Marks every field touched at once. `handleSubmit` calls this for you, so
+ * an invalid submit surfaces errors on fields the user never focused - bind
+ * `touched` on `MultiFieldInput` for it to take effect.
+ */
+ function touchAll() {
+ touched.value = Object.fromEntries(
+ collectFieldPaths(fields, data.value).map(
+ (path) => [path, true] as const,
+ ),
+ );
+ }
+
+ /** Clears the touched map without touching data, errors or dirty state. */
+ function resetTouched() {
+ touched.value = {};
+ }
+
function handleBlur(fieldName: string) {
setFieldTouched(fieldName, true);
if (validateOnBlur) {
const res = validateFields(fields, data.value);
errors.value = res.errors;
+ commitSyncResult(res);
}
}
@@ -70,6 +152,10 @@ export function useDynamicForm({
touched.value = {};
isSubmitting.value = false;
isSubmitted.value = false;
+ validationController?.abort();
+ validationRun += 1;
+ isValidating.value = false;
+ commitSyncResult(validateFields(fields, next));
}
function handleSubmit(
@@ -81,16 +167,50 @@ export function useDynamicForm({
e.preventDefault();
}
isSubmitting.value = true;
+ const thisSubmit = ++submitRun;
try {
- const res = validateFields(fields, data.value);
- errors.value = res.errors;
+ // Touch everything before validating: a submit is the user asserting
+ // the form is finished, so a field they never focused should still
+ // show its error. Without this, submitting an untouched form appears
+ // to do nothing at all.
+ touchAll();
+ const run = ++validationRun;
+ // Cancel any live run so its (older) result cannot land on top of this
+ // one, but validate under a controller of the submit's own.
+ validationController?.abort();
+ submitController?.abort();
+ const controller = new AbortController();
+ submitController = controller;
+ const snapshot = data.value;
+ isValidating.value = true;
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
+ signal: controller.signal,
+ });
+ if (thisSubmit !== submitRun) {
+ return;
+ }
+ // Editing during the submit does not cancel it - the user submitted
+ // this snapshot and is owed an answer for it. What the form *shows*
+ // still has to describe the data on screen, so when it moved on, the
+ // displayed state is re-derived instead of showing the old pass.
+ if (data.value === snapshot && run === validationRun) {
+ errors.value = res.errors;
+ validationResult.value = res;
+ } else {
+ const live = validateFields(fields, data.value);
+ errors.value = live.errors;
+ validationResult.value = live;
+ }
isSubmitted.value = true;
if (res.valid) {
- await onValid(data.value);
+ await onValid(snapshot);
} else if (onInvalid) {
onInvalid(res.errors);
}
} finally {
+ if (thisSubmit === submitRun) {
+ isValidating.value = false;
+ }
isSubmitting.value = false;
}
};
@@ -100,16 +220,22 @@ export function useDynamicForm({
data,
errors,
isValid,
+ isValidating,
+ isValidationComplete,
+ validationStatus,
isDirty,
touched,
isSubmitting,
isSubmitted,
setFieldValue,
setFieldTouched,
+ touchAll,
+ resetTouched,
handleChange,
handleBlur,
reset,
validate,
+ validateAsync,
handleSubmit,
};
}
diff --git a/packages/vue/test/MultiFieldInputBlur.test.ts b/packages/vue/test/MultiFieldInputBlur.test.ts
index 458d59f..caca8f8 100644
--- a/packages/vue/test/MultiFieldInputBlur.test.ts
+++ b/packages/vue/test/MultiFieldInputBlur.test.ts
@@ -22,6 +22,7 @@ const TextRenderer = defineComponent({
props: {
value: null,
id: String,
+ touched: Boolean,
onBlur: Function as PropType<() => void>,
'onUpdate:value': Function as PropType<(v: unknown) => void>,
},
@@ -29,6 +30,7 @@ const TextRenderer = defineComponent({
return () =>
h('input', {
'data-testid': props.id,
+ 'data-touched': String(Boolean(props.touched)),
value: (props.value as string) ?? '',
onBlur: props.onBlur,
});
@@ -43,7 +45,7 @@ describe('MultiFieldInput blur reporting (Vue)', () => {
it('reports which field was blurred', async () => {
const onBlurField = vi.fn();
const wrapper = mount(MultiFieldInput, {
- props: { fieldDescriptions: fields, onBlurField },
+ props: { fieldDescriptions: fields, idPrefix: 'dfk-field', onBlurField },
});
await wrapper.find('[data-testid="dfk-field-second"]').trigger('blur');
@@ -54,7 +56,7 @@ describe('MultiFieldInput blur reporting (Vue)', () => {
it('reports each field separately', async () => {
const onBlurField = vi.fn();
const wrapper = mount(MultiFieldInput, {
- props: { fieldDescriptions: fields, onBlurField },
+ props: { fieldDescriptions: fields, idPrefix: 'dfk-field', onBlurField },
});
await wrapper.find('[data-testid="dfk-field-first"]').trigger('blur');
@@ -68,11 +70,57 @@ describe('MultiFieldInput blur reporting (Vue)', () => {
it('works without a handler', async () => {
const wrapper = mount(MultiFieldInput, {
- props: { fieldDescriptions: fields },
+ props: { fieldDescriptions: fields, idPrefix: 'dfk-field' },
});
await expect(
wrapper.find('[data-testid="dfk-field-first"]').trigger('blur'),
).resolves.not.toThrow();
});
+
+ it('reports the full path for a field inside a repeatable group', async () => {
+ const onBlurField = vi.fn();
+ const wrapper = mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [
+ {
+ name: 'contacts',
+ type: 'text',
+ fields: [{ name: 'email', type: 'text' }],
+ },
+ ],
+ properties: { contacts: [{ email: '' }] },
+ onBlurField,
+ },
+ });
+
+ await wrapper.find('input').trigger('blur');
+ expect(onBlurField).toHaveBeenCalledWith('contacts[0].email');
+ });
+
+ it('lets the owner clear touched inside a repeatable group', async () => {
+ // An item with no touched keys must still count as controlled, or the
+ // nested MultiFieldInput starts tracking touched itself and keeps showing
+ // it after the owner clears the map.
+ const wrapper = mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [
+ {
+ name: 'contacts',
+ type: 'text',
+ fields: [{ name: 'email', type: 'text' }],
+ },
+ ],
+ properties: { contacts: [{ email: '' }] },
+ touched: {} as Record,
+ },
+ });
+
+ await wrapper.find('input').trigger('blur');
+ await wrapper.setProps({ touched: { 'contacts[0].email': true } });
+ expect(wrapper.find('input').attributes('data-touched')).toBe('true');
+
+ await wrapper.setProps({ touched: {} });
+ expect(wrapper.find('input').attributes('data-touched')).toBe('false');
+ });
});
diff --git a/packages/vue/test/reportedIssues.test.ts b/packages/vue/test/reportedIssues.test.ts
new file mode 100644
index 0000000..e114752
--- /dev/null
+++ b/packages/vue/test/reportedIssues.test.ts
@@ -0,0 +1,281 @@
+/**
+ * Regression tests for the issues reported against 1.5.1. Mirrors
+ * packages/react/test/reportedIssues.test.tsx so the two adapters are held to
+ * the same behaviour.
+ */
+import type { FieldDescription } from '@dynamic-field-kit/core';
+import { fieldRegistry } from '@dynamic-field-kit/core';
+import { flushPromises, mount } from '@vue/test-utils';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { defineComponent, h, PropType } from 'vue';
+import MultiFieldInput from '../src/components/MultiFieldInput';
+import { useDynamicForm } from '../src/useDynamicForm';
+import '../src/layout/defaultLayouts';
+
+/** Mirrors what an app renderer does: show the error only once touched. */
+const TextRenderer = defineComponent({
+ name: 'TextRenderer',
+ props: {
+ id: String,
+ value: null,
+ label: String,
+ placeholder: String,
+ touched: Boolean,
+ required: Boolean,
+ dirty: Boolean,
+ error: [String, Array] as PropType,
+ onValueChange: Function as PropType<(v: unknown) => void>,
+ 'onUpdate:value': Function as PropType<(v: unknown) => void>,
+ onBlur: Function as PropType<() => void>,
+ },
+ setup(props) {
+ return () =>
+ h('div', [
+ h('input', {
+ id: props.id,
+ 'data-testid': props.id,
+ 'data-label': props.label,
+ placeholder: props.placeholder,
+ value: (props.value as string) ?? '',
+ onBlur: props.onBlur,
+ }),
+ props.touched && props.error
+ ? h('span', { role: 'alert' }, [props.error].flat()[0] as string)
+ : null,
+ ]);
+ },
+});
+
+beforeEach(() => {
+ fieldRegistry.register('text', TextRenderer as never);
+});
+
+describe('issue 1: duplicate field ids across form instances (Vue)', () => {
+ const fields: FieldDescription[] = [
+ { name: 'title', type: 'text', label: 'Title' },
+ ];
+
+ it('gives two forms sharing a field name different ids', () => {
+ const Host = defineComponent({
+ setup: () => () =>
+ h('div', [
+ h(MultiFieldInput, { fieldDescriptions: fields }),
+ h(MultiFieldInput, { fieldDescriptions: fields }),
+ ]),
+ });
+ const wrapper = mount(Host);
+
+ const ids = wrapper.findAll('input').map((i) => i.attributes('id'));
+ expect(ids).toHaveLength(2);
+ expect(ids[0]).toBeTruthy();
+ expect(ids[0]).not.toBe(ids[1]);
+ });
+
+ it('produces ids usable as CSS selectors', () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: { fieldDescriptions: fields },
+ });
+
+ const id = wrapper.find('input').attributes('id') as string;
+ expect(id).toMatch(/^[A-Za-z][\w-]*$/);
+ });
+
+ it('honours an explicit idPrefix', () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: { fieldDescriptions: fields, idPrefix: 'dfk-field' },
+ });
+
+ expect(wrapper.find('input').attributes('id')).toBe('dfk-field-title');
+ });
+
+ it('lets a field pin its own id', () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [{ ...fields[0], id: 'login-title' }],
+ },
+ });
+
+ expect(wrapper.find('input').attributes('id')).toBe('login-title');
+ });
+});
+
+describe('issue 2/3: touched is controllable and resettable (Vue)', () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ label: 'Username',
+ validate: (v) => (v ? undefined : 'Required'),
+ },
+ ];
+
+ const Form = defineComponent({
+ setup() {
+ const form = useDynamicForm({ fields, initialValues: { username: '' } });
+ const submit = form.handleSubmit(() => {});
+ return () =>
+ h('form', { onSubmit: submit }, [
+ h(MultiFieldInput, { fieldDescriptions: fields, form }),
+ h('button', { type: 'submit' }, 'Submit'),
+ h('button', { type: 'button', onClick: () => form.reset() }, 'Reset'),
+ ]);
+ },
+ });
+
+ it('shows the error on submit without the field ever being focused', async () => {
+ const wrapper = mount(Form);
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false);
+
+ await wrapper.find('form').trigger('submit');
+ await flushPromises();
+
+ expect(wrapper.find('[role="alert"]').text()).toBe('Required');
+ });
+
+ it('clears the error after reset', async () => {
+ const wrapper = mount(Form);
+
+ await wrapper.find('input').trigger('blur');
+ expect(wrapper.find('[role="alert"]').exists()).toBe(true);
+
+ await wrapper.findAll('button')[1].trigger('click');
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false);
+ });
+
+ it('exposes resetTouched for the uncontrolled mode', async () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: { fieldDescriptions: fields, properties: { username: '' } },
+ });
+
+ await wrapper.find('input').trigger('blur');
+ expect(wrapper.find('[role="alert"]').exists()).toBe(true);
+
+ (wrapper.vm as unknown as { resetTouched: () => void }).resetTouched();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false);
+ });
+
+ it('touchAll marks every field touched', () => {
+ const form = useDynamicForm({ fields });
+ expect(form.touched.value).toEqual({});
+ form.touchAll();
+ expect(form.touched.value).toEqual({ username: true });
+ });
+});
+
+describe('issue 4: FieldDescription props reach the renderer (Vue)', () => {
+ it('forwards placeholder declared at the top level', () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [
+ {
+ name: 'username',
+ type: 'text',
+ label: 'Username',
+ placeholder: 'Type your name',
+ },
+ ] as FieldDescription[],
+ },
+ });
+
+ expect(wrapper.find('input').attributes('placeholder')).toBe(
+ 'Type your name',
+ );
+ });
+
+ it('forwards required, label and the numeric/file props', () => {
+ const received: Record[] = [];
+ const Probe = defineComponent({
+ inheritAttrs: false,
+ setup(_props, { attrs }) {
+ received.push({ ...attrs });
+ return () => h('input');
+ },
+ });
+ fieldRegistry.register('text', Probe as never);
+
+ mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [
+ {
+ name: 'n',
+ type: 'text',
+ label: 'N',
+ required: true,
+ min: 1,
+ max: 9,
+ step: 2,
+ accept: '.png',
+ multiple: true,
+ },
+ ] as FieldDescription[],
+ },
+ });
+
+ expect(received[0]).toMatchObject({
+ label: 'N',
+ required: true,
+ min: 1,
+ max: 9,
+ step: 2,
+ accept: '.png',
+ multiple: true,
+ });
+ });
+});
+
+describe('issue 5: form errors are the renderer source of truth (Vue)', () => {
+ it('does not show a live error before the form store records it', async () => {
+ const fields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ validate: () => 'Required',
+ },
+ ];
+ const Form = defineComponent({
+ setup() {
+ const form = useDynamicForm({ fields });
+ return () =>
+ h('div', [
+ h(MultiFieldInput, { fieldDescriptions: fields, form }),
+ h(
+ 'button',
+ { onClick: () => form.setFieldTouched('username') },
+ 'touch',
+ ),
+ h('button', { onClick: form.validate }, 'validate'),
+ ]);
+ },
+ });
+ const wrapper = mount(Form);
+
+ await wrapper.findAll('button')[0].trigger('click');
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false);
+
+ await wrapper.findAll('button')[1].trigger('click');
+ expect(wrapper.find('[role="alert"]').text()).toBe('Required');
+ });
+});
+
+describe('issue 1, worst case: repeatable groups (Vue)', () => {
+ it('gives every group item its own id for the same field name', () => {
+ const wrapper = mount(MultiFieldInput, {
+ props: {
+ fieldDescriptions: [
+ {
+ name: 'contacts',
+ type: 'text',
+ label: 'Contacts',
+ fields: [{ name: 'email', type: 'text', label: 'Email' }],
+ },
+ ] as FieldDescription[],
+ properties: { contacts: [{ email: 'a' }, { email: 'b' }, {}] },
+ },
+ });
+
+ const ids = wrapper.findAll('input').map((i) => i.attributes('id'));
+ expect(ids).toHaveLength(3);
+ expect(new Set(ids).size).toBe(3);
+ });
+});
diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts
index b91fbd5..ed305f3 100644
--- a/packages/vue/test/useDynamicForm.test.ts
+++ b/packages/vue/test/useDynamicForm.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
+import { effectScope } from 'vue';
import type { FieldDescription } from '../src';
import { useDynamicForm } from '../src';
@@ -86,6 +87,16 @@ describe('useDynamicForm submission state', () => {
});
describe('useDynamicForm behaviour', () => {
+ it('reports live validity before errors have been populated', () => {
+ const form = useDynamicForm({ fields });
+
+ expect(form.isValid.value).toBe(false);
+ expect(form.errors.value).toEqual({});
+
+ form.setFieldValue('name', 'Ada');
+ expect(form.isValid.value).toBe(true);
+ });
+
it('tracks dirty state and touched fields', () => {
const form = useDynamicForm({ fields });
@@ -102,6 +113,25 @@ describe('useDynamicForm behaviour', () => {
expect(form.touched.value.nickname).toBe(false);
});
+ it('touchAll expands every existing repeatable group item', () => {
+ const form = useDynamicForm({
+ fields: [
+ {
+ name: 'contacts',
+ type: 'text',
+ fields: [{ name: 'email', type: 'text' }],
+ },
+ ],
+ initialValues: { contacts: [{ email: '' }, { email: '' }] },
+ });
+
+ form.touchAll();
+ expect(form.touched.value).toEqual({
+ 'contacts[0].email': true,
+ 'contacts[1].email': true,
+ });
+ });
+
it('validates on blur by default', () => {
const form = useDynamicForm({ fields });
@@ -140,6 +170,61 @@ describe('useDynamicForm behaviour', () => {
expect(form.errors.value.name).toEqual(['Name is required']);
});
+ it('awaits async validation explicitly and during submit', async () => {
+ const asyncFields: FieldDescription[] = [
+ {
+ name: 'username',
+ type: 'text',
+ validate: async (value) =>
+ value === 'taken' ? 'Already taken' : undefined,
+ },
+ ];
+ const form = useDynamicForm({
+ fields: asyncFields,
+ initialValues: { username: 'taken' },
+ });
+ const onValid = vi.fn();
+ const onInvalid = vi.fn();
+
+ await expect(form.validateAsync()).resolves.toBe(false);
+ expect(form.errors.value).toEqual({ username: ['Already taken'] });
+
+ await form.handleSubmit(onValid, onInvalid)();
+ expect(onValid).not.toHaveBeenCalled();
+ expect(onInvalid).toHaveBeenCalledWith({
+ username: ['Already taken'],
+ });
+ });
+
+ it('ignores a stale async validation result', async () => {
+ const releases = new Map void>();
+ const form = useDynamicForm({
+ fields: [
+ {
+ name: 'username',
+ type: 'text',
+ validationMode: 'async',
+ validate: (value) =>
+ new Promise((resolve) => {
+ releases.set(String(value), resolve);
+ }),
+ },
+ ],
+ initialValues: { username: 'old' },
+ });
+
+ const oldRun = form.validateAsync();
+ form.setFieldValue('username', 'new');
+ const newRun = form.validateAsync();
+ releases.get('new')?.();
+ await newRun;
+ releases.get('old')?.('Stale error');
+ await oldRun;
+
+ expect(form.errors.value).toEqual({});
+ expect(form.isValidating.value).toBe(false);
+ });
+
it('applies computed values to the initial data', () => {
const computed: FieldDescription[] = [
{ name: 'first', type: 'text' },
@@ -176,4 +261,66 @@ describe('useDynamicForm behaviour', () => {
expect(form.data.value).toEqual({ name: 'Bulk', nickname: 'B' });
expect(form.isDirty.value).toBe(true);
});
+
+ it('completes a submit even when the user edits a field while it runs', async () => {
+ let release: ((value: string | undefined) => void) | undefined;
+ const slow: FieldDescription[] = [
+ { name: 'name', type: 'text' },
+ {
+ name: 'code',
+ type: 'text',
+ // Only the submit's async pass invokes it, so `release` belongs to
+ // that one run.
+ validationMode: 'async',
+ validate: () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ },
+ ];
+ const onValid = vi.fn();
+ const form = useDynamicForm({
+ fields: slow,
+ initialValues: { name: 'Ada', code: '1' },
+ });
+
+ const submitted = form.handleSubmit(onValid)();
+ form.handleChange({ name: 'Grace', code: '1' });
+ release?.(undefined);
+ await submitted;
+
+ expect(onValid).toHaveBeenCalledTimes(1);
+ expect(onValid).toHaveBeenCalledWith({ name: 'Ada', code: '1' });
+ expect(form.isSubmitted.value).toBe(true);
+ expect(form.isSubmitting.value).toBe(false);
+ });
+});
+
+describe('scope cleanup', () => {
+ it('aborts an in-flight validation when the owning scope is disposed', async () => {
+ let seen: AbortSignal | undefined;
+ const scope = effectScope();
+ const form = scope.run(() =>
+ useDynamicForm({
+ fields: [
+ {
+ name: 'code',
+ type: 'text',
+ validationMode: 'async',
+ validate: (_v, _d, _r, context) =>
+ new Promise(() => {
+ seen = context?.signal;
+ }),
+ },
+ ],
+ }),
+ );
+
+ void form!.validateAsync();
+ await Promise.resolve();
+ expect(seen?.aborted).toBe(false);
+
+ scope.stop();
+ expect(seen?.aborted).toBe(true);
+ });
});
diff --git a/packages/vue/test/validation.test.ts b/packages/vue/test/validation.test.ts
index a4ab122..603c8b0 100644
--- a/packages/vue/test/validation.test.ts
+++ b/packages/vue/test/validation.test.ts
@@ -97,6 +97,8 @@ describe('Vue validation wiring', () => {
expect(onValidityChange).toHaveBeenLastCalledWith({
valid: false,
errors: { name: ['Required'] },
+ complete: true,
+ status: 'invalid',
});
});
});
diff --git a/scripts/add-dts-extensions.js b/scripts/add-dts-extensions.js
index 0420a7b..118146d 100644
--- a/scripts/add-dts-extensions.js
+++ b/scripts/add-dts-extensions.js
@@ -25,6 +25,14 @@
* rewrite to a path that does not exist would trade one resolution error for
* a harder-to-read one.
*
+ * Since ng-packagr 21 this rewrites nothing: it emits one rolled-up
+ * `dist/types/.d.ts` with no relative specifiers left to fix, so the
+ * step reports "0 relative specifiers" and `arethetypeswrong` is green on
+ * node16 without it. It stays as the guard it was written to be - the emit
+ * shape is ng-packagr's to change back, and TS2834 is silent until a consumer
+ * on node16 resolution hits it. `scripts/verify-package-entrypoints.js` and
+ * the attw check before a release are what would catch a regression.
+ *
* Usage: node scripts/add-dts-extensions.js [...more dirs]
* Paths are resolved against the caller's cwd, so a package can pass `dist`.
*/
diff --git a/scripts/check-docs-api-references.js b/scripts/check-docs-api-references.js
new file mode 100644
index 0000000..34f2fed
--- /dev/null
+++ b/scripts/check-docs-api-references.js
@@ -0,0 +1,191 @@
+#!/usr/bin/env node
+// Verify that every symbol the docs import from a @dynamic-field-kit package
+// is actually exported by it. Documentation drifts silently: a rename lands,
+// the READMEs keep the old name, and nothing fails until a reader copies the
+// snippet. Only import statements inside fenced code blocks are checked -
+// they are unambiguous, unlike prose, and they are what people copy.
+//
+// Exports are read from each package's built .d.ts through the TypeScript
+// compiler, so type-only exports count too. Packages that are not built are
+// skipped, the way the other integration checks skip.
+
+const fs = require('fs');
+const path = require('path');
+
+const PACKAGES = ['core', 'react', 'vue', 'angular'];
+
+const DOC_GLOBS = [
+ 'README.md',
+ 'docs',
+ ...PACKAGES.map((p) => path.join('packages', p, 'README.md')),
+];
+
+const FENCE = /^```([A-Za-z0-9]*)\s*$/;
+const CODE_LANGS = new Set([
+ 'ts',
+ 'tsx',
+ 'js',
+ 'jsx',
+ 'typescript',
+ 'javascript',
+]);
+
+// [^{}] rather than [\s\S] so the span cannot run from one import statement
+// through the next: `import { h } from 'vue'` sitting above an import of this
+// package was otherwise read as a single statement, and every name in the
+// first one was reported as missing.
+const IMPORT =
+ /import\s*\{([^{}]*)\}\s*from\s*['"]@dynamic-field-kit\/([a-z]+)['"]/g;
+
+/** Named imports of @dynamic-field-kit packages inside fenced code blocks. */
+function collectDocImports(file) {
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
+
+ // Blank out everything that is not inside a code fence, keeping line numbers
+ // intact so a problem can point at the real line.
+ let lang = null;
+ const code = lines.map((line) => {
+ const fence = FENCE.exec(line);
+ if (fence) {
+ lang = lang === null ? fence[1].toLowerCase() : null;
+ return '';
+ }
+ return lang !== null && CODE_LANGS.has(lang) ? line : '';
+ });
+
+ const text = code.join('\n');
+ const found = [];
+ for (const match of text.matchAll(IMPORT)) {
+ const line = text.slice(0, match.index).split('\n').length;
+ const names = match[1]
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ // `type Foo`, `Foo as Bar` - the exported name is the first identifier.
+ .map((part) =>
+ part
+ .replace(/^type\s+/, '')
+ .split(/\s+as\s+/)[0]
+ .trim(),
+ )
+ .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
+
+ for (const name of names) {
+ found.push({ pkg: match[2], name, line });
+ }
+ }
+ return found;
+}
+
+/** One message per documented import the package does not export. */
+function findDocApiProblems(files, exportsByPackage) {
+ return files.flatMap((file) =>
+ collectDocImports(file)
+ .filter(({ pkg, name }) => {
+ const exported = exportsByPackage[pkg];
+ // A package that was not built is not evidence of anything.
+ return exported !== undefined && !exported.has(name);
+ })
+ .map(
+ ({ pkg, name, line }) =>
+ `${path.basename(file)}:${line} imports ${name} from @dynamic-field-kit/${pkg}, which does not export it`,
+ ),
+ );
+}
+
+function typesEntry(root, pkg) {
+ const dir = path.join(root, 'packages', pkg);
+ const manifestPath = path.join(dir, 'package.json');
+ if (!fs.existsSync(manifestPath)) {
+ return undefined;
+ }
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ const declared = manifest.types || manifest.typings;
+ if (!declared) {
+ return undefined;
+ }
+ const entry = path.join(dir, declared);
+ return fs.existsSync(entry) ? entry : undefined;
+}
+
+/** Exported names per package, read from the built declarations. */
+function collectExports(root) {
+ // Resolved lazily so the unit tests can exercise the pure functions above
+ // without TypeScript or a build.
+ const ts = require('typescript');
+ const byPackage = {};
+
+ for (const pkg of PACKAGES) {
+ const entry = typesEntry(root, pkg);
+ if (!entry) {
+ continue;
+ }
+ const program = ts.createProgram([entry], {
+ noEmit: true,
+ skipLibCheck: true,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ module: ts.ModuleKind.ESNext,
+ target: ts.ScriptTarget.ES2022,
+ });
+ const source = program.getSourceFile(entry);
+ const symbol =
+ source && program.getTypeChecker().getSymbolAtLocation(source);
+ if (!symbol) {
+ continue;
+ }
+ byPackage[pkg] = new Set(
+ program
+ .getTypeChecker()
+ .getExportsOfModule(symbol)
+ .map((s) => s.getName()),
+ );
+ }
+
+ return byPackage;
+}
+
+function docFiles(root) {
+ return DOC_GLOBS.flatMap((entry) => {
+ const full = path.join(root, entry);
+ if (!fs.existsSync(full)) {
+ return [];
+ }
+ if (fs.statSync(full).isDirectory()) {
+ return fs
+ .readdirSync(full)
+ .filter((f) => f.endsWith('.md'))
+ .map((f) => path.join(full, f));
+ }
+ return [full];
+ });
+}
+
+module.exports = {
+ collectDocImports,
+ findDocApiProblems,
+ collectExports,
+ docFiles,
+};
+
+if (require.main === module) {
+ const root = process.cwd();
+ const exportsByPackage = collectExports(root);
+
+ if (Object.keys(exportsByPackage).length === 0) {
+ console.log('Docs API check skipped: no package has been built yet.');
+ process.exit(0);
+ }
+
+ const problems = findDocApiProblems(docFiles(root), exportsByPackage);
+ if (problems.length > 0) {
+ console.error('Documented imports that do not exist:');
+ for (const problem of problems) {
+ console.error(` - ${problem}`);
+ }
+ process.exit(1);
+ }
+
+ console.log(
+ `Docs API check passed: every documented import resolves against ${Object.keys(exportsByPackage).join(', ')}.`,
+ );
+}
diff --git a/scripts/check-docs-api-references.test.js b/scripts/check-docs-api-references.test.js
new file mode 100644
index 0000000..072bcc2
--- /dev/null
+++ b/scripts/check-docs-api-references.test.js
@@ -0,0 +1,135 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import {
+ collectDocImports,
+ findDocApiProblems,
+} from './check-docs-api-references.js';
+
+const tempRoots = [];
+
+function makeDoc(markdown) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-api-'));
+ tempRoots.push(root);
+ const file = path.join(root, 'README.md');
+ fs.writeFileSync(file, markdown);
+ return file;
+}
+
+afterEach(() => {
+ while (tempRoots.length) {
+ fs.rmSync(tempRoots.pop(), { recursive: true, force: true });
+ }
+});
+
+describe('collectDocImports', () => {
+ it('reads named imports out of a fenced code block', () => {
+ const file = makeDoc(
+ [
+ '```ts',
+ "import { validateFields } from '@dynamic-field-kit/core';",
+ '```',
+ ].join('\n'),
+ );
+
+ expect(collectDocImports(file)).toEqual([
+ { pkg: 'core', name: 'validateFields', line: 2 },
+ ]);
+ });
+
+ it('handles multi-line imports, aliases and type imports', () => {
+ const file = makeDoc(
+ [
+ '```tsx',
+ 'import {',
+ ' fieldRegistry as registry,',
+ ' type ValidationResult,',
+ "} from '@dynamic-field-kit/react';",
+ '```',
+ ].join('\n'),
+ );
+
+ expect(collectDocImports(file).map((i) => i.name)).toEqual([
+ 'fieldRegistry',
+ 'ValidationResult',
+ ]);
+ });
+
+ it('does not read a preceding import as part of this one', () => {
+ const file = makeDoc(
+ [
+ '```ts',
+ "import { defineComponent, h } from 'vue';",
+ "import { MultiFieldInput } from '@dynamic-field-kit/vue';",
+ '```',
+ ].join('\n'),
+ );
+
+ expect(collectDocImports(file).map((i) => i.name)).toEqual([
+ 'MultiFieldInput',
+ ]);
+ });
+
+ it('ignores imports from other packages', () => {
+ const file = makeDoc(
+ ['```ts', "import { ref } from 'vue';", '```'].join('\n'),
+ );
+
+ expect(collectDocImports(file)).toEqual([]);
+ });
+
+ it('ignores prose that merely mentions an import', () => {
+ const file = makeDoc(
+ "Call `import { nope } from '@dynamic-field-kit/core'` to do the thing.",
+ );
+
+ expect(collectDocImports(file)).toEqual([]);
+ });
+});
+
+describe('findDocApiProblems', () => {
+ const exportsByPackage = {
+ core: new Set(['validateFields', 'FieldDescription']),
+ };
+
+ it('accepts a doc that only imports things the package exports', () => {
+ const file = makeDoc(
+ [
+ '```ts',
+ "import { validateFields, FieldDescription } from '@dynamic-field-kit/core';",
+ '```',
+ ].join('\n'),
+ );
+
+ expect(findDocApiProblems([file], exportsByPackage)).toEqual([]);
+ });
+
+ it('flags a name the package does not export', () => {
+ const file = makeDoc(
+ [
+ '```ts',
+ "import { validateAll } from '@dynamic-field-kit/core';",
+ '```',
+ ].join('\n'),
+ );
+
+ const [problem] = findDocApiProblems([file], exportsByPackage);
+ expect(problem).toContain('validateAll');
+ expect(problem).toContain('@dynamic-field-kit/core');
+ expect(problem).toContain('README.md:2');
+ });
+
+ it('says nothing about a package whose exports were not collected', () => {
+ const file = makeDoc(
+ [
+ '```ts',
+ "import { whatever } from '@dynamic-field-kit/vue';",
+ '```',
+ ].join('\n'),
+ );
+
+ expect(findDocApiProblems([file], exportsByPackage)).toEqual([]);
+ });
+});
diff --git a/scripts/check-renderer-prop-parity.js b/scripts/check-renderer-prop-parity.js
new file mode 100644
index 0000000..e847156
--- /dev/null
+++ b/scripts/check-renderer-prop-parity.js
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+// Lint check: every adapter must forward the whole FieldRendererProps contract.
+//
+// core/src/rendererProps.ts owns the list (FIELD_RENDERER_PROP_KEYS) and
+// buildFieldRendererProps produces it, but each adapter still has to carry the
+// keys across its own component boundary: React through DynamicInput's Props
+// interface, Vue through its declared `props` (an undeclared key becomes a
+// fallthrough attribute, not a prop) and its forwarding call, Angular through
+// KNOWN_PROPS plus a matching @Input on BaseInputComponent.
+//
+// Those three lists silently drifted apart before 1.6: React dropped
+// `placeholder`, `min`, `max`, `step`, `accept` and `multiple`; Vue dropped
+// `required`, `id`, `dirty` and the aria flags; Angular dropped `touched`,
+// `dirty`, `id` and the aria flags, leaving Angular renderers with no way to
+// know whether a field had been touched. This check fails the build instead.
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..');
+
+/**
+ * Vue's name for `className`. Forwarding `className` as well is not an option:
+ * a renderer that does not declare it lets the key fall through to its root
+ * element, where Vue assigns `el.className` - an undefined value becomes `''`
+ * and wipes the class the renderer set on itself.
+ */
+const VUE_ALIASES = { className: 'class' };
+
+function read(rel) {
+ return fs.readFileSync(path.join(ROOT, rel), 'utf8');
+}
+
+/** Returns the brace-balanced block that starts at the first `{` after `marker`. */
+function blockAfter(src, marker) {
+ const start = src.indexOf(marker);
+ if (start === -1) {
+ throw new Error(`could not find ${JSON.stringify(marker)}`);
+ }
+ const open = src.indexOf('{', start);
+ let depth = 0;
+ for (let i = open; i < src.length; i += 1) {
+ if (src[i] === '{') {
+ depth += 1;
+ } else if (src[i] === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ return src.slice(open, i + 1);
+ }
+ }
+ }
+ throw new Error(`unbalanced braces after ${JSON.stringify(marker)}`);
+}
+
+/** The contract itself, parsed from the `as const` array in core. */
+function contractKeys() {
+ const src = read('packages/core/src/rendererProps.ts');
+ const arr = src.slice(
+ src.indexOf('FIELD_RENDERER_PROP_KEYS'),
+ src.indexOf('] as const'),
+ );
+ const keys = [...arr.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]);
+ if (keys.length === 0) {
+ throw new Error('FIELD_RENDERER_PROP_KEYS parsed as empty');
+ }
+ return keys;
+}
+
+/** Adapter name -> list of { what, has(key) } probes the key must satisfy. */
+function probes() {
+ const reactProps = blockAfter(
+ read('packages/react/src/components/DynamicInput.tsx'),
+ 'interface Props',
+ );
+
+ const vueSrc = read('packages/vue/src/components/DynamicInput.ts');
+ const vueDeclared = blockAfter(vueSrc, ' props: {');
+ const vueForwarded = blockAfter(vueSrc, 'h(Renderer.value, ');
+
+ const angularSrc = read('packages/angular/src/components/DynamicInput.ts');
+ const angularKnown = angularSrc.slice(
+ angularSrc.indexOf('const KNOWN_PROPS'),
+ angularSrc.indexOf('] as const'),
+ );
+ const angularBase = read('packages/angular/src/components/BaseInput.ts');
+
+ const vueName = (key) => VUE_ALIASES[key] ?? key;
+
+ return {
+ react: [
+ {
+ what: 'DynamicInput Props interface',
+ has: (key) => new RegExp(`\\b${key}\\?:`).test(reactProps),
+ },
+ ],
+ vue: [
+ {
+ what: 'DynamicInput declared props',
+ has: (key) =>
+ new RegExp(`\\b${vueName(key)}: \\{`).test(vueDeclared) ||
+ // `class` is not declarable as a Vue prop name here; it is forwarded
+ // from the declared `className` prop instead.
+ (VUE_ALIASES[key] && new RegExp(`\\b${key}: \\{`).test(vueDeclared)),
+ },
+ {
+ what: 'DynamicInput renderer forwarding',
+ has: (key) =>
+ new RegExp(`\\b${vueName(key)}: props\\.`).test(vueForwarded),
+ },
+ ],
+ angular: [
+ {
+ what: 'DynamicInput KNOWN_PROPS',
+ has: (key) => angularKnown.includes(`'${key}'`),
+ },
+ {
+ what: 'BaseInputComponent @Input',
+ has: (key) =>
+ new RegExp(`@Input\\(\\) ${key}\\??[?:]`).test(angularBase),
+ },
+ ],
+ };
+}
+
+function run() {
+ const keys = contractKeys();
+ const all = probes();
+ const failures = [];
+
+ for (const [adapter, checks] of Object.entries(all)) {
+ for (const key of keys) {
+ for (const check of checks) {
+ if (!check.has(key)) {
+ failures.push(`${adapter}: '${key}' missing from ${check.what}`);
+ }
+ }
+ }
+ }
+
+ return { keys, failures };
+}
+
+module.exports = { run, contractKeys };
+
+if (require.main === module) {
+ const { keys, failures } = run();
+ if (failures.length > 0) {
+ console.error(
+ 'Renderer prop contract not honoured by every adapter:\n' +
+ failures.map((f) => ` - ${f}`).join('\n') +
+ '\n\nSee packages/core/src/rendererProps.ts for the contract.',
+ );
+ process.exit(1);
+ }
+ console.log(
+ `Renderer prop parity OK: ${keys.length} props forwarded by react, vue and angular.`,
+ );
+}
diff --git a/scripts/check-renderer-prop-parity.test.js b/scripts/check-renderer-prop-parity.test.js
new file mode 100644
index 0000000..46baed6
--- /dev/null
+++ b/scripts/check-renderer-prop-parity.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+
+import { run, contractKeys } from './check-renderer-prop-parity.js';
+
+describe('renderer prop parity', () => {
+ it('parses a non-trivial contract out of core', () => {
+ const keys = contractKeys();
+
+ // The props the 1.5.1 reports found missing from one adapter or another.
+ expect(keys).toEqual(
+ expect.arrayContaining([
+ 'placeholder',
+ 'required',
+ 'touched',
+ 'dirty',
+ 'id',
+ 'ariaInvalid',
+ 'ariaRequired',
+ 'min',
+ 'max',
+ 'step',
+ 'accept',
+ 'multiple',
+ ]),
+ );
+ });
+
+ it('finds every contract prop forwarded by all three adapters', () => {
+ expect(run().failures).toEqual([]);
+ });
+});
diff --git a/scripts/verify-angular-peer-range.js b/scripts/verify-angular-peer-range.js
new file mode 100644
index 0000000..fb7c948
--- /dev/null
+++ b/scripts/verify-angular-peer-range.js
@@ -0,0 +1,259 @@
+#!/usr/bin/env node
+/**
+ * Proves the Angular majors `@dynamic-field-kit/angular` claims in its
+ * `peerDependencies` actually work.
+ *
+ * The suite only ever runs against the one Angular the workspace installs, so
+ * the declared floor is never exercised - and the floor is what silently rots.
+ * The package declared `>=14` long after it started importing `signal` and
+ * `computed`, which do not exist before Angular 16: an install on 14 or 15
+ * resolved fine and then failed on import.
+ *
+ * A render is out of reach here (the published fesm2022 needs the CLI's linker
+ * or JIT to instantiate components), so this checks the level that actually
+ * breaks across majors: the package imports under that Angular, its decorated
+ * classes evaluate, and it shares one registry with core. That is the same
+ * depth as scripts/integration-cross-registry.js, run once per range end.
+ *
+ * It then runs the floor's own Angular linker over the published fesm2022
+ * bundle. That is the check that would catch the real cross-major hazard: the
+ * bundle ships partial declarations, and if building on a newer Angular raised
+ * their `minVersion`, every consumer below that version would fail in the
+ * linker while installing and importing perfectly well.
+ *
+ * Run from the repo root, after `npm run build`:
+ * node scripts/verify-angular-peer-range.js
+ */
+const { execFileSync, execSync } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const REPO = path.resolve(__dirname, '..');
+
+// Kept in sync by hand with packages/angular/package.json
+// `peerDependencies['@angular/core']`: the declared floor, and the newest major
+// the range admits. A range end missing here is the gap this script closes.
+const MAJORS = ['16', '21'];
+
+const quote = (a) => (/[\s"]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a);
+
+const npm = (args, cwd) =>
+ execSync(['npm', ...args.map(quote)].join(' '), {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ encoding: 'utf8',
+ });
+
+const node = (args, cwd) =>
+ execFileSync(process.execPath, args, {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ encoding: 'utf8',
+ });
+
+function pack(pkgDir, outDir) {
+ const out = npm(['pack', '--pack-destination', outDir], pkgDir).trim();
+ return path.join(outDir, out.split('\n').pop().trim());
+}
+
+const APP = `
+import 'zone.js';
+// Angular's compiled output falls back to JIT for some providers; loading the
+// compiler up front avoids a throw on import when it is not AOT-linked.
+import '@angular/compiler';
+import { VERSION } from '@angular/core';
+import { fieldRegistry as coreRegistry } from '@dynamic-field-kit/core';
+import {
+ fieldRegistry,
+ MultiFieldInput,
+ createDynamicFormStore,
+ collectFieldPaths,
+} from '@dynamic-field-kit/angular';
+
+const failures = [];
+
+coreRegistry.register('text', (props) => 'core-' + (props?.value ?? ''));
+const renderer = fieldRegistry.get('text');
+if (typeof renderer !== 'function') {
+ failures.push('core and angular do not share a registry');
+} else if (renderer({ value: 'X' }) !== 'core-X') {
+ failures.push('registry wrapper returned the wrong value');
+}
+
+if (typeof MultiFieldInput !== 'function') {
+ failures.push('MultiFieldInput did not evaluate as a class');
+}
+
+// The store is where the signal APIs live - the reason the floor is 16.
+const store = createDynamicFormStore({
+ fields: [{ name: 'a', type: 'text', validate: (v) => (v ? undefined : 'Required') }],
+ initialValues: { a: '' },
+});
+if (store.validationStatus() !== 'invalid') {
+ failures.push('store did not compute a validation status: ' + store.validationStatus());
+}
+if (collectFieldPaths([{ name: 'a', type: 'text' }], {}).length !== 1) {
+ failures.push('re-exported core helper did not work');
+}
+
+if (failures.length) {
+ throw new Error(failures.join('; '));
+}
+console.log('angular ' + VERSION.full + ' imported and shares one registry');
+`;
+
+function verify(major, tarballs, tmpRoot) {
+ const dir = fs.mkdtempSync(path.join(tmpRoot, `ng-${major}-`));
+ fs.writeFileSync(
+ path.join(dir, 'package.json'),
+ JSON.stringify(
+ { name: 'consumer', version: '0.0.0', private: true, type: 'module' },
+ null,
+ 2,
+ ),
+ );
+ fs.writeFileSync(path.join(dir, 'app.mjs'), APP);
+
+ npm(
+ [
+ 'install',
+ '--no-audit',
+ '--no-fund',
+ '--no-package-lock',
+ `@angular/core@^${major}`,
+ `@angular/common@^${major}`,
+ `@angular/compiler@^${major}`,
+ 'rxjs@^7.8.0',
+ 'zone.js',
+ ...tarballs,
+ ],
+ dir,
+ );
+
+ const installed = require(
+ path.join(dir, 'node_modules', '@angular', 'core', 'package.json'),
+ ).version;
+ if (!installed.startsWith(`${major}.`)) {
+ throw new Error(`asked for @angular/core ^${major}, got ${installed}`);
+ }
+
+ const out = node(['app.mjs'], dir).trim();
+ console.log(` ${out}`);
+ return installed;
+}
+
+const LINK = `
+const fs = require('fs');
+const babel = require('@babel/core');
+const { createEs2015LinkerPlugin } = require('@angular/compiler-cli/linker/babel');
+const { NodeJSFileSystem, ConsoleLogger, LogLevel } = require('@angular/compiler-cli');
+
+const file = process.argv[2];
+const out = babel.transformSync(fs.readFileSync(file, 'utf8'), {
+ filename: file,
+ configFile: false,
+ babelrc: false,
+ compact: false,
+ plugins: [
+ createEs2015LinkerPlugin({
+ fileSystem: new NodeJSFileSystem(),
+ logger: new ConsoleLogger(LogLevel.warn),
+ linkerJitMode: false,
+ }),
+ ],
+});
+
+const leftover = (out.code.match(/ɵɵngDeclare/g) || []).length;
+if (leftover > 0) {
+ throw new Error(leftover + ' partial declarations were left unlinked');
+}
+console.log(
+ 'linker ' +
+ require('@angular/compiler-cli/package.json').version +
+ ' linked the published bundle, 0 partial declarations left',
+);
+`;
+
+/**
+ * Links the published bundle with the *floor* Angular's linker. A consumer app
+ * runs this as part of its own build, and it is the step that fails when a
+ * library was compiled by a compiler too new for it.
+ */
+function verifyLinker(major, tmpRoot) {
+ const dir = fs.mkdtempSync(path.join(tmpRoot, `link-${major}-`));
+ fs.writeFileSync(
+ path.join(dir, 'package.json'),
+ JSON.stringify(
+ { name: 'linker', version: '0.0.0', private: true },
+ null,
+ 2,
+ ),
+ );
+ fs.writeFileSync(path.join(dir, 'link.cjs'), LINK);
+
+ npm(
+ [
+ 'install',
+ '--no-audit',
+ '--no-fund',
+ '--no-package-lock',
+ `@angular/compiler-cli@^${major}`,
+ `@angular/compiler@^${major}`,
+ '@babel/core',
+ ],
+ dir,
+ );
+
+ // The bundle straight off disk, not through an install: the linker only
+ // reads the file, and installing the tarball here would drag in the whole
+ // peer set for no benefit.
+ const bundle = path.join(
+ REPO,
+ 'packages',
+ 'angular',
+ 'dist',
+ 'fesm2022',
+ 'dynamic-field-kit-angular.mjs',
+ );
+ const out = node(['link.cjs', bundle], dir).trim();
+ console.log(` ${out}`);
+}
+
+function main() {
+ for (const p of ['core', 'angular']) {
+ const dist = path.join(REPO, 'packages', p, 'dist');
+ if (!fs.existsSync(dist)) {
+ console.error(
+ `packages/${p}/dist is missing - run \`npm run build\` first.`,
+ );
+ process.exit(1);
+ }
+ }
+
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dfk-ng-peer-'));
+ try {
+ const tarballs = [
+ pack(path.join(REPO, 'packages', 'core'), tmpRoot),
+ pack(path.join(REPO, 'packages', 'angular'), tmpRoot),
+ ];
+
+ for (const major of MAJORS) {
+ console.log(`@angular/core ^${major}:`);
+ verify(major, tarballs, tmpRoot);
+ }
+
+ // Only the floor: a newer linker accepting an older declaration is the
+ // direction that has never been in doubt.
+ console.log(`@angular/compiler-cli ^${MAJORS[0]} linking the bundle:`);
+ verifyLinker(MAJORS[0], tmpRoot);
+
+ console.log(
+ `OK: @dynamic-field-kit/angular loads at both ends of its declared Angular range (${MAJORS.join(', ')}).`,
+ );
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+}
+
+main();
diff --git a/scripts/verify-package-entrypoints.js b/scripts/verify-package-entrypoints.js
new file mode 100644
index 0000000..f454daf
--- /dev/null
+++ b/scripts/verify-package-entrypoints.js
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+// Verify that every entry point a package advertises actually exists in its
+// build output. A build tool that renames its output (ng-packagr 21 moved the
+// Angular d.ts from dist/index.d.ts to dist/types/.d.ts) leaves the
+// manifest pointing at a file nobody emits any more - the package still packs
+// and installs, and only breaks once a consumer imports it.
+
+const fs = require('fs');
+const path = require('path');
+
+const PACKAGES = ['core', 'react', 'vue', 'angular'];
+
+// Fields whose value is a single path into the build output.
+const PATH_FIELDS = ['main', 'module', 'types', 'typings', 'browser'];
+
+function readJson(p) {
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
+}
+
+// Entry points always name a file, so the extension is what separates a path
+// from a plain setting such as "type": "module".
+function isFilePath(value) {
+ return typeof value === 'string' && /\.[a-z]+$/i.test(value);
+}
+
+/** Walk the `exports` tree, yielding [label, target] for every string leaf. */
+function exportTargets(node, label) {
+ if (typeof node === 'string') {
+ return [[label, node]];
+ }
+ if (!node || typeof node !== 'object') {
+ return [];
+ }
+ return Object.entries(node).flatMap(([key, child]) =>
+ exportTargets(
+ child,
+ key.startsWith('.')
+ ? `${label}[${JSON.stringify(key)}]`
+ : `${label}.${key}`,
+ ),
+ );
+}
+
+function normalize(target) {
+ return target.replace(/^\.\//, '');
+}
+
+function problemsForPackage(pkgDir, pkgName) {
+ const pjson = readJson(path.join(pkgDir, 'package.json'));
+
+ const declared = [
+ ...PATH_FIELDS.filter((field) => isFilePath(pjson[field])).map((field) => [
+ `"${field}"`,
+ pjson[field],
+ ]),
+ ...exportTargets(pjson.exports, 'exports')
+ .filter(([, target]) => isFilePath(target))
+ .map(([label, target]) => [`"${label}"`, target]),
+ ];
+
+ return declared
+ .filter(
+ ([, target]) => !fs.existsSync(path.join(pkgDir, normalize(target))),
+ )
+ .map(
+ ([label, target]) =>
+ `${pkgName}: ${label} points at ${normalize(target)}, which the build does not emit`,
+ );
+}
+
+/**
+ * Returns one message per entry point that does not resolve. Packages whose
+ * `dist` is missing are skipped, so this is safe to run before a build (and on
+ * a partial build) the way the other integration checks are.
+ */
+function findEntryPointProblems(root, packages = PACKAGES) {
+ return packages.flatMap((pkgName) => {
+ const pkgDir = path.join(root, 'packages', pkgName);
+ if (!fs.existsSync(path.join(pkgDir, 'package.json'))) {
+ return [];
+ }
+ if (!fs.existsSync(path.join(pkgDir, 'dist'))) {
+ return [];
+ }
+ return problemsForPackage(pkgDir, pkgName);
+ });
+}
+
+module.exports = { findEntryPointProblems };
+
+if (require.main === module) {
+ const problems = findEntryPointProblems(process.cwd());
+ if (problems.length > 0) {
+ console.error('Package entry point problems found:');
+ for (const problem of problems) {
+ console.error(` - ${problem}`);
+ }
+ process.exit(1);
+ }
+ console.log('Package entry points verified: every declared path is emitted.');
+}
diff --git a/scripts/verify-package-entrypoints.test.js b/scripts/verify-package-entrypoints.test.js
new file mode 100644
index 0000000..6e722ba
--- /dev/null
+++ b/scripts/verify-package-entrypoints.test.js
@@ -0,0 +1,102 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { findEntryPointProblems } from './verify-package-entrypoints.js';
+
+const tempRoots = [];
+
+function makePackage(manifest, distFiles = []) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoints-'));
+ tempRoots.push(root);
+
+ const pkgDir = path.join(root, 'packages', 'demo');
+ fs.mkdirSync(pkgDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(pkgDir, 'package.json'),
+ JSON.stringify({ name: '@dynamic-field-kit/demo', ...manifest }),
+ );
+
+ for (const file of distFiles) {
+ const full = path.join(pkgDir, file);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, '');
+ }
+
+ return root;
+}
+
+afterEach(() => {
+ while (tempRoots.length) {
+ fs.rmSync(tempRoots.pop(), { recursive: true, force: true });
+ }
+});
+
+describe('findEntryPointProblems', () => {
+ it('accepts a package whose declared entry points all exist', () => {
+ const root = makePackage(
+ {
+ main: 'dist/index.js',
+ types: 'dist/index.d.ts',
+ exports: {
+ '.': { types: './dist/index.d.ts', default: './dist/index.js' },
+ },
+ },
+ ['dist/index.js', 'dist/index.d.ts'],
+ );
+
+ expect(findEntryPointProblems(root, ['demo'])).toEqual([]);
+ });
+
+ it('flags a types path the build no longer emits', () => {
+ // Exactly the ng-packagr 21 breakage: the d.ts moved to types/.d.ts
+ // and the manifest kept pointing at the old dist/index.d.ts.
+ const root = makePackage(
+ { main: 'dist/index.js', types: 'dist/index.d.ts' },
+ ['dist/index.js', 'dist/types/demo.d.ts'],
+ );
+
+ expect(findEntryPointProblems(root, ['demo'])).toEqual([
+ 'demo: "types" points at dist/index.d.ts, which the build does not emit',
+ ]);
+ });
+
+ it('flags a missing target inside an exports condition', () => {
+ const root = makePackage(
+ {
+ main: 'dist/index.js',
+ exports: {
+ '.': { types: './dist/index.d.ts', default: './dist/index.js' },
+ },
+ },
+ ['dist/index.js'],
+ );
+
+ expect(findEntryPointProblems(root, ['demo'])).toEqual([
+ 'demo: "exports["."].types" points at dist/index.d.ts, which the build does not emit',
+ ]);
+ });
+
+ it('ignores bare specifiers and non-path values', () => {
+ const root = makePackage(
+ { main: 'dist/index.js', type: 'module', sideEffects: false },
+ ['dist/index.js'],
+ );
+
+ expect(findEntryPointProblems(root, ['demo'])).toEqual([]);
+ });
+
+ it('skips a package that has not been built yet', () => {
+ const root = makePackage({
+ main: 'dist/index.js',
+ types: 'dist/index.d.ts',
+ });
+
+ expect(findEntryPointProblems(root, ['demo'])).toEqual([]);
+ });
+
+ it('holds for the real packages in this repo', () => {
+ expect(findEntryPointProblems(path.resolve(__dirname, '..'))).toEqual([]);
+ });
+});
diff --git a/scripts/verify-vue-peer-range.js b/scripts/verify-vue-peer-range.js
new file mode 100644
index 0000000..5074b99
--- /dev/null
+++ b/scripts/verify-vue-peer-range.js
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+/**
+ * Proves the Vue versions `@dynamic-field-kit/vue` claims in its
+ * `peerDependencies` actually work.
+ *
+ * The suite only ever runs against whatever Vue the workspace installs, which
+ * is the newest 3.x. The declared floor is never exercised there, and the floor
+ * is the half that breaks: the composable calls `getCurrentScope` /
+ * `onScopeDispose`, which did not exist before Vue 3.2.
+ *
+ * So this does what a consumer does. It packs core and vue into tarballs,
+ * installs them into a throwaway project outside the workspace alongside one
+ * exact Vue minor, and server-renders a form with `@vue/server-renderer` -
+ * no jsdom, and the same API on every 3.x.
+ *
+ * Run from the repo root, after `npm run build`:
+ * node scripts/verify-vue-peer-range.js
+ */
+const { execFileSync, execSync } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const REPO = path.resolve(__dirname, '..');
+
+// Kept in sync by hand with packages/vue/package.json `peerDependencies.vue`:
+// the declared floor, and the newest 3.x. A range end missing here is the gap
+// this script exists to close.
+const VERSIONS = ['3.2', 'latest'];
+
+// npm is a .cmd shim on Windows, which node 24 refuses to spawn without a
+// shell, and passing an argv array *with* a shell only concatenates it. So npm
+// gets one quoted command line, and node - a real executable - gets an argv.
+const quote = (a) => (/[\s"]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a);
+
+const npm = (args, cwd) =>
+ execSync(['npm', ...args.map(quote)].join(' '), {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ encoding: 'utf8',
+ });
+
+const node = (args, cwd) =>
+ execFileSync(process.execPath, args, {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ encoding: 'utf8',
+ });
+
+function pack(pkgDir, outDir) {
+ const out = npm(['pack', '--pack-destination', outDir], pkgDir).trim();
+ return path.join(outDir, out.split('\n').pop().trim());
+}
+
+// A form with one registered renderer and one default renderer, so the render
+// covers both the registry path and the built-in fallback. The composable is
+// exercised inside an effect scope, which is what pulls in the 3.2-only
+// scope API the floor exists to guarantee.
+const APP = `
+const { createSSRApp, defineComponent, effectScope, h } = require('vue');
+const { renderToString } = require('@vue/server-renderer');
+const { fieldRegistry } = require('@dynamic-field-kit/core');
+const { DynamicInput, useDynamicForm } = require('@dynamic-field-kit/vue');
+
+fieldRegistry.register(
+ 'custom',
+ defineComponent({
+ props: { value: null },
+ setup: (props) => () =>
+ h('input', { 'data-custom': '1', value: String(props.value ?? '') }),
+ }),
+);
+
+const scope = effectScope();
+const form = scope.run(() =>
+ useDynamicForm({
+ fields: [{ name: 'a', type: 'custom' }],
+ initialValues: { a: 'x' },
+ }),
+);
+
+const App = defineComponent({
+ setup: () => () =>
+ h('form', null, [
+ h(DynamicInput, { type: 'custom', name: 'a', value: 'x' }),
+ h(DynamicInput, { type: 'text', name: 'b', value: 'y' }),
+ ]),
+});
+
+renderToString(createSSRApp(App)).then((html) => {
+ const failures = [];
+ if (!html.includes('data-custom="1"'))
+ failures.push('registered renderer did not render');
+ if (!html.includes('value="x"'))
+ failures.push('registered renderer lost its value');
+ if (!html.includes('value="y"'))
+ failures.push('default text renderer lost its value');
+ if (typeof form.validationStatus.value !== 'string')
+ failures.push('useDynamicForm did not expose validationStatus');
+
+ // The reason the floor is 3.2: disposing the scope must reach the
+ // composable's onScopeDispose without throwing.
+ scope.stop();
+
+ if (failures.length) {
+ console.error(html);
+ throw new Error(failures.join('; '));
+ }
+ console.log(
+ 'vue ' + require('vue/package.json').version + ' rendered: ' + html.length + ' chars',
+ );
+});
+`;
+
+function verify(version, tarballs, tmpRoot) {
+ const dir = fs.mkdtempSync(path.join(tmpRoot, `vue-${version}-`));
+ fs.writeFileSync(
+ path.join(dir, 'package.json'),
+ JSON.stringify(
+ { name: 'consumer', version: '0.0.0', private: true },
+ null,
+ 2,
+ ),
+ );
+ fs.writeFileSync(path.join(dir, 'app.cjs'), APP);
+
+ const spec = version === 'latest' ? 'vue@latest' : `vue@~${version}`;
+ const rendererSpec =
+ version === 'latest'
+ ? '@vue/server-renderer@latest'
+ : `@vue/server-renderer@~${version}`;
+
+ npm(
+ [
+ 'install',
+ '--no-audit',
+ '--no-fund',
+ '--no-package-lock',
+ spec,
+ rendererSpec,
+ ...tarballs,
+ ],
+ dir,
+ );
+
+ const installed = require(
+ path.join(dir, 'node_modules', 'vue', 'package.json'),
+ ).version;
+ if (version !== 'latest' && !installed.startsWith(`${version}.`)) {
+ throw new Error(`asked for vue ~${version}, got ${installed}`);
+ }
+
+ const out = node(['app.cjs'], dir).trim();
+ console.log(` ${out}`);
+ return installed;
+}
+
+function main() {
+ for (const p of ['core', 'vue']) {
+ const dist = path.join(REPO, 'packages', p, 'dist');
+ if (!fs.existsSync(dist)) {
+ console.error(
+ `packages/${p}/dist is missing - run \`npm run build\` first.`,
+ );
+ process.exit(1);
+ }
+ }
+
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dfk-vue-peer-'));
+ try {
+ const tarballs = [
+ pack(path.join(REPO, 'packages', 'core'), tmpRoot),
+ pack(path.join(REPO, 'packages', 'vue'), tmpRoot),
+ ];
+
+ for (const version of VERSIONS) {
+ console.log(`vue ${version}:`);
+ verify(version, tarballs, tmpRoot);
+ }
+
+ console.log(
+ `OK: @dynamic-field-kit/vue renders at both ends of its declared Vue range (${VERSIONS.join(', ')}).`,
+ );
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+}
+
+main();
diff --git a/smoke/vitest.config.ts b/smoke/vitest.config.mts
similarity index 100%
rename from smoke/vitest.config.ts
rename to smoke/vitest.config.mts