@petrarca/sonnet-forms 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1477,22 +1477,53 @@ FormQuantityInput.displayName = "FormQuantityInput";
1477
1477
  import { Button as Button3 } from "@petrarca/sonnet-ui";
1478
1478
  import { SimpleGroup } from "@petrarca/sonnet-ui";
1479
1479
  import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1480
+ function isSaveDisabled(args) {
1481
+ if (args.disabled || !args.hasChanges || args.isValidating) return true;
1482
+ return args.saveWhenInvalid === "disable" && !args.isValid;
1483
+ }
1480
1484
  function FormActions({
1481
1485
  onSave,
1482
1486
  onCancel,
1483
1487
  disabled = false,
1484
1488
  hasChanges,
1485
1489
  isValid,
1490
+ saveWhenInvalid = "reveal",
1486
1491
  saveLabel = "Save",
1487
1492
  cancelLabel = "Cancel",
1488
1493
  showCancel = true,
1494
+ isValidating = false,
1489
1495
  leadingActions
1490
1496
  }) {
1497
+ const saveDisabled = isSaveDisabled({
1498
+ disabled,
1499
+ hasChanges,
1500
+ isValid,
1501
+ isValidating,
1502
+ saveWhenInvalid
1503
+ });
1504
+ const keepFocus = (e) => e.preventDefault();
1491
1505
  return /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between mt-4", children: [
1492
1506
  /* @__PURE__ */ jsx10("div", { className: "flex items-center gap-2", children: leadingActions }),
1493
1507
  /* @__PURE__ */ jsxs9(SimpleGroup, { justify: "flex-end", children: [
1494
- showCancel && /* @__PURE__ */ jsx10(Button3, { variant: "outline", onClick: onCancel, disabled, children: cancelLabel }),
1495
- /* @__PURE__ */ jsx10(Button3, { onClick: onSave, disabled: disabled || !hasChanges || !isValid, children: saveLabel })
1508
+ showCancel && /* @__PURE__ */ jsx10(
1509
+ Button3,
1510
+ {
1511
+ variant: "outline",
1512
+ onClick: onCancel,
1513
+ onMouseDown: keepFocus,
1514
+ disabled,
1515
+ children: cancelLabel
1516
+ }
1517
+ ),
1518
+ /* @__PURE__ */ jsx10(
1519
+ Button3,
1520
+ {
1521
+ onClick: onSave,
1522
+ onMouseDown: keepFocus,
1523
+ disabled: saveDisabled,
1524
+ children: saveLabel
1525
+ }
1526
+ )
1496
1527
  ] })
1497
1528
  ] });
1498
1529
  }
