@scottish-government/designsystem-react 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/release-package.yml +96 -0
  3. package/@types/common/ConditionalWrapper.d.ts +6 -0
  4. package/@types/common/HintText.d.ts +6 -0
  5. package/@types/common/Icon.d.ts +11 -0
  6. package/@types/common/ScreenReaderText.d.ts +4 -0
  7. package/@types/common/WrapperTag.d.ts +5 -0
  8. package/@types/components/Accordion.d.ts +15 -0
  9. package/@types/components/AspectBox.d.ts +5 -0
  10. package/@types/components/BackToTop.d.ts +5 -0
  11. package/@types/components/Breadcrumbs.d.ts +14 -0
  12. package/@types/components/Button.d.ts +17 -0
  13. package/@types/components/Checkbox.d.ts +13 -0
  14. package/@types/components/ConfirmationMessage.d.ts +7 -0
  15. package/@types/components/ContentsNav.d.ts +15 -0
  16. package/@types/components/DatePicker.d.ts +19 -0
  17. package/@types/components/Details.d.ts +6 -0
  18. package/@types/components/ErrorMessage.d.ts +6 -0
  19. package/@types/components/Metadata.d.ts +11 -0
  20. package/@types/components/NotificationBanner.d.ts +9 -0
  21. package/@types/components/NotificationPanel.d.ts +7 -0
  22. package/@types/components/PageHeader.d.ts +6 -0
  23. package/@types/components/PhaseBanner.d.ts +5 -0
  24. package/@types/components/Question.d.ts +11 -0
  25. package/@types/components/RadioButton.d.ts +15 -0
  26. package/@types/components/Select.d.ts +14 -0
  27. package/@types/components/SequentialNavigation.d.ts +14 -0
  28. package/@types/components/SideNavigation.d.ts +19 -0
  29. package/@types/components/SiteNavigation.d.ts +13 -0
  30. package/@types/components/SiteSearch.d.ts +14 -0
  31. package/@types/components/SkipLinks.d.ts +14 -0
  32. package/@types/components/Tag.d.ts +7 -0
  33. package/@types/components/TaskList.d.ts +21 -0
  34. package/@types/components/TextInput.d.ts +12 -0
  35. package/@types/components/Textarea.d.ts +4 -0
  36. package/@types/global.d.ts +1 -0
  37. package/@types/sgds.d.ts +35 -0
  38. package/package.json +36 -0
  39. package/src/common/conditional-wrapper.test.tsx +36 -0
  40. package/src/common/conditional-wrapper.tsx +9 -0
  41. package/src/common/hint-text.test.tsx +47 -0
  42. package/src/common/hint-text.tsx +21 -0
  43. package/src/common/icon.test.tsx +100 -0
  44. package/src/common/icon.tsx +28 -0
  45. package/src/common/screen-reader-text.test.tsx +31 -0
  46. package/src/common/screen-reader-text.tsx +17 -0
  47. package/src/common/wrapper-tag.test.tsx +42 -0
  48. package/src/common/wrapper-tag.tsx +15 -0
  49. package/src/components/accordion/accordion.test.tsx +212 -0
  50. package/src/components/accordion/accordion.tsx +108 -0
  51. package/src/components/aspect-box/aspect-box.test.tsx +81 -0
  52. package/src/components/aspect-box/aspect-box.tsx +57 -0
  53. package/src/components/back-to-top/back-to-top.test.tsx +45 -0
  54. package/src/components/back-to-top/back-to-top.tsx +33 -0
  55. package/src/components/breadcrumbs/breadcrumbs.test.tsx +77 -0
  56. package/src/components/breadcrumbs/breadcrumbs.tsx +53 -0
  57. package/src/components/button/button.test.tsx +125 -0
  58. package/src/components/button/button.tsx +48 -0
  59. package/src/components/checkbox/checkbox.test.tsx +180 -0
  60. package/src/components/checkbox/checkbox.tsx +107 -0
  61. package/src/components/confirmation-message/confirmation-message.test.tsx +46 -0
  62. package/src/components/confirmation-message/confirmation-message.tsx +32 -0
  63. package/src/components/contents-nav/contents-nav.test.tsx +136 -0
  64. package/src/components/contents-nav/contents-nav.tsx +54 -0
  65. package/src/components/date-picker/date-picker.test.tsx +209 -0
  66. package/src/components/date-picker/date-picker.tsx +129 -0
  67. package/src/components/details/details.test.tsx +38 -0
  68. package/src/components/details/details.tsx +25 -0
  69. package/src/components/error-message/error-message.test.tsx +40 -0
  70. package/src/components/error-message/error-message.tsx +23 -0
  71. package/src/components/inset-text/inset-text.test.tsx +33 -0
  72. package/src/components/inset-text/inset-text.tsx +19 -0
  73. package/src/components/notification-banner/notification-banner.test.tsx +93 -0
  74. package/src/components/notification-banner/notification-banner.tsx +70 -0
  75. package/src/components/notification-panel/notification-panel.test.tsx +77 -0
  76. package/src/components/notification-panel/notification-panel.tsx +31 -0
  77. package/src/components/page-header/page-header.test.tsx +48 -0
  78. package/src/components/page-header/page-header.tsx +22 -0
  79. package/src/components/page-metadata/page-metadata.test.tsx +56 -0
  80. package/src/components/page-metadata/page-metadata.tsx +39 -0
  81. package/src/components/phase-banner/phase-banner.test.tsx +67 -0
  82. package/src/components/phase-banner/phase-banner.tsx +27 -0
  83. package/src/components/question/question.test.tsx +69 -0
  84. package/src/components/question/question.tsx +33 -0
  85. package/src/components/radio-button/radio-button.test.tsx +190 -0
  86. package/src/components/radio-button/radio-button.tsx +88 -0
  87. package/src/components/select/select.test.tsx +208 -0
  88. package/src/components/select/select.tsx +86 -0
  89. package/src/components/sequential-navigation/sequential-navigation.test.tsx +67 -0
  90. package/src/components/sequential-navigation/sequential-navigation.tsx +55 -0
  91. package/src/components/side-navigation/side-navigation.test.tsx +156 -0
  92. package/src/components/side-navigation/side-navigation.tsx +85 -0
  93. package/src/components/site-navigation/site-navigation.test.tsx +63 -0
  94. package/src/components/site-navigation/site-navigation.tsx +40 -0
  95. package/src/components/site-search/site-search.test.tsx +153 -0
  96. package/src/components/site-search/site-search.tsx +97 -0
  97. package/src/components/skip-links/skip-links.test.tsx +84 -0
  98. package/src/components/skip-links/skip-links.tsx +39 -0
  99. package/src/components/tag/tag.test.tsx +45 -0
  100. package/src/components/tag/tag.tsx +23 -0
  101. package/src/components/task-list/task-list.test.tsx +409 -0
  102. package/src/components/task-list/task-list.tsx +132 -0
  103. package/src/components/text-input/text-input.test.tsx +307 -0
  104. package/src/components/text-input/text-input.tsx +98 -0
  105. package/src/components/textarea/textarea.test.tsx +212 -0
  106. package/src/components/textarea/textarea.tsx +82 -0
  107. package/src/components/warning-text/warning-text.test.tsx +40 -0
  108. package/src/components/warning-text/warning-text.tsx +21 -0
  109. package/tsconfig.json +45 -0
  110. package/vite.config.ts +12 -0
  111. package/vitest-setup.ts +13 -0
