@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.
Files changed (35) hide show
  1. package/package.json +14 -1
  2. package/src/components/Card/Card.stories.tsx +21 -0
  3. package/src/components/Card/Card.test.tsx +46 -7
  4. package/src/components/Card/Card.tsx +1 -1
  5. package/src/components/ErrorBoundary/ErrorBoundary.stories.tsx +89 -0
  6. package/src/components/ErrorBoundary/ErrorBoundary.test.tsx +75 -0
  7. package/src/components/ErrorBoundary/ErrorBoundary.tsx +28 -0
  8. package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +70 -0
  9. package/src/components/ErrorBoundary/ErrorFallback.test.tsx +54 -0
  10. package/src/components/ErrorBoundary/ErrorFallback.tsx +30 -0
  11. package/src/components/Paragraph/Paragraph.tsx +9 -5
  12. package/src/components/backToTop/BackToTop.stories.tsx +409 -0
  13. package/src/components/backToTop/BackToTop.test.tsx +57 -0
  14. package/src/components/backToTop/BackToTop.tsx +131 -0
  15. package/src/components/chip/Chip.stories.tsx +40 -0
  16. package/src/components/chip/Chip.test.tsx +31 -0
  17. package/src/components/chip/Chip.tsx +20 -0
  18. package/src/components/cookieBanner/CookieBanner.stories.tsx +258 -0
  19. package/src/components/cookieBanner/CookieBanner.test.tsx +68 -0
  20. package/src/components/cookieBanner/CookieBanner.tsx +73 -0
  21. package/src/components/form/Input.stories.tsx +435 -0
  22. package/src/components/form/Input.test.tsx +214 -0
  23. package/src/components/form/Input.tsx +24 -0
  24. package/src/components/form/TextArea.stories.tsx +465 -0
  25. package/src/components/form/TextArea.test.tsx +236 -0
  26. package/src/components/form/TextArea.tsx +24 -0
  27. package/src/components/googleAnalytics/GlobalVars.tsx +31 -0
  28. package/src/components/googleAnalytics/GoogleAnalytics.tsx +15 -0
  29. package/src/components/index.ts +15 -0
  30. package/src/components/skipLink/SkipLink.stories.tsx +346 -0
  31. package/src/components/skipLink/SkipLink.test.tsx +22 -0
  32. package/src/components/skipLink/SkipLink.tsx +19 -0
  33. package/src/errors/ApiError.ts +71 -0
  34. package/src/errors/index.ts +1 -0
  35. package/src/utils/utils.ts +3 -0
