@transferwise/components 0.0.0-experimental-d087eac → 0.0.0-experimental-186b26b

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 (55) hide show
  1. package/build/field/Field.js +63 -32
  2. package/build/field/Field.js.map +1 -1
  3. package/build/field/Field.messages.js +14 -0
  4. package/build/field/Field.messages.js.map +1 -0
  5. package/build/field/Field.messages.mjs +10 -0
  6. package/build/field/Field.messages.mjs.map +1 -0
  7. package/build/field/Field.mjs +65 -34
  8. package/build/field/Field.mjs.map +1 -1
  9. package/build/i18n/en.json +1 -0
  10. package/build/i18n/en.json.js +1 -0
  11. package/build/i18n/en.json.js.map +1 -1
  12. package/build/i18n/en.json.mjs +1 -0
  13. package/build/i18n/en.json.mjs.map +1 -1
  14. package/build/inputs/TextArea.js +18 -0
  15. package/build/inputs/TextArea.js.map +1 -1
  16. package/build/inputs/TextArea.mjs +19 -1
  17. package/build/inputs/TextArea.mjs.map +1 -1
  18. package/build/inputs/contexts.js +16 -0
  19. package/build/inputs/contexts.js.map +1 -1
  20. package/build/inputs/contexts.mjs +16 -2
  21. package/build/inputs/contexts.mjs.map +1 -1
  22. package/build/main.css +19 -3
  23. package/build/moneyInput/MoneyInput.js +1 -2
  24. package/build/moneyInput/MoneyInput.js.map +1 -1
  25. package/build/moneyInput/MoneyInput.mjs +1 -2
  26. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  27. package/build/styles/field/Field.css +17 -3
  28. package/build/styles/main.css +19 -3
  29. package/build/types/field/Field.d.ts.map +1 -1
  30. package/build/types/field/Field.messages.d.ts +8 -0
  31. package/build/types/field/Field.messages.d.ts.map +1 -0
  32. package/build/types/inputs/TextArea.d.ts.map +1 -1
  33. package/build/types/inputs/contexts.d.ts +6 -0
  34. package/build/types/inputs/contexts.d.ts.map +1 -1
  35. package/build/types/moneyInput/MoneyInput.d.ts +0 -1
  36. package/build/types/moneyInput/MoneyInput.d.ts.map +1 -1
  37. package/build/types/test-utils/index.d.ts +2 -0
  38. package/build/types/test-utils/index.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/field/Field.css +17 -3
  41. package/src/field/Field.less +15 -3
  42. package/src/field/Field.messages.ts +8 -0
  43. package/src/field/Field.story.tsx +5 -1
  44. package/src/field/Field.test.tsx +90 -0
  45. package/src/field/Field.tsx +84 -37
  46. package/src/i18n/en.json +1 -0
  47. package/src/inputs/TextArea.story.tsx +56 -0
  48. package/src/inputs/TextArea.tsx +23 -2
  49. package/src/inputs/contexts.tsx +18 -1
  50. package/src/main.css +19 -3
  51. package/src/moneyInput/MoneyInput.story.tsx +0 -20
  52. package/src/moneyInput/MoneyInput.test.story.tsx +0 -30
  53. package/src/moneyInput/MoneyInput.test.tsx +0 -36
  54. package/src/moneyInput/MoneyInput.tsx +0 -2
  55. package/src/textareaWithDisplayFormat/TextareaWithDisplayFormat.story.tsx +1 -0
