@tpzdsp/next-toolkit 1.2.9 → 1.4.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.
- package/package.json +14 -1
- package/src/components/Card/Card.stories.tsx +21 -0
- package/src/components/Card/Card.test.tsx +46 -7
- package/src/components/Card/Card.tsx +1 -1
- package/src/components/ErrorBoundary/ErrorBoundary.stories.tsx +89 -0
- package/src/components/ErrorBoundary/ErrorBoundary.test.tsx +75 -0
- package/src/components/ErrorBoundary/ErrorBoundary.tsx +28 -0
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +70 -0
- package/src/components/ErrorBoundary/ErrorFallback.test.tsx +54 -0
- package/src/components/ErrorBoundary/ErrorFallback.tsx +30 -0
- package/src/components/Paragraph/Paragraph.tsx +9 -5
- package/src/components/backToTop/BackToTop.stories.tsx +409 -0
- package/src/components/backToTop/BackToTop.test.tsx +57 -0
- package/src/components/backToTop/BackToTop.tsx +131 -0
- package/src/components/chip/Chip.stories.tsx +40 -0
- package/src/components/chip/Chip.test.tsx +31 -0
- package/src/components/chip/Chip.tsx +20 -0
- package/src/components/cookieBanner/CookieBanner.stories.tsx +258 -0
- package/src/components/cookieBanner/CookieBanner.test.tsx +68 -0
- package/src/components/cookieBanner/CookieBanner.tsx +73 -0
- package/src/components/form/Input.stories.tsx +435 -0
- package/src/components/form/Input.test.tsx +214 -0
- package/src/components/form/Input.tsx +24 -0
- package/src/components/form/TextArea.stories.tsx +465 -0
- package/src/components/form/TextArea.test.tsx +236 -0
- package/src/components/form/TextArea.tsx +24 -0
- package/src/components/googleAnalytics/GlobalVars.tsx +31 -0
- package/src/components/googleAnalytics/GoogleAnalytics.tsx +15 -0
- package/src/components/index.ts +15 -0
- package/src/components/skipLink/SkipLink.stories.tsx +346 -0
- package/src/components/skipLink/SkipLink.test.tsx +22 -0
- package/src/components/skipLink/SkipLink.tsx +19 -0
- package/src/errors/ApiError.ts +71 -0
- package/src/errors/index.ts +1 -0
- package/src/utils/utils.ts +3 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { TextArea } from './TextArea';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Form/TextArea',
|
|
8
|
+
component: TextArea,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'centered',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component: 'A styled textarea component with error state support and customizable styling.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
argTypes: {
|
|
19
|
+
hasError: {
|
|
20
|
+
control: 'boolean',
|
|
21
|
+
description: 'Whether the textarea should display in error state',
|
|
22
|
+
defaultValue: false,
|
|
23
|
+
},
|
|
24
|
+
rows: {
|
|
25
|
+
control: { type: 'number', min: 1, max: 20 },
|
|
26
|
+
description: 'Number of visible text lines',
|
|
27
|
+
defaultValue: 4,
|
|
28
|
+
},
|
|
29
|
+
cols: {
|
|
30
|
+
control: { type: 'number', min: 10, max: 100 },
|
|
31
|
+
description: 'Number of visible character widths',
|
|
32
|
+
},
|
|
33
|
+
placeholder: {
|
|
34
|
+
control: 'text',
|
|
35
|
+
description: 'Placeholder text for the textarea',
|
|
36
|
+
},
|
|
37
|
+
disabled: {
|
|
38
|
+
control: 'boolean',
|
|
39
|
+
description: 'Whether the textarea is disabled',
|
|
40
|
+
defaultValue: false,
|
|
41
|
+
},
|
|
42
|
+
required: {
|
|
43
|
+
control: 'boolean',
|
|
44
|
+
description: 'Whether the textarea is required',
|
|
45
|
+
defaultValue: false,
|
|
46
|
+
},
|
|
47
|
+
maxLength: {
|
|
48
|
+
control: { type: 'number', min: 0 },
|
|
49
|
+
description: 'Maximum number of characters allowed',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
} satisfies Meta<typeof TextArea>;
|
|
53
|
+
|
|
54
|
+
export default meta;
|
|
55
|
+
type Story = StoryObj<typeof meta>;
|
|
56
|
+
|
|
57
|
+
export const Default: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
placeholder: 'Enter your message...',
|
|
60
|
+
rows: 4,
|
|
61
|
+
},
|
|
62
|
+
parameters: {
|
|
63
|
+
docs: {
|
|
64
|
+
description: {
|
|
65
|
+
story: 'Default textarea with standard styling.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const WithError: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
hasError: true,
|
|
74
|
+
placeholder: 'This textarea has an error',
|
|
75
|
+
value: 'Invalid content that caused an error',
|
|
76
|
+
rows: 4,
|
|
77
|
+
},
|
|
78
|
+
parameters: {
|
|
79
|
+
docs: {
|
|
80
|
+
description: {
|
|
81
|
+
story: 'Textarea in error state with red border styling.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Disabled: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
disabled: true,
|
|
90
|
+
placeholder: 'This textarea is disabled',
|
|
91
|
+
value: 'This content cannot be edited',
|
|
92
|
+
rows: 4,
|
|
93
|
+
},
|
|
94
|
+
parameters: {
|
|
95
|
+
docs: {
|
|
96
|
+
description: {
|
|
97
|
+
story: 'Disabled textarea with reduced opacity and gray background.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const Required: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<div className="w-96">
|
|
106
|
+
<label htmlFor="required-textarea" className="block text-sm font-medium mb-1">
|
|
107
|
+
Description <span className="text-red-500">*</span>
|
|
108
|
+
</label>
|
|
109
|
+
|
|
110
|
+
<TextArea
|
|
111
|
+
id="required-textarea"
|
|
112
|
+
required
|
|
113
|
+
placeholder="Please provide a detailed description"
|
|
114
|
+
rows={5}
|
|
115
|
+
aria-describedby="required-help"
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
<p id="required-help" className="text-gray-600 text-xs mt-1">
|
|
119
|
+
* This field is required
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
),
|
|
123
|
+
parameters: {
|
|
124
|
+
docs: {
|
|
125
|
+
description: {
|
|
126
|
+
story: 'Required textarea field with visual indicator (red asterisk) and helper text.',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const WithCharacterLimit: Story = {
|
|
133
|
+
render: () => (
|
|
134
|
+
<div className="w-96">
|
|
135
|
+
<label htmlFor="limited-textarea" className="block text-sm font-medium mb-1">
|
|
136
|
+
Comment (max 280 characters)
|
|
137
|
+
</label>
|
|
138
|
+
|
|
139
|
+
<TextArea
|
|
140
|
+
id="limited-textarea"
|
|
141
|
+
placeholder="What's on your mind?"
|
|
142
|
+
maxLength={280}
|
|
143
|
+
rows={4}
|
|
144
|
+
aria-describedby="char-count"
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<p id="char-count" className="text-gray-600 text-xs mt-1">
|
|
148
|
+
280 characters remaining
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
),
|
|
152
|
+
parameters: {
|
|
153
|
+
docs: {
|
|
154
|
+
description: {
|
|
155
|
+
story: 'Textarea with character limit and counter (note: counter is static in this demo).',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const DifferentSizes: Story = {
|
|
162
|
+
render: () => (
|
|
163
|
+
<div className="space-y-6 w-96">
|
|
164
|
+
<div>
|
|
165
|
+
<label htmlFor="small-textarea" className="block text-sm font-medium mb-1">
|
|
166
|
+
Small (3 rows)
|
|
167
|
+
</label>
|
|
168
|
+
|
|
169
|
+
<TextArea id="small-textarea" placeholder="Short description..." rows={3} />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<label htmlFor="medium-textarea" className="block text-sm font-medium mb-1">
|
|
174
|
+
Medium (5 rows)
|
|
175
|
+
</label>
|
|
176
|
+
|
|
177
|
+
<TextArea id="medium-textarea" placeholder="Medium description..." rows={5} />
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div>
|
|
181
|
+
<label htmlFor="large-textarea" className="block text-sm font-medium mb-1">
|
|
182
|
+
Large (8 rows)
|
|
183
|
+
</label>
|
|
184
|
+
|
|
185
|
+
<TextArea id="large-textarea" placeholder="Long description..." rows={8} />
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
),
|
|
189
|
+
parameters: {
|
|
190
|
+
docs: {
|
|
191
|
+
description: {
|
|
192
|
+
story: 'Textareas with different row heights for various use cases.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const FormExample: Story = {
|
|
199
|
+
render: () => (
|
|
200
|
+
<form className="space-y-6 w-96">
|
|
201
|
+
<div>
|
|
202
|
+
<label htmlFor="subject" className="block text-sm font-medium mb-1">
|
|
203
|
+
Subject <span className="text-red-500">*</span>
|
|
204
|
+
</label>
|
|
205
|
+
|
|
206
|
+
<input
|
|
207
|
+
id="subject"
|
|
208
|
+
type="text"
|
|
209
|
+
className="w-full rounded-md border p-2"
|
|
210
|
+
placeholder="Brief subject line"
|
|
211
|
+
required
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div>
|
|
216
|
+
<label htmlFor="message" className="block text-sm font-medium mb-1">
|
|
217
|
+
Message <span className="text-red-500">*</span>
|
|
218
|
+
</label>
|
|
219
|
+
|
|
220
|
+
<TextArea
|
|
221
|
+
id="message"
|
|
222
|
+
placeholder="Enter your detailed message here..."
|
|
223
|
+
rows={6}
|
|
224
|
+
required
|
|
225
|
+
aria-describedby="message-help"
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<p id="message-help" className="text-gray-600 text-xs mt-1">
|
|
229
|
+
Please provide as much detail as possible
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div>
|
|
234
|
+
<label htmlFor="notes" className="block text-sm font-medium mb-1">
|
|
235
|
+
Additional Notes
|
|
236
|
+
</label>
|
|
237
|
+
|
|
238
|
+
<TextArea id="notes" placeholder="Any additional information (optional)" rows={3} />
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<button
|
|
242
|
+
type="submit"
|
|
243
|
+
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700
|
|
244
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
245
|
+
>
|
|
246
|
+
Send Message
|
|
247
|
+
</button>
|
|
248
|
+
</form>
|
|
249
|
+
),
|
|
250
|
+
parameters: {
|
|
251
|
+
docs: {
|
|
252
|
+
description: {
|
|
253
|
+
story:
|
|
254
|
+
'Complete form example showing textareas in a realistic context with proper labels and validation.',
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const ErrorStates: Story = {
|
|
261
|
+
render: () => (
|
|
262
|
+
<div className="space-y-6 w-96">
|
|
263
|
+
<div>
|
|
264
|
+
<label htmlFor="error-required" className="block text-sm font-medium mb-1">
|
|
265
|
+
Required Field <span className="text-red-500">*</span>
|
|
266
|
+
</label>
|
|
267
|
+
|
|
268
|
+
<TextArea
|
|
269
|
+
id="error-required"
|
|
270
|
+
hasError
|
|
271
|
+
placeholder="This field is required"
|
|
272
|
+
rows={4}
|
|
273
|
+
aria-invalid="true"
|
|
274
|
+
aria-describedby="required-error"
|
|
275
|
+
/>
|
|
276
|
+
|
|
277
|
+
<p id="required-error" className="text-red-600 text-xs mt-1" role="alert">
|
|
278
|
+
This field is required.
|
|
279
|
+
</p>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div>
|
|
283
|
+
<label htmlFor="error-length" className="block text-sm font-medium mb-1">
|
|
284
|
+
Description (min 10 characters)
|
|
285
|
+
</label>
|
|
286
|
+
|
|
287
|
+
<TextArea
|
|
288
|
+
id="error-length"
|
|
289
|
+
hasError
|
|
290
|
+
value="Too short"
|
|
291
|
+
rows={4}
|
|
292
|
+
minLength={10}
|
|
293
|
+
aria-invalid="true"
|
|
294
|
+
aria-describedby="length-error"
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
<p id="length-error" className="text-red-600 text-xs mt-1" role="alert">
|
|
298
|
+
Description must be at least 10 characters long.
|
|
299
|
+
</p>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div>
|
|
303
|
+
<label htmlFor="error-content" className="block text-sm font-medium mb-1">
|
|
304
|
+
Content Review
|
|
305
|
+
</label>
|
|
306
|
+
|
|
307
|
+
<TextArea
|
|
308
|
+
id="error-content"
|
|
309
|
+
hasError
|
|
310
|
+
value="This content contains inappropriate language..."
|
|
311
|
+
rows={4}
|
|
312
|
+
aria-invalid="true"
|
|
313
|
+
aria-describedby="content-error"
|
|
314
|
+
/>
|
|
315
|
+
|
|
316
|
+
<p id="content-error" className="text-red-600 text-xs mt-1" role="alert">
|
|
317
|
+
Please review your content and remove inappropriate language.
|
|
318
|
+
</p>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
),
|
|
322
|
+
parameters: {
|
|
323
|
+
docs: {
|
|
324
|
+
description: {
|
|
325
|
+
story:
|
|
326
|
+
'Examples of textarea error states with proper ARIA attributes and error messages for accessibility.',
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const CustomStyling: Story = {
|
|
333
|
+
render: () => (
|
|
334
|
+
<div className="space-y-6 w-96">
|
|
335
|
+
<div>
|
|
336
|
+
<label htmlFor="large-textarea" className="block text-sm font-medium mb-1">
|
|
337
|
+
Large Text
|
|
338
|
+
</label>
|
|
339
|
+
|
|
340
|
+
<TextArea
|
|
341
|
+
id="large-textarea"
|
|
342
|
+
placeholder="Large text size"
|
|
343
|
+
className="text-lg p-3"
|
|
344
|
+
rows={4}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div>
|
|
349
|
+
<label htmlFor="custom-border" className="block text-sm font-medium mb-1">
|
|
350
|
+
Custom Border
|
|
351
|
+
</label>
|
|
352
|
+
|
|
353
|
+
<TextArea
|
|
354
|
+
id="custom-border"
|
|
355
|
+
placeholder="Custom border color"
|
|
356
|
+
className="border-2 border-purple-500 focus:border-purple-700"
|
|
357
|
+
rows={4}
|
|
358
|
+
/>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div>
|
|
362
|
+
<label htmlFor="no-rounded" className="block text-sm font-medium mb-1">
|
|
363
|
+
Square Corners
|
|
364
|
+
</label>
|
|
365
|
+
|
|
366
|
+
<TextArea
|
|
367
|
+
id="no-rounded"
|
|
368
|
+
placeholder="No border radius"
|
|
369
|
+
className="rounded-none"
|
|
370
|
+
rows={4}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<div>
|
|
375
|
+
<label htmlFor="full-width" className="block text-sm font-medium mb-1">
|
|
376
|
+
Full Width
|
|
377
|
+
</label>
|
|
378
|
+
|
|
379
|
+
<TextArea
|
|
380
|
+
id="full-width"
|
|
381
|
+
placeholder="Full width textarea"
|
|
382
|
+
className="w-full resize-none"
|
|
383
|
+
rows={4}
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
),
|
|
388
|
+
parameters: {
|
|
389
|
+
docs: {
|
|
390
|
+
description: {
|
|
391
|
+
story:
|
|
392
|
+
'Examples of custom styling using className prop. The component uses tailwind-merge to properly handle class overrides.',
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
export const Accessibility: Story = {
|
|
399
|
+
render: () => (
|
|
400
|
+
<div className="space-y-6 w-96">
|
|
401
|
+
<div className="bg-blue-50 border border-blue-200 rounded p-4">
|
|
402
|
+
<h3 className="font-bold text-blue-800 mb-2">Accessibility Features:</h3>
|
|
403
|
+
|
|
404
|
+
<ul className="list-disc list-inside space-y-1 text-blue-700 text-sm">
|
|
405
|
+
<li>Proper label association with htmlFor/id</li>
|
|
406
|
+
|
|
407
|
+
<li>ARIA attributes for error states</li>
|
|
408
|
+
|
|
409
|
+
<li>Descriptive error messages</li>
|
|
410
|
+
|
|
411
|
+
<li>Keyboard navigation support</li>
|
|
412
|
+
|
|
413
|
+
<li>Screen reader compatibility</li>
|
|
414
|
+
|
|
415
|
+
<li>Character count announcements</li>
|
|
416
|
+
</ul>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<div>
|
|
420
|
+
<label htmlFor="accessible-textarea" className="block text-sm font-medium mb-1">
|
|
421
|
+
Accessible Textarea Example
|
|
422
|
+
</label>
|
|
423
|
+
|
|
424
|
+
<TextArea
|
|
425
|
+
id="accessible-textarea"
|
|
426
|
+
placeholder="Enter your feedback..."
|
|
427
|
+
rows={5}
|
|
428
|
+
aria-describedby="textarea-help"
|
|
429
|
+
maxLength={500}
|
|
430
|
+
/>
|
|
431
|
+
|
|
432
|
+
<p id="textarea-help" className="text-gray-600 text-xs mt-1">
|
|
433
|
+
Your feedback helps us improve our services. Maximum 500 characters.
|
|
434
|
+
</p>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<div>
|
|
438
|
+
<label htmlFor="accessible-error" className="block text-sm font-medium mb-1">
|
|
439
|
+
Textarea with Error
|
|
440
|
+
</label>
|
|
441
|
+
|
|
442
|
+
<TextArea
|
|
443
|
+
id="accessible-error"
|
|
444
|
+
hasError
|
|
445
|
+
value="This content needs review..."
|
|
446
|
+
rows={4}
|
|
447
|
+
aria-invalid="true"
|
|
448
|
+
aria-describedby="accessible-error-msg"
|
|
449
|
+
/>
|
|
450
|
+
|
|
451
|
+
<p id="accessible-error-msg" className="text-red-600 text-xs mt-1" role="alert">
|
|
452
|
+
Please provide more specific details in your description.
|
|
453
|
+
</p>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
),
|
|
457
|
+
parameters: {
|
|
458
|
+
docs: {
|
|
459
|
+
description: {
|
|
460
|
+
story:
|
|
461
|
+
'Comprehensive accessibility example showing proper ARIA attributes, error handling, and screen reader support.',
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { TextArea } from './TextArea';
|
|
6
|
+
|
|
7
|
+
// Constants for repeated class names
|
|
8
|
+
const ROUNDED_MD = 'rounded-md';
|
|
9
|
+
const BORDER = 'border';
|
|
10
|
+
const P_1 = 'p-1';
|
|
11
|
+
const DISABLED_OPACITY = 'disabled:opacity-60';
|
|
12
|
+
const DISABLED_BG = 'disabled:bg-gray-100';
|
|
13
|
+
const BORDER_ERROR = 'border-error';
|
|
14
|
+
|
|
15
|
+
describe('TextArea', () => {
|
|
16
|
+
describe('general', () => {
|
|
17
|
+
it('renders a textarea element', () => {
|
|
18
|
+
render(<TextArea />);
|
|
19
|
+
|
|
20
|
+
const textarea = screen.getByRole('textbox');
|
|
21
|
+
|
|
22
|
+
expect(textarea).toBeInTheDocument();
|
|
23
|
+
expect(textarea.tagName).toBe('TEXTAREA');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('applies default classes', () => {
|
|
27
|
+
render(<TextArea />);
|
|
28
|
+
|
|
29
|
+
const textarea = screen.getByRole('textbox');
|
|
30
|
+
|
|
31
|
+
expect(textarea).toHaveClass(ROUNDED_MD, BORDER, P_1, DISABLED_OPACITY, DISABLED_BG);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('applies error styling when hasError is true', () => {
|
|
35
|
+
render(<TextArea hasError />);
|
|
36
|
+
|
|
37
|
+
const textarea = screen.getByRole('textbox');
|
|
38
|
+
|
|
39
|
+
expect(textarea).toHaveClass(BORDER_ERROR);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not apply error styling when hasError is false', () => {
|
|
43
|
+
render(<TextArea hasError={false} />);
|
|
44
|
+
|
|
45
|
+
const textarea = screen.getByRole('textbox');
|
|
46
|
+
|
|
47
|
+
expect(textarea).not.toHaveClass(BORDER_ERROR);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not apply error styling when hasError is undefined', () => {
|
|
51
|
+
render(<TextArea />);
|
|
52
|
+
|
|
53
|
+
const textarea = screen.getByRole('textbox');
|
|
54
|
+
|
|
55
|
+
expect(textarea).not.toHaveClass(BORDER_ERROR);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('merges custom className with default classes', () => {
|
|
59
|
+
render(<TextArea className="custom-class" />);
|
|
60
|
+
|
|
61
|
+
const textarea = screen.getByRole('textbox');
|
|
62
|
+
|
|
63
|
+
expect(textarea).toHaveClass(ROUNDED_MD, BORDER, P_1, 'custom-class');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('allows custom className to override default classes', () => {
|
|
67
|
+
render(<TextArea className="p-4" />);
|
|
68
|
+
|
|
69
|
+
const textarea = screen.getByRole('textbox');
|
|
70
|
+
|
|
71
|
+
expect(textarea).toHaveClass('p-4');
|
|
72
|
+
expect(textarea).toHaveClass(BORDER); // Base border class remains
|
|
73
|
+
expect(textarea).not.toHaveClass(P_1); // Overridden by p-4
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('forwards standard textarea props', () => {
|
|
77
|
+
render(
|
|
78
|
+
<TextArea
|
|
79
|
+
placeholder="Enter description"
|
|
80
|
+
value="test content"
|
|
81
|
+
name="description"
|
|
82
|
+
id="description-textarea"
|
|
83
|
+
rows={5}
|
|
84
|
+
cols={50}
|
|
85
|
+
readOnly
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const textarea = screen.getByRole('textbox');
|
|
90
|
+
|
|
91
|
+
expect(textarea).toHaveAttribute('placeholder', 'Enter description');
|
|
92
|
+
expect(textarea).toHaveValue('test content'); // Use toHaveValue instead of toHaveAttribute
|
|
93
|
+
expect(textarea).toHaveAttribute('name', 'description');
|
|
94
|
+
expect(textarea).toHaveAttribute('id', 'description-textarea');
|
|
95
|
+
expect(textarea).toHaveAttribute('rows', '5');
|
|
96
|
+
expect(textarea).toHaveAttribute('cols', '50');
|
|
97
|
+
expect(textarea).toHaveAttribute('readonly');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('applies disabled styling when disabled', () => {
|
|
101
|
+
render(<TextArea disabled />);
|
|
102
|
+
|
|
103
|
+
const textarea = screen.getByRole('textbox');
|
|
104
|
+
|
|
105
|
+
expect(textarea).toBeDisabled();
|
|
106
|
+
expect(textarea).toHaveClass(DISABLED_OPACITY, DISABLED_BG);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('combines hasError with custom className', () => {
|
|
110
|
+
render(<TextArea hasError className="w-full" />);
|
|
111
|
+
|
|
112
|
+
const textarea = screen.getByRole('textbox');
|
|
113
|
+
|
|
114
|
+
expect(textarea).toHaveClass(BORDER_ERROR, 'w-full', ROUNDED_MD, BORDER, P_1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('supports required attribute', () => {
|
|
118
|
+
render(<TextArea required />);
|
|
119
|
+
|
|
120
|
+
const textarea = screen.getByRole('textbox');
|
|
121
|
+
|
|
122
|
+
expect(textarea).toBeRequired();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('supports aria attributes', () => {
|
|
126
|
+
render(
|
|
127
|
+
<TextArea
|
|
128
|
+
aria-label="Description"
|
|
129
|
+
aria-describedby="description-help"
|
|
130
|
+
aria-invalid="true"
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const textarea = screen.getByRole('textbox');
|
|
135
|
+
|
|
136
|
+
expect(textarea).toHaveAttribute('aria-label', 'Description');
|
|
137
|
+
expect(textarea).toHaveAttribute('aria-describedby', 'description-help');
|
|
138
|
+
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles complex className override scenarios', () => {
|
|
142
|
+
render(<TextArea hasError className="border-green-500 p-4" />);
|
|
143
|
+
|
|
144
|
+
const textarea = screen.getByRole('textbox');
|
|
145
|
+
|
|
146
|
+
// Custom classes should override defaults due to twMerge
|
|
147
|
+
expect(textarea).toHaveClass('border-green-500', 'p-4');
|
|
148
|
+
expect(textarea).toHaveClass(ROUNDED_MD); // Default class preserved
|
|
149
|
+
expect(textarea).toHaveClass(BORDER); // Base border class is still present
|
|
150
|
+
expect(textarea).not.toHaveClass(P_1); // Overridden by p-4
|
|
151
|
+
expect(textarea).not.toHaveClass(BORDER_ERROR); // Overridden by border-green-500
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('supports textarea-specific attributes', () => {
|
|
155
|
+
render(<TextArea rows={10} cols={80} wrap="soft" maxLength={500} minLength={10} />);
|
|
156
|
+
|
|
157
|
+
const textarea = screen.getByRole('textbox');
|
|
158
|
+
|
|
159
|
+
expect(textarea).toHaveAttribute('rows', '10');
|
|
160
|
+
expect(textarea).toHaveAttribute('cols', '80');
|
|
161
|
+
expect(textarea).toHaveAttribute('wrap', 'soft');
|
|
162
|
+
expect(textarea).toHaveAttribute('maxlength', '500');
|
|
163
|
+
expect(textarea).toHaveAttribute('minlength', '10');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('accessibility', () => {
|
|
168
|
+
it('is accessible by role', () => {
|
|
169
|
+
render(<TextArea />);
|
|
170
|
+
|
|
171
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('supports form labels via id', () => {
|
|
175
|
+
render(
|
|
176
|
+
<>
|
|
177
|
+
<label htmlFor="test-textarea">Description</label>
|
|
178
|
+
|
|
179
|
+
<TextArea id="test-textarea" />
|
|
180
|
+
</>,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const textarea = screen.getByLabelText('Description');
|
|
184
|
+
|
|
185
|
+
expect(textarea).toBeInTheDocument();
|
|
186
|
+
expect(textarea.tagName).toBe('TEXTAREA');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('supports error state for screen readers', () => {
|
|
190
|
+
render(<TextArea hasError aria-invalid="true" />);
|
|
191
|
+
|
|
192
|
+
const textarea = screen.getByRole('textbox');
|
|
193
|
+
|
|
194
|
+
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
|
195
|
+
expect(textarea).toHaveClass(BORDER_ERROR);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('supports textarea with associated help text', () => {
|
|
199
|
+
render(
|
|
200
|
+
<>
|
|
201
|
+
<label htmlFor="description">Description</label>
|
|
202
|
+
|
|
203
|
+
<TextArea id="description" aria-describedby="description-help" />
|
|
204
|
+
|
|
205
|
+
<div id="description-help">Please provide a detailed description</div>
|
|
206
|
+
</>,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const textarea = screen.getByLabelText('Description');
|
|
210
|
+
|
|
211
|
+
expect(textarea).toHaveAttribute('aria-describedby', 'description-help');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('form integration', () => {
|
|
216
|
+
it('works with controlled components', () => {
|
|
217
|
+
const { rerender } = render(<TextArea value="initial value" readOnly />);
|
|
218
|
+
|
|
219
|
+
let textarea = screen.getByRole('textbox');
|
|
220
|
+
|
|
221
|
+
expect(textarea).toHaveValue('initial value');
|
|
222
|
+
|
|
223
|
+
rerender(<TextArea value="updated value" readOnly />);
|
|
224
|
+
textarea = screen.getByRole('textbox');
|
|
225
|
+
expect(textarea).toHaveValue('updated value');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('supports placeholder text', () => {
|
|
229
|
+
render(<TextArea placeholder="Enter your thoughts here..." />);
|
|
230
|
+
|
|
231
|
+
const textarea = screen.getByPlaceholderText('Enter your thoughts here...');
|
|
232
|
+
|
|
233
|
+
expect(textarea).toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|