@abgov/jsonforms-components 2.70.3 → 2.70.4

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/index.esm.js CHANGED
@@ -6,7 +6,7 @@ import axios from 'axios';
6
6
  import get$1 from 'lodash/get';
7
7
  import { toDataPath, INIT, UPDATE_CORE, isVisible, isEnabled, deriveLabelForUISchemaElement, rankWith, isStringControl, and, optionIs, isDateControl, isNumberControl, isIntegerControl, isDateTimeControl, isTimeControl, uiTypeIs, isControl as isControl$1, isEnumControl, or, isOneOfEnumControl, isBooleanControl, createDefaultValue, Paths, Resolve, schemaTypeIs, formatIs, getAjv, schemaMatches, isObjectArrayControl, isPrimitiveArrayControl, composePaths, hasType, isCategorization, isLayout } from '@jsonforms/core';
8
8
  import * as _$c from 'lodash';
9
- import ___default, { isEmpty, isObject as isObject$i } from 'lodash';
9
+ import ___default, { isEqual, isEmpty, isObject as isObject$i } from 'lodash';
10
10
  import { useJsonForms, withJsonFormsControlProps, withJsonFormsEnumProps, withTranslateProps, JsonFormsDispatch, withJsonFormsAllOfProps, withJsonFormsArrayLayoutProps, withJsonFormsLayoutProps, withJsonFormsCellProps } from '@jsonforms/react';
11
11
  import dompurify from 'dompurify';
12
12
  import * as xss from 'xss';
