@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,409 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
2
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
|
|
5
|
+
import { BackToTop } from './BackToTop';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/BackToTop',
|
|
9
|
+
component: BackToTop,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
'A floating "Back to Top" button that appears when the user scrolls down and allows quick navigation back to the top of the page.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
tags: ['autodocs'],
|
|
20
|
+
argTypes: {
|
|
21
|
+
threshold: {
|
|
22
|
+
control: { type: 'number', min: 0, max: 2000, step: 100 },
|
|
23
|
+
description: 'Scroll threshold in pixels before button appears',
|
|
24
|
+
defaultValue: 600,
|
|
25
|
+
},
|
|
26
|
+
bottom: {
|
|
27
|
+
control: { type: 'number', min: 0, max: 100, step: 4 },
|
|
28
|
+
description: 'Position from bottom in pixels',
|
|
29
|
+
defaultValue: 16,
|
|
30
|
+
},
|
|
31
|
+
left: {
|
|
32
|
+
control: { type: 'number', min: 0, max: 100, step: 4 },
|
|
33
|
+
description: 'Position from left in pixels',
|
|
34
|
+
defaultValue: 8,
|
|
35
|
+
},
|
|
36
|
+
className: {
|
|
37
|
+
control: 'text',
|
|
38
|
+
description: 'Custom className for additional styling',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
} satisfies Meta<typeof BackToTop>;
|
|
42
|
+
|
|
43
|
+
export default meta;
|
|
44
|
+
|
|
45
|
+
type Story = StoryObj<typeof meta>;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {
|
|
48
|
+
parameters: {
|
|
49
|
+
docs: {
|
|
50
|
+
description: {
|
|
51
|
+
story: 'Default back to top button. Scroll down to see it appear at 600px threshold.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
render: (args) => (
|
|
56
|
+
<div>
|
|
57
|
+
<BackToTop {...args} />
|
|
58
|
+
|
|
59
|
+
<div className="space-y-8 p-8">
|
|
60
|
+
<h1 className="text-3xl font-bold">Scroll down to see the Back to Top button</h1>
|
|
61
|
+
|
|
62
|
+
<div className="space-y-6">
|
|
63
|
+
{Array.from({ length: 50 }, (_, i) => (
|
|
64
|
+
<div key={i} className="p-4 bg-gray-100 rounded">
|
|
65
|
+
<h2 className="text-xl font-semibold mb-2">Section {i + 1}</h2>
|
|
66
|
+
|
|
67
|
+
<p>
|
|
68
|
+
This is content section {i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing
|
|
69
|
+
elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
|
70
|
+
minim veniam, quis nostrud exercitation ullamco laboris.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const LowThreshold: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
threshold: 200,
|
|
83
|
+
},
|
|
84
|
+
parameters: {
|
|
85
|
+
docs: {
|
|
86
|
+
description: {
|
|
87
|
+
story: 'Back to top button with a low threshold (200px) - appears quickly when scrolling.',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
render: (args) => (
|
|
92
|
+
<div>
|
|
93
|
+
<BackToTop {...args} />
|
|
94
|
+
|
|
95
|
+
<div className="space-y-8 p-8">
|
|
96
|
+
<h1 className="text-3xl font-bold">Low Threshold Example (200px)</h1>
|
|
97
|
+
|
|
98
|
+
<div className="bg-yellow-100 border border-yellow-400 rounded p-4 mb-6">
|
|
99
|
+
<p className="text-yellow-800">
|
|
100
|
+
This example has a low threshold of 200px. The button will appear much sooner when
|
|
101
|
+
scrolling.
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="space-y-6">
|
|
106
|
+
{Array.from({ length: 30 }, (_, i) => (
|
|
107
|
+
<div key={i} className="p-4 bg-blue-50 rounded">
|
|
108
|
+
<h2 className="text-xl font-semibold mb-2">Content Block {i + 1}</h2>
|
|
109
|
+
|
|
110
|
+
<p>
|
|
111
|
+
Content with low threshold setting. The back to top button should appear after
|
|
112
|
+
scrolling just 200 pixels down the page.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const CustomPosition: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
bottom: 32,
|
|
125
|
+
left: 32,
|
|
126
|
+
},
|
|
127
|
+
parameters: {
|
|
128
|
+
docs: {
|
|
129
|
+
description: {
|
|
130
|
+
story: 'Back to top button with custom positioning (32px from bottom and left).',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
render: (args) => (
|
|
135
|
+
<div>
|
|
136
|
+
<BackToTop {...args} />
|
|
137
|
+
|
|
138
|
+
<div className="space-y-8 p-8">
|
|
139
|
+
<h1 className="text-3xl font-bold">Custom Position Example</h1>
|
|
140
|
+
|
|
141
|
+
<div className="bg-green-100 border border-green-400 rounded p-4 mb-6">
|
|
142
|
+
<p className="text-green-800">
|
|
143
|
+
This button is positioned 32px from both bottom and left edges.
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="space-y-6">
|
|
148
|
+
{Array.from({ length: 40 }, (_, i) => (
|
|
149
|
+
<div key={i} className="p-4 bg-green-50 rounded">
|
|
150
|
+
<h2 className="text-xl font-semibold mb-2">Section {i + 1}</h2>
|
|
151
|
+
|
|
152
|
+
<p>Scroll down to see the back to top button in its custom position.</p>
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const RightAligned: Story = {
|
|
162
|
+
args: {
|
|
163
|
+
left: undefined,
|
|
164
|
+
className: 'right-4',
|
|
165
|
+
},
|
|
166
|
+
parameters: {
|
|
167
|
+
docs: {
|
|
168
|
+
description: {
|
|
169
|
+
story: 'Back to top button positioned on the right side using custom className.',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
render: (args) => (
|
|
174
|
+
<div>
|
|
175
|
+
<BackToTop {...args} />
|
|
176
|
+
|
|
177
|
+
<div className="space-y-8 p-8">
|
|
178
|
+
<h1 className="text-3xl font-bold">Right-Aligned Button</h1>
|
|
179
|
+
|
|
180
|
+
<div className="bg-purple-100 border border-purple-400 rounded p-4 mb-6">
|
|
181
|
+
<p className="text-purple-800">
|
|
182
|
+
This button is positioned on the right side of the screen using className override.
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="space-y-6">
|
|
187
|
+
{Array.from({ length: 35 }, (_, i) => (
|
|
188
|
+
<div key={i} className="p-4 bg-purple-50 rounded">
|
|
189
|
+
<h2 className="text-xl font-semibold mb-2">Content {i + 1}</h2>
|
|
190
|
+
|
|
191
|
+
<p>The back to top button appears on the right side instead of the default left.</p>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const CustomStyling: Story = {
|
|
201
|
+
args: {
|
|
202
|
+
className: 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700 shadow-xl',
|
|
203
|
+
},
|
|
204
|
+
parameters: {
|
|
205
|
+
docs: {
|
|
206
|
+
description: {
|
|
207
|
+
story: 'Back to top button with custom styling using className prop.',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
render: (args) => (
|
|
212
|
+
<div>
|
|
213
|
+
<BackToTop {...args} />
|
|
214
|
+
|
|
215
|
+
<div className="space-y-8 p-8">
|
|
216
|
+
<h1 className="text-3xl font-bold">Custom Styled Button</h1>
|
|
217
|
+
|
|
218
|
+
<div className="bg-blue-100 border border-blue-400 rounded p-4 mb-6">
|
|
219
|
+
<p className="text-blue-800">
|
|
220
|
+
This button has custom blue styling applied via the className prop.
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="space-y-6">
|
|
225
|
+
{Array.from({ length: 25 }, (_, i) => (
|
|
226
|
+
<div key={i} className="p-4 bg-blue-50 rounded">
|
|
227
|
+
<h2 className="text-xl font-semibold mb-2">Styled Section {i + 1}</h2>
|
|
228
|
+
|
|
229
|
+
<p>Custom styled back to top button with blue theme.</p>
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const AlwaysVisible: Story = {
|
|
239
|
+
args: {
|
|
240
|
+
threshold: 0,
|
|
241
|
+
},
|
|
242
|
+
parameters: {
|
|
243
|
+
docs: {
|
|
244
|
+
description: {
|
|
245
|
+
story: 'Back to top button that is always visible (threshold set to 0).',
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
render: (args) => (
|
|
250
|
+
<div>
|
|
251
|
+
<BackToTop {...args} />
|
|
252
|
+
|
|
253
|
+
<div className="space-y-8 p-8">
|
|
254
|
+
<h1 className="text-3xl font-bold">Always Visible Button</h1>
|
|
255
|
+
|
|
256
|
+
<div className="bg-red-100 border border-red-400 rounded p-4 mb-6">
|
|
257
|
+
<p className="text-red-800">
|
|
258
|
+
This button is always visible because threshold is set to 0px.
|
|
259
|
+
</p>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div className="space-y-6">
|
|
263
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
264
|
+
<div key={i} className="p-4 bg-red-50 rounded">
|
|
265
|
+
<h2 className="text-xl font-semibold mb-2">Always Visible {i + 1}</h2>
|
|
266
|
+
|
|
267
|
+
<p>The button is visible even at the top of the page.</p>
|
|
268
|
+
</div>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const LongPageExample: Story = {
|
|
277
|
+
parameters: {
|
|
278
|
+
docs: {
|
|
279
|
+
description: {
|
|
280
|
+
story: 'Comprehensive example with a very long page to test scroll behavior thoroughly.',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
render: (args) => (
|
|
285
|
+
<div>
|
|
286
|
+
<BackToTop {...args} />
|
|
287
|
+
|
|
288
|
+
<div className="space-y-8 p-8">
|
|
289
|
+
<header className="mb-12">
|
|
290
|
+
<h1 className="text-4xl font-bold mb-4">Long Page Scroll Test</h1>
|
|
291
|
+
|
|
292
|
+
<p className="text-lg text-gray-600">
|
|
293
|
+
This page demonstrates the back to top functionality with extensive content. Scroll down
|
|
294
|
+
to see the button appear after 600px.
|
|
295
|
+
</p>
|
|
296
|
+
</header>
|
|
297
|
+
|
|
298
|
+
<section className="space-y-8">
|
|
299
|
+
<h2 className="text-2xl font-bold border-b pb-2">Article Content</h2>
|
|
300
|
+
{Array.from({ length: 100 }, (_, i) => (
|
|
301
|
+
<article key={i} className="p-6 bg-white border rounded-lg shadow-sm">
|
|
302
|
+
<h3 className="text-xl font-semibold mb-3">Article {i + 1}</h3>
|
|
303
|
+
|
|
304
|
+
<p className="mb-4">
|
|
305
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
|
306
|
+
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
|
|
307
|
+
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
|
308
|
+
</p>
|
|
309
|
+
|
|
310
|
+
<p>
|
|
311
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
|
|
312
|
+
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa
|
|
313
|
+
qui officia deserunt mollit anim id est laborum.
|
|
314
|
+
</p>
|
|
315
|
+
{i % 10 === 9 && (
|
|
316
|
+
<div className="mt-4 p-3 bg-gray-100 rounded">
|
|
317
|
+
<p className="text-sm text-gray-600">
|
|
318
|
+
📍 Checkpoint: You've scrolled through {i + 1} articles. Use the back to
|
|
319
|
+
top button to return to the beginning!
|
|
320
|
+
</p>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</article>
|
|
324
|
+
))}
|
|
325
|
+
</section>
|
|
326
|
+
|
|
327
|
+
<footer className="mt-12 p-6 bg-gray-800 text-white rounded">
|
|
328
|
+
<p className="text-center">
|
|
329
|
+
🎉 You've reached the end! Use the back to top button to scroll back up.
|
|
330
|
+
</p>
|
|
331
|
+
</footer>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export const AccessibilityDemo: Story = {
|
|
338
|
+
parameters: {
|
|
339
|
+
docs: {
|
|
340
|
+
description: {
|
|
341
|
+
story:
|
|
342
|
+
'Demonstration of accessibility features including keyboard navigation and screen reader support.',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
render: (args) => (
|
|
347
|
+
<div>
|
|
348
|
+
<BackToTop {...args} />
|
|
349
|
+
|
|
350
|
+
<div className="space-y-8 p-8">
|
|
351
|
+
<h1 className="text-3xl font-bold">Accessibility Features Demo</h1>
|
|
352
|
+
|
|
353
|
+
<div className="bg-blue-50 border border-blue-200 rounded p-6">
|
|
354
|
+
<h2 className="text-xl font-bold text-blue-800 mb-4">Accessibility Features:</h2>
|
|
355
|
+
|
|
356
|
+
<ul className="list-disc list-inside space-y-2 text-blue-700">
|
|
357
|
+
<li>
|
|
358
|
+
<strong>Keyboard Navigation:</strong> Tab to focus, Enter or Space to activate
|
|
359
|
+
</li>
|
|
360
|
+
|
|
361
|
+
<li>
|
|
362
|
+
<strong>Screen Reader:</strong> Proper aria-label and hidden decorative icon
|
|
363
|
+
</li>
|
|
364
|
+
|
|
365
|
+
<li>
|
|
366
|
+
<strong>Focus Management:</strong> Visible focus indicators with ring
|
|
367
|
+
</li>
|
|
368
|
+
|
|
369
|
+
<li>
|
|
370
|
+
<strong>Responsive Text:</strong> “Back to top” text hidden on mobile,
|
|
371
|
+
available to screen readers
|
|
372
|
+
</li>
|
|
373
|
+
|
|
374
|
+
<li>
|
|
375
|
+
<strong>Semantic HTML:</strong> Proper button element with type attribute
|
|
376
|
+
</li>
|
|
377
|
+
</ul>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div className="bg-green-50 border border-green-200 rounded p-4">
|
|
381
|
+
<h3 className="font-bold text-green-800 mb-2">Testing Instructions:</h3>
|
|
382
|
+
|
|
383
|
+
<ol className="list-decimal list-inside space-y-1 text-green-700 text-sm">
|
|
384
|
+
<li>Scroll down until the back to top button appears</li>
|
|
385
|
+
|
|
386
|
+
<li>Use Tab key to navigate to the button</li>
|
|
387
|
+
|
|
388
|
+
<li>Press Enter or Space to activate it</li>
|
|
389
|
+
|
|
390
|
+
<li>Test with a screen reader for proper announcements</li>
|
|
391
|
+
</ol>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div className="space-y-6">
|
|
395
|
+
{Array.from({ length: 30 }, (_, i) => (
|
|
396
|
+
<div key={i} className="p-4 bg-gray-50 rounded">
|
|
397
|
+
<h3 className="text-lg font-semibold mb-2">Accessibility Section {i + 1}</h3>
|
|
398
|
+
|
|
399
|
+
<p>
|
|
400
|
+
This content tests the accessibility of the back to top button. The button should be
|
|
401
|
+
properly focusable and operable via keyboard.
|
|
402
|
+
</p>
|
|
403
|
+
</div>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
),
|
|
409
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BackToTop } from './BackToTop';
|
|
2
|
+
import { render, screen, userEvent } from '../../test/renderers';
|
|
3
|
+
|
|
4
|
+
describe('BackToTop component', () => {
|
|
5
|
+
const originalScrollTo = window.scrollTo;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
Object.defineProperty(window, 'scrollTo', {
|
|
9
|
+
value: vi.fn(),
|
|
10
|
+
writable: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Simulate scrollY (more modern than pageYOffset)
|
|
14
|
+
Object.defineProperty(window, 'pageYOffset', {
|
|
15
|
+
get: () => 700,
|
|
16
|
+
configurable: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
Object.defineProperty(window, 'scrollY', {
|
|
20
|
+
get: () => 700,
|
|
21
|
+
configurable: true,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
window.scrollTo = originalScrollTo;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders correctly when scrolled down', async () => {
|
|
31
|
+
render(<BackToTop />);
|
|
32
|
+
|
|
33
|
+
// Trigger the scroll event manually to call toggleVisibility
|
|
34
|
+
window.dispatchEvent(new Event('scroll'));
|
|
35
|
+
|
|
36
|
+
// Wait for the component to update
|
|
37
|
+
const button = await screen.findByRole('button', { name: /back to top/i });
|
|
38
|
+
|
|
39
|
+
expect(button).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('clickable and triggers scrollToTop', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
|
|
45
|
+
render(<BackToTop />);
|
|
46
|
+
|
|
47
|
+
// Trigger the scroll event manually to call toggleVisibility
|
|
48
|
+
window.dispatchEvent(new Event('scroll'));
|
|
49
|
+
|
|
50
|
+
// Wait for the component to update and find the button
|
|
51
|
+
const button = await screen.findByRole('button', { name: /back to top/i });
|
|
52
|
+
|
|
53
|
+
await user.click(button);
|
|
54
|
+
|
|
55
|
+
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { LuArrowUp } from 'react-icons/lu';
|
|
6
|
+
|
|
7
|
+
export type BackToTopProps = {
|
|
8
|
+
/** Scroll threshold in pixels before button appears */
|
|
9
|
+
threshold?: number;
|
|
10
|
+
/** Position from bottom in pixels */
|
|
11
|
+
bottom?: number;
|
|
12
|
+
/** Position from left in pixels */
|
|
13
|
+
left?: number;
|
|
14
|
+
/** Custom className for styling */
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const BackToTop = ({
|
|
19
|
+
threshold = 600,
|
|
20
|
+
bottom = 16,
|
|
21
|
+
left = 8,
|
|
22
|
+
className = '',
|
|
23
|
+
}: BackToTopProps) => {
|
|
24
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
25
|
+
|
|
26
|
+
// Throttle scroll events for better performance
|
|
27
|
+
const throttle = useCallback((func: () => void, delay: number) => {
|
|
28
|
+
let timeoutId: number | null = null;
|
|
29
|
+
let lastExecTime = 0;
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
const currentTime = Date.now();
|
|
33
|
+
|
|
34
|
+
if (currentTime - lastExecTime > delay) {
|
|
35
|
+
func();
|
|
36
|
+
lastExecTime = currentTime;
|
|
37
|
+
} else {
|
|
38
|
+
if (timeoutId) {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
timeoutId = window.setTimeout(
|
|
43
|
+
() => {
|
|
44
|
+
func();
|
|
45
|
+
lastExecTime = Date.now();
|
|
46
|
+
},
|
|
47
|
+
delay - (currentTime - lastExecTime),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
// Show button when page is scrolled down
|
|
54
|
+
const toggleVisibility = useCallback(() => {
|
|
55
|
+
if (typeof window !== 'undefined') {
|
|
56
|
+
setIsVisible(window.pageYOffset > threshold);
|
|
57
|
+
}
|
|
58
|
+
}, [threshold]);
|
|
59
|
+
|
|
60
|
+
// Scroll to top smoothly
|
|
61
|
+
const scrollToTop = useCallback(() => {
|
|
62
|
+
if (typeof window !== 'undefined') {
|
|
63
|
+
window.scrollTo({
|
|
64
|
+
top: 0,
|
|
65
|
+
behavior: 'smooth',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (typeof window === 'undefined') {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const throttledToggleVisibility = throttle(toggleVisibility, 100);
|
|
76
|
+
|
|
77
|
+
// Check initial scroll position
|
|
78
|
+
toggleVisibility();
|
|
79
|
+
|
|
80
|
+
window.addEventListener('scroll', throttledToggleVisibility, { passive: true });
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
window.removeEventListener('scroll', throttledToggleVisibility);
|
|
84
|
+
};
|
|
85
|
+
}, [toggleVisibility, throttle]);
|
|
86
|
+
|
|
87
|
+
// Handle keyboard interaction
|
|
88
|
+
const handleKeyDown = useCallback(
|
|
89
|
+
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
90
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
scrollToTop();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[scrollToTop],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!isVisible) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={`
|
|
106
|
+
fixed z-50 inline-flex items-center gap-1
|
|
107
|
+
bg-white text-link border border-gray-300
|
|
108
|
+
rounded-md px-3 py-2 shadow-lg
|
|
109
|
+
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
|
110
|
+
transition-all duration-200 ease-in-out
|
|
111
|
+
${className}
|
|
112
|
+
`.trim()}
|
|
113
|
+
// className={`fixed bottom-4 left-2 text-link gap-1 inline-flex items-center bg-white
|
|
114
|
+
// ${className}`.trim()}
|
|
115
|
+
style={{
|
|
116
|
+
bottom: `${bottom}px`,
|
|
117
|
+
left: `${left}px`,
|
|
118
|
+
}}
|
|
119
|
+
onClick={scrollToTop}
|
|
120
|
+
onKeyDown={handleKeyDown}
|
|
121
|
+
aria-label="Scroll back to top of page"
|
|
122
|
+
title="Back to top"
|
|
123
|
+
>
|
|
124
|
+
<LuArrowUp size={20} aria-hidden="true" />
|
|
125
|
+
|
|
126
|
+
<span className="hidden sm:inline">Back to top</span>
|
|
127
|
+
|
|
128
|
+
<span className="sr-only sm:hidden">Back to top</span>
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AiFillChrome } from 'react-icons/ai';
|
|
2
|
+
|
|
3
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
4
|
+
import type { Meta, StoryFn } from '@storybook/react';
|
|
5
|
+
|
|
6
|
+
import { Chip, type ChipProps } from './Chip';
|
|
7
|
+
import { Paragraph } from '../Paragraph/Paragraph';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
children: 'Chip',
|
|
11
|
+
component: Chip,
|
|
12
|
+
} as Meta;
|
|
13
|
+
|
|
14
|
+
const Template: StoryFn<ChipProps> = (args) => <Chip {...args} />;
|
|
15
|
+
|
|
16
|
+
export const Default = Template.bind({});
|
|
17
|
+
Default.args = {
|
|
18
|
+
children: 'Chip',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const JustText = Template.bind({});
|
|
22
|
+
JustText.args = {
|
|
23
|
+
children: 'Hello, this is some simple text',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ParagraphOfText = Template.bind({});
|
|
27
|
+
ParagraphOfText.args = {
|
|
28
|
+
children: <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const TextWithIcon = Template.bind({});
|
|
32
|
+
TextWithIcon.args = {
|
|
33
|
+
children: (
|
|
34
|
+
<div className="flex items-center justify-center gap-2">
|
|
35
|
+
<AiFillChrome className="text-base" />
|
|
36
|
+
|
|
37
|
+
<Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Chip } from './Chip';
|
|
2
|
+
import { render, screen } from '../../test/renderers';
|
|
3
|
+
|
|
4
|
+
describe('Chip component', () => {
|
|
5
|
+
it('should render children correctly', () => {
|
|
6
|
+
render(
|
|
7
|
+
<Chip>
|
|
8
|
+
<p>Hello, World!</p>
|
|
9
|
+
</Chip>,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
expect(screen.getByText('Hello, World!')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should merge custom className correctly', () => {
|
|
16
|
+
render(<Chip className="bg-red-500 rounded-lg">Custom</Chip>);
|
|
17
|
+
const element = screen.getByText('Custom');
|
|
18
|
+
|
|
19
|
+
expect(element).toHaveClass('bg-red-500');
|
|
20
|
+
expect(element).toHaveClass('rounded-lg');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should override conflicting className using twMerge', () => {
|
|
24
|
+
render(<Chip className="px-2">Custom</Chip>);
|
|
25
|
+
const element = screen.getByText('Custom');
|
|
26
|
+
|
|
27
|
+
// Should NOT have original 'pt-[12px]' due to twMerge override
|
|
28
|
+
expect(element?.className).not.toMatch(/px-3/);
|
|
29
|
+
expect(element).toHaveClass('px-2');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
|
|
3
|
+
export type ChipProps = {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const Chip = ({ className, children }: ChipProps) => {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className={twMerge(
|
|
12
|
+
`inline-flex items-center rounded-lg bg-gray-200 px-3 py-1 text-sm font-medium
|
|
13
|
+
text-gray-800`,
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
};
|