@transferwise/components 46.140.1 → 46.142.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 (221) hide show
  1. package/build/avatarLayout/AvatarLayout.js +15 -1
  2. package/build/avatarLayout/AvatarLayout.js.map +1 -1
  3. package/build/avatarLayout/AvatarLayout.mjs +15 -1
  4. package/build/avatarLayout/AvatarLayout.mjs.map +1 -1
  5. package/build/avatarView/AvatarView.js +6 -2
  6. package/build/avatarView/AvatarView.js.map +1 -1
  7. package/build/avatarView/AvatarView.mjs +6 -2
  8. package/build/avatarView/AvatarView.mjs.map +1 -1
  9. package/build/avatarView/Dot.js +8 -0
  10. package/build/avatarView/Dot.js.map +1 -1
  11. package/build/avatarView/Dot.mjs +8 -0
  12. package/build/avatarView/Dot.mjs.map +1 -1
  13. package/build/avatarWrapper/AvatarWrapper.js +3 -4
  14. package/build/avatarWrapper/AvatarWrapper.js.map +1 -1
  15. package/build/avatarWrapper/AvatarWrapper.mjs +4 -5
  16. package/build/avatarWrapper/AvatarWrapper.mjs.map +1 -1
  17. package/build/button/LegacyButton.js.map +1 -1
  18. package/build/button/LegacyButton.mjs.map +1 -1
  19. package/build/common/circle/Circle.js +6 -2
  20. package/build/common/circle/Circle.js.map +1 -1
  21. package/build/common/circle/Circle.mjs +6 -2
  22. package/build/common/circle/Circle.mjs.map +1 -1
  23. package/build/common/hooks/useHasIntersected/useHasIntersected.js +6 -4
  24. package/build/common/hooks/useHasIntersected/useHasIntersected.js.map +1 -1
  25. package/build/common/hooks/useHasIntersected/useHasIntersected.mjs +6 -4
  26. package/build/common/hooks/useHasIntersected/useHasIntersected.mjs.map +1 -1
  27. package/build/common/liveRegion/LiveRegion.js +4 -1
  28. package/build/common/liveRegion/LiveRegion.js.map +1 -1
  29. package/build/common/liveRegion/LiveRegion.mjs +4 -1
  30. package/build/common/liveRegion/LiveRegion.mjs.map +1 -1
  31. package/build/dateInput/DateInput.js +10 -10
  32. package/build/dateInput/DateInput.js.map +1 -1
  33. package/build/dateInput/DateInput.mjs +10 -10
  34. package/build/dateInput/DateInput.mjs.map +1 -1
  35. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js +1 -1
  36. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js.map +1 -1
  37. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs +1 -1
  38. package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs.map +1 -1
  39. package/build/dateLookup/yearCalendar/table/YearCalendarTable.js +1 -1
  40. package/build/dateLookup/yearCalendar/table/YearCalendarTable.js.map +1 -1
  41. package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs +1 -1
  42. package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs.map +1 -1
  43. package/build/expressiveMoneyInput/ExpressiveMoneyInput.js.map +1 -1
  44. package/build/expressiveMoneyInput/ExpressiveMoneyInput.mjs.map +1 -1
  45. package/build/expressiveMoneyInput/amountInput/AmountInput.js +18 -12
  46. package/build/expressiveMoneyInput/amountInput/AmountInput.js.map +1 -1
  47. package/build/expressiveMoneyInput/amountInput/AmountInput.mjs +19 -13
  48. package/build/expressiveMoneyInput/amountInput/AmountInput.mjs.map +1 -1
  49. package/build/expressiveMoneyInput/hooks/useInputStyle.js +8 -6
  50. package/build/expressiveMoneyInput/hooks/useInputStyle.js.map +1 -1
  51. package/build/expressiveMoneyInput/hooks/useInputStyle.mjs +9 -7
  52. package/build/expressiveMoneyInput/hooks/useInputStyle.mjs.map +1 -1
  53. package/build/field/Field.js +63 -32
  54. package/build/field/Field.js.map +1 -1
  55. package/build/field/Field.messages.js +14 -0
  56. package/build/field/Field.messages.js.map +1 -0
  57. package/build/field/Field.messages.mjs +10 -0
  58. package/build/field/Field.messages.mjs.map +1 -0
  59. package/build/field/Field.mjs +65 -34
  60. package/build/field/Field.mjs.map +1 -1
  61. package/build/header/Header.js +1 -1
  62. package/build/header/Header.js.map +1 -1
  63. package/build/header/Header.mjs +1 -1
  64. package/build/header/Header.mjs.map +1 -1
  65. package/build/i18n/en.json +1 -0
  66. package/build/i18n/en.json.js +1 -0
  67. package/build/i18n/en.json.js.map +1 -1
  68. package/build/i18n/en.json.mjs +1 -0
  69. package/build/i18n/en.json.mjs.map +1 -1
  70. package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.js.map +1 -1
  71. package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.mjs.map +1 -1
  72. package/build/inputs/SelectInput/Options/SelectInputOptions.js +34 -22
  73. package/build/inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
  74. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs +35 -23
  75. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
  76. package/build/inputs/SelectInput/Popover/SelectInputPopover.js.map +1 -1
  77. package/build/inputs/SelectInput/Popover/SelectInputPopover.mjs.map +1 -1
  78. package/build/inputs/SelectInput/SelectInput.js +8 -6
  79. package/build/inputs/SelectInput/SelectInput.js.map +1 -1
  80. package/build/inputs/SelectInput/SelectInput.mjs +9 -7
  81. package/build/inputs/SelectInput/SelectInput.mjs.map +1 -1
  82. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  83. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  84. package/build/inputs/TextArea.js +5 -0
  85. package/build/inputs/TextArea.js.map +1 -1
  86. package/build/inputs/TextArea.mjs +6 -1
  87. package/build/inputs/TextArea.mjs.map +1 -1
  88. package/build/inputs/contexts.js +16 -0
  89. package/build/inputs/contexts.js.map +1 -1
  90. package/build/inputs/contexts.mjs +16 -2
  91. package/build/inputs/contexts.mjs.map +1 -1
  92. package/build/main.css +42 -8
  93. package/build/nudge/Nudge.js +31 -15
  94. package/build/nudge/Nudge.js.map +1 -1
  95. package/build/nudge/Nudge.mjs +32 -16
  96. package/build/nudge/Nudge.mjs.map +1 -1
  97. package/build/phoneNumberInput/PhoneNumberInput.js +9 -12
  98. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  99. package/build/phoneNumberInput/PhoneNumberInput.mjs +9 -12
  100. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  101. package/build/promoCard/PromoCardGroup.js +34 -16
  102. package/build/promoCard/PromoCardGroup.js.map +1 -1
  103. package/build/promoCard/PromoCardGroup.mjs +35 -17
  104. package/build/promoCard/PromoCardGroup.mjs.map +1 -1
  105. package/build/segmentedControl/SegmentedControl.js +6 -1
  106. package/build/segmentedControl/SegmentedControl.js.map +1 -1
  107. package/build/segmentedControl/SegmentedControl.mjs +7 -2
  108. package/build/segmentedControl/SegmentedControl.mjs.map +1 -1
  109. package/build/styles/avatarView/AvatarView.css +4 -4
  110. package/build/styles/avatarView/Dot.css +4 -4
  111. package/build/styles/css/neptune.css +15 -1
  112. package/build/styles/expressiveMoneyInput/ExpressiveMoneyInput.css +2 -0
  113. package/build/styles/expressiveMoneyInput/amountInput/AmountInput.css +2 -0
  114. package/build/styles/field/Field.css +19 -3
  115. package/build/styles/main.css +42 -8
  116. package/build/styles/styles/less/neptune.css +15 -1
  117. package/build/tabs/Tabs.js +1 -1
  118. package/build/tabs/Tabs.js.map +1 -1
  119. package/build/tabs/Tabs.mjs +1 -1
  120. package/build/tabs/Tabs.mjs.map +1 -1
  121. package/build/tooltip/Tooltip.js +6 -3
  122. package/build/tooltip/Tooltip.js.map +1 -1
  123. package/build/tooltip/Tooltip.mjs +6 -3
  124. package/build/tooltip/Tooltip.mjs.map +1 -1
  125. package/build/types/avatarView/AvatarView.d.ts +1 -1
  126. package/build/types/avatarView/AvatarView.d.ts.map +1 -1
  127. package/build/types/avatarView/Dot.d.ts.map +1 -1
  128. package/build/types/avatarWrapper/AvatarWrapper.d.ts.map +1 -1
  129. package/build/types/common/circle/Circle.d.ts +1 -1
  130. package/build/types/common/circle/Circle.d.ts.map +1 -1
  131. package/build/types/common/hooks/useHasIntersected/useHasIntersected.d.ts.map +1 -1
  132. package/build/types/common/liveRegion/LiveRegion.d.ts.map +1 -1
  133. package/build/types/dateLookup/monthCalendar/table/MonthCalendarTable.d.ts.map +1 -1
  134. package/build/types/expressiveMoneyInput/ExpressiveMoneyInput.d.ts.map +1 -1
  135. package/build/types/expressiveMoneyInput/amountInput/AmountInput.d.ts.map +1 -1
  136. package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts +2 -2
  137. package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts.map +1 -1
  138. package/build/types/expressiveMoneyInput/hooks/useSelectionRange.d.ts.map +1 -1
  139. package/build/types/field/Field.d.ts.map +1 -1
  140. package/build/types/field/Field.messages.d.ts +8 -0
  141. package/build/types/field/Field.messages.d.ts.map +1 -0
  142. package/build/types/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.d.ts.map +1 -1
  143. package/build/types/inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
  144. package/build/types/inputs/SelectInput/Popover/SelectInputPopover.d.ts.map +1 -1
  145. package/build/types/inputs/SelectInput/SelectInput.d.ts.map +1 -1
  146. package/build/types/inputs/TextArea.d.ts.map +1 -1
  147. package/build/types/inputs/contexts.d.ts +6 -0
  148. package/build/types/inputs/contexts.d.ts.map +1 -1
  149. package/build/types/nudge/Nudge.d.ts.map +1 -1
  150. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  151. package/build/types/promoCard/PromoCardGroup.d.ts.map +1 -1
  152. package/build/types/segmentedControl/SegmentedControl.d.ts.map +1 -1
  153. package/build/types/test-utils/index.d.ts +2 -0
  154. package/build/types/test-utils/index.d.ts.map +1 -1
  155. package/build/types/tooltip/Tooltip.d.ts.map +1 -1
  156. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  157. package/build/uploadInput/UploadInput.js +29 -25
  158. package/build/uploadInput/UploadInput.js.map +1 -1
  159. package/build/uploadInput/UploadInput.mjs +29 -25
  160. package/build/uploadInput/UploadInput.mjs.map +1 -1
  161. package/package.json +3 -3
  162. package/src/avatarLayout/AvatarLayout.story.tsx +1 -1
  163. package/src/avatarLayout/AvatarLayout.tsx +4 -0
  164. package/src/avatarView/AvatarView.css +4 -4
  165. package/src/avatarView/AvatarView.story.tsx +17 -13
  166. package/src/avatarView/AvatarView.tsx +5 -1
  167. package/src/avatarView/Dot.css +4 -4
  168. package/src/avatarView/Dot.less +6 -6
  169. package/src/avatarView/Dot.tsx +2 -0
  170. package/src/avatarWrapper/AvatarWrapper.test.tsx +33 -3
  171. package/src/avatarWrapper/AvatarWrapper.tsx +5 -6
  172. package/src/button/LegacyButton.tsx +1 -1
  173. package/src/button/_stories/Button.test.story.tsx +3 -3
  174. package/src/common/circle/Circle.tsx +5 -1
  175. package/src/common/hooks/useContainerSize.test.tsx +1 -1
  176. package/src/common/hooks/useHasIntersected/useHasIntersected.ts +12 -4
  177. package/src/common/liveRegion/LiveRegion.tsx +5 -2
  178. package/src/dateInput/DateInput.tsx +10 -10
  179. package/src/dateLookup/monthCalendar/table/MonthCalendarTable.tsx +1 -5
  180. package/src/dateLookup/yearCalendar/table/YearCalendarTable.tsx +1 -1
  181. package/src/expressiveMoneyInput/ExpressiveMoneyInput.css +2 -0
  182. package/src/expressiveMoneyInput/ExpressiveMoneyInput.test.story.tsx +43 -0
  183. package/src/expressiveMoneyInput/ExpressiveMoneyInput.tsx +1 -1
  184. package/src/expressiveMoneyInput/amountInput/AmountInput.css +2 -0
  185. package/src/expressiveMoneyInput/amountInput/AmountInput.less +2 -0
  186. package/src/expressiveMoneyInput/amountInput/AmountInput.tsx +23 -16
  187. package/src/expressiveMoneyInput/hooks/useInputStyle.ts +20 -8
  188. package/src/expressiveMoneyInput/hooks/useSelectionRange.ts +2 -0
  189. package/src/field/Field.css +19 -3
  190. package/src/field/Field.less +17 -3
  191. package/src/field/Field.messages.ts +8 -0
  192. package/src/field/Field.story.tsx +5 -1
  193. package/src/field/Field.test.tsx +90 -0
  194. package/src/field/Field.tsx +84 -37
  195. package/src/header/Header.tsx +2 -2
  196. package/src/i18n/en.json +1 -0
  197. package/src/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.tsx +4 -0
  198. package/src/inputs/SelectInput/Options/SelectInputOptions.tsx +43 -27
  199. package/src/inputs/SelectInput/Popover/SelectInputPopover.tsx +4 -0
  200. package/src/inputs/SelectInput/SelectInput.tsx +21 -15
  201. package/src/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +1 -1
  202. package/src/inputs/TextArea.story.tsx +97 -0
  203. package/src/inputs/TextArea.test.story.tsx +142 -0
  204. package/src/inputs/TextArea.tsx +7 -2
  205. package/src/inputs/contexts.tsx +18 -1
  206. package/src/main.css +42 -8
  207. package/src/nudge/Nudge.tsx +29 -20
  208. package/src/phoneNumberInput/PhoneNumberInput.test.tsx +16 -0
  209. package/src/phoneNumberInput/PhoneNumberInput.tsx +11 -13
  210. package/src/promoCard/PromoCard.story.tsx +3 -3
  211. package/src/promoCard/PromoCardGroup.tsx +39 -21
  212. package/src/segmentedControl/SegmentedControl.test.tsx +25 -0
  213. package/src/segmentedControl/SegmentedControl.tsx +7 -1
  214. package/src/select/Select.story.tsx +1 -1
  215. package/src/styles/less/core/_typography.less +28 -6
  216. package/src/styles/less/neptune.css +15 -1
  217. package/src/tabs/Tabs.tsx +1 -1
  218. package/src/textareaWithDisplayFormat/TextareaWithDisplayFormat.story.tsx +1 -0
  219. package/src/tooltip/Tooltip.tsx +3 -0
  220. package/src/uploadInput/UploadInput.test.tsx +19 -0
  221. package/src/uploadInput/UploadInput.tsx +28 -24
