@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,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 TextAreaProps = ExtendProps<'textarea', Props>;
|
|
12
|
+
|
|
13
|
+
export const TextArea = ({ hasError, className, ...props }: TextAreaProps) => {
|
|
14
|
+
return (
|
|
15
|
+
<textarea
|
|
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
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import Script from 'next/script';
|
|
4
|
+
|
|
5
|
+
type GlobalVarsProps = {
|
|
6
|
+
analyticsId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const GlobalVarsComponent = ({ analyticsId }: GlobalVarsProps) => {
|
|
10
|
+
console.log(`Global Vars: ${analyticsId}`); // Debugging line to check the ID
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
{/*
|
|
15
|
+
Set global variables on the window object.
|
|
16
|
+
Any further global variables can be added after these.
|
|
17
|
+
*/}
|
|
18
|
+
<Script
|
|
19
|
+
id="global-vars"
|
|
20
|
+
dangerouslySetInnerHTML={{
|
|
21
|
+
__html: `
|
|
22
|
+
window.swirrl = window.swirrl ?? {};
|
|
23
|
+
window.swirrl.gtagUACode = "${analyticsId}";
|
|
24
|
+
`,
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const GlobalVars = memo(GlobalVarsComponent);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import Script from 'next/script';
|
|
4
|
+
|
|
5
|
+
type GoogleAnalyticsProps = {
|
|
6
|
+
analyticsId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const GoogleAnalyticsComponent = ({ analyticsId }: GoogleAnalyticsProps) => {
|
|
10
|
+
console.log(`Google Analytics ID: ${analyticsId}`); // Debugging line to check the ID
|
|
11
|
+
|
|
12
|
+
return <Script src={`https://www.googletagmanager.com/gtag/js?id=${analyticsId}`} />;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const GoogleAnalytics = memo(GoogleAnalyticsComponent);
|
package/src/components/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// Default components - these can be used in server or client-side rendering
|
|
2
|
+
export { BackToTop } from './backToTop/BackToTop';
|
|
2
3
|
export { Button } from './Button/Button';
|
|
3
4
|
export { Card } from './Card/Card';
|
|
5
|
+
export { Chip } from './chip/Chip';
|
|
6
|
+
export { CookieBanner } from './cookieBanner/CookieBanner';
|
|
4
7
|
export { Container } from './container/Container';
|
|
5
8
|
export { ErrorText } from './ErrorText/ErrorText';
|
|
9
|
+
export { GlobalVars } from './googleAnalytics/GlobalVars';
|
|
10
|
+
export { GoogleAnalytics } from './googleAnalytics/GoogleAnalytics';
|
|
6
11
|
export { Heading } from './Heading/Heading';
|
|
7
12
|
export { Hint } from './Hint/Hint';
|
|
8
13
|
export { DefraLogo } from './images/DefraLogo';
|
|
@@ -12,10 +17,15 @@ export { ExternalLink } from './link/ExternalLink';
|
|
|
12
17
|
export { Link } from './link/Link';
|
|
13
18
|
export { Paragraph } from './Paragraph/Paragraph';
|
|
14
19
|
export { RuleDivider } from './divider/RuleDivider';
|
|
20
|
+
export { SkipLink } from './skipLink/SkipLink';
|
|
21
|
+
export { Input } from './form/Input';
|
|
22
|
+
export { TextArea } from './form/TextArea';
|
|
15
23
|
|
|
16
24
|
// Export default component types
|
|
25
|
+
export type { BackToTopProps } from './backToTop/BackToTop';
|
|
17
26
|
export type { ButtonProps } from './Button/Button';
|
|
18
27
|
export type { CardProps } from './Card/Card';
|
|
28
|
+
export type { ChipProps } from './chip/Chip';
|
|
19
29
|
export type { ContainerProps } from './container/Container';
|
|
20
30
|
export type { ErrorTextProps } from './ErrorText/ErrorText';
|
|
21
31
|
export type { HeadingProps } from './Heading/Heading';
|
|
@@ -24,6 +34,9 @@ export type { ExternalLinkProps } from './link/ExternalLink';
|
|
|
24
34
|
export type { LinkProps } from './link/Link';
|
|
25
35
|
export type { ParagraphProps } from './Paragraph/Paragraph';
|
|
26
36
|
export type { SlidingPanelProps } from './SlidingPanel/SlidingPanel';
|
|
37
|
+
export type { SkipLinkProps } from './skipLink/SkipLink';
|
|
38
|
+
export type { InputProps } from './form/Input';
|
|
39
|
+
export type { TextAreaProps } from './form/TextArea';
|
|
27
40
|
|
|
28
41
|
// Client components - these require 'use client' directive
|
|
29
42
|
export { DropdownMenu } from './dropdown/DropdownMenu';
|
|
@@ -31,6 +44,8 @@ export { useDropdownMenu } from './dropdown/useDropdownMenu';
|
|
|
31
44
|
export { SlidingPanel } from './SlidingPanel/SlidingPanel';
|
|
32
45
|
export { Accordion } from './accordion/Accordion';
|
|
33
46
|
export { Modal } from './Modal/Modal';
|
|
47
|
+
export { ErrorBoundary } from './ErrorBoundary/ErrorBoundary';
|
|
48
|
+
export { ErrorFallback } from './ErrorBoundary/ErrorFallback';
|
|
34
49
|
// NOTE: Select components moved to separate entry point '@tpzdsp/next-toolkit/components/select'
|
|
35
50
|
// export { Select } from './select/Select';
|
|
36
51
|
// export { SelectSkeleton } from './select/SelectSkeleton';
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/* eslint-disable jsx-a11y/anchor-is-valid */
|
|
2
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
|
|
5
|
+
import { SkipLink } from './SkipLink';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/SkipLink',
|
|
9
|
+
component: SkipLink,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
'A skip link component that allows users to jump to the main content, improving accessibility for keyboard and screen reader users.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
tags: ['autodocs'],
|
|
20
|
+
argTypes: {
|
|
21
|
+
mainContentId: {
|
|
22
|
+
control: 'text',
|
|
23
|
+
description: 'The ID of the main content element to skip to',
|
|
24
|
+
defaultValue: 'main-content',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
} satisfies Meta<typeof SkipLink>;
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof meta>;
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {
|
|
33
|
+
parameters: {
|
|
34
|
+
docs: {
|
|
35
|
+
description: {
|
|
36
|
+
story: 'Default skip link with the standard main content ID.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const CustomTarget: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
mainContentId: 'custom-main',
|
|
45
|
+
},
|
|
46
|
+
parameters: {
|
|
47
|
+
docs: {
|
|
48
|
+
description: {
|
|
49
|
+
story: 'Skip link with a custom target ID.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const WithMainContent: Story = {
|
|
56
|
+
render: (args) => (
|
|
57
|
+
<div className="min-h-screen">
|
|
58
|
+
<SkipLink {...args} />
|
|
59
|
+
|
|
60
|
+
<header className="bg-blue-600 text-white p-4">
|
|
61
|
+
<nav>
|
|
62
|
+
<ul className="flex space-x-4">
|
|
63
|
+
<li>
|
|
64
|
+
<a href="#" className="hover:underline">
|
|
65
|
+
Home
|
|
66
|
+
</a>
|
|
67
|
+
</li>
|
|
68
|
+
|
|
69
|
+
<li>
|
|
70
|
+
<a href="#" className="hover:underline">
|
|
71
|
+
About
|
|
72
|
+
</a>
|
|
73
|
+
</li>
|
|
74
|
+
|
|
75
|
+
<li>
|
|
76
|
+
<a href="#" className="hover:underline">
|
|
77
|
+
Services
|
|
78
|
+
</a>
|
|
79
|
+
</li>
|
|
80
|
+
|
|
81
|
+
<li>
|
|
82
|
+
<a href="#" className="hover:underline">
|
|
83
|
+
Contact
|
|
84
|
+
</a>
|
|
85
|
+
</li>
|
|
86
|
+
</ul>
|
|
87
|
+
</nav>
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<main id="main-content" className="p-8">
|
|
91
|
+
<h1 className="text-3xl font-bold mb-6">Main Content</h1>
|
|
92
|
+
|
|
93
|
+
<p className="mb-4">
|
|
94
|
+
This is the main content area. Use Tab key to navigate to the skip link at the top, then
|
|
95
|
+
press Enter to jump directly here.
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
<p className="mb-4">
|
|
99
|
+
The skip link is invisible by default but becomes visible when focused, providing an
|
|
100
|
+
accessible way for keyboard users to bypass navigation.
|
|
101
|
+
</p>
|
|
102
|
+
|
|
103
|
+
<div className="space-y-4">
|
|
104
|
+
<p>Sample content paragraph 1.</p>
|
|
105
|
+
|
|
106
|
+
<p>Sample content paragraph 2.</p>
|
|
107
|
+
|
|
108
|
+
<p>Sample content paragraph 3.</p>
|
|
109
|
+
</div>
|
|
110
|
+
</main>
|
|
111
|
+
|
|
112
|
+
<footer className="bg-gray-800 text-white p-4 mt-8">
|
|
113
|
+
<p>© 2024 Example Site. All rights reserved.</p>
|
|
114
|
+
</footer>
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
parameters: {
|
|
118
|
+
docs: {
|
|
119
|
+
description: {
|
|
120
|
+
story:
|
|
121
|
+
'Complete page layout showing how the skip link works in context. Use Tab to focus the skip link, then Enter to jump to main content.',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const CustomTargetWithContent: Story = {
|
|
128
|
+
args: {
|
|
129
|
+
mainContentId: 'article-content',
|
|
130
|
+
},
|
|
131
|
+
render: (args) => (
|
|
132
|
+
<div className="min-h-screen">
|
|
133
|
+
<SkipLink {...args} />
|
|
134
|
+
|
|
135
|
+
<header className="bg-green-600 text-white p-4">
|
|
136
|
+
<h1 className="text-xl font-bold">Site Header</h1>
|
|
137
|
+
</header>
|
|
138
|
+
|
|
139
|
+
<nav className="bg-gray-200 p-4">
|
|
140
|
+
<ul className="flex space-x-4">
|
|
141
|
+
<li>
|
|
142
|
+
<a href="#" className="text-blue-600 hover:underline">
|
|
143
|
+
Navigation Link 1
|
|
144
|
+
</a>
|
|
145
|
+
</li>
|
|
146
|
+
|
|
147
|
+
<li>
|
|
148
|
+
<a href="#" className="text-blue-600 hover:underline">
|
|
149
|
+
Navigation Link 2
|
|
150
|
+
</a>
|
|
151
|
+
</li>
|
|
152
|
+
|
|
153
|
+
<li>
|
|
154
|
+
<a href="#" className="text-blue-600 hover:underline">
|
|
155
|
+
Navigation Link 3
|
|
156
|
+
</a>
|
|
157
|
+
</li>
|
|
158
|
+
</ul>
|
|
159
|
+
</nav>
|
|
160
|
+
|
|
161
|
+
<aside className="bg-yellow-100 p-4">
|
|
162
|
+
<h2 className="font-bold mb-2">Sidebar</h2>
|
|
163
|
+
|
|
164
|
+
<p>Some sidebar content that users might want to skip.</p>
|
|
165
|
+
</aside>
|
|
166
|
+
|
|
167
|
+
<article id="article-content" className="p-8 bg-white">
|
|
168
|
+
<h1 className="text-3xl font-bold mb-6">Article Title</h1>
|
|
169
|
+
|
|
170
|
+
<p className="mb-4">
|
|
171
|
+
This skip link targets a custom ID "article-content" instead of the default
|
|
172
|
+
"main-content".
|
|
173
|
+
</p>
|
|
174
|
+
|
|
175
|
+
<p className="mb-4">
|
|
176
|
+
This demonstrates how the component can be configured for different page layouts and
|
|
177
|
+
content structures.
|
|
178
|
+
</p>
|
|
179
|
+
</article>
|
|
180
|
+
</div>
|
|
181
|
+
),
|
|
182
|
+
parameters: {
|
|
183
|
+
docs: {
|
|
184
|
+
description: {
|
|
185
|
+
story:
|
|
186
|
+
'Skip link with custom target ID, demonstrating flexibility for different page structures.',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const FocusedState: Story = {
|
|
193
|
+
args: {
|
|
194
|
+
mainContentId: 'article-content-focused',
|
|
195
|
+
},
|
|
196
|
+
render: (args) => (
|
|
197
|
+
<div className="min-h-screen relative">
|
|
198
|
+
<style>
|
|
199
|
+
{`
|
|
200
|
+
.skip-link {
|
|
201
|
+
position: relative !important;
|
|
202
|
+
top: 0 !important;
|
|
203
|
+
display: block !important;
|
|
204
|
+
}
|
|
205
|
+
`}
|
|
206
|
+
</style>
|
|
207
|
+
|
|
208
|
+
<SkipLink {...args} />
|
|
209
|
+
|
|
210
|
+
<div className="p-8">
|
|
211
|
+
<h1 className="text-2xl font-bold mb-4">Skip Link in Focused State</h1>
|
|
212
|
+
|
|
213
|
+
<p className="mb-4">
|
|
214
|
+
This story shows how the skip link appears when focused. Normally it's hidden
|
|
215
|
+
off-screen until a user tabs to it.
|
|
216
|
+
</p>
|
|
217
|
+
|
|
218
|
+
<main id="main-content-focused" className="mt-8 p-4 border-2 border-dashed border-gray-400">
|
|
219
|
+
<h2 className="text-xl font-bold mb-2">Main Content Target</h2>
|
|
220
|
+
|
|
221
|
+
<p>This is where users will land when they activate the skip link.</p>
|
|
222
|
+
</main>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
),
|
|
226
|
+
parameters: {
|
|
227
|
+
docs: {
|
|
228
|
+
description: {
|
|
229
|
+
story:
|
|
230
|
+
'Shows the visual appearance of the skip link when focused (normally hidden off-screen).',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const AccessibilityTest: Story = {
|
|
237
|
+
args: {
|
|
238
|
+
mainContentId: 'article-content-accessibility',
|
|
239
|
+
},
|
|
240
|
+
render: (args) => (
|
|
241
|
+
<div className="min-h-screen">
|
|
242
|
+
<SkipLink {...args} />
|
|
243
|
+
|
|
244
|
+
<div className="p-8">
|
|
245
|
+
<h1 className="text-2xl font-bold mb-6">Accessibility Testing</h1>
|
|
246
|
+
|
|
247
|
+
<div className="bg-blue-50 border border-blue-200 rounded p-4 mb-6">
|
|
248
|
+
<h2 className="font-bold text-blue-800 mb-2">Testing Instructions:</h2>
|
|
249
|
+
|
|
250
|
+
<ol className="list-decimal list-inside space-y-1 text-blue-700">
|
|
251
|
+
<li>Use Tab key to navigate to the skip link (it will appear at the top)</li>
|
|
252
|
+
|
|
253
|
+
<li>Press Enter or Space to activate the skip link</li>
|
|
254
|
+
|
|
255
|
+
<li>Verify that focus moves to the main content below</li>
|
|
256
|
+
|
|
257
|
+
<li>Test with screen reader for proper announcement</li>
|
|
258
|
+
</ol>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<nav className="mb-8">
|
|
262
|
+
<h2 className="text-lg font-semibold mb-3">Navigation Menu</h2>
|
|
263
|
+
|
|
264
|
+
<ul className="space-y-2">
|
|
265
|
+
<li>
|
|
266
|
+
<a
|
|
267
|
+
href="#"
|
|
268
|
+
className="text-blue-600 hover:underline focus:outline-2 focus:outline-blue-500"
|
|
269
|
+
>
|
|
270
|
+
Link 1
|
|
271
|
+
</a>
|
|
272
|
+
</li>
|
|
273
|
+
|
|
274
|
+
<li>
|
|
275
|
+
<a
|
|
276
|
+
href="#"
|
|
277
|
+
className="text-blue-600 hover:underline focus:outline-2 focus:outline-blue-500"
|
|
278
|
+
>
|
|
279
|
+
Link 2
|
|
280
|
+
</a>
|
|
281
|
+
</li>
|
|
282
|
+
|
|
283
|
+
<li>
|
|
284
|
+
<a
|
|
285
|
+
href="#"
|
|
286
|
+
className="text-blue-600 hover:underline focus:outline-2 focus:outline-blue-500"
|
|
287
|
+
>
|
|
288
|
+
Link 3
|
|
289
|
+
</a>
|
|
290
|
+
</li>
|
|
291
|
+
|
|
292
|
+
<li>
|
|
293
|
+
<a
|
|
294
|
+
href="#"
|
|
295
|
+
className="text-blue-600 hover:underline focus:outline-2 focus:outline-blue-500"
|
|
296
|
+
>
|
|
297
|
+
Link 4
|
|
298
|
+
</a>
|
|
299
|
+
</li>
|
|
300
|
+
|
|
301
|
+
<li>
|
|
302
|
+
<a
|
|
303
|
+
href="#"
|
|
304
|
+
className="text-blue-600 hover:underline focus:outline-2 focus:outline-blue-500"
|
|
305
|
+
>
|
|
306
|
+
Link 5
|
|
307
|
+
</a>
|
|
308
|
+
</li>
|
|
309
|
+
</ul>
|
|
310
|
+
</nav>
|
|
311
|
+
|
|
312
|
+
<main
|
|
313
|
+
id="main-content-accessibility"
|
|
314
|
+
className="p-6 bg-green-50 border border-green-200 rounded"
|
|
315
|
+
>
|
|
316
|
+
<h2 className="text-xl font-bold mb-4 text-green-800">Main Content Area</h2>
|
|
317
|
+
|
|
318
|
+
<p className="mb-4">
|
|
319
|
+
If the skip link worked correctly, focus should now be on this main content area,
|
|
320
|
+
bypassing all the navigation links above.
|
|
321
|
+
</p>
|
|
322
|
+
|
|
323
|
+
<p className="mb-4">
|
|
324
|
+
This is particularly important for users who rely on keyboard navigation or screen
|
|
325
|
+
readers, as it saves them from having to tab through all navigation elements on every
|
|
326
|
+
page.
|
|
327
|
+
</p>
|
|
328
|
+
|
|
329
|
+
<button
|
|
330
|
+
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 focus:outline-2
|
|
331
|
+
focus:outline-green-500"
|
|
332
|
+
>
|
|
333
|
+
Interactive Element
|
|
334
|
+
</button>
|
|
335
|
+
</main>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
),
|
|
339
|
+
parameters: {
|
|
340
|
+
docs: {
|
|
341
|
+
description: {
|
|
342
|
+
story: 'Complete accessibility testing scenario with instructions for manual testing.',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SkipLink } from './SkipLink';
|
|
2
|
+
import { render, screen, userEvent } from '../../test/renderers';
|
|
3
|
+
|
|
4
|
+
describe('SkipLink Component', () => {
|
|
5
|
+
it('should render correctly', () => {
|
|
6
|
+
render(<SkipLink />);
|
|
7
|
+
|
|
8
|
+
expect(screen.getByRole('link', { name: /skip to main content/i })).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should be focusable', async () => {
|
|
12
|
+
render(<SkipLink />);
|
|
13
|
+
|
|
14
|
+
const user = userEvent.setup();
|
|
15
|
+
|
|
16
|
+
await user.tab();
|
|
17
|
+
|
|
18
|
+
const link = screen.getByRole('link', { name: /skip to main content/i });
|
|
19
|
+
|
|
20
|
+
expect(link).toHaveFocus();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Link } from '../link/Link';
|
|
2
|
+
|
|
3
|
+
export type SkipLinkProps = {
|
|
4
|
+
mainContentId?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
|
|
8
|
+
return (
|
|
9
|
+
<nav aria-label="Skip navigation">
|
|
10
|
+
<Link
|
|
11
|
+
className="bg-focus focus:relative focus:top-0 w-full absolute -top-full text-black
|
|
12
|
+
visited:text-black hover:text-black p-3 skip-link"
|
|
13
|
+
href={`#${mainContentId}`}
|
|
14
|
+
>
|
|
15
|
+
Skip to main content
|
|
16
|
+
</Link>
|
|
17
|
+
</nav>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax */
|
|
2
|
+
|
|
3
|
+
import { Http } from '../utils/http';
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
public readonly status: number;
|
|
7
|
+
public readonly code?: string;
|
|
8
|
+
public readonly details?: unknown;
|
|
9
|
+
|
|
10
|
+
constructor(message: string, status: number, code?: string, details?: unknown) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ApiError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.details = details;
|
|
16
|
+
|
|
17
|
+
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
18
|
+
if (Error.captureStackTrace) {
|
|
19
|
+
Error.captureStackTrace(this, ApiError);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper method to check if it's a client error (4xx)
|
|
24
|
+
get isClientError(): boolean {
|
|
25
|
+
return this.status >= Http.BadRequest && this.status < Http.InternalServerError;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper method to check if it's a server error (5xx)
|
|
29
|
+
get isServerError(): boolean {
|
|
30
|
+
return this.status >= Http.InternalServerError;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Convert to a plain object for JSON serialization
|
|
34
|
+
toJSON() {
|
|
35
|
+
return {
|
|
36
|
+
name: this.name,
|
|
37
|
+
message: this.message,
|
|
38
|
+
status: this.status,
|
|
39
|
+
code: this.code,
|
|
40
|
+
details: this.details,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Static factory methods for common error types
|
|
45
|
+
static badRequest(message: string, details?: unknown): ApiError {
|
|
46
|
+
return new ApiError(message, Http.BadRequest, 'BAD_REQUEST', details);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static notFound(message = 'Resource not found'): ApiError {
|
|
50
|
+
return new ApiError(message, Http.NotFound, 'NOT_FOUND');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static unauthorized(message = 'Unauthorized'): ApiError {
|
|
54
|
+
return new ApiError(message, Http.Unauthorized, 'UNAUTHORIZED');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static forbidden(message = 'Forbidden'): ApiError {
|
|
58
|
+
return new ApiError(message, Http.Forbidden, 'FORBIDDEN');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static internalServerError(message = 'Internal server error'): ApiError {
|
|
62
|
+
return new ApiError(message, Http.InternalServerError, 'INTERNAL_SERVER_ERROR');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static fromResponse(response: Response, message?: string): ApiError {
|
|
66
|
+
return new ApiError(
|
|
67
|
+
message ?? `HTTP ${response.status}: ${response.statusText}`,
|
|
68
|
+
response.status,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ApiError';
|
package/src/utils/utils.ts
CHANGED