@@ -0,0 +1,435 @@
1
+ /* eslint-disable custom/padding-between-jsx-elements */
2
+ /* eslint-disable storybook/no-renderer-packages */
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+
5
+ import { Input } from './Input';
6
+
7
+ const meta = {
8
+ title: 'Components/Form/Input',
9
+ component: Input,
10
+ parameters: {
11
+ layout: 'centered',
12
+ docs: {
13
+ description: {
14
+ component: 'A styled input component with error state support and customizable styling.',
15
+ },
16
+ },
17
+ },
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ hasError: {
21
+ control: 'boolean',
22
+ description: 'Whether the input should display in error state',
23
+ defaultValue: false,
24
+ },
25
+ type: {
26
+ control: 'select',
27
+ options: ['text', 'email', 'password', 'number', 'tel', 'url', 'search'],
28
+ description: 'The type of input',
29
+ defaultValue: 'text',
30
+ },
31
+ placeholder: {
32
+ control: 'text',
33
+ description: 'Placeholder text for the input',
34
+ },
35
+ disabled: {
36
+ control: 'boolean',
37
+ description: 'Whether the input is disabled',
38
+ defaultValue: false,
39
+ },
40
+ required: {
41
+ control: 'boolean',
42
+ description: 'Whether the input is required',
43
+ defaultValue: false,
44
+ },
45
+ },
46
+ } satisfies Meta<typeof Input>;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ export const Default: Story = {
52
+ args: {
53
+ placeholder: 'Enter text...',
54
+ },
55
+ parameters: {
56
+ docs: {
57
+ description: {
58
+ story: 'Default input with standard styling.',
59
+ },
60
+ },
61
+ },
62
+ };
63
+
64
+ export const WithError: Story = {
65
+ args: {
66
+ hasError: true,
67
+ placeholder: 'This input has an error',
68
+ value: 'Invalid input',
69
+ },
70
+ parameters: {
71
+ docs: {
72
+ description: {
73
+ story: 'Input in error state with red border styling.',
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ export const Disabled: Story = {
80
+ args: {
81
+ disabled: true,
82
+ placeholder: 'This input is disabled',
83
+ value: 'Disabled input',
84
+ },
85
+ parameters: {
86
+ docs: {
87
+ description: {
88
+ story: 'Disabled input with reduced opacity and gray background.',
89
+ },
90
+ },
91
+ },
92
+ };
93
+
94
+ export const Required: Story = {
95
+ render: () => (
96
+ <div className="w-80">
97
+ <label htmlFor="required-input" className="block text-sm font-medium mb-1">
98
+ Full Name <span className="text-red-500">*</span>
99
+ </label>
100
+ <Input
101
+ id="required-input"
102
+ required
103
+ placeholder="Enter your full name"
104
+ aria-describedby="required-help"
105
+ />
106
+ <p id="required-help" className="text-gray-600 text-xs mt-1">
107
+ * This field is required
108
+ </p>
109
+ </div>
110
+ ),
111
+ parameters: {
112
+ docs: {
113
+ description: {
114
+ story: 'Required input field with visual indicator (red asterisk) and helper text.',
115
+ },
116
+ },
117
+ },
118
+ };
119
+
120
+ export const RequiredWithError: Story = {
121
+ render: () => (
122
+ <div className="w-80">
123
+ <label htmlFor="required-error-input" className="block text-sm font-medium mb-1">
124
+ Full Name <span className="text-red-500">*</span>
125
+ </label>
126
+ <Input
127
+ id="required-error-input"
128
+ required
129
+ hasError
130
+ aria-invalid="true"
131
+ aria-describedby="required-error-help"
132
+ />
133
+ <p id="required-error-help" className="text-red-600 text-xs mt-1" role="alert">
134
+ This field is required
135
+ </p>
136
+ </div>
137
+ ),
138
+ parameters: {
139
+ docs: {
140
+ description: {
141
+ story: 'Required field showing error state when left empty.',
142
+ },
143
+ },
144
+ },
145
+ };
146
+
147
+ export const DifferentTypes: Story = {
148
+ render: () => (
149
+ <div className="space-y-4 w-80">
150
+ <div>
151
+ <label htmlFor="email-input" className="block text-sm font-medium mb-1">
152
+ Email
153
+ </label>
154
+
155
+ <Input id="email-input" type="email" placeholder="user@example.com" />
156
+ </div>
157
+
158
+ <div>
159
+ <label htmlFor="password-input" className="block text-sm font-medium mb-1">
160
+ Password
161
+ </label>
162
+
163
+ <Input id="password-input" type="password" placeholder="Enter password" />
164
+ </div>
165
+
166
+ <div>
167
+ <label htmlFor="number-input" className="block text-sm font-medium mb-1">
168
+ Age
169
+ </label>
170
+
171
+ <Input id="number-input" type="number" placeholder="25" min={0} max={120} />
172
+ </div>
173
+
174
+ <div>
175
+ <label htmlFor="tel-input" className="block text-sm font-medium mb-1">
176
+ Phone
177
+ </label>
178
+
179
+ <Input id="tel-input" type="tel" placeholder="+1 (555) 123-4567" />
180
+ </div>
181
+
182
+ <div>
183
+ <label htmlFor="url-input" className="block text-sm font-medium mb-1">
184
+ Website
185
+ </label>
186
+
187
+ <Input id="url-input" type="url" placeholder="https://example.com" />
188
+ </div>
189
+ </div>
190
+ ),
191
+ parameters: {
192
+ docs: {
193
+ description: {
194
+ story: 'Showcase of different input types with proper labels for accessibility.',
195
+ },
196
+ },
197
+ },
198
+ };
199
+
200
+ export const FormExample: Story = {
201
+ render: () => (
202
+ <form className="space-y-4 w-80">
203
+ <div>
204
+ <label htmlFor="name" className="block text-sm font-medium mb-1">
205
+ Full Name
206
+ </label>
207
+
208
+ <Input id="name" type="text" placeholder="John Doe" required />
209
+ </div>
210
+
211
+ <div>
212
+ <label htmlFor="email" className="block text-sm font-medium mb-1">
213
+ Email Address
214
+ </label>
215
+
216
+ <Input id="email" type="email" placeholder="john@example.com" required />
217
+ </div>
218
+
219
+ <div>
220
+ <label htmlFor="phone" className="block text-sm font-medium mb-1">
221
+ Phone Number
222
+ </label>
223
+
224
+ <Input id="phone" type="tel" placeholder="+1 (555) 123-4567" />
225
+ </div>
226
+
227
+ <div>
228
+ <label htmlFor="age" className="block text-sm font-medium mb-1">
229
+ Age
230
+ </label>
231
+
232
+ <Input id="age" type="number" placeholder="25" min={18} max={120} />
233
+ </div>
234
+
235
+ <button
236
+ type="submit"
237
+ className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700
238
+ focus:outline-none focus:ring-2 focus:ring-blue-500"
239
+ >
240
+ Submit
241
+ </button>
242
+ </form>
243
+ ),
244
+ parameters: {
245
+ docs: {
246
+ description: {
247
+ story:
248
+ 'Complete form example showing inputs in a realistic context with proper labels and validation.',
249
+ },
250
+ },
251
+ },
252
+ };
253
+
254
+ export const ErrorStates: Story = {
255
+ render: () => (
256
+ <div className="space-y-4 w-80">
257
+ <div>
258
+ <label htmlFor="error-email" className="block text-sm font-medium mb-1">
259
+ Email (Invalid)
260
+ </label>
261
+
262
+ <Input
263
+ id="error-email"
264
+ type="email"
265
+ hasError
266
+ value="invalid-email"
267
+ aria-invalid="true"
268
+ aria-describedby="email-error"
269
+ />
270
+
271
+ <p id="email-error" className="text-red-600 text-xs mt-1">
272
+ Please enter a valid email address.
273
+ </p>
274
+ </div>
275
+
276
+ <div>
277
+ <label htmlFor="error-required" className="block text-sm font-medium mb-1">
278
+ Required Field
279
+ </label>
280
+
281
+ <Input
282
+ id="error-required"
283
+ hasError
284
+ placeholder="This field is required"
285
+ aria-invalid="true"
286
+ aria-describedby="required-error"
287
+ />
288
+
289
+ <p id="required-error" className="text-red-600 text-xs mt-1">
290
+ This field is required.
291
+ </p>
292
+ </div>
293
+
294
+ <div>
295
+ <label htmlFor="error-number" className="block text-sm font-medium mb-1">
296
+ Age (Out of Range)
297
+ </label>
298
+
299
+ <Input
300
+ id="error-number"
301
+ type="number"
302
+ hasError
303
+ value="150"
304
+ min={0}
305
+ max={120}
306
+ aria-invalid="true"
307
+ aria-describedby="age-error"
308
+ />
309
+
310
+ <p id="age-error" className="text-red-600 text-xs mt-1">
311
+ Age must be between 0 and 120.
312
+ </p>
313
+ </div>
314
+ </div>
315
+ ),
316
+ parameters: {
317
+ docs: {
318
+ description: {
319
+ story:
320
+ 'Examples of input error states with proper ARIA attributes and error messages for accessibility.',
321
+ },
322
+ },
323
+ },
324
+ };
325
+
326
+ export const CustomStyling: Story = {
327
+ render: () => (
328
+ <div className="space-y-4 w-80">
329
+ <div>
330
+ <label htmlFor="large-input" className="block text-sm font-medium mb-1">
331
+ Large Input
332
+ </label>
333
+
334
+ <Input id="large-input" placeholder="Large input" className="text-lg p-3" />
335
+ </div>
336
+
337
+ <div>
338
+ <label htmlFor="custom-border" className="block text-sm font-medium mb-1">
339
+ Custom Border
340
+ </label>
341
+
342
+ <Input
343
+ id="custom-border"
344
+ placeholder="Custom border color"
345
+ className="border-2 border-purple-500 focus:border-purple-700"
346
+ />
347
+ </div>
348
+
349
+ <div>
350
+ <label htmlFor="no-rounded" className="block text-sm font-medium mb-1">
351
+ Square Corners
352
+ </label>
353
+
354
+ <Input id="no-rounded" placeholder="No border radius" className="rounded-none" />
355
+ </div>
356
+
357
+ <div>
358
+ <label htmlFor="full-width" className="block text-sm font-medium mb-1">
359
+ Full Width
360
+ </label>
361
+
362
+ <Input id="full-width" placeholder="Full width input" className="w-full" />
363
+ </div>
364
+ </div>
365
+ ),
366
+ parameters: {
367
+ docs: {
368
+ description: {
369
+ story:
370
+ 'Examples of custom styling using className prop. The component uses tailwind-merge to properly handle class overrides.',
371
+ },
372
+ },
373
+ },
374
+ };
375
+
376
+ export const Accessibility: Story = {
377
+ render: () => (
378
+ <div className="space-y-6 w-80">
379
+ <div className="bg-blue-50 border border-blue-200 rounded p-4">
380
+ <h3 className="font-bold text-blue-800 mb-2">Accessibility Features:</h3>
381
+
382
+ <ul className="list-disc list-inside space-y-1 text-blue-700 text-sm">
383
+ <li>Proper label association with htmlFor/id</li>
384
+ <li>ARIA attributes for error states</li>a<li>Descriptive error messages</li>
385
+ <li>Keyboard navigation support</li>
386
+ <li>Screen reader compatibility</li>
387
+ </ul>
388
+ </div>
389
+
390
+ <div>
391
+ <label htmlFor="accessible-input" className="block text-sm font-medium mb-1">
392
+ Accessible Input Example
393
+ </label>
394
+
395
+ <Input
396
+ id="accessible-input"
397
+ type="email"
398
+ placeholder="user@example.com"
399
+ aria-describedby="input-help input-error"
400
+ required
401
+ />
402
+
403
+ <p id="input-help" className="text-gray-600 text-xs mt-1">
404
+ We&apos;ll never share your email with anyone else.
405
+ </p>
406
+ </div>
407
+
408
+ <div>
409
+ <label htmlFor="accessible-error" className="block text-sm font-medium mb-1">
410
+ Input with Error
411
+ </label>
412
+
413
+ <Input
414
+ id="accessible-error"
415
+ hasError
416
+ value="invalid@"
417
+ aria-invalid="true"
418
+ aria-describedby="accessible-error-msg"
419
+ />
420
+
421
+ <p id="accessible-error-msg" className="text-red-600 text-xs mt-1" role="alert">
422
+ Please enter a valid email address.
423
+ </p>
424
+ </div>
425
+ </div>
426
+ ),
427
+ parameters: {
428
+ docs: {
429
+ description: {
430
+ story:
431
+ 'Comprehensive accessibility example showing proper ARIA attributes, error handling, and screen reader support.',
432
+ },
433
+ },
434
+ },
435
+ };
@@ -0,0 +1,214 @@
1
+ import { Input } from './Input';
2
+ import { render, screen } from '../../test/renderers';
3
+
4
+ const ROUNDED_MD = 'rounded-md';
5
+ const BORDER_ERROR = 'border-error';
6
+
7
+ describe('Input', () => {
8
+ describe('general', () => {
9
+ it('renders an input element', () => {
10
+ render(<Input />);
11
+
12
+ const input = screen.getByRole('textbox');
13
+
14
+ expect(input).toBeInTheDocument();
15
+ });
16
+
17
+ it('applies default classes', () => {
18
+ render(<Input />);
19
+
20
+ const input = screen.getByRole('textbox');
21
+
22
+ expect(input).toHaveClass(
23
+ ROUNDED_MD,
24
+ 'border',
25
+ 'p-1',
26
+ 'disabled:opacity-60',
27
+ 'disabled:bg-gray-100',
28
+ );
29
+ });
30
+
31
+ it('applies error styling when hasError is true', () => {
32
+ render(<Input hasError />);
33
+
34
+ const input = screen.getByRole('textbox');
35
+
36
+ expect(input).toHaveClass(BORDER_ERROR);
37
+ });
38
+
39
+ it('does not apply error styling when hasError is false', () => {
40
+ render(<Input hasError={false} />);
41
+
42
+ const input = screen.getByRole('textbox');
43
+
44
+ expect(input).not.toHaveClass(BORDER_ERROR);
45
+ });
46
+
47
+ it('does not apply error styling when hasError is undefined', () => {
48
+ render(<Input />);
49
+
50
+ const input = screen.getByRole('textbox');
51
+
52
+ expect(input).not.toHaveClass(BORDER_ERROR);
53
+ });
54
+
55
+ it('merges custom className with default classes', () => {
56
+ render(<Input className="custom-class" />);
57
+
58
+ const input = screen.getByRole('textbox');
59
+
60
+ expect(input).toHaveClass(ROUNDED_MD, 'border', 'p-1', 'custom-class');
61
+ });
62
+
63
+ it('allows custom className to override default classes', () => {
64
+ render(<Input className="border-2" />); // This actually overrides border (1px)
65
+
66
+ const input = screen.getByRole('textbox');
67
+
68
+ expect(input).toHaveClass('border-2');
69
+ expect(input).not.toHaveClass('border'); // Now this is correctly overridden
70
+ });
71
+
72
+ it('forwards standard input props', () => {
73
+ render(
74
+ <Input
75
+ type="email"
76
+ placeholder="Enter email"
77
+ value="test@example.com"
78
+ name="email"
79
+ id="email-input"
80
+ readOnly
81
+ />,
82
+ );
83
+
84
+ const input = screen.getByRole('textbox');
85
+
86
+ expect(input).toHaveAttribute('type', 'email');
87
+ expect(input).toHaveAttribute('placeholder', 'Enter email');
88
+ expect(input).toHaveAttribute('value', 'test@example.com');
89
+ expect(input).toHaveAttribute('name', 'email');
90
+ expect(input).toHaveAttribute('id', 'email-input');
91
+ expect(input).toHaveAttribute('readonly');
92
+ });
93
+
94
+ it('applies disabled styling when disabled', () => {
95
+ render(<Input disabled />);
96
+
97
+ const input = screen.getByRole('textbox');
98
+
99
+ expect(input).toBeDisabled();
100
+ expect(input).toHaveClass('disabled:opacity-60', 'disabled:bg-gray-100');
101
+ });
102
+
103
+ it('combines hasError with custom className', () => {
104
+ render(<Input hasError className="w-full" />);
105
+
106
+ const input = screen.getByRole('textbox');
107
+
108
+ expect(input).toHaveClass(BORDER_ERROR, 'w-full', ROUNDED_MD, 'border', 'p-1');
109
+ });
110
+
111
+ it('handles different input types', () => {
112
+ const { rerender } = render(
113
+ <>
114
+ <label htmlFor="password-input">Password</label>
115
+
116
+ <Input type="password" id="password-input" />
117
+ </>,
118
+ );
119
+
120
+ let input = screen.getByLabelText('Password');
121
+
122
+ expect(input).toHaveAttribute('type', 'password');
123
+
124
+ rerender(
125
+ <>
126
+ <label htmlFor="number-input">Age</label>
127
+
128
+ <Input type="number" id="number-input" />
129
+ </>,
130
+ );
131
+ input = screen.getByLabelText('Age');
132
+ expect(input).toHaveAttribute('type', 'number');
133
+ expect(input).toHaveAttribute('type', 'number');
134
+ // We can also verify the role changed
135
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument();
136
+
137
+ rerender(
138
+ <>
139
+ <label htmlFor="email-input">Email</label>
140
+
141
+ <Input type="email" id="email-input" />
142
+ </>,
143
+ );
144
+ input = screen.getByLabelText('Email');
145
+ expect(input).toHaveAttribute('type', 'email');
146
+ // Still a textbox role for email inputs
147
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
148
+ });
149
+
150
+ it('supports required attribute', () => {
151
+ render(<Input required />);
152
+
153
+ const input = screen.getByRole('textbox');
154
+
155
+ expect(input).toBeRequired();
156
+ });
157
+
158
+ it('supports aria attributes', () => {
159
+ render(
160
+ <Input aria-label="Email address" aria-describedby="email-help" aria-invalid="true" />,
161
+ );
162
+
163
+ const input = screen.getByRole('textbox');
164
+
165
+ expect(input).toHaveAttribute('aria-label', 'Email address');
166
+ expect(input).toHaveAttribute('aria-describedby', 'email-help');
167
+ expect(input).toHaveAttribute('aria-invalid', 'true');
168
+ });
169
+
170
+ it('handles complex className override scenarios', () => {
171
+ render(<Input hasError className="border-green-500 p-4" />);
172
+
173
+ const input = screen.getByRole('textbox');
174
+
175
+ // Custom classes should override defaults due to twMerge
176
+ expect(input).toHaveClass('border-green-500', 'p-4');
177
+ expect(input).toHaveClass(ROUNDED_MD); // Default class preserved
178
+ expect(input).toHaveClass('border'); // Base border class is still present
179
+ expect(input).not.toHaveClass('p-1'); // Overridden by p-4
180
+ expect(input).not.toHaveClass(BORDER_ERROR); // Overridden by border-green-500
181
+ });
182
+ });
183
+
184
+ describe('accessibility', () => {
185
+ it('is accessible by role', () => {
186
+ render(<Input />);
187
+
188
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
189
+ });
190
+
191
+ it('supports form labels via id', () => {
192
+ render(
193
+ <>
194
+ <label htmlFor="test-input">Test Label</label>
195
+
196
+ <Input id="test-input" />
197
+ </>,
198
+ );
199
+
200
+ const input = screen.getByLabelText('Test Label');
201
+
202
+ expect(input).toBeInTheDocument();
203
+ });
204
+
205
+ it('supports error state for screen readers', () => {
206
+ render(<Input hasError aria-invalid="true" />);
207
+
208
+ const input = screen.getByRole('textbox');
209
+
210
+ expect(input).toHaveAttribute('aria-invalid', 'true');
211
+ expect(input).toHaveClass(BORDER_ERROR);
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { twMerge } from 'tailwind-merge';
4
+
5
+ import type { ExtendProps } from '../../types/utils';
6
+
7
+ type Props = {
8
+ hasError?: boolean;
9
+ };
10
+
11
+ export type InputProps = ExtendProps<'input', Props>;
12
+
13
+ export const Input = ({ hasError, className, ...props }: InputProps) => {
14
+ return (
15
+ <input
16
+ {...props}
17
+ className={twMerge(
18
+ 'rounded-md border p-1 disabled:opacity-60 disabled:bg-gray-100',
19
+ hasError ? 'border-error' : '',
20
+ className,
21
+ )}
22
+ />
23
+ );
24
+ };