@transferwise/components 46.140.1 → 46.141.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.
Files changed (134) hide show
  1. package/build/avatarWrapper/AvatarWrapper.js +3 -4
  2. package/build/avatarWrapper/AvatarWrapper.js.map +1 -1
  3. package/build/avatarWrapper/AvatarWrapper.mjs +4 -5
  4. package/build/avatarWrapper/AvatarWrapper.mjs.map +1 -1
  5. package/build/button/LegacyButton.js.map +1 -1
  6. package/build/button/LegacyButton.mjs.map +1 -1
  7. package/build/common/hooks/useHasIntersected/useHasIntersected.js +6 -4
  8. package/build/common/hooks/useHasIntersected/useHasIntersected.js.map +1 -1
  9. package/build/common/hooks/useHasIntersected/useHasIntersected.mjs +6 -4
  10. package/build/common/hooks/useHasIntersected/useHasIntersected.mjs.map +1 -1
  11. package/build/common/liveRegion/LiveRegion.js +4 -1
  12. package/build/common/liveRegion/LiveRegion.js.map +1 -1
  13. package/build/common/liveRegion/LiveRegion.mjs +4 -1
  14. package/build/common/liveRegion/LiveRegion.mjs.map +1 -1
  15. package/build/dateInput/DateInput.js +10 -10
  16. package/build/dateInput/DateInput.js.map +1 -1
  17. package/build/dateInput/DateInput.mjs +10 -10
  18. package/build/dateInput/DateInput.mjs.map +1 -1
  19. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js +1 -1
  20. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js.map +1 -1
  21. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs +1 -1
  22. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs.map +1 -1
  23. package/build/dateLookup/yearCalendar/table/YearCalendarTable.js +1 -1
  24. package/build/dateLookup/yearCalendar/table/YearCalendarTable.js.map +1 -1
  25. package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs +1 -1
  26. package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs.map +1 -1
  27. package/build/expressiveMoneyInput/ExpressiveMoneyInput.js.map +1 -1
  28. package/build/expressiveMoneyInput/ExpressiveMoneyInput.mjs.map +1 -1
  29. package/build/expressiveMoneyInput/amountInput/AmountInput.js +17 -11
  30. package/build/expressiveMoneyInput/amountInput/AmountInput.js.map +1 -1
  31. package/build/expressiveMoneyInput/amountInput/AmountInput.mjs +18 -12
  32. package/build/expressiveMoneyInput/amountInput/AmountInput.mjs.map +1 -1
  33. package/build/expressiveMoneyInput/hooks/useInputStyle.js +8 -6
  34. package/build/expressiveMoneyInput/hooks/useInputStyle.js.map +1 -1
  35. package/build/expressiveMoneyInput/hooks/useInputStyle.mjs +9 -7
  36. package/build/expressiveMoneyInput/hooks/useInputStyle.mjs.map +1 -1
  37. package/build/header/Header.js +1 -1
  38. package/build/header/Header.js.map +1 -1
  39. package/build/header/Header.mjs +1 -1
  40. package/build/header/Header.mjs.map +1 -1
  41. package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.js.map +1 -1
  42. package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.mjs.map +1 -1
  43. package/build/inputs/SelectInput/Options/SelectInputOptions.js +34 -22
  44. package/build/inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
  45. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs +35 -23
  46. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
  47. package/build/inputs/SelectInput/Popover/SelectInputPopover.js.map +1 -1
  48. package/build/inputs/SelectInput/Popover/SelectInputPopover.mjs.map +1 -1
  49. package/build/inputs/SelectInput/SelectInput.js +8 -6
  50. package/build/inputs/SelectInput/SelectInput.js.map +1 -1
  51. package/build/inputs/SelectInput/SelectInput.mjs +9 -7
  52. package/build/inputs/SelectInput/SelectInput.mjs.map +1 -1
  53. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  54. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  55. package/build/nudge/Nudge.js +31 -15
  56. package/build/nudge/Nudge.js.map +1 -1
  57. package/build/nudge/Nudge.mjs +32 -16
  58. package/build/nudge/Nudge.mjs.map +1 -1
  59. package/build/phoneNumberInput/PhoneNumberInput.js +9 -12
  60. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  61. package/build/phoneNumberInput/PhoneNumberInput.mjs +9 -12
  62. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  63. package/build/promoCard/PromoCardGroup.js +34 -16
  64. package/build/promoCard/PromoCardGroup.js.map +1 -1
  65. package/build/promoCard/PromoCardGroup.mjs +35 -17
  66. package/build/promoCard/PromoCardGroup.mjs.map +1 -1
  67. package/build/segmentedControl/SegmentedControl.js +6 -1
  68. package/build/segmentedControl/SegmentedControl.js.map +1 -1
  69. package/build/segmentedControl/SegmentedControl.mjs +7 -2
  70. package/build/segmentedControl/SegmentedControl.mjs.map +1 -1
  71. package/build/tabs/Tabs.js +1 -1
  72. package/build/tabs/Tabs.js.map +1 -1
  73. package/build/tabs/Tabs.mjs +1 -1
  74. package/build/tabs/Tabs.mjs.map +1 -1
  75. package/build/tooltip/Tooltip.js +6 -3
  76. package/build/tooltip/Tooltip.js.map +1 -1
  77. package/build/tooltip/Tooltip.mjs +6 -3
  78. package/build/tooltip/Tooltip.mjs.map +1 -1
  79. package/build/types/avatarWrapper/AvatarWrapper.d.ts.map +1 -1
  80. package/build/types/common/hooks/useHasIntersected/useHasIntersected.d.ts.map +1 -1
  81. package/build/types/common/liveRegion/LiveRegion.d.ts.map +1 -1
  82. package/build/types/dateLookup/monthCalendar/table/MonthCalendarTable.d.ts.map +1 -1
  83. package/build/types/expressiveMoneyInput/ExpressiveMoneyInput.d.ts.map +1 -1
  84. package/build/types/expressiveMoneyInput/amountInput/AmountInput.d.ts.map +1 -1
  85. package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts +2 -2
  86. package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts.map +1 -1
  87. package/build/types/expressiveMoneyInput/hooks/useSelectionRange.d.ts.map +1 -1
  88. package/build/types/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.d.ts.map +1 -1
  89. package/build/types/inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
  90. package/build/types/inputs/SelectInput/Popover/SelectInputPopover.d.ts.map +1 -1
  91. package/build/types/inputs/SelectInput/SelectInput.d.ts.map +1 -1
  92. package/build/types/nudge/Nudge.d.ts.map +1 -1
  93. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  94. package/build/types/promoCard/PromoCardGroup.d.ts.map +1 -1
  95. package/build/types/segmentedControl/SegmentedControl.d.ts.map +1 -1
  96. package/build/types/tooltip/Tooltip.d.ts.map +1 -1
  97. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  98. package/build/uploadInput/UploadInput.js +29 -25
  99. package/build/uploadInput/UploadInput.js.map +1 -1
  100. package/build/uploadInput/UploadInput.mjs +29 -25
  101. package/build/uploadInput/UploadInput.mjs.map +1 -1
  102. package/package.json +2 -2
  103. package/src/avatarWrapper/AvatarWrapper.test.tsx +33 -3
  104. package/src/avatarWrapper/AvatarWrapper.tsx +5 -6
  105. package/src/button/LegacyButton.tsx +1 -1
  106. package/src/button/_stories/Button.test.story.tsx +3 -3
  107. package/src/common/hooks/useContainerSize.test.tsx +1 -1
  108. package/src/common/hooks/useHasIntersected/useHasIntersected.ts +12 -4
  109. package/src/common/liveRegion/LiveRegion.tsx +5 -2
  110. package/src/dateInput/DateInput.tsx +10 -10
  111. package/src/dateLookup/monthCalendar/table/MonthCalendarTable.tsx +1 -5
  112. package/src/dateLookup/yearCalendar/table/YearCalendarTable.tsx +1 -1
  113. package/src/expressiveMoneyInput/ExpressiveMoneyInput.tsx +1 -1
  114. package/src/expressiveMoneyInput/amountInput/AmountInput.tsx +22 -15
  115. package/src/expressiveMoneyInput/hooks/useInputStyle.ts +20 -8
  116. package/src/expressiveMoneyInput/hooks/useSelectionRange.ts +2 -0
  117. package/src/header/Header.tsx +2 -2
  118. package/src/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.tsx +4 -0
  119. package/src/inputs/SelectInput/Options/SelectInputOptions.tsx +43 -27
  120. package/src/inputs/SelectInput/Popover/SelectInputPopover.tsx +4 -0
  121. package/src/inputs/SelectInput/SelectInput.tsx +21 -15
  122. package/src/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +1 -1
  123. package/src/nudge/Nudge.tsx +29 -20
  124. package/src/phoneNumberInput/PhoneNumberInput.test.tsx +16 -0
  125. package/src/phoneNumberInput/PhoneNumberInput.tsx +11 -13
  126. package/src/promoCard/PromoCard.story.tsx +3 -3
  127. package/src/promoCard/PromoCardGroup.tsx +39 -21
  128. package/src/segmentedControl/SegmentedControl.test.tsx +25 -0
  129. package/src/segmentedControl/SegmentedControl.tsx +7 -1
  130. package/src/select/Select.story.tsx +1 -1
  131. package/src/tabs/Tabs.tsx +1 -1
  132. package/src/tooltip/Tooltip.tsx +3 -0
  133. package/src/uploadInput/UploadInput.test.tsx +19 -0
  134. package/src/uploadInput/UploadInput.tsx +28 -24