@@ -1533,7 +1564,7 @@ function ExtraPropertiesCard({ properties, schema }) {
1533
1564
  var ExtraPropertiesCard_default = ExtraPropertiesCard;
1534
1565
 
1535
1566
  // src/JsonSchemaFormRenderer.tsx
1536
- import React13, { useCallback as useCallback8, useMemo as useMemo8 } from "react";
1567
+ import React13, { useCallback as useCallback8, useContext as useContext2, useMemo as useMemo9 } from "react";
1537
1568
  import { SimpleStack } from "@petrarca/sonnet-ui";
1538
1569
  import { AlertCircle as AlertCircle2 } from "lucide-react";
1539
1570
  import {
@@ -2194,9 +2225,11 @@ function resolveLayout(uiSchema, propertySchema) {
2194
2225
  return uiSchema?.["x-ui-layout"] ?? propertySchema["x-ui-layout"];
2195
2226
  }
2196
2227
  function ObjectWidget({
2228
+ id,
2197
2229
  propertySchema,
2198
2230
  value,
2199
2231
  onChange,
2232
+ onBlur,
2200
2233
  registry,
2201
2234
  uiSchema,
2202
2235
  disabled,
@@ -2233,7 +2266,9 @@ function ObjectWidget({
2233
2266
  readOnly: readonly,
2234
2267
  widgets: registry,
2235
2268
  uiSchema: effectiveUiSchema,
2236
- displayContents
2269
+ displayContents,
2270
+ idPrefix: id,
2271
+ onBlur: onBlur ? () => onBlur(id, value) : void 0
2237
2272
  }
2238
2273
  );
2239
2274
  if (displayContents) return /* @__PURE__ */ jsx21(Fragment3, { children: content });
@@ -2991,7 +3026,8 @@ function OneOfWidget({
2991
3026
  disabled,
2992
3027
  readOnly: readonly,
2993
3028
  widgets: registry,
2994
- uiSchema: subUiSchema
3029
+ uiSchema: subUiSchema,
3030
+ idPrefix: id
2995
3031
  }
2996
3032
  )
2997
3033
  }
@@ -3414,7 +3450,8 @@ function FormFieldRenderer({
3414
3450
  registry,
3415
3451
  globalOptions = {},
3416
3452
  formData,
3417
- idPrefix = "field"
3453
+ idPrefix = "field",
3454
+ rawErrors
3418
3455
  }) {
3419
3456
  const label = getFieldTitle(fieldName, property);
3420
3457
  const { description, placeholder } = resolvePresentationText(
@@ -3455,25 +3492,219 @@ function FormFieldRenderer({
3455
3492
  disabled: flags.disabled,
3456
3493
  readonly: flags.readOnly,
3457
3494
  options: mergedOptions,
3458
- formData
3495
+ formData,
3496
+ rawErrors
3459
3497
  }
3460
3498
  );
3461
3499
  return applyFieldWidth(widget, uiSchema, mergedOptions);
3462
3500
  }
3463
3501
 
3464
3502
  // src/hooks/useFormEngine.ts
3465
- import { useState as useState9, useEffect as useEffect3, useMemo as useMemo7, useCallback as useCallback7, useRef as useRef5 } from "react";
3503
+ import { useState as useState10, useEffect as useEffect4, useMemo as useMemo8, useCallback as useCallback7, useRef as useRef6 } from "react";
3466
3504
  import { isEqual } from "@petrarca/sonnet-core";
3467
3505
  import {
3468
- getSchemaProperties,
3506
+ getSchemaProperties as getSchemaProperties4,
3469
3507
  getFieldDefault,
3470
3508
  applySchemaDefaults as applySchemaDefaults2,
3471
3509
  resolveSchema,
3472
3510
  resolveRefs,
3473
3511
  validateFormData,
3474
- diffFormData
3512
+ mergeFieldErrors,
3513
+ diffFormData,
3514
+ ensureFormItemIds
3475
3515
  } from "@petrarca/sonnet-core/schema";
3476
3516
 
3517
+ // src/validators/runValidators.ts
3518
+ import { getSchemaProperties as getSchemaProperties2 } from "@petrarca/sonnet-core/schema";
3519
+
3520
+ // src/validators/shared.ts
3521
+ import { getSchemaProperties } from "@petrarca/sonnet-core/schema";
3522
+ function toRefs(v) {
3523
+ if (v === void 0) return [];
3524
+ const arr = Array.isArray(v) ? v : [v];
3525
+ return arr.map((item) => typeof item === "string" ? { name: item } : item);
3526
+ }
3527
+ function escapeToken(token) {
3528
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
3529
+ }
3530
+ function fieldPointer(fieldName) {
3531
+ return `/${escapeToken(fieldName)}`;
3532
+ }
3533
+ function resolveFieldRefs(fieldName, schema, uiSchema) {
3534
+ const property = getSchemaProperties(schema)[fieldName] ?? {};
3535
+ return toRefs(
3536
+ uiSchema[fieldName]?.["x-ui-validate"] ?? property["x-ui-validate"]
3537
+ );
3538
+ }
3539
+ function toFieldErrors(result, pointer) {
3540
+ if (result == null || result === "") return [];
3541
+ if (typeof result === "string") return [{ pointer, message: result }];
3542
+ if (Array.isArray(result)) return result;
3543
+ return [];
3544
+ }
3545
+
3546
+ // src/validators/runValidators.ts
3547
+ function isKnownName(registry, name) {
3548
+ return !!(registry.field?.[name] || registry.asyncField?.[name] || registry.form?.[name] || registry.asyncForm?.[name]);
3549
+ }
3550
+ function unknownValidator(kind, name, registry) {
3551
+ const registered = [
3552
+ ...Object.keys(registry.field ?? {}),
3553
+ ...Object.keys(registry.asyncField ?? {}),
3554
+ ...Object.keys(registry.form ?? {}),
3555
+ ...Object.keys(registry.asyncForm ?? {})
3556
+ ];
3557
+ throw new Error(
3558
+ `unknown ${kind} validator '${name}' (registered: [${registered.join(", ")}])`
3559
+ );
3560
+ }
3561
+ function runFieldRefs(fieldName, data, registry, uiSchema, schema, out) {
3562
+ const refs = resolveFieldRefs(fieldName, schema, uiSchema);
3563
+ if (refs.length === 0) return;
3564
+ const pointer = fieldPointer(fieldName);
3565
+ const value = data[fieldName];
3566
+ for (const ref of refs) {
3567
+ if (!isKnownName(registry, ref.name)) {
3568
+ unknownValidator("field", ref.name, registry);
3569
+ }
3570
+ const fn = registry.field?.[ref.name];
3571
+ if (!fn) continue;
3572
+ const ctx = {
3573
+ formData: data,
3574
+ path: pointer,
3575
+ args: ref.args
3576
+ };
3577
+ out.push(...toFieldErrors(fn(value, ctx), pointer));
3578
+ }
3579
+ }
3580
+ function runFormRefs(data, registry, schema, out) {
3581
+ for (const ref of toRefs(schema["x-ui-validate"])) {
3582
+ if (!isKnownName(registry, ref.name)) {
3583
+ unknownValidator("form", ref.name, registry);
3584
+ }
3585
+ const fn = registry.form?.[ref.name];
3586
+ if (!fn) continue;
3587
+ const ctx = { formData: data, path: "", args: ref.args };
3588
+ out.push(...fn(data, ctx));
3589
+ }
3590
+ }
3591
+ function runValidators(schema, data, registry, uiSchema, includeFormValidators) {
3592
+ const out = [];
3593
+ if (!registry) return out;
3594
+ for (const fieldName of Object.keys(getSchemaProperties2(schema))) {
3595
+ runFieldRefs(fieldName, data, registry, uiSchema, schema, out);
3596
+ }
3597
+ if (includeFormValidators) {
3598
+ runFormRefs(data, registry, schema, out);
3599
+ }
3600
+ return out;
3601
+ }
3602
+
3603
+ // src/validators/useAsyncValidators.ts
3604
+ import { useEffect as useEffect3, useMemo as useMemo7, useRef as useRef5, useState as useState9 } from "react";
3605
+ import { getSchemaProperties as getSchemaProperties3 } from "@petrarca/sonnet-core/schema";
3606
+ function keyOf(value) {
3607
+ try {
3608
+ return JSON.stringify(value) ?? "undefined";
3609
+ } catch {
3610
+ return String(value);
3611
+ }
3612
+ }
3613
+ function collectFieldCandidates(schema, data, registry, uiSchema, out) {
3614
+ for (const fieldName of Object.keys(getSchemaProperties3(schema))) {
3615
+ const refs = resolveFieldRefs(fieldName, schema, uiSchema);
3616
+ const pointer = fieldPointer(fieldName);
3617
+ const value = data[fieldName];
3618
+ for (const ref of refs) {
3619
+ const fn = registry[ref.name];
3620
+ if (!fn) continue;
3621
+ const ctx = {
3622
+ formData: data,
3623
+ path: pointer,
3624
+ args: ref.args
3625
+ };
3626
+ out.push({
3627
+ id: `asyncField:${pointer}:${ref.name}`,
3628
+ key: keyOf(value),
3629
+ pointer,
3630
+ run: () => fn(value, ctx)
3631
+ });
3632
+ }
3633
+ }
3634
+ }
3635
+ function collectFormCandidates(schema, data, registry, out) {
3636
+ for (const ref of toRefs(schema["x-ui-validate"])) {
3637
+ const fn = registry[ref.name];
3638
+ if (!fn) continue;
3639
+ const ctx = { formData: data, path: "", args: ref.args };
3640
+ const basis = ref.dependsOn ? ref.dependsOn.map((f) => keyOf(data[f])).join("|") : keyOf(data);
3641
+ out.push({
3642
+ id: `asyncForm:${ref.name}`,
3643
+ key: basis,
3644
+ pointer: "",
3645
+ run: () => fn(data, ctx)
3646
+ });
3647
+ }
3648
+ }
3649
+ function collectCandidates(schema, data, registry, uiSchema, isRoot) {
3650
+ const out = [];
3651
+ if (registry.asyncField) {
3652
+ collectFieldCandidates(schema, data, registry.asyncField, uiSchema, out);
3653
+ }
3654
+ if (registry.asyncForm && isRoot) {
3655
+ collectFormCandidates(schema, data, registry.asyncForm, out);
3656
+ }
3657
+ return out;
3658
+ }
3659
+ function useAsyncValidators(schema, data, registry, uiSchema, isRoot) {
3660
+ const [entries, setEntries] = useState9({});
3661
+ const handledKey = useRef5({});
3662
+ const candidates = registry && (registry.asyncField || registry.asyncForm) ? collectCandidates(schema, data, registry, uiSchema, isRoot) : [];
3663
+ const liveIds = new Set(candidates.map((c) => c.id));
3664
+ const signature = candidates.map((c) => `${c.id}=${c.key}`).join(";");
3665
+ useEffect3(() => {
3666
+ for (const c of candidates) {
3667
+ if (handledKey.current[c.id] === c.key) continue;
3668
+ handledKey.current[c.id] = c.key;
3669
+ setEntries((e) => ({
3670
+ ...e,
3671
+ [c.id]: { key: c.key, pending: true, errors: [] }
3672
+ }));
3673
+ const settle = (errors) => {
3674
+ if (handledKey.current[c.id] !== c.key) return;
3675
+ setEntries((e) => ({
3676
+ ...e,
3677
+ [c.id]: { key: c.key, pending: false, errors }
3678
+ }));
3679
+ };
3680
+ c.run().then((r) => settle(toFieldErrors(r, c.pointer))).catch(() => settle([]));
3681
+ }
3682
+ }, [signature]);
3683
+ const liveKey = [...liveIds].sort().join(",");
3684
+ useEffect3(() => {
3685
+ setEntries((e) => {
3686
+ const kept = Object.entries(e).filter(([id]) => liveIds.has(id));
3687
+ return kept.length === Object.keys(e).length ? e : Object.fromEntries(kept);
3688
+ });
3689
+ for (const id of Object.keys(handledKey.current)) {
3690
+ if (!liveIds.has(id)) delete handledKey.current[id];
3691
+ }
3692
+ }, [liveKey]);
3693
+ let isValidating = false;
3694
+ const errorList = [];
3695
+ for (const c of candidates) {
3696
+ const entry = entries[c.id];
3697
+ if (!entry || entry.key !== c.key || entry.pending) {
3698
+ isValidating = true;
3699
+ continue;
3700
+ }
3701
+ errorList.push(...entry.errors);
3702
+ }
3703
+ const errorSig = errorList.map((e) => `${e.pointer}\0${e.message}`).join("|");
3704
+ const asyncErrors = useMemo7(() => errorList, [errorSig]);
3705
+ return { asyncErrors, isValidating };
3706
+ }
3707
+
3477
3708
  // src/hooks/orderProperties.ts
3478
3709
  function orderProperties(properties, orderArray) {
3479
3710
  const allEntries = Object.entries(properties);
@@ -3517,6 +3748,11 @@ function computeBaselineUpdate(newData, originalData, initialized, schemaPropert
3517
3748
  }
3518
3749
  return null;
3519
3750
  }
3751
+ function computeAllErrors(args) {
3752
+ const { schema, data, customSync, asyncErrors, extraErrors } = args;
3753
+ const base = validateFormData(schema, data).fieldErrors;
3754
+ return mergeFieldErrors(base, customSync, asyncErrors, extraErrors ?? []);
3755
+ }
3520
3756
  function useFormEngine({
3521
3757
  schema,
3522
3758
  data,
@@ -3531,44 +3767,54 @@ function useFormEngine({
3531
3767
  deferChanges = false,
3532
3768
  deferStateUpdates = false,
3533
3769
  allowPristineSave = false,
3534
- refResolver
3770
+ refResolver,
3771
+ extraErrors,
3772
+ validators,
3773
+ isRoot = true
3535
3774
  }) {
3536
- const refResolvedSchema = useMemo7(
3775
+ const refResolvedSchema = useMemo8(
3537
3776
  () => resolveRefs(schema, refResolver),
3538
3777
  [schema, refResolver]
3539
3778
  );
3540
- const [originalData, setOriginalData] = useState9(
3541
- () => applySchemaDefaults2(data, refResolvedSchema)
3779
+ const seed = useState10(
3780
+ () => ensureFormItemIds(
3781
+ applySchemaDefaults2(data, refResolvedSchema),
3782
+ refResolvedSchema
3783
+ )
3784
+ )[0];
3785
+ const [originalData, setOriginalData] = useState10(
3786
+ () => seed
3542
3787
  );
3543
- const [originalDataInitialized, setOriginalDataInitialized] = useState9(false);
3544
- const [nonSchemaData, setNonSchemaData] = useState9(
3788
+ const [originalDataInitialized, setOriginalDataInitialized] = useState10(false);
3789
+ const [nonSchemaData, setNonSchemaData] = useState10(
3545
3790
  () => {
3546
- const props = getSchemaProperties(refResolvedSchema);
3791
+ const props = getSchemaProperties4(refResolvedSchema);
3547
3792
  const names = Object.keys(props);
3548
3793
  return Object.fromEntries(
3549
3794
  Object.entries(data).filter(([k]) => !names.includes(k))
3550
3795
  );
3551
3796
  }
3552
3797
  );
3553
- const [formData, setFormData] = useState9(
3554
- () => applySchemaDefaults2(data, refResolvedSchema)
3555
- );
3556
- const formDataRef = useRef5(formData);
3798
+ const [formData, setFormData] = useState10(() => seed);
3799
+ const formDataRef = useRef6(formData);
3557
3800
  formDataRef.current = formData;
3558
- const [pendingChanges, setPendingChanges] = useState9({});
3559
- const resolvedSchema = useMemo7(
3801
+ const [pendingChanges, setPendingChanges] = useState10({});
3802
+ const resolvedSchema = useMemo8(
3560
3803
  () => resolveSchema(refResolvedSchema, formData),
3561
3804
  [refResolvedSchema, formData]
3562
3805
  );
3563
- const orderedProperties = useMemo7(() => {
3564
- const properties2 = getSchemaProperties(resolvedSchema);
3806
+ const orderedProperties = useMemo8(() => {
3807
+ const properties2 = getSchemaProperties4(resolvedSchema);
3565
3808
  const rawOrder = uiSchema["x-ui-order"] ?? resolvedSchema["x-ui-order"];
3566
3809
  const orderArray = Array.isArray(rawOrder) ? rawOrder : void 0;
3567
3810
  return orderProperties(properties2, orderArray);
3568
3811
  }, [resolvedSchema, uiSchema]);
3569
- const [initialDefaultsSent, setInitialDefaultsSent] = useState9(false);
3570
- const properties = getSchemaProperties(resolvedSchema);
3571
- useEffect3(() => {
3812
+ const [initialDefaultsSent, setInitialDefaultsSent] = useState10(false);
3813
+ const [edited, setEdited] = useState10({});
3814
+ const [touched, setTouched] = useState10({});
3815
+ const [submitAttempted, setSubmitAttempted] = useState10(false);
3816
+ const properties = getSchemaProperties4(resolvedSchema);
3817
+ useEffect4(() => {
3572
3818
  if (!initialDefaultsSent) {
3573
3819
  const hasDefaults = Object.entries(properties).some(
3574
3820
  ([fieldName, property]) => data[fieldName] === void 0 && getFieldDefault(property) !== void 0
@@ -3579,7 +3825,7 @@ function useFormEngine({
3579
3825
  setInitialDefaultsSent(true);
3580
3826
  }
3581
3827
  }, [initialDefaultsSent, properties, data, formData, onChange]);
3582
- useEffect3(() => {
3828
+ useEffect4(() => {
3583
3829
  const currentPropertyNames = Object.keys(properties);
3584
3830
  const keysToRemove = Object.keys(formData).filter(
3585
3831
  (k) => !currentPropertyNames.includes(k)
@@ -3593,11 +3839,14 @@ function useFormEngine({
3593
3839
  if (onChange) onChange(cleanedData);
3594
3840
  }
3595
3841
  }, [properties, formData, onChange]);
3596
- const prevDataRef = useRef5(data);
3597
- useEffect3(() => {
3842
+ const prevDataRef = useRef6(data);
3843
+ useEffect4(() => {
3598
3844
  if (isEqual(prevDataRef.current, data)) return;
3599
3845
  prevDataRef.current = data;
3600
- const newData = applySchemaDefaults2(data, refResolvedSchema);
3846
+ const newData = ensureFormItemIds(
3847
+ applySchemaDefaults2(data, refResolvedSchema),
3848
+ refResolvedSchema
3849
+ );
3601
3850
  setFormData(newData);
3602
3851
  const schemaPropertyNames = Object.keys(properties);
3603
3852
  const result = computeBaselineUpdate(
@@ -3621,29 +3870,53 @@ function useFormEngine({
3621
3870
  originalData,
3622
3871
  originalDataInitialized
3623
3872
  ]);
3624
- const hasChanges = useMemo7(
3873
+ const hasChanges = useMemo8(
3625
3874
  () => !isEqual(formData, originalData),
3626
3875
  [formData, originalData]
3627
3876
  );
3628
- const displayData = useMemo7(() => {
3877
+ const displayData = useMemo8(() => {
3629
3878
  if (deferStateUpdates && Object.keys(pendingChanges).length > 0) {
3630
3879
  return { ...formData, ...pendingChanges };
3631
3880
  }
3632
3881
  return formData;
3633
3882
  }, [formData, pendingChanges, deferStateUpdates]);
3634
- const validation = useMemo7(
3635
- () => validateFormData(resolvedSchema, displayData),
3636
- [resolvedSchema, displayData]
3883
+ const customSync = useMemo8(
3884
+ () => runValidators(resolvedSchema, displayData, validators, uiSchema, isRoot),
3885
+ [resolvedSchema, displayData, validators, uiSchema, isRoot]
3886
+ );
3887
+ const { asyncErrors, isValidating } = useAsyncValidators(
3888
+ resolvedSchema,
3889
+ displayData,
3890
+ validators,
3891
+ uiSchema,
3892
+ isRoot
3637
3893
  );
3894
+ const validation = useMemo8(() => {
3895
+ const merged = computeAllErrors({
3896
+ schema: resolvedSchema,
3897
+ data: displayData,
3898
+ customSync,
3899
+ asyncErrors,
3900
+ extraErrors
3901
+ });
3902
+ return {
3903
+ fieldErrors: merged,
3904
+ errors: merged.map((e) => e.message),
3905
+ isValid: merged.length === 0
3906
+ };
3907
+ }, [resolvedSchema, displayData, customSync, asyncErrors, extraErrors]);
3638
3908
  const { isValid, fieldErrors } = validation;
3639
- const onValidationChangeRef = useRef5(onValidationChange);
3909
+ const onValidationChangeRef = useRef6(onValidationChange);
3640
3910
  onValidationChangeRef.current = onValidationChange;
3641
- useEffect3(() => {
3911
+ useEffect4(() => {
3642
3912
  onValidationChangeRef.current?.(validation.errors, validation.isValid);
3643
3913
  }, [validation]);
3644
3914
  const handleFieldChange = useCallback7(
3645
3915
  (fieldName, value) => {
3646
3916
  if (readOnly) return;
3917
+ setEdited(
3918
+ (prev) => prev[fieldName] ? prev : { ...prev, [fieldName]: true }
3919
+ );
3647
3920
  const immediate = isImmediateField(fieldName, properties);
3648
3921
  if (deferStateUpdates && !immediate) {
3649
3922
  setPendingChanges((prev) => ({ ...prev, [fieldName]: value }));
@@ -3684,6 +3957,11 @@ function useFormEngine({
3684
3957
  const handleFieldBlur = useCallback7(
3685
3958
  (fieldName) => {
3686
3959
  if (readOnly) return;
3960
+ if (edited[fieldName]) {
3961
+ setTouched(
3962
+ (prev) => prev[fieldName] ? prev : { ...prev, [fieldName]: true }
3963
+ );
3964
+ }
3687
3965
  flushPendingField(fieldName);
3688
3966
  const latest = formDataRef.current;
3689
3967
  if (!showActions && deferChanges && onChange) {
@@ -3697,6 +3975,7 @@ function useFormEngine({
3697
3975
  },
3698
3976
  [
3699
3977
  readOnly,
3978
+ edited,
3700
3979
  pendingChanges,
3701
3980
  deferChanges,
3702
3981
  deferStateUpdates,
@@ -3707,11 +3986,25 @@ function useFormEngine({
3707
3986
  ]
3708
3987
  );
3709
3988
  const handleSave = useCallback7(() => {
3989
+ setSubmitAttempted(true);
3710
3990
  if (!hasChanges && !allowPristineSave) return;
3991
+ if (isValidating) return;
3711
3992
  const finalData = deferStateUpdates && Object.keys(pendingChanges).length > 0 ? { ...formData, ...pendingChanges } : formData;
3712
3993
  const completeData = { ...nonSchemaData, ...finalData };
3713
- const validation2 = validateFormData(resolvedSchema, finalData);
3714
- if (validation2.isValid && onUpdate) {
3994
+ const saveErrors = computeAllErrors({
3995
+ schema: resolvedSchema,
3996
+ data: finalData,
3997
+ customSync: runValidators(
3998
+ resolvedSchema,
3999
+ finalData,
4000
+ validators,
4001
+ uiSchema,
4002
+ isRoot
4003
+ ),
4004
+ asyncErrors,
4005
+ extraErrors
4006
+ });
4007
+ if (saveErrors.length === 0 && onUpdate) {
3715
4008
  const changed = {};
3716
4009
  for (const key of Object.keys(finalData)) {
3717
4010
  if (!isEqual(finalData[key], originalData[key])) {
@@ -3731,11 +4024,20 @@ function useFormEngine({
3731
4024
  pendingChanges,
3732
4025
  deferStateUpdates,
3733
4026
  nonSchemaData,
3734
- onUpdate
4027
+ onUpdate,
4028
+ extraErrors,
4029
+ asyncErrors,
4030
+ isValidating,
4031
+ uiSchema,
4032
+ validators,
4033
+ isRoot
3735
4034
  ]);
3736
4035
  const handleCancel = useCallback7(() => {
3737
4036
  setFormData({ ...originalData });
3738
4037
  setPendingChanges({});
4038
+ setEdited({});
4039
+ setTouched({});
4040
+ setSubmitAttempted(false);
3739
4041
  onCancel?.();
3740
4042
  }, [originalData, onCancel]);
3741
4043
  return {
@@ -3745,7 +4047,10 @@ function useFormEngine({
3745
4047
  orderedProperties,
3746
4048
  hasChanges,
3747
4049
  isValid,
4050
+ isValidating,
3748
4051
  fieldErrors,
4052
+ touched,
4053
+ submitAttempted,
3749
4054
  handleFieldChange,
3750
4055
  handleFieldBlur,
3751
4056
  handleSave,
@@ -3754,6 +4059,10 @@ function useFormEngine({
3754
4059
  }
3755
4060
 
3756
4061
  // src/JsonSchemaFormRenderer.tsx
4062
+ import {
4063
+ useValidationDisplay,
4064
+ useSaveWhenInvalid
4065
+ } from "@petrarca/sonnet-core/config";
3757
4066
  import { jsx as jsx37, jsxs as jsxs23 } from "react/jsx-runtime";
3758
4067
  function stripFids(data) {
3759
4068
  const result = {};
@@ -3901,10 +4210,42 @@ function layoutFieldsInRows(fields, rowDefs, defaultRowGap) {
3901
4210
  );
3902
4211
  return elements.concat(collectUnplacedFields(fields, placed));
3903
4212
  }
4213
+ function rootFormData(nestedContext, ownData) {
4214
+ return nestedContext?.rootData ?? ownData;
4215
+ }
4216
+ function rootSubmitAttempted(nestedContext, ownSubmitAttempted) {
4217
+ return ownSubmitAttempted || (nestedContext?.submitAttempted ?? false);
4218
+ }
4219
+ function blurHandlerNeeded({
4220
+ deferStateUpdates,
4221
+ deferChanges,
4222
+ hasOnBlur,
4223
+ validationDisplay
4224
+ }) {
4225
+ return deferStateUpdates || deferChanges || hasOnBlur || validationDisplay === "onTouched";
4226
+ }
4227
+ function fieldErrorsVisible(validationDisplay, submitAttempted, isTouched) {
4228
+ if (validationDisplay === "always") return true;
4229
+ if (submitAttempted) return true;
4230
+ return validationDisplay === "onTouched" && !!isTouched;
4231
+ }
3904
4232
  function pointerRoot(pointer) {
3905
4233
  const token = pointer.split("/")[1] ?? "";
3906
4234
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
3907
4235
  }
4236
+ function fieldRawErrors(fieldName, fieldErrors, extraErrors, gated) {
4237
+ const forField = fieldErrors.filter(
4238
+ (e) => pointerRoot(e.pointer) === fieldName
4239
+ );
4240
+ if (forField.length === 0) return void 0;
4241
+ const extraMessages = new Set(
4242
+ (extraErrors ?? []).filter((e) => pointerRoot(e.pointer) === fieldName).map((e) => e.message)
4243
+ );
4244
+ const always = forField.filter((e) => extraMessages.has(e.message)).map((e) => e.message);
4245
+ const rest = gated ? forField.filter((e) => !extraMessages.has(e.message)).map((e) => e.message) : [];
4246
+ const all = [...always, ...rest];
4247
+ return all.length > 0 ? all : void 0;
4248
+ }
3908
4249
  function groupErrorCount(tab, fieldErrors) {
3909
4250
  if (fieldErrors.length === 0) return 0;
3910
4251
  const owned = new Set(tab.rows.flatMap((row) => row.fields));
@@ -3942,6 +4283,23 @@ function HiddenErrorNotice({
3942
4283
  count === 1 ? "1 required field is in a section that is not currently shown." : `${count} required fields are in sections that are not currently shown.`
3943
4284
  ] });
3944
4285
  }
4286
+ function FormLevelErrors({
4287
+ messages
4288
+ }) {
4289
+ if (messages.length === 0) return null;
4290
+ return /* @__PURE__ */ jsx37("div", { className: "space-y-1", children: messages.map((msg, i) => /* @__PURE__ */ jsxs23(
4291
+ "p",
4292
+ {
4293
+ role: "alert",
4294
+ className: "flex items-center gap-1.5 text-sm text-destructive",
4295
+ children: [
4296
+ /* @__PURE__ */ jsx37(AlertCircle2, { className: "h-4 w-4 shrink-0" }),
4297
+ msg
4298
+ ]
4299
+ },
4300
+ i
4301
+ )) });
4302
+ }
3945
4303
  function layoutFieldsInGroups(fields, groupDefs, defaultRowGap, fieldErrors, formData, GroupTemplate) {
3946
4304
  const fieldMap = buildFieldMap(fields);
3947
4305
  const placed = /* @__PURE__ */ new Set();
@@ -3973,11 +4331,14 @@ function FormContainer({
3973
4331
  disabled,
3974
4332
  hasChanges,
3975
4333
  isValid,
4334
+ isValidating,
4335
+ saveWhenInvalid,
3976
4336
  saveLabel,
3977
4337
  cancelLabel,
3978
4338
  showCancel,
3979
4339
  leadingActions,
3980
4340
  hiddenErrorCount,
4341
+ formLevelErrors,
3981
4342
  children
3982
4343
  }) {
3983
4344
  if (displayContents) {
@@ -3987,6 +4348,7 @@ function FormContainer({
3987
4348
  children,
3988
4349
  showExtraProperties && /* @__PURE__ */ jsx37(ExtraPropertiesCard_default, { properties: formData, schema: resolvedSchema }),
3989
4350
  /* @__PURE__ */ jsx37(HiddenErrorNotice, { count: hiddenErrorCount }),
4351
+ /* @__PURE__ */ jsx37(FormLevelErrors, { messages: formLevelErrors }),
3990
4352
  showActions && /* @__PURE__ */ jsx37(
3991
4353
  FormActions,
3992
4354
  {
@@ -3995,6 +4357,8 @@ function FormContainer({
3995
4357
  disabled,
3996
4358
  hasChanges,
3997
4359
  isValid,
4360
+ isValidating,
4361
+ saveWhenInvalid,
3998
4362
  saveLabel,
3999
4363
  cancelLabel,
4000
4364
  showCancel,
@@ -4013,9 +4377,12 @@ function JsonSchemaFormRenderer(props) {
4013
4377
  onUpdate,
4014
4378
  onCancel,
4015
4379
  templates,
4380
+ validators,
4016
4381
  leadingActions,
4017
4382
  idPrefix = "field"
4018
4383
  } = props;
4384
+ const nestedFormContext = useContext2(NestedFormContext);
4385
+ const isRootForm = nestedFormContext === null;
4019
4386
  const {
4020
4387
  disabled,
4021
4388
  readOnly,
@@ -4036,7 +4403,10 @@ function JsonSchemaFormRenderer(props) {
4036
4403
  orderedProperties,
4037
4404
  hasChanges,
4038
4405
  isValid,
4406
+ isValidating,
4039
4407
  fieldErrors,
4408
+ touched,
4409
+ submitAttempted,
4040
4410
  handleFieldChange,
4041
4411
  handleFieldBlur,
4042
4412
  handleSave,
@@ -4055,17 +4425,20 @@ function JsonSchemaFormRenderer(props) {
4055
4425
  deferChanges,
4056
4426
  deferStateUpdates,
4057
4427
  allowPristineSave,
4058
- refResolver: props.refResolver
4428
+ refResolver: props.refResolver,
4429
+ extraErrors: props.extraErrors,
4430
+ validators,
4431
+ isRoot: isRootForm
4059
4432
  });
4060
4433
  const layoutLabelPlacement = uiSchema["x-ui-layout"]?.labelPlacement;
4061
- const globalOptions = useMemo8(
4434
+ const globalOptions = useMemo9(
4062
4435
  () => ({
4063
4436
  ...layoutLabelPlacement ? { labelPlacement: layoutLabelPlacement } : {},
4064
4437
  ...uiSchema["x-ui-globalOptions"] || {}
4065
4438
  }),
4066
4439
  [layoutLabelPlacement, uiSchema]
4067
4440
  );
4068
- const resolvedTemplates = useMemo8(
4441
+ const resolvedTemplates = useMemo9(
4069
4442
  () => ({ ...DEFAULT_TEMPLATES, ...templates }),
4070
4443
  [templates]
4071
4444
  );
@@ -4075,21 +4448,47 @@ function JsonSchemaFormRenderer(props) {
4075
4448
  {
4076
4449
  ...props2,
4077
4450
  templates: resolvedTemplates,
4451
+ validators,
4078
4452
  showActions: false,
4079
4453
  showExtraProperties: false
4080
4454
  }
4081
4455
  ),
4082
- [resolvedTemplates]
4456
+ [resolvedTemplates, validators]
4083
4457
  );
4084
- const contextValue = useMemo8(
4085
- () => ({ renderer: nestedRenderer, templates: resolvedTemplates }),
4086
- [nestedRenderer, resolvedTemplates]
4458
+ const rootData = rootFormData(nestedFormContext, displayData);
4459
+ const effectiveSubmitAttempted = rootSubmitAttempted(
4460
+ nestedFormContext,
4461
+ submitAttempted
4087
4462
  );
4088
- const needsBlurHandler = deferStateUpdates || !!onBlur;
4089
- const fields = useMemo8(
4463
+ const contextValue = useMemo9(
4464
+ () => ({
4465
+ renderer: nestedRenderer,
4466
+ templates: resolvedTemplates,
4467
+ rootData,
4468
+ submitAttempted: effectiveSubmitAttempted
4469
+ }),
4470
+ [nestedRenderer, resolvedTemplates, rootData, effectiveSubmitAttempted]
4471
+ );
4472
+ const validationDisplay = useValidationDisplay(props.validationDisplay);
4473
+ const saveWhenInvalid = useSaveWhenInvalid(props.saveWhenInvalid);
4474
+ const needsBlurHandler = blurHandlerNeeded({
4475
+ deferStateUpdates,
4476
+ deferChanges,
4477
+ hasOnBlur: !!onBlur,
4478
+ validationDisplay
4479
+ });
4480
+ const showFieldErrors = useCallback8(
4481
+ (fieldName) => fieldErrorsVisible(
4482
+ validationDisplay,
4483
+ effectiveSubmitAttempted,
4484
+ touched[fieldName]
4485
+ ),
4486
+ [validationDisplay, effectiveSubmitAttempted, touched]
4487
+ );
4488
+ const fields = useMemo9(
4090
4489
  () => orderedProperties.flatMap(([fieldName, property]) => {
4091
4490
  const rule = uiSchema[fieldName]?.["x-ui-rule"] ?? property["x-ui-rule"];
4092
- const { visible, enabled } = evaluateUiRule(rule, displayData);
4491
+ const { visible, enabled } = evaluateUiRule(rule, rootData);
4093
4492
  if (!visible) return [];
4094
4493
  return [
4095
4494
  /* @__PURE__ */ jsx37(
@@ -4108,7 +4507,13 @@ function JsonSchemaFormRenderer(props) {
4108
4507
  registry: widgets,
4109
4508
  globalOptions,
4110
4509
  formData: displayData,
4111
- idPrefix
4510
+ idPrefix,
4511
+ rawErrors: fieldRawErrors(
4512
+ fieldName,
4513
+ fieldErrors,
4514
+ props.extraErrors,
4515
+ showFieldErrors(fieldName)
4516
+ )
4112
4517
  },
4113
4518
  fieldName
4114
4519
  )
@@ -4117,6 +4522,7 @@ function JsonSchemaFormRenderer(props) {
4117
4522
  [
4118
4523
  orderedProperties,
4119
4524
  displayData,
4525
+ rootData,
4120
4526
  resolvedSchema,
4121
4527
  disabled,
4122
4528
  readOnly,
@@ -4126,6 +4532,9 @@ function JsonSchemaFormRenderer(props) {
4126
4532
  globalOptions,
4127
4533
  idPrefix,
4128
4534
  needsBlurHandler,
4535
+ showFieldErrors,
4536
+ fieldErrors,
4537
+ props.extraErrors,
4129
4538
  handleFieldChange,
4130
4539
  handleFieldBlur
4131
4540
  ]
@@ -4138,14 +4547,15 @@ function JsonSchemaFormRenderer(props) {
4138
4547
  resolvedTemplates,
4139
4548
  layoutConfig?.variant
4140
4549
  );
4141
- const layoutFields = useMemo8(() => {
4550
+ const layoutFields = useMemo9(() => {
4142
4551
  if (groupDefs && groupDefs.length > 0) {
4143
4552
  return layoutFieldsInGroups(
4144
4553
  fields,
4145
4554
  groupDefs,
4146
4555
  defaultRowGap,
4147
4556
  fieldErrors,
4148
- displayData,
4557
+ // Tab rules are x-ui-rule too: root-anchored like field rules.
4558
+ rootData,
4149
4559
  groupTemplate
4150
4560
  );
4151
4561
  }
@@ -4159,15 +4569,19 @@ function JsonSchemaFormRenderer(props) {
4159
4569
  groupDefs,
4160
4570
  defaultRowGap,
4161
4571
  fieldErrors,
4162
- displayData,
4572
+ rootData,
4163
4573
  groupTemplate
4164
4574
  ]);
4165
- const hiddenErrorCount = useMemo8(() => {
4575
+ const hiddenErrorCount = useMemo9(() => {
4166
4576
  if (!groupDefs?.length || fieldErrors.length === 0) return 0;
4167
- const hidden = hiddenGroupFieldNames(groupDefs, displayData);
4577
+ const hidden = hiddenGroupFieldNames(groupDefs, rootData);
4168
4578
  if (hidden.size === 0) return 0;
4169
4579
  return fieldErrors.filter((e) => hidden.has(pointerRoot(e.pointer))).length;
4170
- }, [groupDefs, fieldErrors, displayData]);
4580
+ }, [groupDefs, fieldErrors, rootData]);
4581
+ const formLevelErrors = useMemo9(() => {
4582
+ if (validationDisplay !== "always" && !submitAttempted) return [];
4583
+ return fieldErrors.filter((e) => e.pointer === "").map((e) => e.message);
4584
+ }, [fieldErrors, validationDisplay, submitAttempted]);
4171
4585
  return /* @__PURE__ */ jsx37(NestedFormContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx37(
4172
4586
  FormContainer,
4173
4587
  {
@@ -4181,20 +4595,23 @@ function JsonSchemaFormRenderer(props) {
4181
4595
  disabled,
4182
4596
  hasChanges: hasChanges || allowPristineSave,
4183
4597
  isValid,
4598
+ isValidating,
4599
+ saveWhenInvalid,
4184
4600
  saveLabel,
4185
4601
  cancelLabel,
4186
4602
  showCancel,
4187
4603
  leadingActions,
4188
4604
  hiddenErrorCount,
4605
+ formLevelErrors,
4189
4606
  children: layoutFields
4190
4607
  }
4191
4608
  ) });
4192
4609
  }
4193
4610
 
4194
4611
  // src/hooks/useResolvedSchema.ts
4195
- import { useMemo as useMemo9 } from "react";
4612
+ import { useMemo as useMemo10 } from "react";
4196
4613
  import {
4197
- getSchemaProperties as getSchemaProperties2,
4614
+ getSchemaProperties as getSchemaProperties5,
4198
4615
  resolveSchema as resolveSchema2
4199
4616
  } from "@petrarca/sonnet-core/schema";
4200
4617
  function useResolvedSchema({
@@ -4202,12 +4619,12 @@ function useResolvedSchema({
4202
4619
  formData,
4203
4620
  uiSchema
4204
4621
  }) {
4205
- const resolvedSchema = useMemo9(
4622
+ const resolvedSchema = useMemo10(
4206
4623
  () => resolveSchema2(schema, formData),
4207
4624
  [schema, formData]
4208
4625
  );
4209
- const orderedProperties = useMemo9(() => {
4210
- const properties = getSchemaProperties2(resolvedSchema);
4626
+ const orderedProperties = useMemo10(() => {
4627
+ const properties = getSchemaProperties5(resolvedSchema);
4211
4628
  const rawOrder = uiSchema["x-ui-order"] ?? resolvedSchema["x-ui-order"];
4212
4629
  const orderArray = Array.isArray(rawOrder) ? rawOrder : void 0;
4213
4630
  return orderProperties(properties, orderArray);