@@ -3,7 +3,7 @@ import { forwardRef } from 'react';
3
3
 
4
4
  import { Merge } from '../utils';
5
5
  import { inputClassNameBase } from './_common';
6
- import { useInputAttributes } from './contexts';
6
+ import { useTextareaCharacterCount, useInputAttributes } from './contexts';
7
7
 
8
8
  export interface TextAreaProps extends Merge<
9
9
  React.ComponentPropsWithRef<'textarea'>,
@@ -13,15 +13,20 @@ export interface TextAreaProps extends Merge<
13
13
  > {}
14
14
 
15
15
  export const TextArea = forwardRef(function TextArea(
16
- { className, ...restProps }: TextAreaProps,
16
+ { className, maxLength, ...restProps }: TextAreaProps,
17
17
  reference: React.ForwardedRef<HTMLTextAreaElement | null>,
18
18
  ) {
19
19
  const inputAttributes = useInputAttributes();
20
+ const value = restProps.value ?? restProps.defaultValue ?? '';
21
+ const currentLength = typeof value === 'string' ? value.length : String(value).length;
22
+
23
+ useTextareaCharacterCount(currentLength, maxLength);
20
24
 
21
25
  return (
22
26
  <textarea
23
27
  ref={reference}
24
28
  className={clsx(className, inputClassNameBase(), 'np-text-area')}
29
+ maxLength={maxLength}
25
30
  {...inputAttributes}
26
31
  {...restProps}
27
32
  />
@@ -1,4 +1,4 @@
1
- import { createContext, useContext } from 'react';
1
+ import { createContext, useContext, useEffect } from 'react';
2
2
 
3
3
  type FieldLabelContextType = {
4
4
  id?: string;
@@ -36,6 +36,23 @@ export function useFieldLabelRef() {
36
36
  return useContext(FieldLabelContext)?.ref;
37
37
  }
38
38
 
39
+ export type TextareaCharacterCountState = { current: number; max: number } | null;
40
+
41
+ const TextareaCharacterCountContext = createContext<
42
+ ((state: TextareaCharacterCountState) => void) | undefined
43
+ >(undefined);
44
+ export const TextareaCharacterCountProvider = TextareaCharacterCountContext.Provider;
45
+
46
+ export function useTextareaCharacterCount(current: number, max: number | undefined) {
47
+ const setCharacterCount = useContext(TextareaCharacterCountContext);
48
+ useEffect(() => {
49
+ if (setCharacterCount && max != null) {
50
+ setCharacterCount({ current, max });
51
+ return () => setCharacterCount(null);
52
+ }
53
+ }, [setCharacterCount, current, max]);
54
+ }
55
+
39
56
  export interface WithInputAttributesProps {
40
57
  inputAttributes: ReturnType<typeof useInputAttributes>;
41
58
  }
package/src/main.css CHANGED
@@ -3238,7 +3238,16 @@ a,
3238
3238
  .np-text-display-extra-large,
3239
3239
  .np-text-display-large,
3240
3240
  .np-text-display-medium,
3241
- .np-text-display-small {
3241
+ .np-text-display-small,
3242
+ .display-1--forced,
3243
+ .display-2--forced,
3244
+ .display-3--forced,
3245
+ .display-4--forced,
3246
+ .display-5--forced,
3247
+ .np-text-display-extra-large--forced,
3248
+ .np-text-display-large--forced,
3249
+ .np-text-display-medium--forced,
3250
+ .np-text-display-small--forced {
3242
3251
  font-family: 'Wise Sans', 'Inter', sans-serif;
3243
3252
  font-family: var(--font-family-display);
3244
3253
  font-synthesis: none;
@@ -3285,9 +3294,14 @@ a,
3285
3294
  * of Japanese ones for the logged out ones (exposed by the Editorial DS). Unfortunately,
3286
3295
  * font files are browser-cached and we carried over to launchpad, where it causes issues
3287
3296
  * for unsupported locales, especially those that share glyphs, like Japanese and Chinese.
3297
+ * There are exceptions for small UI parts where Wise Sans is fine or expected — e.g. the
3298
+ * numeric input of ExpressiveMoneyInput.
3299
+ * Add `--forced` BEM modifier to the original class name to guarantee it.
3288
3300
  */
3289
3301
  font-family: 'Inter', Helvetica, Arial, sans-serif;
3290
3302
  font-family: var(--font-family-regular);
3303
+ line-height: 1.2;
3304
+ line-height: var(--line-height-title);
3291
3305
  }
3292
3306
 
3293
3307
  /* DEPRECATED(.np-text-display-extra-large): use .np-text-display-large instead */
@@ -26633,10 +26647,10 @@ a[data-toggle="tooltip"] {
26633
26647
  }
26634
26648
 
26635
26649
  .np-dot-mask {
26636
- -webkit-mask-image: radial-gradient(circle at bottom calc(100% - (var(--np-dot-size) / 2)) left calc(100% - (var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black 0);
26637
- mask-image: radial-gradient(circle at bottom calc(100% - (var(--np-dot-size) / 2)) left calc(100% - (var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black 0);
26638
- -webkit-mask-image: radial-gradient(circle at bottom calc(100% - calc(var(--np-dot-size) / 2)) left calc(100% - calc(var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black 0);
26639
- mask-image: radial-gradient(circle at bottom calc(100% - calc(var(--np-dot-size) / 2)) left calc(100% - calc(var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black 0);
26650
+ -webkit-mask-image: radial-gradient(circle at bottom calc(100% - (var(--np-dot-size) / 2)) left calc(100% - (var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black calc(var(--np-dot-size) / 2 + var(--np-dot-offset) + 0.5px));
26651
+ mask-image: radial-gradient(circle at bottom calc(100% - (var(--np-dot-size) / 2)) left calc(100% - (var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black calc(var(--np-dot-size) / 2 + var(--np-dot-offset) + 0.5px));
26652
+ -webkit-mask-image: radial-gradient(circle at bottom calc(100% - calc(var(--np-dot-size) / 2)) left calc(100% - calc(var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black calc(var(--np-dot-size) / 2 + var(--np-dot-offset) + 0.5px));
26653
+ mask-image: radial-gradient(circle at bottom calc(100% - calc(var(--np-dot-size) / 2)) left calc(100% - calc(var(--np-dot-size) / 2)), transparent 0, transparent calc(var(--np-dot-size) / 2 + var(--np-dot-offset)), black calc(var(--np-dot-size) / 2 + var(--np-dot-offset) + 0.5px));
26640
26654
  }
26641
26655
 
26642
26656
  .np-dot-badge {
@@ -29935,18 +29949,36 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
29935
29949
  stroke-dasharray: var(--wds-list-item-spotlight-strokeDashSize) var(--wds-list-item-spotlight-strokeDashSize);
29936
29950
  }
29937
29951
 
29938
- .np-field-control,
29939
- .np-field__prompt {
29952
+ .np-field-control {
29940
29953
  margin-top: 4px;
29941
29954
  margin-top: var(--size-4);
29942
29955
  }
29943
29956
 
29957
+ .np-field-validation {
29958
+ display: flex;
29959
+ align-items: flex-start;
29960
+ margin-top: 4px;
29961
+ margin-top: var(--size-4);
29962
+ gap: 8px;
29963
+ gap: var(--size-8);
29964
+ }
29965
+
29966
+ .np-field-textarea-char-counter {
29967
+ min-width: 55px;
29968
+ text-align: right;
29969
+ margin-left: auto;
29970
+ padding: 4px 0;
29971
+ padding: var(--size-4) 0;
29972
+ color: #768e9c;
29973
+ color: var(--color-content-tertiary);
29974
+ }
29975
+
29944
29976
  .np-field .form-group--typeahead[class],
29945
29977
  .np-field .np-checkbox-label[class] {
29946
29978
  margin-bottom: 0;
29947
29979
  }
29948
29980
 
29949
- .np-field:has(.wds-radio-group) .np-field__prompt {
29981
+ .np-field:has(.wds-radio-group) .np-field-validation {
29950
29982
  margin-top: 12px;
29951
29983
  margin-top: var(--size-12);
29952
29984
  }
@@ -31052,6 +31084,7 @@ button.np-link {
31052
31084
  flex-grow: 1;
31053
31085
  text-align: right;
31054
31086
  background-color: transparent;
31087
+ line-height: inherit;
31055
31088
  }
31056
31089
 
31057
31090
  .wds-amount-input-input:focus-visible {
@@ -31062,6 +31095,7 @@ button.np-link {
31062
31095
  flex-grow: 0;
31063
31096
  display: flex;
31064
31097
  align-items: center;
31098
+ line-height: inherit;
31065
31099
  }
31066
31100
 
31067
31101
  .wds-currency-selector:disabled {
@@ -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
 
@@ -67,8 +67,45 @@ const PromoCardGroup: FunctionComponent<PromoCardGroupProps> = ({
67
67
  onChange = () => {},
68
68
  testId,
69
69
  }) => {
70
- const [state, setState] = useState<string>(defaultChecked);
71
- const [containerRole, setContainerRole] = useState<string | null>(null);
70
+ const [promoCardState, setPromoCardState] = useState<{
71
+ defaultChecked: string;
72
+ state: string;
73
+ }>({
74
+ defaultChecked,
75
+ state: defaultChecked,
76
+ });
77
+
78
+ useEffect(() => {
79
+ if (promoCardState.defaultChecked !== defaultChecked) {
80
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing defaultChecked prop to internal state
81
+ setPromoCardState({
82
+ defaultChecked,
83
+ state: defaultChecked,
84
+ });
85
+ }
86
+ }, [defaultChecked, promoCardState.defaultChecked]);
87
+
88
+ const { state } = promoCardState;
89
+ const setState = (newState: string) =>
90
+ setPromoCardState((prev) => ({ ...prev, state: newState }));
91
+
92
+ // Derive container role from children
93
+ const containerRole = useMemo(() => {
94
+ // Collect an array of types from the children PromoCard components
95
+ const types =
96
+ React.Children.map(children, (child) => {
97
+ if (React.isValidElement<PromoCardProps>(child) && child.props.type) {
98
+ return child.props.type;
99
+ }
100
+ return null;
101
+ })?.filter((type): type is 'radio' | 'checkbox' => type !== null && type !== undefined) ?? [];
102
+
103
+ // Check if all types are the same
104
+ const allTypesAreTheSame = types.every((type) => type === types[0]);
105
+
106
+ // If all types are the same and the type is 'radio', return 'radiogroup'
107
+ return allTypesAreTheSame && types[0] === 'radio' ? 'radiogroup' : null;
108
+ }, [children]);
72
109
 
73
110
  /**
74
111
  * The context value for the PromoCardGroup.
@@ -103,25 +140,6 @@ const PromoCardGroup: FunctionComponent<PromoCardGroupProps> = ({
103
140
  role: containerRole as AriaRoleRadioGroup | undefined, // Add the role attribute here
104
141
  };
105
142
 
106
- useEffect(() => {
107
- setState(defaultChecked);
108
-
109
- // Collect an array of types from the children PromoCard components
110
- const types =
111
- React.Children.map(children, (child) => {
112
- if (React.isValidElement<PromoCardProps>(child) && child.props.type) {
113
- return child.props.type;
114
- }
115
- return null;
116
- })?.filter((type): type is 'radio' | 'checkbox' => type !== null && type !== undefined) ?? [];
117
-
118
- // Check if all types are the same
119
- const allTypesAreTheSame = types.every((type) => type === types[0]);
120
-
121
- // If all types are the same and the type is 'radio', set the container role
122
- setContainerRole(allTypesAreTheSame && types[0] === 'radio' ? 'radiogroup' : null);
123
- }, [defaultChecked, children]);
124
-
125
143
  return (
126
144
  <PromoCardContext.Provider value={contextValue}>
127
145
  <div {...commonProps}>{children}</div>
@@ -188,4 +188,29 @@ describe('SegmentedControl', () => {
188
188
  'SegmentedControl only supports up to 3 segments. Please refer to: https://wise.design/components/segmented-control',
189
189
  );
190
190
  });
191
+
192
+ describe('animation behavior', () => {
193
+ it('skips animation on initial render', () => {
194
+ renderSegmentedControl();
195
+
196
+ // On initial render, the component should render without triggering animation
197
+ // The isMountedRef ensures animation is skipped on first render
198
+ const segmentedControl = screen.getByTestId('segmented-control');
199
+
200
+ // Verify the component renders successfully
201
+ expect(segmentedControl).toBeInTheDocument();
202
+ });
203
+
204
+ it('enables animation after value change', async () => {
205
+ renderSegmentedControl();
206
+
207
+ // Change value by clicking on a different segment
208
+ const payroll = screen.getByRole('radio', { name: 'Payroll' });
209
+ await userEvent.click(payroll);
210
+
211
+ // After a value change, the component should have completed at least one update cycle
212
+ // verifying that the isMountedRef tracking works correctly
213
+ expect(onChange).toHaveBeenCalledWith('payroll');
214
+ });
215
+ });
191
216
  });
@@ -38,6 +38,7 @@ const SegmentedControl = ({
38
38
  segments,
39
39
  onChange,
40
40
  }: SegmentedControlProps) => {
41
+ const isMountedRef = useRef(false);
41
42
  const [animate, setAnimate] = useState(false);
42
43
 
43
44
  const segmentsRef = useRef<HTMLDivElement>(null);
@@ -67,7 +68,12 @@ const SegmentedControl = ({
67
68
  };
68
69
 
69
70
  useEffect(() => {
70
- setAnimate(true);
71
+ if (isMountedRef.current) {
72
+ setAnimate(true);
73
+ } else {
74
+ isMountedRef.current = true;
75
+ }
76
+
71
77
  updateSegmentPosition();
72
78
 
73
79
  const handleWindowSizeChange = () => {
@@ -43,7 +43,7 @@ const ImageIcon = () => (
43
43
  );
44
44
 
45
45
  const isSelectOptionItem = (option: SelectItem | null): option is SelectOptionItem => {
46
- return option !== null && typeof option.value !== 'undefined';
46
+ return typeof option?.value !== 'undefined';
47
47
  };
48
48
 
49
49
  export const Basic: Story = {
@@ -8,7 +8,17 @@
8
8
 
9
9
  /* DEPRECATED: use .np-text-*-title instead */
10
10
  /* stylelint-disable-next-line selector-list-comma-newline-after */
11
- .h1, .h2, .h3, .h4, .h5, .h6, .title-1, .title-2, .title-3, .title-4, .title-5,
11
+ .h1,
12
+ .h2,
13
+ .h3,
14
+ .h4,
15
+ .h5,
16
+ .h6,
17
+ .title-1,
18
+ .title-2,
19
+ .title-3,
20
+ .title-4,
21
+ .title-5,
12
22
  h1,
13
23
  h2,
14
24
  h3,
@@ -114,8 +124,12 @@ h6,
114
124
 
115
125
  /* DEPRECATED: use .np-text-body-default instead */
116
126
  /* stylelint-disable-next-line selector-list-comma-newline-after */
117
- .body-2, .body-3, .small, .tiny,
118
- body, small,
127
+ .body-2,
128
+ .body-3,
129
+ .small,
130
+ .tiny,
131
+ body,
132
+ small,
119
133
  .np-text-body-default {
120
134
  font-size: var(--font-size-14);
121
135
  line-height: 155%;
@@ -139,7 +153,8 @@ body, small,
139
153
 
140
154
  /* DEPRECATED: use .np-text-body-large instead */
141
155
  /* stylelint-disable-next-line selector-list-comma-newline-after */
142
- .body-1, .value,
156
+ .body-1,
157
+ .value,
143
158
  .np-text-body-large {
144
159
  font-weight: var(--font-weight-regular);
145
160
  font-size: var(--font-size-16);
@@ -204,8 +219,11 @@ a,
204
219
  .np-text-display-large,
205
220
  .np-text-display-medium,
206
221
  .np-text-display-small {
207
- font-family: var(--font-family-display);
208
- font-synthesis: none;
222
+ &,
223
+ &--forced {
224
+ font-family: var(--font-family-display);
225
+ font-synthesis: none;
226
+ }
209
227
 
210
228
  :lang(ja) &,
211
229
  :lang(th) &,
@@ -216,8 +234,12 @@ a,
216
234
  * of Japanese ones for the logged out ones (exposed by the Editorial DS). Unfortunately,
217
235
  * font files are browser-cached and we carried over to launchpad, where it causes issues
218
236
  * for unsupported locales, especially those that share glyphs, like Japanese and Chinese.
237
+ * There are exceptions for small UI parts where Wise Sans is fine or expected — e.g. the
238
+ * numeric input of ExpressiveMoneyInput.
239
+ * Add `--forced` BEM modifier to the original class name to guarantee it.
219
240
  */
220
241
  font-family: var(--font-family-regular);
242
+ line-height: var(--line-height-title);
221
243
  }
222
244
  }
223
245
 
@@ -3238,7 +3238,16 @@ a,
3238
3238
  .np-text-display-extra-large,
3239
3239
  .np-text-display-large,
3240
3240
  .np-text-display-medium,
3241
- .np-text-display-small {
3241
+ .np-text-display-small,
3242
+ .display-1--forced,
3243
+ .display-2--forced,
3244
+ .display-3--forced,
3245
+ .display-4--forced,
3246
+ .display-5--forced,
3247
+ .np-text-display-extra-large--forced,
3248
+ .np-text-display-large--forced,
3249
+ .np-text-display-medium--forced,
3250
+ .np-text-display-small--forced {
3242
3251
  font-family: 'Wise Sans', 'Inter', sans-serif;
3243
3252
  font-family: var(--font-family-display);
3244
3253
  font-synthesis: none;
@@ -3285,9 +3294,14 @@ a,
3285
3294
  * of Japanese ones for the logged out ones (exposed by the Editorial DS). Unfortunately,
3286
3295
  * font files are browser-cached and we carried over to launchpad, where it causes issues
3287
3296
  * for unsupported locales, especially those that share glyphs, like Japanese and Chinese.
3297
+ * There are exceptions for small UI parts where Wise Sans is fine or expected — e.g. the
3298
+ * numeric input of ExpressiveMoneyInput.
3299
+ * Add `--forced` BEM modifier to the original class name to guarantee it.
3288
3300
  */
3289
3301
  font-family: 'Inter', Helvetica, Arial, sans-serif;
3290
3302
  font-family: var(--font-family-regular);
3303
+ line-height: 1.2;
3304
+ line-height: var(--line-height-title);
3291
3305
  }
3292
3306
 
3293
3307
  /* DEPRECATED(.np-text-display-extra-large): use .np-text-display-large instead */
package/src/tabs/Tabs.tsx CHANGED
@@ -254,7 +254,7 @@ export default class Tabs extends Component<TabsProps, TabsState> {
254
254
  };
255
255
 
256
256
  onKeyDown = (index: number) => (event: React.KeyboardEvent<HTMLLIElement>) => {
257
- if (event && event.key === 'Enter') {
257
+ if (event?.key === 'Enter') {
258
258
  this.switchTab(index);
259
259
  }
260
260
  };
@@ -21,6 +21,7 @@ export const Basic: Story = {
21
21
  <TextareaWithDisplayFormat
22
22
  id={id}
23
23
  value="0000"
24
+ maxLength={20}
24
25
  displayPattern="**** - **** - ****"
25
26
  onChange={console.log}
26
27
  />
@@ -51,6 +51,7 @@ const Tooltip = ({
51
51
  middleware: [
52
52
  offset(16),
53
53
  flip({ fallbackPlacements: [Position.TOP] }),
54
+ // eslint-disable-next-line react-hooks/refs -- arrowRef is passed to floating-ui middleware, legitimate API usage
54
55
  arrowMiddleware({ element: arrowRef, padding: 8 }),
55
56
  ],
56
57
  whileElementsMounted: open ? autoUpdate : undefined,
@@ -63,6 +64,7 @@ const Tooltip = ({
63
64
  };
64
65
 
65
66
  return (
67
+ /* eslint-disable react-hooks/refs -- setReference and setFloating are callback refs from floating-ui, safe to pass during render */
66
68
  <span
67
69
  ref={refs.setReference}
68
70
  className="tw-tooltip-container"
@@ -96,6 +98,7 @@ const Tooltip = ({
96
98
  </div>
97
99
  </div>
98
100
  </span>
101
+ /* eslint-enable react-hooks/refs */
99
102
  );
100
103
  };
101
104
 
@@ -66,6 +66,25 @@ describe('UploadInput', () => {
66
66
  jest.useRealTimers();
67
67
  });
68
68
 
69
+ describe('onFilesChange mount behavior', () => {
70
+ it('should not call onFilesChange on initial mount', () => {
71
+ const onFilesChange = jest.fn();
72
+ renderComponent({
73
+ ...props,
74
+ files: [
75
+ {
76
+ id: 1,
77
+ filename: 'existing-file.pdf',
78
+ status: Status.SUCCEEDED,
79
+ },
80
+ ],
81
+ onFilesChange,
82
+ });
83
+
84
+ expect(onFilesChange).not.toHaveBeenCalled();
85
+ });
86
+ });
87
+
69
88
  describe('single file upload', () => {
70
89
  it('should trigger onUploadFiles & onFilesChange with a single FormData entry containing `file` field', async () => {
71
90
  const date = Date.now();