@@ -42,7 +42,7 @@ export const AmountInput = ({
42
42
  const intl = useIntl();
43
43
  const { focus, setFocus, visualFocus, setVisualFocus } = useFocus();
44
44
 
45
- const [value, setValue] = useState<string>(
45
+ const [value, setValue] = useState<string>(() =>
46
46
  amount
47
47
  ? getFormattedString({
48
48
  value: amount,
@@ -51,6 +51,7 @@ export const AmountInput = ({
51
51
  })
52
52
  : '',
53
53
  );
54
+ const prevAmountRef = useRef<string | number | null | undefined>(amount);
54
55
  const numericValue = useMemo(() => {
55
56
  return getUnformattedNumber({
56
57
  value,
@@ -85,23 +86,28 @@ export const AmountInput = ({
85
86
  const decimalMode = decimalSeparator && value.includes(decimalSeparator);
86
87
 
87
88
  useEffect(() => {
88
- if (!focus) {
89
- setValue(
90
- amount
91
- ? getFormattedString({
92
- value: amount,
93
- currency,
94
- locale: intl.locale,
95
- })
96
- : '',
97
- );
89
+ if (prevAmountRef.current !== amount) {
90
+ prevAmountRef.current = amount;
91
+
92
+ // Only update the displayed value if not focused (preserve user input when focused)
93
+ if (!focus) {
94
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing external prop to internal state when unfocused
95
+ setValue(
96
+ amount
97
+ ? getFormattedString({
98
+ value: amount,
99
+ currency,
100
+ locale: intl.locale,
101
+ })
102
+ : '',
103
+ );
104
+ }
98
105
  }
99
- // eslint-disable-next-line react-hooks/exhaustive-deps
100
- }, [amount]);
106
+ }, [amount, focus, currency, intl.locale]);
101
107
 
102
108
  useEffect(() => {
103
109
  onFocusChange?.(visualFocus);
104
- }, [visualFocus]);
110
+ }, [onFocusChange, visualFocus]);
105
111
 
106
112
  const shouldReformatAfterUserInput = (newValue: string) => {
107
113
  // don't reformat if formatting would wipe out user's input
@@ -246,6 +252,7 @@ export const AmountInput = ({
246
252
  const addonContent = useMemo((): string | null | undefined => {
247
253
  // because we're using a separate "addon" element for the placeholder decimals, there is a possibility that the input itself will become scrollable
248
254
  // and the decimals will appear on top of the input. Safest thing to do is to just hide the addon if there is not enough room
255
+ // eslint-disable-next-line react-hooks/refs -- Reading layout dimensions for overflow detection
249
256
  if (isInputPossiblyOverflowing({ ref, value })) {
250
257
  return null;
251
258
  }
@@ -284,7 +291,7 @@ export const AmountInput = ({
284
291
  // whenever decimals are shown, we need to account for the full decimal part for the font size calculation
285
292
  value: addonContent ? valueWithFullDecimals : value,
286
293
  focus: visualFocus,
287
- inputElement: ref.current,
294
+ inputElement: ref,
288
295
  loading,
289
296
  });
290
297
 
@@ -1,17 +1,27 @@
1
- import { type CSSProperties, useEffect, useLayoutEffect, useState } from 'react';
1
+ import {
2
+ type CSSProperties,
3
+ type RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useState,
8
+ } from 'react';
2
9
  import { Props as ExpressiveMoneyInputProps } from '../ExpressiveMoneyInput';
3
10
 
4
11
  type InputStyleObject = {
5
12
  value: string;
6
13
  focus: boolean;
7
- inputElement: HTMLInputElement | null;
14
+ inputElement: RefObject<HTMLInputElement> | null;
8
15
  } & Pick<ExpressiveMoneyInputProps, 'loading'>;
9
16
 
10
17
  export const useInputStyle = ({ value, focus, inputElement, loading }: InputStyleObject) => {
11
18
  const initialRender = !useTimeout(300);
12
- const inputWidth = useFirstDefinedValue(inputElement?.clientWidth, [inputElement, value]);
19
+ const inputWidth = useFirstDefinedValue(inputElement?.current?.clientWidth, [
20
+ inputElement?.current,
21
+ value,
22
+ ]);
13
23
 
14
- const getStyle = (): CSSProperties => {
24
+ const getStyle = useCallback((): CSSProperties => {
15
25
  const fontSize = getFontSize(value, focus, inputWidth);
16
26
 
17
27
  return {
@@ -23,13 +33,14 @@ export const useInputStyle = ({ value, focus, inputElement, loading }: InputStyl
23
33
  transition: initialRender ? 'none' : undefined,
24
34
  color: loading ? 'var(--color-interactive-secondary)' : undefined,
25
35
  };
26
- };
36
+ }, [value, focus, inputWidth, loading, initialRender]);
27
37
 
28
- const [style, setStyle] = useState(getStyle());
38
+ const [style, setStyle] = useState<CSSProperties>(() => getStyle());
29
39
 
30
40
  useLayoutEffect(() => {
41
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Computing style based on layout measurements
31
42
  setStyle(getStyle());
32
- }, [value, focus, loading, inputWidth]);
43
+ }, [value, focus, loading, inputWidth, getStyle]);
33
44
 
34
45
  return style;
35
46
  };
@@ -60,10 +71,11 @@ const useFirstDefinedValue = (newValue: number | undefined, dependencies: unknow
60
71
 
61
72
  useLayoutEffect(() => {
62
73
  if (typeof newValue !== 'undefined' && typeof value === 'undefined') {
74
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Lazy initialization from prop
63
75
  setValue(newValue);
64
76
  }
65
77
  // eslint-disable-next-line react-hooks/exhaustive-deps
66
- }, [...dependencies, value]);
78
+ }, [...dependencies, value, newValue]);
67
79
 
68
80
  return value;
69
81
  };
@@ -15,9 +15,11 @@ export const useSelectionRange = () => {
15
15
  selection.current = undefined;
16
16
  };
17
17
 
18
+ /* eslint-disable react-hooks/refs */
18
19
  return {
19
20
  selection: selection.current,
20
21
  handleSelect,
21
22
  handleSelectionBlur,
22
23
  };
24
+ /* eslint-enable react-hooks/refs */
23
25
  };
@@ -104,7 +104,7 @@ const Header: FunctionComponent<HeaderProps> = React.forwardRef(
104
104
  useEffect(() => {
105
105
  if (as === 'legend' && internalRef.current) {
106
106
  const { parentElement } = internalRef.current;
107
- if (!parentElement || parentElement.tagName.toLowerCase() !== 'fieldset') {
107
+ if (parentElement?.tagName.toLowerCase() !== 'fieldset') {
108
108
  console.warn(
109
109
  'Legends should be the first child in a fieldset, and this is not possible when including an action',
110
110
  );
@@ -121,7 +121,7 @@ const Header: FunctionComponent<HeaderProps> = React.forwardRef(
121
121
  }
122
122
 
123
123
  return (
124
- <div {...commonProps} {...props} ref={ref as React.Ref<HTMLDivElement>}>
124
+ <div {...commonProps} {...props} ref={ref}>
125
125
  <Title as={as} type={levelTypography} className="np-header__title">
126
126
  {title}
127
127
  </Title>
@@ -65,10 +65,12 @@ export function SelectInputBottomSheet({
65
65
  return (
66
66
  <>
67
67
  {open ? <PreventScroll /> : null}
68
+ {/* eslint-disable react-hooks/refs -- setReference is a callback ref from floating-ui, safe to pass during render */}
68
69
  {renderTrigger?.({
69
70
  ref: refs.setReference,
70
71
  getInteractionProps: getReferenceProps,
71
72
  })}
73
+ {/* eslint-enable react-hooks/refs */}
72
74
 
73
75
  <FloatingPortal>
74
76
  <ThemeProvider theme="personal" screenMode={theme === 'personal' ? screenMode : 'light'}>
@@ -94,6 +96,7 @@ export function SelectInputBottomSheet({
94
96
  <Fragment
95
97
  key={floatingKey} // Force inner state invalidation on open
96
98
  >
99
+ {/* eslint-disable react-hooks/refs -- setFloating is a callback ref from floating-ui, safe to pass during render */}
97
100
  <TransitionChild
98
101
  ref={refs.setFloating}
99
102
  as="div"
@@ -102,6 +105,7 @@ export function SelectInputBottomSheet({
102
105
  leaveTo="np-bottom-sheet-v2-content--closed"
103
106
  {...getFloatingProps()}
104
107
  >
108
+ {/* eslint-enable react-hooks/refs */}
105
109
  <div className="np-bottom-sheet-v2-header">
106
110
  <CloseButton
107
111
  size={Size.SMALL}
@@ -77,7 +77,7 @@ export function SelectInputOptions<T = string>({
77
77
  const intl = useIntl();
78
78
  const virtualiserHandlerRef = useRef<VirtualizerHandle>(null);
79
79
  const controllerRef = filterable ? searchInputRef : listboxRef;
80
- const [initialRender, setInitialRender] = useState(true);
80
+ const initialRenderRef = useRef(true);
81
81
 
82
82
  const needle = useMemo(() => {
83
83
  if (filterable) {
@@ -166,28 +166,42 @@ export function SelectInputOptions<T = string>({
166
166
  // Items shown once shall be kept mounted until the needle changes, otherwise
167
167
  // the scroll position may jump around inadvertently. Pattern adopted from:
168
168
  // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
169
- const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
170
- const prevNeedleRef = useRef(needle);
171
-
169
+ const [virtualState, setVirtualState] = useState<{
170
+ needle: typeof needle;
171
+ length: number;
172
+ mountedIndexes: number[];
173
+ }>({
174
+ needle,
175
+ length: filteredItems.length,
176
+ mountedIndexes: [],
177
+ });
178
+
179
+ // Note: virtualState.mountedIndexes is in deps but only read in the guarded branch.
180
+ // This means external updates to mountedIndexes will trigger this effect but hit the guard
181
+ // and bail out early. This is intentional and harmless - the guard ensures no unnecessary work.
172
182
  useEffect(() => {
173
- const needleChanged = prevNeedleRef.current !== needle;
174
- prevNeedleRef.current = needle;
175
-
176
- if (needleChanged) {
177
- // Reset mounted indexes when search changes to avoid stale scroll positions
178
- setMountedIndexes([]);
179
- return;
180
- }
181
-
182
- // Ensure the 'End' key works as intended by keeping the last item mounted.
183
- // Skipped on needle change to prevent auto-scrolling on search.
184
- if (filteredItems.length > 0) {
185
- setMountedIndexes((prevMountedIndexes) => {
186
- // Create a new array with existing indexes plus the last item index
187
- return [...new Set([...prevMountedIndexes, filteredItems.length - 1])]; // Sorting is redundant by nature here
183
+ if (virtualState.needle !== needle || virtualState.length !== filteredItems.length) {
184
+ const needleChanged = virtualState.needle !== needle;
185
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing virtual scroll state with filtered items
186
+ setVirtualState({
187
+ needle,
188
+ length: filteredItems.length,
189
+ mountedIndexes: needleChanged
190
+ ? [] // Reset on needle change
191
+ : filteredItems.length > 0
192
+ ? [...new Set([...virtualState.mountedIndexes, filteredItems.length - 1])] // Add last index
193
+ : virtualState.mountedIndexes,
188
194
  });
189
195
  }
190
- }, [needle, filteredItems.length]);
196
+ }, [
197
+ needle,
198
+ filteredItems.length,
199
+ virtualState.needle,
200
+ virtualState.length,
201
+ virtualState.mountedIndexes,
202
+ ]);
203
+
204
+ const { mountedIndexes } = virtualState;
191
205
 
192
206
  const listboxContainerRef = useRef<HTMLDivElement>(null);
193
207
  useEffect(() => {
@@ -200,7 +214,7 @@ export function SelectInputOptions<T = string>({
200
214
  }, []);
201
215
 
202
216
  useEffect(() => {
203
- setInitialRender(false);
217
+ initialRenderRef.current = false;
204
218
  }, []);
205
219
 
206
220
  const showStatus = resultsEmpty;
@@ -251,7 +265,7 @@ export function SelectInputOptions<T = string>({
251
265
  className="np-select-input-options-container"
252
266
  onAriaActiveDescendantChange={(value: React.AriaAttributes['aria-activedescendant']) => {
253
267
  if (controllerRef.current != null) {
254
- if (!initialRender && value != null) {
268
+ if (!initialRenderRef.current && value != null) {
255
269
  controllerRef.current.setAttribute('aria-activedescendant', value);
256
270
  } else {
257
271
  controllerRef.current.removeAttribute('aria-activedescendant');
@@ -288,7 +302,7 @@ export function SelectInputOptions<T = string>({
288
302
  const inputValue = event.currentTarget.value;
289
303
 
290
304
  // Free up resources and ensure not to go out of bounds
291
- setMountedIndexes([]);
305
+ setVirtualState((prev) => ({ ...prev, mountedIndexes: [] }));
292
306
  onFilterChange(inputValue);
293
307
  }}
294
308
  onInput={(event) => {
@@ -358,7 +372,7 @@ export function SelectInputOptions<T = string>({
358
372
  virtualiserHandlerRef.current.viewportSize,
359
373
  );
360
374
 
361
- setMountedIndexes((prevMountedIndexes) => {
375
+ setVirtualState((prev) => {
362
376
  // Create an array of all indexes that should be visible
363
377
 
364
378
  const visibleIndexes = [];
@@ -368,9 +382,11 @@ export function SelectInputOptions<T = string>({
368
382
  }
369
383
 
370
384
  // Combine with previous indexes and sort
371
- return [...new Set([...prevMountedIndexes, ...visibleIndexes])].sort(
372
- (a, b) => a - b,
373
- );
385
+ const newMountedIndexes = [
386
+ ...new Set([...prev.mountedIndexes, ...visibleIndexes]),
387
+ ].sort((a, b) => a - b);
388
+
389
+ return { ...prev, mountedIndexes: newMountedIndexes };
374
390
  });
375
391
  }}
376
392
  >
@@ -85,10 +85,12 @@ export function SelectInputPopover({
85
85
  return (
86
86
  <>
87
87
  {open ? <PreventScroll /> : null}
88
+ {/* eslint-disable react-hooks/refs -- setReference is a callback ref from floating-ui, safe to pass during render */}
88
89
  {renderTrigger({
89
90
  ref: refs.setReference,
90
91
  getInteractionProps: getReferenceProps,
91
92
  })}
93
+ {/* eslint-enable react-hooks/refs */}
92
94
 
93
95
  <FloatingPortal>
94
96
  <ThemeProvider theme="personal" screenMode={theme === 'personal' ? screenMode : 'light'}>
@@ -104,6 +106,7 @@ export function SelectInputPopover({
104
106
  >
105
107
  <FocusScope>
106
108
  <FloatingFocusManager context={context}>
109
+ {/* eslint-disable react-hooks/refs -- setFloating is a callback ref from floating-ui, safe to pass during render */}
107
110
  <div
108
111
  key={floatingKey} // Force inner state invalidation on open
109
112
  ref={refs.setFloating}
@@ -114,6 +117,7 @@ export function SelectInputPopover({
114
117
  style={floatingStyles}
115
118
  {...getFloatingProps()}
116
119
  >
120
+ {/* eslint-enable react-hooks/refs */}
117
121
  <div
118
122
  className={clsx('np-popover-v2', title && 'np-popover-v2--has-title', {
119
123
  'np-popover-v2--padding-md': padding === 'md',
@@ -1,5 +1,5 @@
1
1
  import mergeProps from 'merge-props';
2
- import { useEffect, useRef, useState, useDeferredValue } from 'react';
2
+ import { useCallback, useEffect, useRef, useState, useDeferredValue } from 'react';
3
3
  import { Listbox as ListboxBase } from '@headlessui/react';
4
4
  import { useScreenSize } from '../../common/hooks/useScreenSize';
5
5
  import { Breakpoint } from '../../common/propsValues/breakpoint';
@@ -60,6 +60,7 @@ export function SelectInput<T = string, M extends boolean = false>({
60
60
  const initialized = useRef(false);
61
61
  const handleClose = useEffectEvent(onClose ?? (() => {}));
62
62
  const handleOpen = useEffectEvent(onOpen ?? (() => {}));
63
+
63
64
  useEffect(() => {
64
65
  if (initialized.current) {
65
66
  if (open) {
@@ -70,29 +71,34 @@ export function SelectInput<T = string, M extends boolean = false>({
70
71
  } else {
71
72
  initialized.current = true;
72
73
  }
73
- }, [handleClose, handleOpen, open]);
74
+ }, [open]);
74
75
 
75
76
  const [filterQuery, _setFilterQuery] = useState('');
76
77
  const deferredFilterQuery = useDeferredValue(filterQuery);
77
- const setFilterQuery = useEffectEvent((query: string) => {
78
- _setFilterQuery(query);
79
- if (query !== filterQuery) {
80
- onFilterChange({
81
- query,
82
- queryNormalized: query ? searchableString(query) : null,
83
- });
84
- }
85
- });
86
-
87
- const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
78
+ const previousFilterQueryRef = useRef(filterQuery);
88
79
 
89
- const screenSm = useScreenSize(Breakpoint.SMALL);
90
- const OptionsOverlay = screenSm ? SelectInputPopover : SelectInputBottomSheet;
80
+ const setFilterQuery = useCallback(
81
+ (query: string) => {
82
+ _setFilterQuery(query);
83
+ if (query !== previousFilterQueryRef.current) {
84
+ onFilterChange({
85
+ query,
86
+ queryNormalized: query ? searchableString(query) : null,
87
+ });
88
+ previousFilterQueryRef.current = query;
89
+ }
90
+ },
91
+ [onFilterChange],
92
+ );
91
93
 
94
+ const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
92
95
  const searchInputRef = useRef<HTMLInputElement>(null);
93
96
  const listboxRef = useRef<HTMLDivElement>(null);
94
97
  const controllerRef = filterable ? searchInputRef : listboxRef;
95
98
 
99
+ const screenSm = useScreenSize(Breakpoint.SMALL);
100
+ const OptionsOverlay = screenSm ? SelectInputPopover : SelectInputBottomSheet;
101
+
96
102
  /**
97
103
  * Attempts to resolve the `listbox` label
98
104
  * @see https://storybook.wise.design/?path=/docs/forms-selectinput-accessibility--docs#labelling
@@ -29,7 +29,7 @@ export function SelectInputTriggerButton<T extends SelectInputTriggerButtonEleme
29
29
  ref={ref}
30
30
  as={PolymorphicWithOverrides}
31
31
  role="combobox"
32
- __overrides={{ as, size, ...interactionProps } as Record<string, unknown>}
32
+ __overrides={{ as, size, ...interactionProps }}
33
33
  {...mergeProps({ onClick, onKeyDown }, restProps)}
34
34
  />
35
35
  );
@@ -1,6 +1,6 @@
1
1
  import { Illustration, Assets, type IllustrationNames } from '@wise/art';
2
2
  import { clsx } from 'clsx';
3
- import { ReactNode, useEffect, useState, MouseEvent } from 'react';
3
+ import { ReactNode, useEffect, useState, MouseEvent, useCallback } from 'react';
4
4
 
5
5
  import Body from '../body';
6
6
  import { Typography } from '../common';
@@ -96,8 +96,27 @@ const Nudge = ({
96
96
  action,
97
97
  }: Props) => {
98
98
  const intl = useIntl();
99
- const [isDismissed, setIsDismissed] = useState(false);
100
- const [isMounted, setIsMounted] = useState(false);
99
+ const getIsDismissed = useCallback(
100
+ () => (persistDismissal && id ? !!getLocalStorage()?.find((item) => item === id) : false),
101
+ [persistDismissal, id],
102
+ );
103
+
104
+ const [nudgeState, setNudgeState] = useState(() => ({
105
+ isDismissed: getIsDismissed(),
106
+ isMounted: false,
107
+ }));
108
+
109
+ useEffect(() => {
110
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Setting mount state in mount effect
111
+ setNudgeState((prev) => ({ ...prev, isMounted: true }));
112
+ }, []);
113
+
114
+ useEffect(() => {
115
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing dismissed state from localStorage on prop change
116
+ setNudgeState((prev) => ({ ...prev, isDismissed: getIsDismissed() }));
117
+ }, [getIsDismissed, id, persistDismissal]);
118
+
119
+ const { isDismissed } = nudgeState;
101
120
 
102
121
  const handleOnDismiss = () => {
103
122
  const dismissedNudgesStorage = getLocalStorage();
@@ -105,9 +124,9 @@ const Nudge = ({
105
124
  if (persistDismissal && id) {
106
125
  try {
107
126
  localStorage.setItem(STORAGE_NAME, JSON.stringify([...dismissedNudgesStorage, id]));
108
- } catch (error) {}
127
+ } catch {}
109
128
 
110
- setIsDismissed(true);
129
+ setNudgeState((prev) => ({ ...prev, isDismissed: true }));
111
130
  }
112
131
 
113
132
  if (onDismiss) {
@@ -116,25 +135,15 @@ const Nudge = ({
116
135
  };
117
136
 
118
137
  useEffect(() => {
119
- if (persistDismissal && id) {
138
+ if (persistDismissal && id && isPreviouslyDismissed) {
120
139
  const dismissedNudgesStorage = getLocalStorage();
121
- let isDismissed = false;
122
-
123
- if (dismissedNudgesStorage?.find((item) => item === id)) {
124
- setIsDismissed(true);
125
- isDismissed = true;
126
- }
127
-
128
- if (isPreviouslyDismissed) {
129
- isPreviouslyDismissed(isDismissed);
130
- }
140
+ const wasDismissed = !!dismissedNudgesStorage?.find((item) => item === id);
141
+ isPreviouslyDismissed(wasDismissed);
131
142
  }
132
-
133
- setIsMounted(true);
134
143
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
144
  }, [id, persistDismissal]);
136
145
 
137
- if (persistDismissal && (isDismissed || !isMounted)) {
146
+ if (persistDismissal && (isDismissed || !nudgeState.isMounted)) {
138
147
  return null;
139
148
  }
140
149
 
@@ -143,7 +152,7 @@ const Nudge = ({
143
152
  {!!mediaName && (
144
153
  <div className="wds-nudge-media">
145
154
  <Illustration
146
- name={mediaName as IllustrationNames}
155
+ name={mediaName}
147
156
  className={clsx(`wds-nudge-media-${mediaName}`)}
148
157
  size="small"
149
158
  disablePadding
@@ -283,6 +283,22 @@ describe('PhoneNumberInput', () => {
283
283
  expect(props.onChange).toHaveBeenCalledWith('+201111111', '+20');
284
284
  });
285
285
  });
286
+
287
+ describe('onChange deduplication', () => {
288
+ it('should not call onChange when the composed phone number has not changed', () => {
289
+ const onChangeMock = jest.fn();
290
+ const { rerender } = render(
291
+ <PhoneNumberInput initialValue="+441234567890" onChange={onChangeMock} />,
292
+ );
293
+
294
+ onChangeMock.mockClear();
295
+
296
+ // Rerender with the same initialValue - internal state should not trigger onChange
297
+ rerender(<PhoneNumberInput initialValue="+441234567890" onChange={onChangeMock} />);
298
+
299
+ expect(onChangeMock).not.toHaveBeenCalled();
300
+ });
301
+ });
286
302
  });
287
303
 
288
304
  describe('when selectProps is supplied', () => {
@@ -75,12 +75,13 @@ const PhoneNumberInput = ({
75
75
 
76
76
  const { locale, formatMessage } = useIntl();
77
77
 
78
+ const [randomId] = useState(() => Math.random().toString(36).slice(2, 8));
79
+
78
80
  const createId = (customID: string | undefined, backup: string): string => {
79
81
  if (customID) {
80
82
  return customID + (backup ? `-${backup}` : '');
81
83
  }
82
- const random = Math.random().toString(36).slice(2, 8);
83
- return `${backup}-${random}`;
84
+ return `${backup}-${randomId}`;
84
85
  };
85
86
 
86
87
  // Link the first non-disabled input to the the Field label, if present
@@ -107,14 +108,16 @@ const PhoneNumberInput = ({
107
108
 
108
109
  return explodeNumberModel(cleanValue);
109
110
  });
110
- const [broadcastedValue, setBroadcastedValue] = useState<PhoneNumber | null>(null);
111
+ const broadcastedValueRef = useRef<PhoneNumber>(internalValue);
111
112
 
112
113
  const [suffixDirty, setSuffixDirty] = useState(false);
114
+
113
115
  useEffect(() => {
114
- if (internalValue.suffix) {
116
+ if (!suffixDirty && internalValue.suffix) {
117
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Tracking when suffix becomes dirty
115
118
  setSuffixDirty(true);
116
119
  }
117
- }, [internalValue.suffix]);
120
+ }, [internalValue.suffix, suffixDirty]);
118
121
 
119
122
  const countriesByPrefix = useMemo(
120
123
  () =>
@@ -152,13 +155,8 @@ const PhoneNumberInput = ({
152
155
  };
153
156
 
154
157
  useEffect(() => {
155
- if (broadcastedValue === null) {
156
- setBroadcastedValue(internalValue);
157
- return;
158
- }
159
-
160
158
  const internalPhoneNumber = `${internalValue.prefix ?? ''}${internalValue.suffix}`;
161
- const broadcastedPhoneNumber = `${broadcastedValue.prefix ?? ''}${broadcastedValue.suffix}`;
159
+ const broadcastedPhoneNumber = `${broadcastedValueRef.current.prefix ?? ''}${broadcastedValueRef.current.suffix}`;
162
160
 
163
161
  if (internalPhoneNumber === broadcastedPhoneNumber) {
164
162
  return;
@@ -172,8 +170,8 @@ const PhoneNumberInput = ({
172
170
  newValue,
173
171
  internalValue.prefix ?? '', // TODO: Allow `null` in public API
174
172
  );
175
- setBroadcastedValue(internalValue);
176
- }, [onChange, broadcastedValue, internalValue]);
173
+ broadcastedValueRef.current = internalValue;
174
+ }, [onChange, internalValue]);
177
175
 
178
176
  useEffect(() => {
179
177
  const labelRef = fieldLabelRef?.current;
@@ -47,7 +47,7 @@ export const TaskCard: Story = {
47
47
  isSmall: true,
48
48
  useDisplayFont: false,
49
49
  className: 'taskCard',
50
- } as PromoCardLinkProps,
50
+ },
51
51
  decorators: [
52
52
  (Story) => (
53
53
  <div>
@@ -90,7 +90,7 @@ export const TaskCardWithCustomIcon: Story = {
90
90
  args: {
91
91
  ...TaskCard.args,
92
92
  indicatorIcon: <StarFill size={24} aria-hidden="true" />,
93
- } as PromoCardLinkProps,
93
+ },
94
94
  decorators: TaskCard.decorators,
95
95
  };
96
96
 
@@ -101,7 +101,7 @@ export const TaskCardCompleted: Story = {
101
101
  href: undefined,
102
102
  indicatorIcon: 'check',
103
103
  className: 'taskCard taskCard--completed np-theme--personal np-theme-personal--forest-green',
104
- } as PromoCardLinkProps,
104
+ },
105
105
  decorators: TaskCard.decorators,
106
106
  };
107
107