@@ -8818,7 +8818,75 @@ const GoATextControl = props => jsx(GoAInputBaseControl, Object.assign({}, props
8818
8818
  const GoATextControlTester = rankWith(1, isStringControl);
8819
8819
  const GoAInputTextControl = withJsonFormsControlProps(GoATextControl);
8820
8820
 
8821
- const DEBOUNCE_DELAY = 550;
8821
+ const COMMIT_DELAY = 300;
8822
+ /**
8823
+ * Keeps a live, per-keystroke mirror of a control's value and pushes it into the form data only
8824
+ * once the user pauses typing.
8825
+ *
8826
+ * The GoA web components keep the value in their own DOM and report changes back a macrotask
8827
+ * late, while their update path writes an incoming `value` prop straight back into the field
8828
+ * whenever it differs from what is in the box. So a render that commits a value older than what
8829
+ * has been typed since wipes out the characters in between. Committing on every keystroke
8830
+ * re-renders and revalidates the whole form, which makes that window wide enough to swallow
8831
+ * characters; debouncing the commit keeps it to a single local render.
8832
+ *
8833
+ * The echo of our own commit is ignored while further edits are outstanding, so data arriving
8834
+ * back from the form can never overwrite what the user has typed in the meantime.
8835
+ *
8836
+ * @param externalValue the value held in the form data; must be referentially stable between
8837
+ * renders (memoize it when it is an object), otherwise the sync effect runs on every render
8838
+ * @param commit pushes a settled value into the form data
8839
+ * @param delay how long to wait for typing to stop before committing
8840
+ */
8841
+ function useDebouncedCommit(externalValue, commit, delay = COMMIT_DELAY) {
8842
+ const [value, setLocalValue] = useState(externalValue);
8843
+ // Set while the user has edits we have not pushed upstream yet.
8844
+ const pendingRef = useRef(null);
8845
+ const autoCommitRef = useRef(true);
8846
+ const commitRef = useRef(commit);
8847
+ useEffect(() => {
8848
+ commitRef.current = commit;
8849
+ });
8850
+ const debouncedValue = useDebounce(value, delay);
8851
+ const flush = useCallback(() => {
8852
+ const pending = pendingRef.current;
8853
+ if (pending === null) {
8854
+ return;
8855
+ }
8856
+ pendingRef.current = null;
8857
+ autoCommitRef.current = true;
8858
+ commitRef.current(pending);
8859
+ }, []);
8860
+ const setValue = useCallback((next, options) => {
8861
+ pendingRef.current = next;
8862
+ autoCommitRef.current = (options == null ? void 0 : options.autoCommit) !== false;
8863
+ setLocalValue(next);
8864
+ }, []);
8865
+ useEffect(() => {
8866
+ if (!autoCommitRef.current) {
8867
+ return;
8868
+ }
8869
+ // Only commit once the debounce has caught up with the latest keystroke.
8870
+ if (pendingRef.current === null || pendingRef.current !== debouncedValue) {
8871
+ return;
8872
+ }
8873
+ flush();
8874
+ }, [debouncedValue, flush]);
8875
+ useEffect(() => {
8876
+ if (pendingRef.current !== null) {
8877
+ return;
8878
+ }
8879
+ setLocalValue(current => isEqual(current, externalValue) ? current : externalValue);
8880
+ }, [externalValue]);
8881
+ // Don't drop the tail of what was typed when the control goes away, e.g. on stepper navigation.
8882
+ useEffect(() => flush, [flush]);
8883
+ return {
8884
+ value,
8885
+ setValue,
8886
+ flush
8887
+ };
8888
+ }
8889
+
8822
8890
  const MultiLineText = props => {
8823
8891
  var _uischema$options$com, _uischema$options, _uischema$options2, _uischema$options3, _uischema$options$com2, _uischema$options4, _uischema$options5;
8824
8892
  const {
@@ -8834,29 +8902,29 @@ const MultiLineText = props => {
8834
8902
  errors,
8835
8903
  setIsVisited
8836
8904
  } = props;
8837
- const [textAreaValue, setTextAreaValue] = React.useState(data || '');
8838
- const debouncedValue = useDebounce(textAreaValue, DEBOUNCE_DELAY);
8839
- useEffect(() => {
8840
- setTextAreaValue(data || '');
8841
- }, [data]);
8842
- /* istanbul ignore next */
8843
- useEffect(() => {
8844
- if (debouncedValue !== data && (debouncedValue !== '' || data !== undefined)) {
8845
- onChangeForInputControl({
8846
- value: debouncedValue,
8847
- controlProps: props
8848
- });
8849
- }
8850
- }, [debouncedValue]);
8905
+ const {
8906
+ value: textAreaValue,
8907
+ setValue: setTextAreaValue,
8908
+ flush: flushTextArea
8909
+ } = useDebouncedCommit(data || '', value => onChangeForInputControl({
8910
+ value,
8911
+ controlProps: props
8912
+ }));
8851
8913
  const appliedUiSchemaOptions = Object.assign({}, config, uischema == null ? void 0 : uischema.options);
8852
8914
  const placeholder = (appliedUiSchemaOptions == null ? void 0 : appliedUiSchemaOptions.placeholder) || (schema == null ? void 0 : schema.description) || '';
8853
8915
  const width = (_uischema$options$com = uischema == null || (_uischema$options = uischema.options) == null || (_uischema$options = _uischema$options.componentProps) == null ? void 0 : _uischema$options.width) != null ? _uischema$options$com : '100%';
8854
8916
  const autoCapitalize = (uischema == null || (_uischema$options2 = uischema.options) == null || (_uischema$options2 = _uischema$options2.componentProps) == null ? void 0 : _uischema$options2.autoCapitalize) === true || (uischema == null || (_uischema$options3 = uischema.options) == null ? void 0 : _uischema$options3.autoCapitalize) === true;
8855
8917
  const readOnly = (_uischema$options$com2 = uischema == null || (_uischema$options4 = uischema.options) == null || (_uischema$options4 = _uischema$options4.componentProps) == null ? void 0 : _uischema$options4.readOnly) != null ? _uischema$options$com2 : false;
8856
8918
  const textAreaName = `${label || path}-text-area` || '';
8919
+ const applyCapitalization = value => autoCapitalize ? value.toUpperCase() : value;
8920
+ const markVisited = () => {
8921
+ if (isVisited === false && setIsVisited) {
8922
+ setIsVisited();
8923
+ }
8924
+ };
8857
8925
  const txtAreaComponent = jsx(GoabTextArea, Object.assign({
8858
8926
  error: isVisited && errors.length > 0,
8859
- value: debouncedValue,
8927
+ value: textAreaValue,
8860
8928
  disabled: !enabled,
8861
8929
  readOnly: readOnly,
8862
8930
  placeholder: placeholder,
@@ -8865,21 +8933,21 @@ const MultiLineText = props => {
8865
8933
  width: width,
8866
8934
  // Note: Paul Jan-09-2023. The latest ui-component come with the maxCount. We need to uncomment the following line when the component is updated
8867
8935
  // maxCount={schema.maxLength || 256}
8936
+ // The component raises _change from its input event, so unlike _keyPress (keyup) this also
8937
+ // covers pasting, IME composition and held-key repeats.
8938
+ onChange: detail => {
8939
+ setTextAreaValue(applyCapitalization(detail.value));
8940
+ markVisited();
8941
+ },
8868
8942
  onKeyPress: detail => {
8869
- const newValue = autoCapitalize ? detail.value.toUpperCase() : detail.value;
8870
- setTextAreaValue(newValue);
8871
- if (isVisited === false && setIsVisited) {
8872
- setIsVisited();
8873
- }
8943
+ setTextAreaValue(applyCapitalization(detail.value));
8944
+ markVisited();
8874
8945
  },
8875
8946
  onBlur: detail => {
8876
- const newValue = autoCapitalize ? detail.value.toUpperCase() : detail.value;
8877
- setTextAreaValue(newValue);
8878
- if (isVisited === false && setIsVisited) {
8879
- setIsVisited();
8880
- }
8881
- },
8882
- onChange: detail => {}
8947
+ setTextAreaValue(applyCapitalization(detail.value));
8948
+ flushTextArea();
8949
+ markVisited();
8950
+ }
8883
8951
  }, uischema == null || (_uischema$options5 = uischema.options) == null ? void 0 : _uischema$options5.componentProps));
8884
8952
  return txtAreaComponent;
8885
8953
  };
@@ -18523,21 +18591,19 @@ const filterSuggestionsWithoutAddressCount = suggestions => {
18523
18591
  });
18524
18592
  };
18525
18593
  const mapSuggestionToAddress = suggestion => {
18526
- let addressLine1, addressLine2;
18527
- const suiteMatch = suggestion.Text.match(/(Suite|Apt|Unit|#)+/i);
18528
- const textParts = suggestion.Text.split(' ');
18529
- if (suiteMatch) {
18530
- addressLine1 = suggestion.Text.replace(textParts[0], '').trim();
18531
- addressLine2 = textParts[0].trim();
18532
- } else {
18533
- addressLine2 = '';
18534
- addressLine1 = suggestion.Text.trim();
18535
- }
18536
- const descriptionParts = suggestion.Description.split(',');
18537
- const municipality = descriptionParts[0].trim();
18538
- const provinceAndPostalCode = descriptionParts[1].trim().split(' ');
18539
- const subdivisionCode = provinceAndPostalCode[0];
18540
- const postalCode = descriptionParts[2].trim();
18594
+ var _suggestion$Text$trim, _suggestion$Text, _suggestion$Descripti;
18595
+ const text = (_suggestion$Text$trim = suggestion == null || (_suggestion$Text = suggestion.Text) == null ? void 0 : _suggestion$Text.trim()) != null ? _suggestion$Text$trim : '';
18596
+ const textParts = text.split(' ');
18597
+ const hasSuite = /(Suite|Apt|Unit|#)+/i.test(text) && textParts.length > 1;
18598
+ const addressLine1 = hasSuite ? text.replace(textParts[0], '').trim() : text;
18599
+ const addressLine2 = hasSuite ? textParts[0].trim() : '';
18600
+ // Descriptions usually read "municipality, province, postalCode", but the lookup also returns
18601
+ // "municipality, province postalCode" and, for partial matches, drops the postal code entirely.
18602
+ // Reading the parts positionally used to throw on anything but the three part form.
18603
+ const descriptionParts = ((_suggestion$Descripti = suggestion == null ? void 0 : suggestion.Description) != null ? _suggestion$Descripti : '').split(',').map(part => part.trim());
18604
+ const [municipality = '', provinceAndPostalCode = '', trailingPostalCode = ''] = descriptionParts;
18605
+ const [subdivisionCode = '', ...postalCodeParts] = provinceAndPostalCode.split(' ').filter(Boolean);
18606
+ const postalCode = postalCodeParts.length > 0 ? postalCodeParts.join(' ') : trailingPostalCode;
18541
18607
  return {
18542
18608
  addressLine1,
18543
18609
  addressLine2,
@@ -18639,17 +18705,18 @@ const AddressLookUpControl = props => {
18639
18705
  const [open, setOpen] = useState(false);
18640
18706
  const addressContainerRef = useRef(null);
18641
18707
  const label = typeof (uischema == null ? void 0 : uischema.label) === 'string' && uischema.label ? uischema.label : '';
18642
- const [address, setAddress] = useState(normalizeAddressData(data, isAlbertaAddress));
18708
+ const normalizedData = useMemo(() => normalizeAddressData(data, isAlbertaAddress), [data, isAlbertaAddress]);
18709
+ const {
18710
+ value: address,
18711
+ setValue: setAddress,
18712
+ flush: flushAddress
18713
+ } = useDebouncedCommit(normalizedData, updatedAddress => handleChange(path, updatedAddress));
18643
18714
  const [searchTerm, setSearchTerm] = useState('');
18644
18715
  const [suggestions, setSuggestions] = useState([]);
18645
18716
  const [loading, setLoading] = useState(false);
18646
18717
  const [errors, setErrors] = useState({});
18647
18718
  const requiredFields = schema.required;
18648
18719
  const [dropdownSelected, setDropdownSelected] = useState(false);
18649
- const updateFormData = updatedAddress => {
18650
- handleChange(path, updatedAddress);
18651
- };
18652
- const debouncedRenderAddress = useDebounce(searchTerm, 500);
18653
18720
  const [activeIndex, setActiveIndex] = useState(0);
18654
18721
  const dropdownRef = useRef(null);
18655
18722
  const debouncedTerm = useDebounce(searchTerm, 500);
@@ -18657,39 +18724,35 @@ const AddressLookUpControl = props => {
18657
18724
  const lastQueryRef = useRef('');
18658
18725
  const cacheRef = useRef(new Map());
18659
18726
  const spinnerTimerRef = useRef(null);
18660
- useEffect(() => {
18661
- const nextAddress = normalizeAddressData(data, isAlbertaAddress);
18662
- setAddress(currentAddress => {
18663
- const keys = ['addressLine1', 'addressLine2', 'municipality', 'postalCode', 'country', 'subdivisionCode'];
18664
- const unchanged = keys.every(key => currentAddress[key] === nextAddress[key]);
18665
- return unchanged ? currentAddress : nextAddress;
18666
- });
18667
- }, [data, isAlbertaAddress]);
18668
- const handleInputChange = (field, value) => {
18727
+ const handleInputChange = (field, value, options) => {
18669
18728
  var _schema$errorMessage;
18670
- if (field === 'addressLine1') {
18671
- setDropdownSelected(false);
18672
- }
18673
- let newAddress;
18729
+ let newValue = value;
18674
18730
  const postalCodeErrorMessage = (_schema$errorMessage = schema.errorMessage) == null || (_schema$errorMessage = _schema$errorMessage.properties) == null ? void 0 : _schema$errorMessage.postalCode;
18675
18731
  if (field === 'postalCode') {
18676
18732
  const validatePc = validatePostalCode(value);
18677
- setErrors(handlePostalCodeValidation(validatePc, postalCodeErrorMessage ? postalCodeErrorMessage : '', value, errors));
18678
- value = formatPostalCode(value);
18679
- newAddress = Object.assign({}, address, {
18680
- [field]: value.toUpperCase()
18681
- });
18733
+ setErrors(currentErrors => handlePostalCodeValidation(validatePc, postalCodeErrorMessage != null ? postalCodeErrorMessage : '', value, currentErrors));
18734
+ newValue = formatPostalCode(value).toUpperCase();
18682
18735
  } else {
18683
- newAddress = Object.assign({}, address, {
18684
- [field]: value
18736
+ setErrors(currentErrors => {
18737
+ if (!(field in currentErrors)) {
18738
+ return currentErrors;
18739
+ }
18740
+ const nextErrors = Object.assign({}, currentErrors);
18741
+ delete nextErrors[field];
18742
+ return nextErrors;
18685
18743
  });
18686
- delete errors[field];
18687
18744
  }
18688
- if (value === '' && field in newAddress) {
18745
+ const newAddress = Object.assign({}, address, {
18746
+ [field]: newValue
18747
+ });
18748
+ if (newValue === '') {
18689
18749
  delete newAddress[field];
18690
18750
  }
18691
- setAddress(newAddress);
18692
- updateFormData(newAddress);
18751
+ setAddress(newAddress, options);
18752
+ // Picking a province isn't typing, so there is nothing to wait for.
18753
+ if (field === 'subdivisionCode') {
18754
+ flushAddress();
18755
+ }
18693
18756
  };
18694
18757
  const renderHelp = () => {
18695
18758
  return jsx(HelpContentComponent, Object.assign({}, props, {
@@ -18697,70 +18760,46 @@ const AddressLookUpControl = props => {
18697
18760
  showLabel: false
18698
18761
  }));
18699
18762
  };
18700
- useEffect(() => {
18701
- const fetchSuggestions = async () => {
18702
- /* istanbul ignore next */
18703
- if (debouncedRenderAddress.length > 2 && dropdownSelected === false) {
18704
- setLoading(true);
18705
- setOpen(true);
18706
- try {
18707
- await fetchAddressSuggestions(formUrl, formatPostalCodeIfNeeded(searchTerm), isAlbertaAddress).then(response => {
18708
- const suggestions = filterSuggestionsWithoutAddressCount(response);
18709
- if (isAlbertaAddress) {
18710
- setSuggestions(filterAlbertaAddresses(suggestions));
18711
- } else {
18712
- setSuggestions(suggestions);
18713
- }
18714
- setLoading(false);
18715
- });
18716
- // eslint-disable-next-line
18717
- } catch (error) {
18718
- if (error.name !== 'AbortError') {
18719
- console.error('Address fetch failed:', error);
18720
- }
18721
- } finally {
18722
- setLoading(false);
18723
- }
18724
- } else {
18725
- setSuggestions([]);
18726
- setOpen(false);
18727
- }
18728
- };
18729
- fetchSuggestions();
18730
- }, [debouncedTerm]); // eslint-disable-line react-hooks/exhaustive-deps
18731
- const handleDropdownChange = value => {
18763
+ const handleAddressLine1Change = value => {
18732
18764
  setDropdownSelected(false);
18733
18765
  setSearchTerm(value);
18766
+ // Mirror the keystroke so the field never renders a stale value, but leave the form data alone
18767
+ // until blur or a suggestion is picked. Committing mid-lookup re-renders the whole form while
18768
+ // the search is still debouncing, which is what closed the suggestions dropdown.
18769
+ handleInputChange('addressLine1', value, {
18770
+ autoCommit: false
18771
+ });
18734
18772
  };
18735
18773
  /* istanbul ignore next */
18736
18774
  const handleSuggestionClick = suggestion => {
18737
18775
  const suggestAddress = mapSuggestionToAddress(suggestion);
18738
18776
  setAddress(suggestAddress);
18739
- handleChange(path, suggestAddress);
18777
+ flushAddress();
18740
18778
  setSuggestions([]);
18741
18779
  setErrors({});
18742
18780
  setDropdownSelected(true);
18743
18781
  };
18744
18782
  const handleRequiredFieldBlur = (name, value) => {
18745
- var _ref, _ref2, _ref3, _addressContainerRef$;
18746
- const err = Object.assign({}, errors);
18747
- const addressValue = name in address ? address[name] : undefined;
18748
- const liveInputValue = (_ref = (_ref2 = (_ref3 = value != null ? value : (_addressContainerRef$ = addressContainerRef.current) == null || (_addressContainerRef$ = _addressContainerRef$.querySelector(`goa-input[name="${name}"]`)) == null ? void 0 : _addressContainerRef$.value) != null ? _ref3 : addressValue) != null ? _ref2 : data == null ? void 0 : data[name]) != null ? _ref : '';
18749
- if (liveInputValue.trim() === '') {
18750
- err[name] = `${getAddressLookupFieldLabel(name)} is required`;
18751
- } else {
18752
- delete err[name];
18753
- }
18754
- setErrors(err);
18783
+ var _ref, _ref2;
18784
+ const liveInputValue = (_ref = (_ref2 = value != null ? value : address[name]) != null ? _ref2 : data == null ? void 0 : data[name]) != null ? _ref : '';
18785
+ setErrors(currentErrors => {
18786
+ const nextErrors = Object.assign({}, currentErrors);
18787
+ if (liveInputValue.trim() === '') {
18788
+ nextErrors[name] = `${getAddressLookupFieldLabel(name)} is required`;
18789
+ } else {
18790
+ delete nextErrors[name];
18791
+ }
18792
+ return nextErrors;
18793
+ });
18755
18794
  setSuggestions([]);
18756
18795
  setOpen(false);
18757
18796
  if (name === 'addressLine1' && value !== undefined) {
18758
- const newAddress = Object.assign({}, address, {
18797
+ setAddress(Object.assign({}, address, {
18759
18798
  addressLine1: value
18760
- });
18761
- setAddress(newAddress);
18762
- updateFormData(newAddress);
18799
+ }));
18763
18800
  }
18801
+ // Leaving the field ends the burst of typing, so push what is there now.
18802
+ flushAddress();
18764
18803
  };
18765
18804
  useEffect(() => {
18766
18805
  if (dropdownRef.current && activeIndex !== -1) {
@@ -18889,12 +18928,14 @@ const AddressLookUpControl = props => {
18889
18928
  ariaLabel: 'address-form-address1',
18890
18929
  placeholder: "Start typing the first line of your address, required.",
18891
18930
  value: (address == null ? void 0 : address.addressLine1) || '',
18892
- onChange: detail => handleDropdownChange(detail.value),
18931
+ onChange: detail => handleAddressLine1Change(detail.value),
18893
18932
  onBlur: detail => handleRequiredFieldBlur(detail.name, detail.value),
18894
18933
  width: "100%",
18895
18934
  onKeyPress: detail => {
18896
18935
  if (open) {
18897
- const newIndex = handleAddressKeyDown(detail.key, detail.value, activeIndex, suggestions, val => handleInputChange('addressLine1', val), suggestion => handleSuggestionClick(suggestion));
18936
+ const newIndex = handleAddressKeyDown(detail.key, detail.value, activeIndex, suggestions,
18937
+ // Navigating the suggestions must never blank out what has been typed.
18938
+ val => typeof val === 'string' && handleInputChange('addressLine1', val), suggestion => handleSuggestionClick(suggestion));
18898
18939
  setActiveIndex(newIndex);
18899
18940
  }
18900
18941
  }
@@ -18947,7 +18988,8 @@ const NameInputs = ({
18947
18988
  requiredFields,
18948
18989
  disabled,
18949
18990
  errors,
18950
- onFieldBlur
18991
+ onFieldBlur,
18992
+ onFieldCommit
18951
18993
  }) => {
18952
18994
  var _displayedErrors$firs, _displayedErrors$last;
18953
18995
  const [internalErrors, setInternalErrors] = useState({});
@@ -19001,6 +19043,7 @@ const NameInputs = ({
19001
19043
  };
19002
19044
  const displayedErrors = errors != null ? errors : internalErrors;
19003
19045
  const handleBlur = name => {
19046
+ onFieldCommit == null || onFieldCommit();
19004
19047
  if (onFieldBlur) {
19005
19048
  onFieldBlur(name);
19006
19049
  } else {
@@ -19047,6 +19090,7 @@ const NameInputs = ({
19047
19090
  ariaLabel: 'name-form-middle-name',
19048
19091
  value: middleName || '',
19049
19092
  onChange: detail => handleInputChange(detail.name, detail.value),
19093
+ onBlur: () => onFieldCommit == null ? void 0 : onFieldCommit(),
19050
19094
  width: "100%"
19051
19095
  })
19052
19096
  }), jsx(GoabFormItem, {
@@ -19137,24 +19181,19 @@ const FullNameControl = props => {
19137
19181
  uischema
19138
19182
  } = props;
19139
19183
  const requiredFields = schema.required;
19140
- const [nameData, setNameData] = useState(normalizeNameData(data));
19141
19184
  const controlRef = useRef(null);
19142
19185
  const formStepperCtx = useContext(JsonFormsStepperContext);
19143
19186
  const stepperState = formStepperCtx == null || formStepperCtx.selectStepperState == null ? void 0 : formStepperCtx.selectStepperState();
19144
- const updateFormData = updatedData => {
19145
- updatedData = Object.fromEntries(Object.entries(updatedData).filter(([_, value]) => value !== ''));
19146
- handleChange(path, updatedData);
19147
- };
19148
- useEffect(() => {
19149
- const nextData = normalizeNameData(data);
19150
- setNameData(currentData => currentData.firstName === nextData.firstName && currentData.middleName === nextData.middleName && currentData.lastName === nextData.lastName ? currentData : nextData);
19151
- }, [data]);
19187
+ const normalizedData = useMemo(() => normalizeNameData(data), [data]);
19188
+ const {
19189
+ value: nameData,
19190
+ setValue: setNameData,
19191
+ flush: flushName
19192
+ } = useDebouncedCommit(normalizedData, updatedData => handleChange(path, Object.fromEntries(Object.entries(updatedData).filter(([, value]) => value !== ''))));
19152
19193
  const handleInputChange = (field, value) => {
19153
- const updatedName = Object.assign({}, nameData, {
19194
+ setNameData(Object.assign({}, nameData, {
19154
19195
  [field]: value
19155
- });
19156
- setNameData(updatedName);
19157
- updateFormData(updatedName);
19196
+ }));
19158
19197
  };
19159
19198
  /* istanbul ignore next */
19160
19199
  useEffect(() => {
@@ -19197,7 +19236,8 @@ const FullNameControl = props => {
19197
19236
  handleInputChange: handleInputChange,
19198
19237
  data: data,
19199
19238
  disabled: !enabled,
19200
- requiredFields: requiredFields
19239
+ requiredFields: requiredFields,
19240
+ onFieldCommit: flushName
19201
19241
  })
19202
19242
  })
19203
19243
  });
@@ -19270,21 +19310,20 @@ const FullNameDobControl = props => {
19270
19310
  maxDate: currentDate.toISOString().substring(0, 10)
19271
19311
  };
19272
19312
  };
19273
- const [formData, setFormData] = useState(normalizeNameDobData(data));
19313
+ const normalizedData = useMemo(() => normalizeNameDobData(data), [data]);
19314
+ const {
19315
+ value: formData,
19316
+ setValue: setFormData,
19317
+ flush: flushFormData
19318
+ } = useDebouncedCommit(normalizedData, updatedData => handleChange(path, Object.fromEntries(Object.entries(updatedData).filter(([, value]) => value !== ''))));
19274
19319
  const updateFormData = updatedData => {
19275
- const nextData = Object.fromEntries(Object.entries(updatedData).filter(([_, value]) => value !== ''));
19276
- setFormData(nextData);
19277
- handleChange(path, nextData);
19320
+ setFormData(updatedData);
19321
+ flushFormData();
19278
19322
  };
19279
- useEffect(() => {
19280
- const nextData = normalizeNameDobData(data);
19281
- setFormData(currentData => currentData.firstName === nextData.firstName && currentData.middleName === nextData.middleName && currentData.lastName === nextData.lastName && currentData.dateOfBirth === nextData.dateOfBirth ? currentData : nextData);
19282
- }, [data]);
19283
19323
  const handleInputChange = (field, value) => {
19284
- const updatedData = Object.assign({}, formData, {
19324
+ setFormData(Object.assign({}, formData, {
19285
19325
  [field]: value
19286
- });
19287
- updateFormData(updatedData);
19326
+ }));
19288
19327
  };
19289
19328
  // eslint-disable-next-line
19290
19329
  const handleRequiredFieldBlur = (name, updatedData) => {
@@ -19348,7 +19387,8 @@ const FullNameDobControl = props => {
19348
19387
  disabled: !enabled,
19349
19388
  requiredFields: requiredFields,
19350
19389
  errors: errors,
19351
- onFieldBlur: handleRequiredFieldBlur
19390
+ onFieldBlur: handleRequiredFieldBlur,
19391
+ onFieldCommit: flushFormData
19352
19392
  }), jsx(GoabGrid, {
19353
19393
  minChildWidth: "0ch",
19354
19394
  gap: "s",
@@ -19369,7 +19409,7 @@ const FullNameDobControl = props => {
19369
19409
  value: formData == null ? void 0 : formData.dateOfBirth,
19370
19410
  onChange: detail => handleInputChange(detail.name, detail.value),
19371
19411
  onBlur: detail => {
19372
- /* istanbul ignore next */
19412
+ flushFormData();
19373
19413
  handleRequiredFieldBlur(detail.name);
19374
19414
  },
19375
19415
  width: "100%"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/jsonforms-components",
3
- "version": "2.70.3",
3
+ "version": "2.70.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Government of Alberta - React renderers for JSON Forms based on the design system.",
6
6
  "repository": "https://github.com/GovAlta/adsp-monorepo",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-07-28T15:31:21.353Z",
4
- "sourceCommit": "7656ec403e45371b12001c0a2f44a8ade8632339",
3
+ "generatedAt": "2026-07-31T19:55:33.198Z",
4
+ "sourceCommit": "ef8ca17fd76dacfba02abd9bb758c3be5c38e9ae",
5
5
  "sourcePath": "libs/jsonforms-components/src/index.ts",
6
6
  "rendererCount": 33,
7
7
  "renderers": [
@@ -9,6 +9,8 @@ interface NameInputsProps {
9
9
  requiredFields?: string[];
10
10
  errors?: Record<string, string>;
11
11
  onFieldBlur?: (name: string) => void;
12
+ /** Called whenever a field loses focus, so the owner can push pending edits without waiting out the debounce. */
13
+ onFieldCommit?: () => void;
12
14
  handleInputChange: (field: string, value: string) => void;
13
15
  }
14
16
  export declare const NameInputs: React.FC<NameInputsProps>;
@@ -0,0 +1,37 @@
1
+ export declare const COMMIT_DELAY = 300;
2
+ export interface SetValueOptions {
3
+ /**
4
+ * Whether the edit should be pushed upstream once typing stops. Pass false for a field whose
5
+ * owner commits on its own schedule; the value is still mirrored and still protected from
6
+ * incoming data until something calls flush.
7
+ */
8
+ autoCommit?: boolean;
9
+ }
10
+ export interface DebouncedCommit<T> {
11
+ /** The live value to bind to the input; updated on every keystroke. */
12
+ value: T;
13
+ /** Records a keystroke and, unless told otherwise, schedules the commit. */
14
+ setValue: (next: T, options?: SetValueOptions) => void;
15
+ /** Commits straight away, for events that are not typing (blur, selecting a suggestion, unmount). */
16
+ flush: () => void;
17
+ }
18
+ /**
19
+ * Keeps a live, per-keystroke mirror of a control's value and pushes it into the form data only
20
+ * once the user pauses typing.
21
+ *
22
+ * The GoA web components keep the value in their own DOM and report changes back a macrotask
23
+ * late, while their update path writes an incoming `value` prop straight back into the field
24
+ * whenever it differs from what is in the box. So a render that commits a value older than what
25
+ * has been typed since wipes out the characters in between. Committing on every keystroke
26
+ * re-renders and revalidates the whole form, which makes that window wide enough to swallow
27
+ * characters; debouncing the commit keeps it to a single local render.
28
+ *
29
+ * The echo of our own commit is ignored while further edits are outstanding, so data arriving
30
+ * back from the form can never overwrite what the user has typed in the meantime.
31
+ *
32
+ * @param externalValue the value held in the form data; must be referentially stable between
33
+ * renders (memoize it when it is an object), otherwise the sync effect runs on every render
34
+ * @param commit pushes a settled value into the form data
35
+ * @param delay how long to wait for typing to stop before committing
36
+ */
37
+ export declare function useDebouncedCommit<T>(externalValue: T, commit: (value: T) => void, delay?: number): DebouncedCommit<T>;