@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
@@ -1,9 +1,14 @@
1
1
  import { clsx } from 'clsx';
2
- import { useId, useRef } from 'react';
2
+ import { useCallback, useId, useRef, useState } from 'react';
3
+ import { useIntl } from 'react-intl';
3
4
 
5
+ import Body from '../body';
4
6
  import { Sentiment } from '../common';
7
+ import messages from './Field.messages';
5
8
  import { InlinePrompt, type InlinePromptProps } from '../prompt';
6
9
  import {
10
+ TextareaCharacterCountProvider,
11
+ type TextareaCharacterCountState,
7
12
  FieldLabelContextProvider,
8
13
  InputDescribedByProvider,
9
14
  InputIdContextProvider,
@@ -54,6 +59,7 @@ export const Field = ({
54
59
  children,
55
60
  ...props
56
61
  }: FieldProps) => {
62
+ const { formatMessage } = useIntl();
57
63
  const labelRef = useRef<HTMLLabelElement>(null);
58
64
  const sentiment = props.error ? Sentiment.NEGATIVE : propType;
59
65
  const message = propMessage || props.error;
@@ -66,6 +72,18 @@ export const Field = ({
66
72
 
67
73
  const messageId = useId();
68
74
  const descriptionId = useId();
75
+ const textareaCharCounterId = useId();
76
+
77
+ const [textareaCharacterCount, setTextareaCharacterCount] =
78
+ useState<TextareaCharacterCountState>(null);
79
+ const handleTextareaCharacterCount = useCallback(
80
+ (state: TextareaCharacterCountState) => setTextareaCharacterCount(state),
81
+ [],
82
+ );
83
+
84
+ const isNearCharLimit =
85
+ textareaCharacterCount != null &&
86
+ textareaCharacterCount.current >= textareaCharacterCount.max * 0.8;
69
87
 
70
88
  /**
71
89
  * form control can have multiple messages to describe it,
@@ -79,6 +97,9 @@ export const Field = ({
79
97
  if (message) {
80
98
  messageIds.push(messageId);
81
99
  }
100
+ if (textareaCharacterCount) {
101
+ messageIds.push(textareaCharCounterId);
102
+ }
82
103
  return messageIds.length > 0 ? messageIds.join(' ') : undefined;
83
104
  }
84
105
 
@@ -87,43 +108,69 @@ export const Field = ({
87
108
  <InputIdContextProvider value={inputId}>
88
109
  <InputDescribedByProvider value={ariaDescribedbyByIds()}>
89
110
  <InputInvalidProvider value={hasError}>
90
- <div
91
- className={clsx(
92
- 'np-field form-group d-block',
93
- {
94
- 'has-success': sentiment === Sentiment.POSITIVE,
95
- 'has-warning': sentiment === Sentiment.WARNING,
96
- 'has-error': hasError,
97
- 'has-info': sentiment === Sentiment.NEUTRAL,
98
- },
99
- className,
100
- )}
101
- >
102
- {label != null ? (
103
- <>
104
- <Label ref={labelRef} id={labelId} htmlFor={inputId}>
105
- {required ? label : <Label.Optional>{label}</Label.Optional>}
106
- </Label>
107
- <Label.Description id={descriptionId}>{description}</Label.Description>
108
- <div className="np-field-control">{children}</div>
109
- </>
110
- ) : (
111
- children
112
- )}
111
+ <TextareaCharacterCountProvider value={handleTextareaCharacterCount}>
112
+ <div
113
+ className={clsx(
114
+ 'np-field form-group d-block',
115
+ {
116
+ 'has-success': sentiment === Sentiment.POSITIVE,
117
+ 'has-warning': sentiment === Sentiment.WARNING,
118
+ 'has-error': hasError,
119
+ 'has-info': sentiment === Sentiment.NEUTRAL,
120
+ },
121
+ className,
122
+ )}
123
+ >
124
+ {label != null ? (
125
+ <>
126
+ <Label ref={labelRef} id={labelId} htmlFor={inputId}>
127
+ {required ? label : <Label.Optional>{label}</Label.Optional>}
128
+ </Label>
129
+ <Label.Description id={descriptionId}>{description}</Label.Description>
130
+ <div className="np-field-control">{children}</div>
131
+ </>
132
+ ) : (
133
+ children
134
+ )}
113
135
 
114
- {message && (
115
- <InlinePrompt
116
- sentiment={sentiment}
117
- id={messageId}
118
- mediaLabel={messageIconLabel}
119
- className="np-field__prompt"
120
- loading={messageLoading}
121
- width="full"
122
- >
123
- {message}
124
- </InlinePrompt>
125
- )}
126
- </div>
136
+ {(message || textareaCharacterCount) && (
137
+ <div className="np-field-validation">
138
+ {message && (
139
+ <InlinePrompt
140
+ sentiment={sentiment}
141
+ id={messageId}
142
+ mediaLabel={messageIconLabel}
143
+ className="np-field__prompt"
144
+ loading={messageLoading}
145
+ width="full"
146
+ >
147
+ {message}
148
+ </InlinePrompt>
149
+ )}
150
+ {textareaCharacterCount && (
151
+ <Body
152
+ as="span"
153
+ id={textareaCharCounterId}
154
+ {...(isNearCharLimit
155
+ ? {
156
+ role: 'status' as const,
157
+ 'aria-live': 'polite' as const,
158
+ 'aria-atomic': 'true' as const,
159
+ }
160
+ : {})}
161
+ aria-label={formatMessage(messages.characterCount, {
162
+ current: textareaCharacterCount.current,
163
+ max: textareaCharacterCount.max,
164
+ })}
165
+ className="np-field-textarea-char-counter"
166
+ >
167
+ {textareaCharacterCount.current}/{textareaCharacterCount.max}
168
+ </Body>
169
+ )}
170
+ </div>
171
+ )}
172
+ </div>
173
+ </TextareaCharacterCountProvider>
127
174
  </InputInvalidProvider>
128
175
  </InputDescribedByProvider>
129
176
  </InputIdContextProvider>
@@ -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>
package/src/i18n/en.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "neptune.Expander.expandAriaLabel": "Expand",
21
21
  "neptune.ExpressiveMoneyInput.currency.search.placeholder": "Type a currency / country",
22
22
  "neptune.ExpressiveMoneyInput.currency.select.currency": "Select currency",
23
+ "neptune.Field.characterCount": "{current} of {max} characters used",
23
24
  "neptune.FlowNavigation.back": "back to previous step",
24
25
  "neptune.Info.ariaLabel": "More information",
25
26
  "neptune.Label.optional": "(Optional)",
@@ -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
  );
@@ -0,0 +1,97 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Meta, StoryObj } from '@storybook/react-webpack5';
3
+
4
+ import { Field } from '../field/Field';
5
+ import { Sentiment } from '../common';
6
+ import { TextArea } from './TextArea';
7
+
8
+ /**
9
+ * TextArea must be the only input with `maxLength` inside a given Field.
10
+ * The character counter uses shared context β€” multiple TextAreas with maxLength
11
+ * in the same Field will race to set the counter state.
12
+ */
13
+ const meta: Meta<typeof TextArea> = {
14
+ title: 'Forms/TextArea',
15
+ component: TextArea,
16
+ argTypes: {
17
+ value: {
18
+ control: 'text',
19
+ },
20
+ maxLength: {
21
+ control: 'number',
22
+ },
23
+ placeholder: {
24
+ control: 'text',
25
+ },
26
+ disabled: {
27
+ control: 'boolean',
28
+ },
29
+ rows: {
30
+ control: 'number',
31
+ },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof TextArea>;
37
+
38
+ /** Explore all props via the controls panel. */
39
+ export const Playground: Story = {
40
+ args: {
41
+ value: '',
42
+ maxLength: 200,
43
+ placeholder: 'Type something...',
44
+ },
45
+ render: (args) => {
46
+ const [value, setValue] = useState(args.value ?? '');
47
+ useEffect(() => setValue(args.value ?? ''), [args.value]);
48
+
49
+ return (
50
+ <Field label="Message" required={false}>
51
+ <TextArea {...args} value={value} onChange={({ target }) => setValue(target.value)} />
52
+ </Field>
53
+ );
54
+ },
55
+ };
56
+
57
+ export const Basic: Story = {
58
+ render: () => {
59
+ const [value, setValue] = useState('');
60
+
61
+ return (
62
+ <Field label="Message" required={false}>
63
+ <TextArea maxLength={200} value={value} onChange={({ target }) => setValue(target.value)} />
64
+ </Field>
65
+ );
66
+ },
67
+ parameters: {
68
+ docs: {
69
+ source: {
70
+ code: `<Field label="Message" required={false}>
71
+ <TextArea
72
+ maxLength={200}
73
+ value={value}
74
+ onChange={({ target }) => setValue(target.value)}
75
+ />
76
+ </Field>`,
77
+ },
78
+ },
79
+ },
80
+ };
81
+
82
+ export const WithError: Story = {
83
+ render: () => {
84
+ const [value, setValue] = useState('');
85
+
86
+ return (
87
+ <Field
88
+ label="Message"
89
+ required={false}
90
+ sentiment={Sentiment.NEGATIVE}
91
+ message="You have exceeded the character limit"
92
+ >
93
+ <TextArea maxLength={200} value={value} onChange={({ target }) => setValue(target.value)} />
94
+ </Field>
95
+ );
96
+ },
97
+ };
@@ -0,0 +1,142 @@
1
+ import { useState } from 'react';
2
+ import { userEvent, within } from 'storybook/test';
3
+ import { Meta, StoryObj } from '@storybook/react-webpack5';
4
+
5
+ import { Field } from '../field/Field';
6
+ import { TextArea } from './TextArea';
7
+
8
+ const meta: Meta<typeof TextArea> = {
9
+ title: 'Forms/TextArea/Tests',
10
+ component: TextArea,
11
+ tags: ['!autodocs', '!manifest'],
12
+ };
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof TextArea>;
16
+
17
+ export const AsciiCharacters: Story = {
18
+ name: 'counter at limit with ASCII input',
19
+ render: () => {
20
+ const [value, setValue] = useState('');
21
+
22
+ return (
23
+ <Field label="Message" required={false}>
24
+ <TextArea maxLength={10} value={value} onChange={({ target }) => setValue(target.value)} />
25
+ </Field>
26
+ );
27
+ },
28
+ play: async ({ canvasElement }) => {
29
+ const canvas = within(canvasElement);
30
+ await userEvent.type(canvas.getByRole('textbox'), '0123456789');
31
+ },
32
+ };
33
+
34
+ export const EmojiCharacters: Story = {
35
+ name: 'counter with emoji input',
36
+ render: () => {
37
+ const [value, setValue] = useState('');
38
+
39
+ return (
40
+ <Field label="Message" required={false}>
41
+ <TextArea maxLength={10} value={value} onChange={({ target }) => setValue(target.value)} />
42
+ </Field>
43
+ );
44
+ },
45
+ play: async ({ canvasElement }) => {
46
+ const canvas = within(canvasElement);
47
+ const textarea = canvas.getByRole('textbox');
48
+ await userEvent.click(textarea);
49
+ await userEvent.paste('πŸ±πŸ’•πŸ±πŸ’•');
50
+ },
51
+ };
52
+
53
+ export const EmojiAtLimit: Story = {
54
+ name: 'truncates emoji paste exceeding limit',
55
+ render: () => {
56
+ const [value, setValue] = useState('');
57
+
58
+ return (
59
+ <Field label="Message" required={false}>
60
+ <TextArea maxLength={10} value={value} onChange={({ target }) => setValue(target.value)} />
61
+ </Field>
62
+ );
63
+ },
64
+ play: async ({ canvasElement }) => {
65
+ const canvas = within(canvasElement);
66
+ const textarea = canvas.getByRole('textbox');
67
+ await userEvent.click(textarea);
68
+ await userEvent.paste('πŸ±πŸ’•πŸ±πŸ’•πŸ±πŸ’•πŸ±πŸ’•πŸ±πŸ’•πŸ±πŸ’•πŸ±πŸ’•');
69
+ },
70
+ };
71
+
72
+ export const CJKCharacters: Story = {
73
+ name: 'truncates CJK paste exceeding limit',
74
+ render: () => {
75
+ const [value, setValue] = useState('');
76
+
77
+ return (
78
+ <Field label="Message" required={false}>
79
+ <TextArea maxLength={10} value={value} onChange={({ target }) => setValue(target.value)} />
80
+ </Field>
81
+ );
82
+ },
83
+ play: async ({ canvasElement }) => {
84
+ const canvas = within(canvasElement);
85
+ const textarea = canvas.getByRole('textbox');
86
+ await userEvent.click(textarea);
87
+ await userEvent.paste('ε‰π£˜Ίε‰π£˜Ίε‰π£˜Ίε‰π£˜Ίε‰π£˜Ίε‰π£˜Ί');
88
+ },
89
+ };
90
+
91
+ export const InitialValueExceedsLimit: Story = {
92
+ name: 'initial value exceeding maxLength is preserved',
93
+ render: () => {
94
+ const [value, setValue] = useState('Hello World!');
95
+
96
+ return (
97
+ <Field label="Message" required={false}>
98
+ <TextArea maxLength={3} value={value} onChange={({ target }) => setValue(target.value)} />
99
+ </Field>
100
+ );
101
+ },
102
+ };
103
+
104
+ export const EmojiTypingBlocked: Story = {
105
+ name: 'blocks emoji paste when already at limit',
106
+ render: () => {
107
+ const [value, setValue] = useState('');
108
+
109
+ return (
110
+ <Field label="Message" required={false}>
111
+ <TextArea maxLength={3} value={value} onChange={({ target }) => setValue(target.value)} />
112
+ </Field>
113
+ );
114
+ },
115
+ play: async ({ canvasElement }) => {
116
+ const canvas = within(canvasElement);
117
+ const textarea = canvas.getByRole('textbox');
118
+ await userEvent.click(textarea);
119
+ await userEvent.paste('πŸ±πŸ’•');
120
+ await userEvent.paste('πŸŽ‰πŸŽ‰πŸŽ‰');
121
+ },
122
+ };
123
+
124
+ export const CJKTypingBlocked: Story = {
125
+ name: 'blocks CJK paste when already at limit',
126
+ render: () => {
127
+ const [value, setValue] = useState('');
128
+
129
+ return (
130
+ <Field label="Message" required={false}>
131
+ <TextArea maxLength={3} value={value} onChange={({ target }) => setValue(target.value)} />
132
+ </Field>
133
+ );
134
+ },
135
+ play: async ({ canvasElement }) => {
136
+ const canvas = within(canvasElement);
137
+ const textarea = canvas.getByRole('textbox');
138
+ await userEvent.click(textarea);
139
+ await userEvent.paste('ε‰π£˜Ί');
140
+ await userEvent.paste('ε‰π£˜Ίε‰π£˜Ίε‰π£˜Ί');
141
+ },
142
+ };