@abgov/jsonforms-components 2.70.2 → 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';
@@ -8007,7 +8007,13 @@ const USER_FIELD_DEFINITIONS = {
8007
8007
  }
8008
8008
  };
8009
8009
  const AUTO_POPULATE_ACTION_TYPES = new Set([INIT, UPDATE_CORE]);
8010
- const isEmptyAutoPopulateTarget = value => value === undefined || value === null || value === '';
8010
+ // clean-code-ignore: RULE-19 — covered by ./autoPopulate.spec.tsx, colocated. The rule only looks
8011
+ // for a .test.ts sibling; adding one would duplicate the existing suite.
8012
+ // Only a value the user has never set is a populate target. An empty string is a real value here:
8013
+ // the text controls write '' when a field is cleared (see onChangeForInputControl), so treating it
8014
+ // as empty made auto-populate refill the field the instant it was emptied, and the user could
8015
+ // never clear it.
8016
+ const isEmptyAutoPopulateTarget = value => value === undefined || value === null;
8011
8017
  const shouldAutoPopulate = actionType => AUTO_POPULATE_ACTION_TYPES.has(actionType);
8012
8018
  const isAutoPopulateSource = value => typeof value === 'string' && AUTO_POPULATE_SOURCES.includes(value);
8013
8019
  const autoPopulateValue = (user, props) => {
@@ -8812,7 +8818,75 @@ const GoATextControl = props => jsx(GoAInputBaseControl, Object.assign({}, props
8812
8818
  const GoATextControlTester = rankWith(1, isStringControl);
8813
8819
  const GoAInputTextControl = withJsonFormsControlProps(GoATextControl);
8814
8820
 
8815
- 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
+
8816
8890
  const MultiLineText = props => {
8817
8891
  var _uischema$options$com, _uischema$options, _uischema$options2, _uischema$options3, _uischema$options$com2, _uischema$options4, _uischema$options5;
8818
8892
  const {
@@ -8828,29 +8902,29 @@ const MultiLineText = props => {
8828
8902
  errors,
8829
8903
  setIsVisited
8830
8904
  } = props;
8831
- const [textAreaValue, setTextAreaValue] = React.useState(data || '');
8832
- const debouncedValue = useDebounce(textAreaValue, DEBOUNCE_DELAY);
8833
- useEffect(() => {
8834
- setTextAreaValue(data || '');
8835
- }, [data]);
8836
- /* istanbul ignore next */
8837
- useEffect(() => {
8838
- if (debouncedValue !== data && (debouncedValue !== '' || data !== undefined)) {
8839
- onChangeForInputControl({
8840
- value: debouncedValue,
8841
- controlProps: props
8842
- });
8843
- }
8844
- }, [debouncedValue]);
8905
+ const {
8906
+ value: textAreaValue,
8907
+ setValue: setTextAreaValue,
8908
+ flush: flushTextArea
8909
+ } = useDebouncedCommit(data || '', value => onChangeForInputControl({
8910
+ value,
8911
+ controlProps: props
8912
+ }));
8845
8913
  const appliedUiSchemaOptions = Object.assign({}, config, uischema == null ? void 0 : uischema.options);
8846
8914
  const placeholder = (appliedUiSchemaOptions == null ? void 0 : appliedUiSchemaOptions.placeholder) || (schema == null ? void 0 : schema.description) || '';
8847
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%';
8848
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;
8849
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;
8850
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
+ };
8851
8925
  const txtAreaComponent = jsx(GoabTextArea, Object.assign({
8852
8926
  error: isVisited && errors.length > 0,
8853
- value: debouncedValue,
8927
+ value: textAreaValue,
8854
8928
  disabled: !enabled,
8855
8929
  readOnly: readOnly,
8856
8930
  placeholder: placeholder,
@@ -8859,21 +8933,21 @@ const MultiLineText = props => {
8859
8933
  width: width,
8860
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
8861
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
+ },
8862
8942
  onKeyPress: detail => {
8863
- const newValue = autoCapitalize ? detail.value.toUpperCase() : detail.value;
8864
- setTextAreaValue(newValue);
8865
- if (isVisited === false && setIsVisited) {
8866
- setIsVisited();
8867
- }
8943
+ setTextAreaValue(applyCapitalization(detail.value));
8944
+ markVisited();
8868
8945
  },
8869
8946
  onBlur: detail => {
8870
- const newValue = autoCapitalize ? detail.value.toUpperCase() : detail.value;
8871
- setTextAreaValue(newValue);
8872
- if (isVisited === false && setIsVisited) {
8873
- setIsVisited();
8874
- }
8875
- },
8876
- onChange: detail => {}
8947
+ setTextAreaValue(applyCapitalization(detail.value));
8948
+ flushTextArea();
8949
+ markVisited();
8950
+ }
8877
8951
  }, uischema == null || (_uischema$options5 = uischema.options) == null ? void 0 : _uischema$options5.componentProps));
8878
8952
  return txtAreaComponent;
8879
8953
  };
@@ -18517,21 +18591,19 @@ const filterSuggestionsWithoutAddressCount = suggestions => {
18517
18591
  });
18518
18592
  };
