@trackunit/react-form-components 2.6.20 → 2.6.22

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.cjs.js CHANGED
@@ -4880,19 +4880,38 @@ const FormFieldSelectAdapter = ({ className, "data-testid": dataTestId, helpText
4880
4880
  // eslint-disable-next-line @trackunit/no-typescript-assertion, @typescript-eslint/no-non-null-assertion
4881
4881
  react.useImperativeHandle(ref, () => innerRef.current, []);
4882
4882
  // Bridges a react-select pick back to the hidden native <select> so `register`/native form
4883
- // consumers see a real change event. It must fire only when the value actually changes: seeding
4884
- // the ref with the initial value means mounting with a value is not reported as a change, which
4885
- // would otherwise write to the consumer's form and mark it dirty with no user interaction.
4883
+ // consumers see a real change event.
4884
+ //
4885
+ // Only picks are bridged. A value arriving from the `value` prop is something the parent already
4886
+ // holds, so reporting it back would be an echo - and through `register(...).onChange` that echo
4887
+ // is indistinguishable from typing, marking the consumer's form dirty with no user interaction.
4886
4888
  const lastNotifiedValueRef = react.useRef(innerValue);
4889
+ // A box, not a bare value: `null` then unambiguously means "no pick pending", so the origin gate
4890
+ // below cannot be satisfied by a parent-driven change that merely matches the ref's empty state.
4891
+ const pendingUserPickRef = react.useRef(null);
4887
4892
  react.useEffect(() => {
4888
- if (innerValue === undefined || lastNotifiedValueRef.current === innerValue) {
4893
+ if (lastNotifiedValueRef.current === innerValue) {
4889
4894
  return;
4890
4895
  }
4891
4896
  lastNotifiedValueRef.current = innerValue;
4892
- const event = new Event("change");
4897
+ // Keep the hidden select in sync whatever the origin - this is state, not notification.
4893
4898
  if (innerValue === "" && innerRef.current) {
4894
4899
  innerRef.current.value = ""; // Set the desired option value
4895
4900
  }
4901
+ // Keyed to the value the pick expected, not a boolean: react-select reports a re-pick of the
4902
+ // current option, which leaves `innerValue` untouched and so never runs this effect. A boolean
4903
+ // would still be set when the parent's next value arrives, and that value would be reported
4904
+ // back as a user edit.
4905
+ const pendingUserPick = pendingUserPickRef.current;
4906
+ pendingUserPickRef.current = null;
4907
+ const isUserChange = pendingUserPick !== null && pendingUserPick.value === innerValue;
4908
+ // No `undefined` guard needed: a pick is always a string or number (react-select's clear maps
4909
+ // to ""), so matching one narrows `innerValue` away from `undefined`. Only the parent can put
4910
+ // `undefined` there, and the gate above already rejects parent-driven values.
4911
+ if (!isUserChange) {
4912
+ return;
4913
+ }
4914
+ const event = new Event("change");
4896
4915
  innerRef.current?.dispatchEvent(event);
4897
4916
  onChangeRef.current?.(event);
4898
4917
  }, [innerValue]);
@@ -4931,7 +4950,9 @@ const FormFieldSelectAdapter = ({ className, "data-testid": dataTestId, helpText
4931
4950
  // If theres no value, set the inner value to an empty string
4932
4951
  // A native select can not have a null value
4933
4952
  // So even if react-select sends a null value, we need to convert it to an empty string
4934
- setInnerValue(!e ? "" : e.value);
4953
+ const nextValue = !e ? "" : e.value;
4954
+ pendingUserPickRef.current = { value: nextValue };
4955
+ setInnerValue(nextValue);
4935
4956
  },
4936
4957
  "data-testid": dataTestId,
4937
4958
  value: selectedOption,
package/index.esm.js CHANGED
@@ -4879,19 +4879,38 @@ const FormFieldSelectAdapter = ({ className, "data-testid": dataTestId, helpText
4879
4879
  // eslint-disable-next-line @trackunit/no-typescript-assertion, @typescript-eslint/no-non-null-assertion
4880
4880
  useImperativeHandle(ref, () => innerRef.current, []);
4881
4881
  // Bridges a react-select pick back to the hidden native <select> so `register`/native form
4882
- // consumers see a real change event. It must fire only when the value actually changes: seeding
4883
- // the ref with the initial value means mounting with a value is not reported as a change, which
4884
- // would otherwise write to the consumer's form and mark it dirty with no user interaction.
4882
+ // consumers see a real change event.
4883
+ //
4884
+ // Only picks are bridged. A value arriving from the `value` prop is something the parent already
4885
+ // holds, so reporting it back would be an echo - and through `register(...).onChange` that echo
4886
+ // is indistinguishable from typing, marking the consumer's form dirty with no user interaction.
4885
4887
  const lastNotifiedValueRef = useRef(innerValue);
4888
+ // A box, not a bare value: `null` then unambiguously means "no pick pending", so the origin gate
4889
+ // below cannot be satisfied by a parent-driven change that merely matches the ref's empty state.
4890
+ const pendingUserPickRef = useRef(null);
4886
4891
  useEffect(() => {
4887
- if (innerValue === undefined || lastNotifiedValueRef.current === innerValue) {
4892
+ if (lastNotifiedValueRef.current === innerValue) {
4888
4893
  return;
4889
4894
  }
4890
4895
  lastNotifiedValueRef.current = innerValue;
4891
- const event = new Event("change");
4896
+ // Keep the hidden select in sync whatever the origin - this is state, not notification.
4892
4897
  if (innerValue === "" && innerRef.current) {
4893
4898
  innerRef.current.value = ""; // Set the desired option value
4894
4899
  }
4900
+ // Keyed to the value the pick expected, not a boolean: react-select reports a re-pick of the
4901
+ // current option, which leaves `innerValue` untouched and so never runs this effect. A boolean
4902
+ // would still be set when the parent's next value arrives, and that value would be reported
4903
+ // back as a user edit.
4904
+ const pendingUserPick = pendingUserPickRef.current;
4905
+ pendingUserPickRef.current = null;
4906
+ const isUserChange = pendingUserPick !== null && pendingUserPick.value === innerValue;
4907
+ // No `undefined` guard needed: a pick is always a string or number (react-select's clear maps
4908
+ // to ""), so matching one narrows `innerValue` away from `undefined`. Only the parent can put
4909
+ // `undefined` there, and the gate above already rejects parent-driven values.
4910
+ if (!isUserChange) {
4911
+ return;
4912
+ }
4913
+ const event = new Event("change");
4895
4914
  innerRef.current?.dispatchEvent(event);
4896
4915
  onChangeRef.current?.(event);
4897
4916
  }, [innerValue]);
@@ -4930,7 +4949,9 @@ const FormFieldSelectAdapter = ({ className, "data-testid": dataTestId, helpText
4930
4949
  // If theres no value, set the inner value to an empty string
4931
4950
  // A native select can not have a null value
4932
4951
  // So even if react-select sends a null value, we need to convert it to an empty string
4933
- setInnerValue(!e ? "" : e.value);
4952
+ const nextValue = !e ? "" : e.value;
4953
+ pendingUserPickRef.current = { value: nextValue };
4954
+ setInnerValue(nextValue);
4934
4955
  },
4935
4956
  "data-testid": dataTestId,
4936
4957
  value: selectedOption,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-form-components",
3
- "version": "2.6.20",
3
+ "version": "2.6.22",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -16,11 +16,11 @@
16
16
  "zod": "^3.25.76",
17
17
  "tailwind-merge": "^2.0.0",
18
18
  "@trackunit/css-class-variance-utilities": "1.14.19",
19
- "@trackunit/react-components": "2.10.14",
19
+ "@trackunit/react-components": "2.10.15",
20
20
  "@trackunit/ui-icons": "1.14.18",
21
21
  "@trackunit/shared-utils": "1.16.22",
22
22
  "@trackunit/ui-design-tokens": "1.15.9",
23
- "@trackunit/i18n-library-translation": "2.4.20",
23
+ "@trackunit/i18n-library-translation": "2.4.21",
24
24
  "string-ts": "^2.0.0",
25
25
  "es-toolkit": "^1.39.10"
26
26
  },
@@ -32,6 +32,11 @@ export interface SelectFieldProps<TOption extends BaseOptionType = BaseOptionTyp
32
32
  /**
33
33
  * The onChange handler for the SelectField.
34
34
  *
35
+ * Called when the user picks a different option. It is not called when the `value` prop changes,
36
+ * since that value is one the parent already holds, and not when the user re-picks the option that
37
+ * is already selected. It is also forwarded to the hidden native `<select>`, so a change event
38
+ * dispatched on that node invokes it too.
39
+ *
35
40
  * @important
36
41
  */
37
42
  onChange?: (event: {