@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.
- package/build/field/Field.js +63 -32
- package/build/field/Field.js.map +1 -1
- package/build/field/Field.messages.js +14 -0
- package/build/field/Field.messages.js.map +1 -0
- package/build/field/Field.messages.mjs +10 -0
- package/build/field/Field.messages.mjs.map +1 -0
- package/build/field/Field.mjs +65 -34
- package/build/field/Field.mjs.map +1 -1
- package/build/i18n/en.json +1 -0
- package/build/i18n/en.json.js +1 -0
- package/build/i18n/en.json.js.map +1 -1
- package/build/i18n/en.json.mjs +1 -0
- package/build/i18n/en.json.mjs.map +1 -1
- package/build/inputs/TextArea.js +18 -0
- package/build/inputs/TextArea.js.map +1 -1
- package/build/inputs/TextArea.mjs +19 -1
- package/build/inputs/TextArea.mjs.map +1 -1
- package/build/inputs/contexts.js +16 -0
- package/build/inputs/contexts.js.map +1 -1
- package/build/inputs/contexts.mjs +16 -2
- package/build/inputs/contexts.mjs.map +1 -1
- package/build/main.css +19 -3
- package/build/moneyInput/MoneyInput.js +1 -2
- package/build/moneyInput/MoneyInput.js.map +1 -1
- package/build/moneyInput/MoneyInput.mjs +1 -2
- package/build/moneyInput/MoneyInput.mjs.map +1 -1
- package/build/styles/field/Field.css +17 -3
- package/build/styles/main.css +19 -3
- package/build/types/field/Field.d.ts.map +1 -1
- package/build/types/field/Field.messages.d.ts +8 -0
- package/build/types/field/Field.messages.d.ts.map +1 -0
- package/build/types/inputs/TextArea.d.ts.map +1 -1
- package/build/types/inputs/contexts.d.ts +6 -0
- package/build/types/inputs/contexts.d.ts.map +1 -1
- package/build/types/moneyInput/MoneyInput.d.ts +0 -1
- package/build/types/moneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/test-utils/index.d.ts +2 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/field/Field.css +17 -3
- package/src/field/Field.less +15 -3
- package/src/field/Field.messages.ts +8 -0
- package/src/field/Field.story.tsx +5 -1
- package/src/field/Field.test.tsx +90 -0
- package/src/field/Field.tsx +84 -37
- package/src/i18n/en.json +1 -0
- package/src/inputs/TextArea.story.tsx +56 -0
- package/src/inputs/TextArea.tsx +23 -2
- package/src/inputs/contexts.tsx +18 -1
- package/src/main.css +19 -3
- package/src/moneyInput/MoneyInput.story.tsx +0 -20
- package/src/moneyInput/MoneyInput.test.story.tsx +0 -30
- package/src/moneyInput/MoneyInput.test.tsx +0 -36
- package/src/moneyInput/MoneyInput.tsx +0 -2
- 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;
|
|
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
|
|
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
package/src/field/Field.css
CHANGED
|
@@ -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-
|
|
24
|
+
.np-field:has(.wds-radio-group) .np-field-validation {
|
|
11
25
|
margin-top: 12px;
|
|
12
26
|
margin-top: var(--size-12);
|
|
13
27
|
}
|
package/src/field/Field.less
CHANGED
|
@@ -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)
|
|
25
|
+
&:has(.wds-radio-group) &-validation {
|
|
14
26
|
margin-top: var(--size-12);
|
|
15
27
|
}
|
|
16
28
|
}
|
|
@@ -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
|
package/src/field/Field.test.tsx
CHANGED
|
@@ -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
|
});
|
package/src/field/Field.tsx
CHANGED
|
@@ -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
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
};
|
package/src/inputs/TextArea.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/inputs/contexts.tsx
CHANGED
|
@@ -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-
|
|
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: [],
|