18519
18593
  const mapSuggestionToAddress = suggestion => {
18520
- let addressLine1, addressLine2;
18521
- const suiteMatch = suggestion.Text.match(/(Suite|Apt|Unit|#)+/i);
18522
- const textParts = suggestion.Text.split(' ');
18523
- if (suiteMatch) {
18524
- addressLine1 = suggestion.Text.replace(textParts[0], '').trim();
18525
- addressLine2 = textParts[0].trim();
18526
- } else {
18527
- addressLine2 = '';
18528
- addressLine1 = suggestion.Text.trim();
18529
- }
18530
- const descriptionParts = suggestion.Description.split(',');
18531
- const municipality = descriptionParts[0].trim();
18532
- const provinceAndPostalCode = descriptionParts[1].trim().split(' ');
18533
- const subdivisionCode = provinceAndPostalCode[0];
18534
- 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;
18535
18607
  return {
18536
18608
  addressLine1,
18537
18609
  addressLine2,
@@ -18633,17 +18705,18 @@ const AddressLookUpControl = props => {
18633
18705
  const [open, setOpen] = useState(false);
18634
18706
  const addressContainerRef = useRef(null);
18635
18707
  const label = typeof (uischema == null ? void 0 : uischema.label) === 'string' && uischema.label ? uischema.label : '';
18636
- 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));
18637
18714
  const [searchTerm, setSearchTerm] = useState('');
18638
18715
  const [suggestions, setSuggestions] = useState([]);
18639
18716
  const [loading, setLoading] = useState(false);
18640
18717
  const [errors, setErrors] = useState({});
18641
18718
  const requiredFields = schema.required;
18642
18719
  const [dropdownSelected, setDropdownSelected] = useState(false);
18643
- const updateFormData = updatedAddress => {
18644
- handleChange(path, updatedAddress);
18645
- };
18646
- const debouncedRenderAddress = useDebounce(searchTerm, 500);
18647
18720
  const [activeIndex, setActiveIndex] = useState(0);
18648
18721
  const dropdownRef = useRef(null);
18649
18722
  const debouncedTerm = useDebounce(searchTerm, 500);
@@ -18651,39 +18724,35 @@ const AddressLookUpControl = props => {
18651
18724
  const lastQueryRef = useRef('');
18652
18725
  const cacheRef = useRef(new Map());
18653
18726
  const spinnerTimerRef = useRef(null);
18654
- useEffect(() => {
18655
- const nextAddress = normalizeAddressData(data, isAlbertaAddress);
18656
- setAddress(currentAddress => {
18657
- const keys = ['addressLine1', 'addressLine2', 'municipality', 'postalCode', 'country', 'subdivisionCode'];
18658
- const unchanged = keys.every(key => currentAddress[key] === nextAddress[key]);
18659
- return unchanged ? currentAddress : nextAddress;
18660
- });
18661
- }, [data, isAlbertaAddress]);
18662
- const handleInputChange = (field, value) => {
18727
+ const handleInputChange = (field, value, options) => {
18663
18728
  var _schema$errorMessage;
18664
- if (field === 'addressLine1') {
18665
- setDropdownSelected(false);
18666
- }
18667
- let newAddress;
18729
+ let newValue = value;
18668
18730
  const postalCodeErrorMessage = (_schema$errorMessage = schema.errorMessage) == null || (_schema$errorMessage = _schema$errorMessage.properties) == null ? void 0 : _schema$errorMessage.postalCode;
18669
18731
  if (field === 'postalCode') {
18670
18732
  const validatePc = validatePostalCode(value);
18671
- setErrors(handlePostalCodeValidation(validatePc, postalCodeErrorMessage ? postalCodeErrorMessage : '', value, errors));
18672
- value = formatPostalCode(value);
18673
- newAddress = Object.assign({}, address, {
18674
- [field]: value.toUpperCase()
18675
- });
18733
+ setErrors(currentErrors => handlePostalCodeValidation(validatePc, postalCodeErrorMessage != null ? postalCodeErrorMessage : '', value, currentErrors));
18734
+ newValue = formatPostalCode(value).toUpperCase();
18676
18735
  } else {
18677
- newAddress = Object.assign({}, address, {
18678
- [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;
18679
18743
  });
18680
- delete errors[field];
18681
18744
  }
18682
- if (value === '' && field in newAddress) {
18745
+ const newAddress = Object.assign({}, address, {
18746
+ [field]: newValue
18747
+ });
18748
+ if (newValue === '') {
18683
18749
  delete newAddress[field];
18684
18750
  }
18685
- setAddress(newAddress);
18686
- 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
+ }
18687
18756
  };
18688
18757
  const renderHelp = () => {
18689
18758
  return jsx(HelpContentComponent, Object.assign({}, props, {
@@ -18691,70 +18760,46 @@ const AddressLookUpControl = props => {
18691
18760
  showLabel: false
18692
18761
  }));
18693
18762
  };
18694
- useEffect(() => {
18695
- const fetchSuggestions = async () => {
18696
- /* istanbul ignore next */
18697
- if (debouncedRenderAddress.length > 2 && dropdownSelected === false) {
18698
- setLoading(true);
18699
- setOpen(true);
18700
- try {
18701
- await fetchAddressSuggestions(formUrl, formatPostalCodeIfNeeded(searchTerm), isAlbertaAddress).then(response => {
18702
- const suggestions = filterSuggestionsWithoutAddressCount(response);
18703
- if (isAlbertaAddress) {
18704
- setSuggestions(filterAlbertaAddresses(suggestions));
18705
- } else {
18706
- setSuggestions(suggestions);
18707
- }
18708
- setLoading(false);
18709
- });
18710
- // eslint-disable-next-line
18711
- } catch (error) {
18712
- if (error.name !== 'AbortError') {
18713
- console.error('Address fetch failed:', error);
18714
- }
18715
- } finally {
18716
- setLoading(false);
18717
- }
18718
- } else {
18719
- setSuggestions([]);
18720
- setOpen(false);
18721
- }
18722
- };
18723
- fetchSuggestions();
18724
- }, [debouncedTerm]); // eslint-disable-line react-hooks/exhaustive-deps
18725
- const handleDropdownChange = value => {
18763
+ const handleAddressLine1Change = value => {
18726
18764
  setDropdownSelected(false);
18727
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
+ });
18728
18772
  };
18729
18773
  /* istanbul ignore next */
18730
18774
  const handleSuggestionClick = suggestion => {
18731
18775
  const suggestAddress = mapSuggestionToAddress(suggestion);
18732
18776
  setAddress(suggestAddress);
18733
- handleChange(path, suggestAddress);
18777
+ flushAddress();
18734
18778
  setSuggestions([]);
18735
18779
  setErrors({});
18736
18780
  setDropdownSelected(true);
18737
18781
  };
18738
18782
  const handleRequiredFieldBlur = (name, value) => {
18739
- var _ref, _ref2, _ref3, _addressContainerRef$;
18740
- const err = Object.assign({}, errors);
18741
- const addressValue = name in address ? address[name] : undefined;
18742
- 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 : '';
18743
- if (liveInputValue.trim() === '') {
18744
- err[name] = `${getAddressLookupFieldLabel(name)} is required`;
18745
- } else {
18746
- delete err[name];
18747
- }
18748
- 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
+ });
18749
18794
  setSuggestions([]);
18750
18795
  setOpen(false);
18751
18796
  if (name === 'addressLine1' && value !== undefined) {
18752
- const newAddress = Object.assign({}, address, {
18797
+ setAddress(Object.assign({}, address, {
18753
18798
  addressLine1: value
18754
- });
18755
- setAddress(newAddress);
18756
- updateFormData(newAddress);
18799
+ }));
18757
18800
  }
18801
+ // Leaving the field ends the burst of typing, so push what is there now.
18802
+ flushAddress();
18758
18803
  };
18759
18804
  useEffect(() => {
18760
18805
  if (dropdownRef.current && activeIndex !== -1) {
@@ -18883,12 +18928,14 @@ const AddressLookUpControl = props => {
18883
18928
  ariaLabel: 'address-form-address1',
18884
18929
  placeholder: "Start typing the first line of your address, required.",
18885
18930
  value: (address == null ? void 0 : address.addressLine1) || '',
18886
- onChange: detail => handleDropdownChange(detail.value),
18931
+ onChange: detail => handleAddressLine1Change(detail.value),
18887
18932
  onBlur: detail => handleRequiredFieldBlur(detail.name, detail.value),
18888
18933
  width: "100%",
18889
18934
  onKeyPress: detail => {
18890
18935
  if (open) {
18891
- 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));
18892
18939
  setActiveIndex(newIndex);
18893
18940
  }
18894
18941
  }
@@ -18941,7 +18988,8 @@ const NameInputs = ({
18941
18988
  requiredFields,
18942
18989
  disabled,
18943
18990
  errors,
18944
- onFieldBlur
18991
+ onFieldBlur,
18992
+ onFieldCommit
18945
18993
  }) => {
18946
18994
  var _displayedErrors$firs, _displayedErrors$last;
18947
18995
  const [internalErrors, setInternalErrors] = useState({});
@@ -18995,6 +19043,7 @@ const NameInputs = ({
18995
19043
  };
18996
19044
  const displayedErrors = errors != null ? errors : internalErrors;
18997
19045
  const handleBlur = name => {
19046
+ onFieldCommit == null || onFieldCommit();
18998
19047
  if (onFieldBlur) {
18999
19048
  onFieldBlur(name);
19000
19049
  } else {
@@ -19041,6 +19090,7 @@ const NameInputs = ({
19041
19090
  ariaLabel: 'name-form-middle-name',
19042
19091
  value: middleName || '',
19043
19092
  onChange: detail => handleInputChange(detail.name, detail.value),
19093
+ onBlur: () => onFieldCommit == null ? void 0 : onFieldCommit(),
19044
19094
  width: "100%"
19045
19095
  })
19046
19096
  }), jsx(GoabFormItem, {
@@ -19131,24 +19181,19 @@ const FullNameControl = props => {
19131
19181
  uischema
19132
19182
  } = props;
19133
19183
  const requiredFields = schema.required;
19134
- const [nameData, setNameData] = useState(normalizeNameData(data));
19135
19184
  const controlRef = useRef(null);
19136
19185
  const formStepperCtx = useContext(JsonFormsStepperContext);
19137
19186
  const stepperState = formStepperCtx == null || formStepperCtx.selectStepperState == null ? void 0 : formStepperCtx.selectStepperState();
19138
- const updateFormData = updatedData => {
19139
- updatedData = Object.fromEntries(Object.entries(updatedData).filter(([_, value]) => value !== ''));
19140
- handleChange(path, updatedData);
19141
- };
19142
- useEffect(() => {
19143
- const nextData = normalizeNameData(data);
19144
- setNameData(currentData => currentData.firstName === nextData.firstName && currentData.middleName === nextData.middleName && currentData.lastName === nextData.lastName ? currentData : nextData);
19145
- }, [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 !== ''))));
19146
19193
  const handleInputChange = (field, value) => {
19147
- const updatedName = Object.assign({}, nameData, {
19194
+ setNameData(Object.assign({}, nameData, {
19148
19195
  [field]: value
19149
- });
19150
- setNameData(updatedName);
19151
- updateFormData(updatedName);
19196
+ }));
19152
19197
  };
19153
19198
  /* istanbul ignore next */
19154
19199
  useEffect(() => {
@@ -19191,7 +19236,8 @@ const FullNameControl = props => {
19191
19236
  handleInputChange: handleInputChange,
19192
19237
  data: data,
19193
19238
  disabled: !enabled,
19194
- requiredFields: requiredFields
19239
+ requiredFields: requiredFields,
19240
+ onFieldCommit: flushName
19195
19241
  })
19196
19242
  })
19197
19243
  });
@@ -19264,21 +19310,20 @@ const FullNameDobControl = props => {
19264
19310
  maxDate: currentDate.toISOString().substring(0, 10)
19265
19311
  };
19266
19312
  };
19267
- 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 !== ''))));
19268
19319
  const updateFormData = updatedData => {
19269
- const nextData = Object.fromEntries(Object.entries(updatedData).filter(([_, value]) => value !== ''));
19270
- setFormData(nextData);
19271
- handleChange(path, nextData);
19320
+ setFormData(updatedData);
19321
+ flushFormData();
19272
19322
  };
