@transferwise/components 0.0.0-experimental-369191f β†’ 0.0.0-experimental-77bcd0d

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 (54) 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 +43 -0
  15. package/build/inputs/TextArea.js.map +1 -1
  16. package/build/inputs/TextArea.mjs +45 -2
  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 +74 -61
  23. package/build/styles/css/neptune.css +53 -58
  24. package/build/styles/field/Field.css +19 -3
  25. package/build/styles/less/neptune-tokens.less +2 -2
  26. package/build/styles/main.css +74 -61
  27. package/build/styles/props/neptune-tokens.css +1 -1
  28. package/build/styles/styles/less/core/viewport-themes.css +42 -46
  29. package/build/styles/styles/less/neptune.css +53 -58
  30. package/build/types/field/Field.d.ts.map +1 -1
  31. package/build/types/field/Field.messages.d.ts +8 -0
  32. package/build/types/field/Field.messages.d.ts.map +1 -0
  33. package/build/types/inputs/TextArea.d.ts.map +1 -1
  34. package/build/types/inputs/contexts.d.ts +6 -0
  35. package/build/types/inputs/contexts.d.ts.map +1 -1
  36. package/build/types/test-utils/index.d.ts +2 -0
  37. package/build/types/test-utils/index.d.ts.map +1 -1
  38. package/package.json +4 -4
  39. package/src/field/Field.css +19 -3
  40. package/src/field/Field.less +17 -3
  41. package/src/field/Field.messages.ts +8 -0
  42. package/src/field/Field.story.tsx +5 -1
  43. package/src/field/Field.test.tsx +90 -0
  44. package/src/field/Field.tsx +84 -37
  45. package/src/i18n/en.json +1 -0
  46. package/src/inputs/TextArea.story.tsx +56 -0
  47. package/src/inputs/TextArea.test.story.tsx +129 -0
  48. package/src/inputs/TextArea.tsx +54 -3
  49. package/src/inputs/contexts.tsx +18 -1
  50. package/src/main.css +74 -61
  51. package/src/styles/less/core/viewport-themes.css +42 -46
  52. package/src/styles/less/core/viewport-themes.less +45 -2
  53. package/src/styles/less/neptune.css +53 -58
  54. package/src/textareaWithDisplayFormat/TextareaWithDisplayFormat.story.tsx +1 -0
@@ -1,16 +1,30 @@
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
+ min-width: 55px;
15
+ text-align: right;
16
+ margin-left: auto;
17
+ padding: var(--size-4) 0;
18
+ color: var(--color-content-tertiary);
19
+ }
20
+
7
21
  // @FIXME space between individual fields should be 24px, while some older inputs
8
22
  // inject extraneous space.
9
23
  .form-group--typeahead[class],
10
24
  .np-checkbox-label[class] {
11
25
  margin-bottom: 0;
12
26
  }
13
- &:has(.wds-radio-group) &__prompt {
27
+ &:has(.wds-radio-group) &-validation {
14
28
  margin-top: var(--size-12);
15
29
  }
16
30
  }
@@ -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={10} 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
+ };
@@ -0,0 +1,129 @@
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 EmojiTypingBlocked: Story = {
92
+ name: 'typing emoji beyond limit is blocked',
93
+ render: () => {
94
+ const [value, setValue] = useState('');
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
+ play: async ({ canvasElement }) => {
103
+ const canvas = within(canvasElement);
104
+ const textarea = canvas.getByRole('textbox');
105
+ await userEvent.click(textarea);
106
+ await userEvent.paste('πŸ±πŸ’•');
107
+ await userEvent.paste('πŸŽ‰πŸŽ‰πŸŽ‰');
108
+ },
109
+ };
110
+
111
+ export const CJKTypingBlocked: Story = {
112
+ name: 'typing CJK surrogates beyond limit is blocked',
113
+ render: () => {
114
+ const [value, setValue] = useState('');
115
+
116
+ return (
117
+ <Field label="Message" required={false}>
118
+ <TextArea maxLength={3} value={value} onChange={({ target }) => setValue(target.value)} />
119
+ </Field>
120
+ );
121
+ },
122
+ play: async ({ canvasElement }) => {
123
+ const canvas = within(canvasElement);
124
+ const textarea = canvas.getByRole('textbox');
125
+ await userEvent.click(textarea);
126
+ await userEvent.paste('ε‰π£˜Ί');
127
+ await userEvent.paste('ε‰π£˜Ίε‰π£˜Ίε‰π£˜Ί');
128
+ },
129
+ };
@@ -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
+ }
@@ -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
  }