@@ -0,0 +1,307 @@
1
+ import { test, expect, vi } from 'vitest';
2
+ import { render, screen, within, fireEvent } from '@testing-library/react';
3
+ import TextInput from './text-input';
4
+
5
+ const id = 'text-input';
6
+ const labelText = 'First name';
7
+
8
+ test('text input renders correctly', () => {
9
+ render(
10
+ <TextInput
11
+ id={id}
12
+ label={labelText}
13
+ />
14
+ );
15
+
16
+ const textInput = screen.getByRole('textbox');
17
+ const label = screen.getByText(labelText);
18
+
19
+ expect(textInput).toHaveClass('ds_input');
20
+ expect(textInput).toHaveAttribute('id', id);
21
+ expect(textInput).toHaveAttribute('name', id);
22
+ expect(textInput).toHaveAttribute('type', 'text');
23
+ expect(textInput.tagName).toEqual('INPUT');
24
+
25
+ expect(label).toHaveClass('ds_label');
26
+ expect(label).toHaveAttribute('for', id);
27
+ expect(label.tagName).toEqual('LABEL');
28
+ expect(label.textContent).toEqual(labelText);
29
+
30
+ expect(textInput.previousSibling).toEqual(label);
31
+ });
32
+
33
+ test('text input with custom class(es)', () => {
34
+ render(
35
+ <TextInput
36
+ id={id}
37
+ label={labelText}
38
+ className="foo bar"
39
+ />
40
+ );
41
+
42
+ const textInput = screen.getByRole('textbox');
43
+
44
+ expect(textInput).toHaveClass('ds_input', 'foo', 'bar');
45
+ });
46
+
47
+ test('text input with character count', () => {
48
+ const maxLength = 100;
49
+
50
+ render(
51
+ <TextInput
52
+ id={id}
53
+ label={labelText}
54
+ maxlength={maxLength}
55
+ />
56
+ );
57
+
58
+ const textInput = screen.getByRole('textbox');
59
+ const textInputWrapper = textInput.parentNode;
60
+
61
+ expect(textInputWrapper).toHaveAttribute('data-maxlength', maxLength.toString());
62
+ expect(textInputWrapper).toHaveAttribute('data-module', 'ds-character-count');
63
+ });
64
+
65
+ test('text input with character count and threshold', () => {
66
+ const maxLength = 100;
67
+ const countThreshold = 80;
68
+
69
+ render(
70
+ <TextInput
71
+ id={id}
72
+ label={labelText}
73
+ maxlength={maxLength}
74
+ countThreshold={countThreshold}
75
+ />
76
+ );
77
+
78
+ const textInput = screen.getByRole('textbox');
79
+ const textInputWrapper = textInput.parentNode;
80
+
81
+ expect(textInputWrapper).toHaveAttribute('data-threshold', countThreshold.toString());
82
+ });
83
+
84
+ test('text input with width', () => {
85
+ const width = 'fixed-10';
86
+
87
+ render(
88
+ <TextInput
89
+ id={id}
90
+ label={labelText}
91
+ width={width}
92
+ />
93
+ );
94
+
95
+ const textInput = screen.getByRole('textbox');
96
+ expect(textInput).toHaveClass(`ds_input--${width}`);
97
+ });
98
+
99
+ test('text input with currency', () => {
100
+ render(
101
+ <TextInput
102
+ id={id}
103
+ label={labelText}
104
+ currency
105
+ />
106
+ );
107
+
108
+ const textInput = screen.getByRole('textbox');
109
+ const textInputWrapper = textInput.parentNode;
110
+
111
+ expect(textInputWrapper.tagName).toEqual('DIV')
112
+ expect(textInputWrapper).toHaveClass('ds_currency-wrapper');
113
+ expect(textInputWrapper).not.toHaveAttribute('data-symbol');
114
+ });
115
+
116
+ test('text input with custom currency symbol', () => {
117
+ const symbol = '@';
118
+
119
+ render(
120
+ <TextInput
121
+ id={id}
122
+ label={labelText}
123
+ currency
124
+ currencySymbol={symbol}
125
+ />
126
+ );
127
+
128
+ const textInput = screen.getByRole('textbox');
129
+ const textInputWrapper = textInput.parentNode;
130
+
131
+ expect(textInputWrapper).toHaveAttribute('data-symbol', symbol);
132
+ });
133
+
134
+ test('text input with button', () => {
135
+ const buttonText = 'Search';
136
+ const buttonIcon = 'search';
137
+ render(
138
+ <TextInput
139
+ id={id}
140
+ label={labelText}
141
+ buttonIcon="search"
142
+ buttonText="Search"
143
+ hasButton
144
+ />
145
+ );
146
+
147
+ const textInput = screen.getByRole('textbox');
148
+ const textInputWrapper = textInput.parentNode;
149
+ const button = screen.getByRole('button');
150
+ const buttonTextElement = within(button).getByText(buttonText);
151
+ const buttonIconElement = within(button).getByRole('img', { hidden: true });
152
+
153
+ expect(textInputWrapper).toHaveClass('ds_input__wrapper', 'ds_input__wrapper--has-icon ');
154
+
155
+ expect(button).toBeInTheDocument();
156
+ expect(button).toHaveClass('ds_button');
157
+ expect(button).toHaveAttribute('type', 'button');
158
+ expect(button.tagName).toEqual('BUTTON');
159
+ expect(buttonTextElement).toHaveClass('visually-hidden');
160
+ expect(buttonTextElement.tagName).toEqual('SPAN');
161
+
162
+ // todo: check for correct icon
163
+ });
164
+
165
+ test('text input with hint text', () => {
166
+ const hintText = 'hint text';
167
+ render(
168
+ <TextInput
169
+ id={id}
170
+ label={labelText}
171
+ hintText={hintText}
172
+ />
173
+ );
174
+
175
+ const hintTextEl = screen.getByText(hintText);
176
+ const textInput = screen.getByRole('textbox');
177
+
178
+ expect(hintTextEl).toBeInTheDocument();
179
+ expect(textInput).toHaveAttribute('aria-describedby', hintTextEl.id);
180
+ });
181
+
182
+ test('text input with custom name', () => {
183
+ const name = 'foo';
184
+
185
+ render(
186
+ <TextInput
187
+ id={id}
188
+ label={labelText}
189
+ name={name}
190
+ />
191
+ );
192
+
193
+ const textInput = screen.getByRole('textbox');
194
+ expect(textInput).toHaveAttribute('name', name);
195
+ });
196
+
197
+ test('text input with blur function', () => {
198
+ const onBlurFn = vi.fn();
199
+ render(
200
+ <TextInput
201
+ id={id}
202
+ label={labelText}
203
+ onBlur={onBlurFn}
204
+ />
205
+ );
206
+
207
+ const textInput = screen.getByRole('textbox');
208
+
209
+ fireEvent.blur(textInput);
210
+
211
+ expect(onBlurFn).toHaveBeenCalled();
212
+ });
213
+
214
+ test('text input with change function', () => {
215
+ const onChangeFn = vi.fn();
216
+ render(
217
+ <TextInput
218
+ id={id}
219
+ label={labelText}
220
+ onChange={onChangeFn}
221
+ />
222
+ );
223
+
224
+ const textInput = screen.getByRole('textbox');
225
+
226
+ fireEvent.change(textInput, {target: {value: 'foo'}});
227
+
228
+ expect(onChangeFn).toHaveBeenCalled();
229
+ });
230
+
231
+ test('text input with placeholder text', () => {
232
+ const placeholder = 'foo';
233
+
234
+ render(
235
+ <TextInput
236
+ id={id}
237
+ label={labelText}
238
+ placeholder={placeholder}
239
+ />
240
+ );
241
+
242
+ const textInput = screen.getByRole('textbox');
243
+ expect(textInput).toHaveAttribute('placeholder', placeholder);
244
+ });
245
+
246
+ test('text input with different type', () => {
247
+ const type = 'foo';
248
+
249
+ render(
250
+ <TextInput
251
+ id={id}
252
+ label={labelText}
253
+ type={type}
254
+ />
255
+ );
256
+
257
+ const textInput = screen.getByRole('textbox');
258
+ expect(textInput).toHaveAttribute('type', type);
259
+ });
260
+
261
+ test('text input with initial value', () => {
262
+ const initialValue = 'initial value';
263
+
264
+ render(
265
+ <TextInput
266
+ id={id}
267
+ label={labelText}
268
+ value={initialValue}
269
+ />
270
+ );
271
+
272
+ const textInput = screen.getByRole('textbox');
273
+ expect(textInput).toHaveAttribute('value', initialValue);
274
+ });
275
+
276
+ test('text input with error message', () => {
277
+ const errorMessage = 'This is a required field';
278
+ render(
279
+ <TextInput
280
+ id={id}
281
+ label={labelText}
282
+ error
283
+ errorMessage={errorMessage}
284
+ />
285
+ );
286
+
287
+ const textInput = screen.getByRole('textbox');
288
+ const errorMessageElement = screen.getByText(errorMessage);
289
+
290
+ expect(textInput).toHaveClass('ds_input--error')
291
+ expect(textInput).toHaveAttribute('aria-describedby', errorMessageElement.id);
292
+ expect(errorMessageElement).toBeInTheDocument();
293
+ expect(errorMessageElement).toHaveClass('ds_question__error-message');
294
+ });
295
+
296
+ test('passing additional props', () => {
297
+ render(
298
+ <TextInput
299
+ id={id}
300
+ label={labelText}
301
+ data-test="foo"
302
+ />
303
+ );
304
+
305
+ const textInput = screen.getByRole('textbox');
306
+ expect(textInput?.dataset.test).toEqual('foo');
307
+ });
@@ -0,0 +1,98 @@
1
+ import { useEffect, useRef } from 'react';
2
+ // @ts-ignore
3
+ import DSCharacterCount from '@scottish-government/design-system/src/forms/character-count/character-count';
4
+ import Button from '../button/button';
5
+ import ConditionalWrapper from '../../common/conditional-wrapper';
6
+ import ErrorMessage from '../error-message/error-message';
7
+ import HintText from '../../common/hint-text';
8
+
9
+ const TextInput: React.FC<SGDS.Component.TextInput> = ({
10
+ buttonIcon,
11
+ buttonText,
12
+ children,
13
+ className,
14
+ countThreshold,
15
+ width,
16
+ currency,
17
+ currencySymbol,
18
+ error,
19
+ errorMessage,
20
+ hasButton = false,
21
+ hintText,
22
+ id,
23
+ label,
24
+ maxlength,
25
+ name,
26
+ onBlur,
27
+ onChange,
28
+ placeholder,
29
+ type = 'text',
30
+ value,
31
+ ...props
32
+ }) => {
33
+ const errorMessageId = `error-message-${id}`;
34
+ const hintTextId = `hint-text-${id}`;
35
+ const ref = useRef(null);
36
+ const inputWrapperClasses = `${hasButton ? 'ds_input__wrapper ds_input__wrapper--has-icon' : ''} ${currency ? 'ds_currency-wrapper' : ''}`;
37
+ const describedbys: string[] = [];
38
+
39
+ if (hintText) { describedbys.push(hintTextId) };
40
+ if (errorMessage) { describedbys.push(errorMessageId) };
41
+
42
+ useEffect(() => {
43
+ if (ref.current) {
44
+ new DSCharacterCount(ref.current).init();
45
+ }
46
+ }, [ref]);
47
+
48
+ function handleBlur(event: React.FocusEvent) {
49
+ if (typeof onBlur === 'function') {
50
+ onBlur(event);
51
+ }
52
+ }
53
+
54
+ function handleChange(event: React.ChangeEvent) {
55
+ if (typeof onChange === 'function') {
56
+ onChange(event);
57
+ }
58
+ }
59
+
60
+ return (
61
+ <ConditionalWrapper
62
+ condition={typeof maxlength !== 'undefined' && maxlength > 0}
63
+ wrapper={(children: React.JSX.Element) => <div ref={ref} data-threshold={countThreshold} data-module="ds-character-count">{children}</div>}
64
+ >
65
+ <label className="ds_label" htmlFor={id}>{label}</label>
66
+ {hintText && <HintText id={hintTextId} text={hintText} />}
67
+ {errorMessage && <ErrorMessage id={errorMessageId} text={errorMessage}/>}
68
+ <ConditionalWrapper
69
+ condition={hasButton || typeof currency !== 'undefined' && currency}
70
+ wrapper={(children: React.JSX.Element) => <div className={inputWrapperClasses} data-symbol={currencySymbol}>{children}</div>}
71
+ >
72
+ <input
73
+ aria-describedby={describedbys.join(' ')}
74
+ className={[
75
+ 'ds_input',
76
+ className,
77
+ error ? 'ds_input--error' : '',
78
+ width ? `ds_input--${width}` : '',
79
+ ].join(' ')}
80
+ defaultValue={value}
81
+ id={id}
82
+ maxLength={maxlength}
83
+ name={name || id}
84
+ onBlur={handleBlur}
85
+ onChange={handleChange}
86
+ placeholder={placeholder}
87
+ type={type}
88
+ {...props}
89
+ />
90
+ {hasButton && (buttonText || buttonIcon) && <Button iconOnly icon={buttonIcon}>{buttonText}</Button>}
91
+ </ConditionalWrapper>
92
+ </ConditionalWrapper>
93
+ );
94
+ };
95
+
96
+ TextInput.displayName = 'TextInput';
97
+
98
+ export default TextInput;
@@ -0,0 +1,212 @@
1
+ import { test, expect, vi } from 'vitest';
2
+ import { render, screen, within, fireEvent } from '@testing-library/react';
3
+ import Textarea from './textarea';
4
+
5
+ const id = 'textarea';
6
+ const labelText = 'Description';
7
+
8
+ test('text input renders correctly', () => {
9
+ render(
10
+ <Textarea
11
+ id={id}
12
+ label={labelText}
13
+ />
14
+ );
15
+
16
+ const textarea = screen.getByRole('textbox');
17
+ const label = screen.getByText(labelText);
18
+
19
+ expect(textarea).toHaveClass('ds_input');
20
+ expect(textarea).toHaveAttribute('id', id);
21
+ expect(textarea).toHaveAttribute('name', id);
22
+ expect(textarea).toHaveAttribute('rows', String(4));
23
+
24
+ expect(label).toHaveClass('ds_label');
25
+ expect(label).toHaveAttribute('for', id);
26
+ expect(label.tagName).toEqual('LABEL');
27
+ expect(label.textContent).toEqual(labelText);
28
+
29
+ expect(textarea.previousSibling).toEqual(label);
30
+ });
31
+
32
+ test('textarea with character count', () => {
33
+ const maxLength = 250;
34
+
35
+ render(
36
+ <Textarea
37
+ id={id}
38
+ label={labelText}
39
+ maxlength={maxLength}
40
+ />
41
+ );
42
+
43
+ const textarea = screen.getByRole('textbox');
44
+ const textareaWrapper = textarea.parentNode;
45
+
46
+ expect(textareaWrapper).toHaveAttribute('data-maxlength', maxLength.toString());
47
+ expect(textareaWrapper).toHaveAttribute('data-module', 'ds-character-count');
48
+ });
49
+
50
+ test('text input with character count and threshold', () => {
51
+ const maxLength = 250;
52
+ const countThreshold = 80;
53
+
54
+ render(
55
+ <Textarea
56
+ id={id}
57
+ label={labelText}
58
+ maxlength={maxLength}
59
+ countThreshold={countThreshold}
60
+ />
61
+ );
62
+
63
+ const textarea = screen.getByRole('textbox');
64
+ const textareaWrapper = textarea.parentNode;
65
+
66
+ expect(textareaWrapper).toHaveAttribute('data-threshold', countThreshold.toString());
67
+ });
68
+
69
+ test('textarea with hint text', () => {
70
+ const hintText = 'hint text';
71
+ render(
72
+ <Textarea
73
+ id={id}
74
+ label={labelText}
75
+ hintText={hintText}
76
+ />
77
+ );
78
+
79
+ const hintTextEl = screen.getByText(hintText);
80
+ const textarea = screen.getByRole('textbox');
81
+
82
+ expect(hintTextEl).toBeInTheDocument();
83
+ expect(textarea).toHaveAttribute('aria-describedby', hintTextEl.id);
84
+ });
85
+
86
+ test('textarea with custom name', () => {
87
+ const name = 'foo';
88
+
89
+ render(
90
+ <Textarea
91
+ id={id}
92
+ label={labelText}
93
+ name={name}
94
+ />
95
+ );
96
+
97
+ const textarea = screen.getByRole('textbox');
98
+ expect(textarea).toHaveAttribute('name', name);
99
+ });
100
+
101
+ test('textarea with blur function', () => {
102
+ const onBlurFn = vi.fn();
103
+ render(
104
+ <Textarea
105
+ id={id}
106
+ label={labelText}
107
+ onBlur={onBlurFn}
108
+ />
109
+ );
110
+
111
+ const textarea = screen.getByRole('textbox');
112
+
113
+ fireEvent.blur(textarea);
114
+
115
+ expect(onBlurFn).toHaveBeenCalled();
116
+ });
117
+
118
+ test('textarea with change function', () => {
119
+ const onChangeFn = vi.fn();
120
+ render(
121
+ <Textarea
122
+ id={id}
123
+ label={labelText}
124
+ onChange={onChangeFn}
125
+ />
126
+ );
127
+
128
+ const textarea = screen.getByRole('textbox');
129
+
130
+ fireEvent.change(textarea, {target: {value: 'foo'}});
131
+
132
+ expect(onChangeFn).toHaveBeenCalled();
133
+ });
134
+
135
+ test('textarea with placeholder text', () => {
136
+ const placeholder = 'foo';
137
+
138
+ render(
139
+ <Textarea
140
+ id={id}
141
+ label={labelText}
142
+ placeholder={placeholder}
143
+ />
144
+ );
145
+
146
+ const textarea = screen.getByRole('textbox');
147
+ expect(textarea).toHaveAttribute('placeholder', placeholder);
148
+ });
149
+
150
+ test('textarea with custom rows', () => {
151
+ const rows = 2;
152
+
153
+ render(
154
+ <Textarea
155
+ id={id}
156
+ label={labelText}
157
+ rows={rows}
158
+ />
159
+ );
160
+
161
+ const textarea = screen.getByRole('textbox');
162
+
163
+ expect(textarea).toHaveAttribute('rows', String(rows));
164
+ });
165
+
166
+ test('textarea with initial value', () => {
167
+ const content = 'Mygov.scot gives people and businesses information about and access to public services in Scotland. We work closely with public sector organisations to make public services easy to find and understand.';
168
+
169
+ render(
170
+ <Textarea
171
+ defaultValue={content}
172
+ id={id}
173
+ label={labelText}
174
+ />
175
+ );
176
+
177
+ const textarea = screen.getByRole('textbox');
178
+ expect(textarea.textContent).toEqual(content);
179
+ });
180
+
181
+ test('textarea with error message', () => {
182
+ const errorMessage = 'This is a required field';
183
+ render(
184
+ <Textarea
185
+ id={id}
186
+ label={labelText}
187
+ error
188
+ errorMessage={errorMessage}
189
+ />
190
+ );
191
+
192
+ const textarea = screen.getByRole('textbox');
193
+ const errorMessageElement = screen.getByText(errorMessage);
194
+
195
+ expect(textarea).toHaveClass('ds_input--error')
196
+ expect(textarea).toHaveAttribute('aria-describedby', errorMessageElement.id);
197
+ expect(errorMessageElement).toBeInTheDocument();
198
+ expect(errorMessageElement).toHaveClass('ds_question__error-message');
199
+ });
200
+
201
+ test('passing additional props', () => {
202
+ render(
203
+ <Textarea
204
+ id={id}
205
+ label={labelText}
206
+ data-test="foo"
207
+ />
208
+ );
209
+
210
+ const textarea = screen.getByRole('textbox');
211
+ expect(textarea?.dataset.test).toEqual('foo');
212
+ });
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef } from 'react';
2
+ // @ts-ignore
3
+ import DSCharacterCount from '@scottish-government/design-system/src/forms/character-count/character-count';
4
+ import ConditionalWrapper from '../../common/conditional-wrapper';
5
+ import ErrorMessage from '../error-message/error-message';
6
+ import HintText from '../../common/hint-text';
7
+
8
+ const Textarea: React.FC<SGDS.Component.Textarea> = ({
9
+ countThreshold,
10
+ error,
11
+ errorMessage,
12
+ hintText,
13
+ id,
14
+ label,
15
+ maxlength,
16
+ name,
17
+ onBlur,
18
+ onChange,
19
+ placeholder,
20
+ rows = 4,
21
+ value,
22
+ ...props
23
+ }) => {
24
+ const errorMessageId = `error-message-${id}`;
25
+ const hintTextId = `hint-text-${id}`;
26
+ const ref = useRef(null);
27
+ const describedbys: string[] = [];
28
+
29
+ if (hintText) { describedbys.push(hintTextId) };
30
+ if (errorMessage) { describedbys.push(errorMessageId) };
31
+
32
+ useEffect(() => {
33
+ if (ref.current) {
34
+ new DSCharacterCount(ref.current).init();
35
+ }
36
+ }, [ref]);
37
+
38
+ function handleBlur(event: React.FocusEvent) {
39
+ if (typeof onBlur === 'function') {
40
+ onBlur(event);
41
+ }
42
+ }
43
+
44
+ function handleChange(event: React.ChangeEvent) {
45
+ if (typeof onChange === 'function') {
46
+ onChange(event);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <ConditionalWrapper
52
+ condition={typeof maxlength !== 'undefined' && maxlength > 0}
53
+ wrapper={(children: React.JSX.Element) => <div ref={ref} data-threshold={countThreshold} data-module="ds-character-count">{children}</div>}
54
+ >
55
+ <label className="ds_label" htmlFor={id}>{label}</label>
56
+ {hintText && <HintText id={hintTextId} text={hintText} />}
57
+ {errorMessage && <ErrorMessage id={errorMessageId} text={errorMessage}/>}
58
+
59
+ <textarea
60
+ aria-describedby={describedbys.join(' ')}
61
+ className={[
62
+ 'ds_input',
63
+ error && 'ds_input--error',
64
+ ].join(' ')}
65
+ defaultValue={value}
66
+ id={id}
67
+ maxLength={maxlength}
68
+ name={name || id}
69
+ onBlur={handleBlur}
70
+ onChange={handleChange}
71
+ placeholder={placeholder}
72
+ rows={rows}
73
+ {...props}
74
+ />
75
+
76
+ </ConditionalWrapper>
77
+ );
78
+ };
79
+
80
+ Textarea.displayName = 'Textarea';
81
+
82
+ export default Textarea;