19273
- useEffect(() => {
19274
- const nextData = normalizeNameDobData(data);
19275
- setFormData(currentData => currentData.firstName === nextData.firstName && currentData.middleName === nextData.middleName && currentData.lastName === nextData.lastName && currentData.dateOfBirth === nextData.dateOfBirth ? currentData : nextData);
19276
- }, [data]);
19277
19323
  const handleInputChange = (field, value) => {
19278
- const updatedData = Object.assign({}, formData, {
19324
+ setFormData(Object.assign({}, formData, {
19279
19325
  [field]: value
19280
- });
19281
- updateFormData(updatedData);
19326
+ }));
19282
19327
  };
19283
19328
  // eslint-disable-next-line
19284
19329
  const handleRequiredFieldBlur = (name, updatedData) => {
@@ -19342,7 +19387,8 @@ const FullNameDobControl = props => {
19342
19387
  disabled: !enabled,
19343
19388
  requiredFields: requiredFields,
19344
19389
  errors: errors,
19345
- onFieldBlur: handleRequiredFieldBlur
19390
+ onFieldBlur: handleRequiredFieldBlur,
19391
+ onFieldCommit: flushFormData
19346
19392
  }), jsx(GoabGrid, {
19347
19393
  minChildWidth: "0ch",
19348
19394
  gap: "s",
@@ -19363,7 +19409,7 @@ const FullNameDobControl = props => {
19363
19409
  value: formData == null ? void 0 : formData.dateOfBirth,
19364
19410
  onChange: detail => handleInputChange(detail.name, detail.value),
19365
19411
  onBlur: detail => {
19366
- /* istanbul ignore next */
19412
+ flushFormData();
19367
19413
  handleRequiredFieldBlur(detail.name);
19368
19414
  },
19369
19415
  width: "100%"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/jsonforms-components",
3
- "version": "2.70.2",
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-27T16:49:26.205Z",
4
- "sourceCommit": "691dde2da272c7fc91ab319fa14ffa66ebafab47",
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>;