@transferwise/components 0.0.0-experimental-5f4dd6a → 0.0.0-experimental-cdd318f
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 +43 -0
- package/build/inputs/TextArea.js.map +1 -1
- package/build/inputs/TextArea.mjs +45 -2
- 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 +21 -3
- package/build/styles/field/Field.css +19 -3
- package/build/styles/main.css +21 -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/test-utils/index.d.ts +2 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/field/Field.css +19 -3
- package/src/field/Field.less +17 -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 +93 -0
- package/src/inputs/TextArea.test.story.tsx +142 -0
- package/src/inputs/TextArea.tsx +54 -3
- package/src/inputs/contexts.tsx +18 -1
- package/src/main.css +21 -3
- package/src/textareaWithDisplayFormat/TextareaWithDisplayFormat.story.tsx +1 -0
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,93 @@
|
|
|
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
|
+
const meta: Meta<typeof TextArea> = {
|
|
9
|
+
title: 'Forms/TextArea',
|
|
10
|
+
component: TextArea,
|
|
11
|
+
argTypes: {
|
|
12
|
+
value: {
|
|
13
|
+
control: 'text',
|
|
14
|
+
name: 'value',
|
|
15
|
+
},
|
|
16
|
+
maxLength: {
|
|
17
|
+
control: 'number',
|
|
18
|
+
},
|
|
19
|
+
placeholder: {
|
|
20
|
+
control: 'text',
|
|
21
|
+
},
|
|
22
|
+
disabled: {
|
|
23
|
+
control: 'boolean',
|
|
24
|
+
},
|
|
25
|
+
rows: {
|
|
26
|
+
control: 'number',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof TextArea>;
|
|
33
|
+
|
|
34
|
+
/** Explore all props via the controls panel. */
|
|
35
|
+
export const Playground: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
value: '',
|
|
38
|
+
maxLength: 200,
|
|
39
|
+
placeholder: 'Type something...',
|
|
40
|
+
},
|
|
41
|
+
render: (args) => {
|
|
42
|
+
const [value, setValue] = useState(args.value ?? '');
|
|
43
|
+
useEffect(() => setValue(args.value ?? ''), [args.value]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Field label="Message" required={false}>
|
|
47
|
+
<TextArea {...args} value={value} onChange={({ target }) => setValue(target.value)} />
|
|
48
|
+
</Field>
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Basic: Story = {
|
|
54
|
+
render: () => {
|
|
55
|
+
const [value, setValue] = useState('');
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Field label="Message" required={false}>
|
|
59
|
+
<TextArea maxLength={10} value={value} onChange={({ target }) => setValue(target.value)} />
|
|
60
|
+
</Field>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
parameters: {
|
|
64
|
+
docs: {
|
|
65
|
+
source: {
|
|
66
|
+
code: `<Field label="Message" required={false}>
|
|
67
|
+
<TextArea
|
|
68
|
+
maxLength={200}
|
|
69
|
+
value={value}
|
|
70
|
+
onChange={({ target }) => setValue(target.value)}
|
|
71
|
+
/>
|
|
72
|
+
</Field>`,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const WithError: Story = {
|
|
79
|
+
render: () => {
|
|
80
|
+
const [value, setValue] = useState('');
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Field
|
|
84
|
+
label="Message"
|
|
85
|
+
required={false}
|
|
86
|
+
sentiment={Sentiment.NEGATIVE}
|
|
87
|
+
message="You have exceeded the character limit"
|
|
88
|
+
>
|
|
89
|
+
<TextArea maxLength={200} value={value} onChange={({ target }) => setValue(target.value)} />
|
|
90
|
+
</Field>
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -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: 'maxLength with ASCII characters (10/10)',
|
|
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: 'maxLength with emoji characters (grapheme clusters)',
|
|
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: 'maxLength enforcement stops input at grapheme 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: 'maxLength with CJK surrogate pair characters',
|
|
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: 'typing emoji beyond limit is blocked',
|
|
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: 'typing CJK surrogates beyond limit is blocked',
|
|
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
|
+
};
|
package/src/inputs/TextArea.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { clsx } from 'clsx';
|
|
2
|
-
import { forwardRef } from 'react';
|
|
2
|
+
import { forwardRef, useCallback } 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,68 @@ export interface TextAreaProps extends Merge<
|
|
|
13
13
|
> {}
|
|
14
14
|
|
|
15
15
|
export const TextArea = forwardRef(function TextArea(
|
|
16
|
-
{ className, ...restProps }: TextAreaProps,
|
|
16
|
+
{ className, maxLength, onChange, ...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);
|
|
24
|
+
|
|
25
|
+
const handleChange = useCallback(
|
|
26
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
27
|
+
if (maxLength != null) {
|
|
28
|
+
const truncated = truncateToGraphemes(e.target.value, maxLength);
|
|
29
|
+
if (truncated !== e.target.value) {
|
|
30
|
+
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call(
|
|
31
|
+
e.target,
|
|
32
|
+
truncated,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
onChange?.(e);
|
|
37
|
+
},
|
|
38
|
+
[maxLength, onChange],
|
|
39
|
+
);
|
|
20
40
|
|
|
21
41
|
return (
|
|
22
42
|
<textarea
|
|
23
43
|
ref={reference}
|
|
24
44
|
className={clsx(className, inputClassNameBase(), 'np-text-area')}
|
|
45
|
+
onChange={handleChange}
|
|
25
46
|
{...inputAttributes}
|
|
26
47
|
{...restProps}
|
|
27
48
|
/>
|
|
28
49
|
);
|
|
29
50
|
});
|
|
51
|
+
|
|
52
|
+
const segmenter =
|
|
53
|
+
typeof Intl.Segmenter === 'function'
|
|
54
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
function getGraphemeLength(str: string): number {
|
|
58
|
+
if (segmenter) {
|
|
59
|
+
let count = 0;
|
|
60
|
+
for (const _ of segmenter.segment(str)) {
|
|
61
|
+
count += 1;
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
return Array.from(str).length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function truncateToGraphemes(str: string, max: number): string {
|
|
69
|
+
if (segmenter) {
|
|
70
|
+
let result = '';
|
|
71
|
+
let count = 0;
|
|
72
|
+
for (const { segment } of segmenter.segment(str)) {
|
|
73
|
+
if (count >= max) break;
|
|
74
|
+
result += segment;
|
|
75
|
+
count += 1;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
return Array.from(str).slice(0, max).join('');
|
|
80
|
+
}
|
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
|
@@ -29935,18 +29935,36 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
|
|
|
29935
29935
|
stroke-dasharray: var(--wds-list-item-spotlight-strokeDashSize) var(--wds-list-item-spotlight-strokeDashSize);
|
|
29936
29936
|
}
|
|
29937
29937
|
|
|
29938
|
-
.np-field-control
|
|
29939
|
-
.np-field__prompt {
|
|
29938
|
+
.np-field-control {
|
|
29940
29939
|
margin-top: 4px;
|
|
29941
29940
|
margin-top: var(--size-4);
|
|
29942
29941
|
}
|
|
29943
29942
|
|
|
29943
|
+
.np-field-validation {
|
|
29944
|
+
display: flex;
|
|
29945
|
+
align-items: flex-start;
|
|
29946
|
+
margin-top: 4px;
|
|
29947
|
+
margin-top: var(--size-4);
|
|
29948
|
+
gap: 8px;
|
|
29949
|
+
gap: var(--size-8);
|
|
29950
|
+
}
|
|
29951
|
+
|
|
29952
|
+
.np-field-textarea-char-counter {
|
|
29953
|
+
min-width: 55px;
|
|
29954
|
+
text-align: right;
|
|
29955
|
+
margin-left: auto;
|
|
29956
|
+
padding: 4px 0;
|
|
29957
|
+
padding: var(--size-4) 0;
|
|
29958
|
+
color: #768e9c;
|
|
29959
|
+
color: var(--color-content-tertiary);
|
|
29960
|
+
}
|
|
29961
|
+
|
|
29944
29962
|
.np-field .form-group--typeahead[class],
|
|
29945
29963
|
.np-field .np-checkbox-label[class] {
|
|
29946
29964
|
margin-bottom: 0;
|
|
29947
29965
|
}
|
|
29948
29966
|
|
|
29949
|
-
.np-field:has(.wds-radio-group) .np-
|
|
29967
|
+
.np-field:has(.wds-radio-group) .np-field-validation {
|
|
29950
29968
|
margin-top: 12px;
|
|
29951
29969
|
margin-top: var(--size-12);
|
|
29952
29970
|
}
|