@@ -1 +1 @@
1
- {"version":3,"file":"MoneyInput.d.ts","sourceRoot":"","sources":["../../../src/moneyInput/MoneyInput.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAc,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAE/D,OAAO,EAGL,SAAS,EACT,UAAU,EACV,SAAS,EAEV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAuB,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAEnF,OAAO,EAKL,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAO/B,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,YAAY,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAoDnE,MAAM,WAAW,eAAgB,SAAQ,qBAAqB;IAC5D,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,SAAS,YAAY,EAAE,CAAC;IACpC,gBAAgB,EAAE,kBAAkB,CAAC;IACrC,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAC1C,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,YAAY,EAAE,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3F,iBAAiB,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAC5D;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,kCAAkC,GAAG,eAAe,GAC9D,OAAO,CAAC,wBAAwB,CAAC,CAAC;;;;;;;;AAqapC,wBAA2F"}
1
+ {"version":3,"file":"MoneyInput.d.ts","sourceRoot":"","sources":["../../../src/moneyInput/MoneyInput.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAc,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAE/D,OAAO,EAGL,SAAS,EACT,UAAU,EACV,SAAS,EAEV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAuB,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAEnF,OAAO,EAKL,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAO/B,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,YAAY,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAoDnE,MAAM,WAAW,eAAgB,SAAQ,qBAAqB;IAC5D,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,SAAS,YAAY,EAAE,CAAC;IACpC,gBAAgB,EAAE,kBAAkB,CAAC;IACrC,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAC1C,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,YAAY,EAAE,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3F,iBAAiB,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAC5D;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,kCAAkC,GAAG,eAAe,GAC9D,OAAO,CAAC,wBAAwB,CAAC,CAAC;;;;;;;;AAoapC,wBAA2F"}
@@ -30,6 +30,7 @@ declare function customRender(ui: ReactElement, { locale, messages, ...renderOpt
30
30
  "neptune.Expander.expandAriaLabel": string;
31
31
  "neptune.ExpressiveMoneyInput.currency.search.placeholder": string;
32
32
  "neptune.ExpressiveMoneyInput.currency.select.currency": string;
33
+ "neptune.Field.characterCount": string;
33
34
  "neptune.FlowNavigation.back": string;
34
35
  "neptune.Info.ariaLabel": string;
35
36
  "neptune.Label.optional": string;
@@ -117,6 +118,7 @@ declare function customRenderHook(callback: () => unknown, { locale, messages }?
117
118
  "neptune.Expander.expandAriaLabel": string;
118
119
  "neptune.ExpressiveMoneyInput.currency.search.placeholder": string;
119
120
  "neptune.ExpressiveMoneyInput.currency.select.currency": string;
121
+ "neptune.Field.characterCount": string;
120
122
  "neptune.FlowNavigation.back": string;
121
123
  "neptune.Info.ariaLabel": string;
122
124
  "neptune.Label.optional": string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/test-utils/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAM,MAAM,OAAO,CAAC;AACzC,OAAO,EAAE,MAAM,EAAc,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAMxD;;;;GAIG;AACH,iBAAS,YAAY,CACnB,EAAE,EAAE,YAAY,EAChB,EAAE,MAAuB,EAAE,QAAa,EAAE,GAAG,aAAa,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAK,GAChE,UAAU,CAAC,OAAO,MAAM,CAAC,CAK3B;AAED;;;GAGG;AACH,iBAAS,gBAAgB,CACvB,QAAQ,EAAE,MAAM,OAAO,EACvB,EAAE,MAAuB,EAAE,QAAa,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAK,uEAKhD;AAED,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,YAAY,IAAI,MAAM,EAAE,gBAAgB,IAAI,UAAU,EAAE,SAAS,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/test-utils/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAM,MAAM,OAAO,CAAC;AACzC,OAAO,EAAE,MAAM,EAAc,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAMxD;;;;GAIG;AACH,iBAAS,YAAY,CACnB,EAAE,EAAE,YAAY,EAChB,EAAE,MAAuB,EAAE,QAAa,EAAE,GAAG,aAAa,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAK,GAChE,UAAU,CAAC,OAAO,MAAM,CAAC,CAK3B;AAED;;;GAGG;AACH,iBAAS,gBAAgB,CACvB,QAAQ,EAAE,MAAM,OAAO,EACvB,EAAE,MAAuB,EAAE,QAAa,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAK,uEAKhD;AAED,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,YAAY,IAAI,MAAM,EAAE,gBAAgB,IAAI,UAAU,EAAE,SAAS,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transferwise/components",
3
- "version": "0.0.0-experimental-d087eac",
3
+ "version": "0.0.0-experimental-186b26b",
4
4
  "description": "Neptune React components",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,13 +1,27 @@
1
- .np-field-control,
2
- .np-field__prompt {
1
+ .np-field-control {
3
2
  margin-top: 4px;
4
3
  margin-top: var(--size-4);
5
4
  }
5
+ .np-field-validation {
6
+ display: flex;
7
+ align-items: flex-start;
8
+ margin-top: 4px;
9
+ margin-top: var(--size-4);
10
+ gap: 8px;
11
+ gap: var(--size-8);
12
+ }
13
+ .np-field-textarea-char-counter {
14
+ margin-left: auto;
15
+ padding: 4px 0;
16
+ padding: var(--size-4) 0;
17
+ color: #768e9c;
18
+ color: var(--color-content-tertiary);
19
+ }
6
20
  .np-field .form-group--typeahead[class],
7
21
  .np-field .np-checkbox-label[class] {
8
22
  margin-bottom: 0;
9
23
  }
10
- .np-field:has(.wds-radio-group) .np-field__prompt {
24
+ .np-field:has(.wds-radio-group) .np-field-validation {
11
25
  margin-top: 12px;
12
26
  margin-top: var(--size-12);
13
27
  }
@@ -1,16 +1,28 @@
1
1
  .np-field {
2
- &-control,
3
- &__prompt {
2
+ &-control {
4
3
  margin-top: var(--size-4);
5
4
  }
6
5
 
6
+ &-validation {
7
+ display: flex;
8
+ align-items: flex-start;
9
+ margin-top: var(--size-4);
10
+ gap: var(--size-8);
11
+ }
12
+
13
+ &-textarea-char-counter {
14
+ margin-left: auto;
15
+ padding: var(--size-4) 0;
16
+ color: var(--color-content-tertiary);
17
+ }
18
+
7
19
  // @FIXME space between individual fields should be 24px, while some older inputs
8
20
  // inject extraneous space.
9
21
  .form-group--typeahead[class],
10
22
  .np-checkbox-label[class] {
11
23
  margin-bottom: 0;
12
24
  }
13
- &:has(.wds-radio-group) &__prompt {
25
+ &:has(.wds-radio-group) &-validation {
14
26
  margin-top: var(--size-12);
15
27
  }
16
28
  }
@@ -0,0 +1,8 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ export default defineMessages({
4
+ characterCount: {
5
+ id: 'neptune.Field.characterCount',
6
+ defaultMessage: '{current} of {max} characters used',
7
+ },
8
+ });
@@ -84,7 +84,11 @@ export const Basic = (args: FieldProps) => {
84
84
  messageIconLabel={args.messageIconLabel}
85
85
  messageLoading={args.messageLoading}
86
86
  >
87
- <TextArea />
87
+ <TextArea
88
+ maxLength={200}
89
+ value={value}
90
+ onChange={({ target }) => setValue(target.value)}
91
+ />
88
92
  </Field>
89
93
 
90
94
  <Field
@@ -1,5 +1,6 @@
1
1
  import Info from '../info/Info';
2
2
  import { Input } from '../inputs/Input';
3
+ import { TextArea } from '../inputs/TextArea';
3
4
  import { mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
5
 
5
6
  import { Field } from './Field';
@@ -163,4 +164,93 @@ describe('Field', () => {
163
164
  expect(screen.getByTestId('InlinePrompt_ProcessIndicator')).toBeInTheDocument();
164
165
  expect(screen.getByText('Processing your request')).toBeInTheDocument();
165
166
  });
167
+
168
+ describe('TextArea character count', () => {
169
+ it('renders counter when TextArea has maxLength', () => {
170
+ render(
171
+ <Field label="Message">
172
+ <TextArea maxLength={200} value="hello" onChange={() => {}} />
173
+ </Field>,
174
+ );
175
+
176
+ expect(screen.getByText('5/200')).toBeInTheDocument();
177
+ });
178
+
179
+ it('does not render counter when TextArea has no maxLength', () => {
180
+ render(
181
+ <Field label="Message">
182
+ <TextArea value="hello" onChange={() => {}} />
183
+ </Field>,
184
+ );
185
+
186
+ expect(screen.queryByText(/\/\d+/)).not.toBeInTheDocument();
187
+ });
188
+
189
+ it('includes counter id in aria-describedby of the textarea', () => {
190
+ render(
191
+ <Field label="Message">
192
+ <TextArea maxLength={200} value="hello" onChange={() => {}} />
193
+ </Field>,
194
+ );
195
+
196
+ const textarea = screen.getByRole('textbox');
197
+ const counter = screen.getByText('5/200');
198
+ expect(textarea).toHaveAttribute('aria-describedby', expect.stringContaining(counter.id));
199
+ });
200
+
201
+ it('does not have role=status below 80% threshold', () => {
202
+ render(
203
+ <Field label="Message">
204
+ <TextArea maxLength={200} value="short" onChange={() => {}} />
205
+ </Field>,
206
+ );
207
+
208
+ const counter = screen.getByText('5/200');
209
+ expect(counter).not.toHaveAttribute('role');
210
+ expect(counter).not.toHaveAttribute('aria-live');
211
+ });
212
+
213
+ it('has role=status and aria-live=polite at 80% threshold', () => {
214
+ const value = 'x'.repeat(160);
215
+ render(
216
+ <Field label="Message">
217
+ <TextArea maxLength={200} value={value} onChange={() => {}} />
218
+ </Field>,
219
+ );
220
+
221
+ const counter = screen.getByText('160/200');
222
+ expect(counter).toHaveAttribute('role', 'status');
223
+ expect(counter).toHaveAttribute('aria-live', 'polite');
224
+ expect(counter).toHaveAttribute('aria-atomic', 'true');
225
+ });
226
+
227
+ it('updates counter when text changes', () => {
228
+ const { rerender } = render(
229
+ <Field label="Message">
230
+ <TextArea maxLength={200} value="hi" onChange={() => {}} />
231
+ </Field>,
232
+ );
233
+
234
+ expect(screen.getByText('2/200')).toBeInTheDocument();
235
+
236
+ rerender(
237
+ <Field label="Message">
238
+ <TextArea maxLength={200} value="hello world" onChange={() => {}} />
239
+ </Field>,
240
+ );
241
+
242
+ expect(screen.getByText('11/200')).toBeInTheDocument();
243
+ });
244
+
245
+ it('provides accessible aria-label on the counter', () => {
246
+ render(
247
+ <Field label="Message">
248
+ <TextArea maxLength={200} value="hello" onChange={() => {}} />
249
+ </Field>,
250
+ );
251
+
252
+ const counter = screen.getByText('5/200');
253
+ expect(counter).toHaveAttribute('aria-label', '5 of 200 characters used');
254
+ });
255
+ });
166
256
  });
@@ -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>
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)",
@@ -0,0 +1,56 @@
1
+ import { 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
+ const meta: Meta<typeof TextArea> = {
9
+ title: 'Forms/TextArea',
10
+ component: TextArea,
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof TextArea>;
15
+
16
+ export const Basic: Story = {
17
+ render: () => {
18
+ const [value, setValue] = useState('');
19
+
20
+ return (
21
+ <Field label="Message" required={false}>
22
+ <TextArea maxLength={200} value={value} onChange={({ target }) => setValue(target.value)} />
23
+ </Field>
24
+ );
25
+ },
26
+ parameters: {
27
+ docs: {
28
+ source: {
29
+ code: `<Field label="Message" required={false}>
30
+ <TextArea
31
+ maxLength={200}
32
+ value={value}
33
+ onChange={({ target }) => setValue(target.value)}
34
+ />
35
+ </Field>`,
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export const WithError: Story = {
42
+ render: () => {
43
+ const [value, setValue] = useState('');
44
+
45
+ return (
46
+ <Field
47
+ label="Message"
48
+ required={false}
49
+ sentiment={Sentiment.NEGATIVE}
50
+ message="You have exceeded the character limit"
51
+ >
52
+ <TextArea maxLength={200} value={value} onChange={({ target }) => setValue(target.value)} />
53
+ </Field>
54
+ );
55
+ },
56
+ };
@@ -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,17 +13,38 @@ 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 = getGraphemeLength(typeof value === 'string' ? value : String(value));
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
  />
28
33
  );
29
34
  });
35
+
36
+ const segmenter =
37
+ typeof Intl.Segmenter === 'function'
38
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
39
+ : undefined;
40
+
41
+ function getGraphemeLength(str: string): number {
42
+ if (segmenter) {
43
+ let count = 0;
44
+ for (const _ of segmenter.segment(str)) {
45
+ count += 1;
46
+ }
47
+ return count;
48
+ }
49
+ return Array.from(str).length;
50
+ }
@@ -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
@@ -30193,18 +30193,34 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
30193
30193
  stroke-dasharray: var(--wds-list-item-spotlight-strokeDashSize) var(--wds-list-item-spotlight-strokeDashSize);
30194
30194
  }
30195
30195
 
30196
- .np-field-control,
30197
- .np-field__prompt {
30196
+ .np-field-control {
30198
30197
  margin-top: 4px;
30199
30198
  margin-top: var(--size-4);
30200
30199
  }
30201
30200
 
30201
+ .np-field-validation {
30202
+ display: flex;
30203
+ align-items: flex-start;
30204
+ margin-top: 4px;
30205
+ margin-top: var(--size-4);
30206
+ gap: 8px;
30207
+ gap: var(--size-8);
30208
+ }
30209
+
30210
+ .np-field-textarea-char-counter {
30211
+ margin-left: auto;
30212
+ padding: 4px 0;
30213
+ padding: var(--size-4) 0;
30214
+ color: #768e9c;
30215
+ color: var(--color-content-tertiary);
30216
+ }
30217
+
30202
30218
  .np-field .form-group--typeahead[class],
30203
30219
  .np-field .np-checkbox-label[class] {
30204
30220
  margin-bottom: 0;
30205
30221
  }
30206
30222
 
30207
- .np-field:has(.wds-radio-group) .np-field__prompt {
30223
+ .np-field:has(.wds-radio-group) .np-field-validation {
30208
30224
  margin-top: 12px;
30209
30225
  margin-top: var(--size-12);
30210
30226
  }
@@ -164,26 +164,6 @@ export const WithDecimals: Story = {
164
164
  },
165
165
  };
166
166
 
167
- export const DisabledOptions: Story = {
168
- args: {
169
- currencies: [
170
- { header: 'Popular currencies' },
171
- exampleCurrency.eur,
172
- exampleCurrency.gbp,
173
- { ...exampleCurrency.usd, disabled: true },
174
- { header: 'All currencies' },
175
- exampleCurrency.aud,
176
- { ...exampleCurrency.cny, disabled: true },
177
- exampleCurrency.jpy,
178
- ],
179
- selectedCurrency: exampleCurrency.eur,
180
- },
181
- play: async ({ canvasElement }) => {
182
- const canvas = within(canvasElement);
183
- await userEvent.click(canvas.getByRole('combobox'));
184
- },
185
- };
186
-
187
167
  export const OpenedInput: Story = {
188
168
  ...MultipleCurrencies,
189
169
  play: async ({ canvasElement }) => {
@@ -103,36 +103,6 @@ export const SmoothScrollReset: Story = {
103
103
  },
104
104
  };
105
105
 
106
- export const DisabledOptions: Story = {
107
- args: {
108
- currencies: [
109
- {
110
- value: 'EUR',
111
- label: 'EUR',
112
- note: 'Euro',
113
- currency: 'eur',
114
- },
115
- {
116
- value: 'USD',
117
- label: 'USD',
118
- note: 'United States dollar',
119
- currency: 'usd',
120
- disabled: true,
121
- },
122
- {
123
- value: 'GBP',
124
- label: 'GBP',
125
- note: 'British pound',
126
- currency: 'gbp',
127
- },
128
- ],
129
- },
130
- play: async ({ canvasElement }) => {
131
- const canvas = within(canvasElement);
132
- await userEvent.click(canvas.getByRole('combobox'));
133
- },
134
- };
135
-
136
106
  export const WithDecimals: Story = {
137
107
  args: {
138
108
  currencies: [],