@tpzdsp/next-toolkit 1.6.0 → 1.8.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/README.md CHANGED
@@ -262,7 +262,7 @@ yarn preview
262
262
 
263
263
  ## Local Development with Yalc
264
264
 
265
- This library works perfectly with `yalc` for local testing before publishing to npm. Since we distribute source files, changes are reflected immediately.
265
+ This library works well with `yalc` for local testing before publishing to npm. However, **hot reloading doesn't work reliably** with yalc - you'll need to manually push changes and restart your development server.
266
266
 
267
267
  ### Setup Yalc (one-time)
268
268
 
@@ -275,8 +275,6 @@ npm install -g yalc
275
275
 
276
276
  ```bash
277
277
  # In this library directory
278
- yarn yalc:publish
279
- # or
280
278
  yalc publish
281
279
  ```
282
280
 
@@ -284,9 +282,9 @@ yalc publish
284
282
 
285
283
  ```bash
286
284
  # In your Next.js app directory
287
- yalc add @your-org/nextjs-library
285
+ yalc add @tpzdsp/next-toolkit
288
286
 
289
- # Install dependencies (yalc creates a symlink-like structure)
287
+ # Install dependencies
290
288
  yarn install
291
289
  ```
292
290
 
@@ -297,69 +295,48 @@ Add to your `next.config.js`:
297
295
  ```js
298
296
  /** @type {import('next').NextConfig} */
299
297
  const nextConfig = {
300
- transpilePackages: ['@your-org/nextjs-library'],
301
- // Enable if you want faster refresh during development
298
+ transpilePackages: ['@tpzdsp/next-toolkit'],
302
299
  experimental: {
303
- externalDir: true,
300
+ externalDir: true, // May help with yalc symlinks
304
301
  },
305
302
  };
306
303
 
307
304
  module.exports = nextConfig;
308
305
  ```
309
306
 
310
- ### Development Workflow
307
+ ### Development Workflow (Manual Process)
311
308
 
312
309
  ```bash
313
- # 1. Make changes to the library
314
- # 2. Push changes to apps using the library
315
- yarn yalc:push
316
- # or
310
+ # 1. Make changes to the library components/hooks/etc.
311
+
312
+ # 2. Push changes to connected apps
317
313
  yalc push
318
314
 
319
- # 3. Your Next.js app will automatically pick up the changes!
315
+ # 3. In your test app, clear Next.js cache and restart
316
+ rm -rf .next && yarn dev
320
317
  ```
321
318
 
319
+ ### Why No Hot Reloading?
320
+
321
+ - **Symlink Issues**: Next.js file watching doesn't reliably detect changes in yalc-linked packages
322
+ - **Module Resolution**: TypeScript and bundler caching can prevent updates from being picked up
323
+ - **Source Distribution**: While our source distribution strategy helps with tree-shaking, it doesn't solve the hot reload problem with yalc
324
+
322
325
  ### Removing from Test App
323
326
 
324
327
  ```bash
325
328
  # In your Next.js app directory
326
- yalc remove @your-org/nextjs-library
329
+ yalc remove @tpzdsp/next-toolkit
327
330
  yarn install
328
331
  ```
329
332
 
330
- ### Benefits with Source Distribution
333
+ ### Benefits Despite Manual Process
331
334
 
332
- - ✅ **Instant Changes**: Since we distribute source files, changes appear immediately
335
+ - ✅ **Real Environment Testing**: Test in an actual Next.js app setup
333
336
  - ✅ **Full TypeScript**: Complete type checking and IntelliSense
334
- - ✅ **Tree Shaking**: Your app's bundler handles optimization
335
- - ✅ **Hot Reload**: Works with Next.js development server
336
- - **No Build Step**: No need to rebuild the library for every change
337
-
338
- ## 🚀 Quick Yalc Workflow
339
-
340
- ### Library Development (this repo):
341
-
342
- ```bash
343
- # First time setup
344
- yalc publish
345
-
346
- # After making changes
347
- yalc push # Pushes to all connected apps
348
- ```
349
-
350
- ### In Your Next.js Test App:
351
-
352
- ```bash
353
- # Add the library
354
- yalc add @your-org/nextjs-library
355
- yarn install
356
-
357
- # Configure next.config.js (transpilePackages: ['@your-org/nextjs-library'])
358
-
359
- # Remove when done testing
360
- yalc remove @your-org/nextjs-library
361
- yarn install
362
- ```
337
+ - ✅ **Tree Shaking**: Your app's bundler handles optimization
338
+ - ✅ **No Publishing**: Test without polluting npm registry
339
+ - **Manual Refresh Required**: No automatic hot reloading
363
340
 
364
341
  ## 🛠️ Adding New Components
365
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "type": "module",
6
6
  "private": false,
@@ -38,6 +38,11 @@
38
38
  "import": "./src/utils/http.ts",
39
39
  "require": "./src/utils/http.ts"
40
40
  },
41
+ "./utils/schema": {
42
+ "types": "./src/utils/schema.ts",
43
+ "import": "./src/utils/schema.ts",
44
+ "require": "./src/utils/schema.ts"
45
+ },
41
46
  "./components": {
42
47
  "types": "./src/components/index.ts",
43
48
  "import": "./src/components/index.ts",
@@ -48,6 +53,11 @@
48
53
  "import": "./src/components/select/index.ts",
49
54
  "require": "./src/components/select/index.ts"
50
55
  },
56
+ "./http": {
57
+ "types": "./src/http/index.ts",
58
+ "import": "./src/http/index.ts",
59
+ "require": "./src/http/index.ts"
60
+ },
51
61
  "./map": {
52
62
  "types": "./src/map/index.ts",
53
63
  "import": "./src/map/index.ts",
@@ -104,6 +114,7 @@
104
114
  "release": "semantic-release"
105
115
  },
106
116
  "devDependencies": {
117
+ "@better-fetch/fetch": "1.1.19-beta.1",
107
118
  "@eslint/js": "^9.30.1",
108
119
  "@semantic-release/changelog": "^6.0.3",
109
120
  "@semantic-release/git": "^10.0.1",
@@ -113,11 +124,13 @@
113
124
  "@storybook/addon-interactions": "^8.6.14",
114
125
  "@storybook/addon-links": "8.6.14",
115
126
  "@storybook/addon-onboarding": "8.6.14",
127
+ "@storybook/preview-api": "^8.6.14",
116
128
  "@storybook/react": "8.6.14",
117
129
  "@storybook/react-vite": "8.6.14",
118
130
  "@storybook/test": "^8.6.14",
119
131
  "@storybook/types": "8.6.14",
120
132
  "@tailwindcss/typography": "^0.5.16",
133
+ "@tanstack/react-query": "^5.89.0",
121
134
  "@testing-library/dom": "^10.4.0",
122
135
  "@testing-library/jest-dom": "^6.6.3",
123
136
  "@testing-library/react": "^16.3.0",
@@ -137,6 +150,7 @@
137
150
  "autoprefixer": "^10.4.21",
138
151
  "buffer": "^6.0.3",
139
152
  "crypto-browserify": "^3.12.1",
153
+ "date-fns": "^4.1.0",
140
154
  "eslint": "^9.30.1",
141
155
  "eslint-config-prettier": "^10.1.8",
142
156
  "eslint-import-resolver-alias": "^1.1.2",
@@ -182,14 +196,18 @@
182
196
  "util": "^0.12.5",
183
197
  "vite": "^7.0.4",
184
198
  "vite-plugin-dts": "^4.5.4",
185
- "vitest": "^3.2.4"
199
+ "vitest": "^3.2.4",
200
+ "zod": "^4.1.8"
186
201
  },
187
202
  "peerDependencies": {
203
+ "@better-fetch/fetch": "1.1.19-beta.1",
204
+ "@tanstack/react-query": "^5.89.0",
188
205
  "@testing-library/react": "^16.0.0",
189
206
  "@testing-library/user-event": "^14.6.1",
190
207
  "@turf/turf": "^7.2.0",
191
208
  "@types/geojson": "^7946.0.16",
192
209
  "@types/jsonwebtoken": "^9.0.10",
210
+ "date-fns": "^4.1.0",
193
211
  "geojson": "^0.5.0",
194
212
  "jsonwebtoken": "^9.0.2",
195
213
  "next": "^15.4.2",
@@ -200,9 +218,16 @@
200
218
  "react": "^19.1.0",
201
219
  "react-dom": "^19.1.0",
202
220
  "react-error-boundary": "^6.0.0",
203
- "react-icons": "^5.5.0"
221
+ "react-icons": "^5.5.0",
222
+ "zod": "^4.1.8"
204
223
  },
205
224
  "peerDependenciesMeta": {
225
+ "@tanstack/react-query": {
226
+ "optional": true
227
+ },
228
+ "@better-fetch/fetch": {
229
+ "optional": true
230
+ },
206
231
  "@turf/turf": {
207
232
  "optional": true
208
233
  },
@@ -212,6 +237,9 @@
212
237
  "@types/jsonwebtoken": {
213
238
  "optional": true
214
239
  },
240
+ "date-fns": {
241
+ "optional": true
242
+ },
215
243
  "geojson": {
216
244
  "optional": true
217
245
  },
@@ -232,6 +260,9 @@
232
260
  },
233
261
  "react-select": {
234
262
  "optional": true
263
+ },
264
+ "zod": {
265
+ "optional": true
235
266
  }
236
267
  },
237
268
  "optionalDependencies": {
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
3
3
 
4
4
  import { ErrorFallback } from './ErrorFallback';
5
5
  import { ApiError } from '../../errors/ApiError';
6
- import { Http } from '../../utils/http';
6
+ import { HttpStatus } from '../../http';
7
7
 
8
8
  const meta = {
9
9
  title: 'Components/ErrorFallback',
@@ -13,7 +13,7 @@ const meta = {
13
13
  docs: {
14
14
  description: {
15
15
  component:
16
- 'A UI component displayed when an error is caught by `ErrorBoundary`. It shows the error message, optional cause, and a retry button.',
16
+ 'Displayed when an error is caught by `ErrorBoundary`. Shows the error message, optional details in an Accordion, and Copy/Retry buttons.',
17
17
  },
18
18
  },
19
19
  },
@@ -23,7 +23,7 @@ const meta = {
23
23
  export default meta;
24
24
  type Story = StoryObj<typeof meta>;
25
25
 
26
- // Shared mock reset fn
26
+ // Shared mock reset function
27
27
  const mockReset = () => alert('Reset error boundary called');
28
28
 
29
29
  export const Default: Story = {
@@ -34,7 +34,8 @@ export const Default: Story = {
34
34
  parameters: {
35
35
  docs: {
36
36
  description: {
37
- story: 'Default rendering when given a standard JavaScript `Error`.',
37
+ story:
38
+ 'Default rendering with a standard JavaScript `Error`. Shows heading, reason, and Copy/Retry buttons.',
38
39
  },
39
40
  },
40
41
  },
@@ -42,13 +43,14 @@ export const Default: Story = {
42
43
 
43
44
  export const WithApiError: Story = {
44
45
  args: {
45
- error: new ApiError('Request failed', Http.InternalServerError, undefined, 'Extra details'),
46
+ error: new ApiError('Request failed', HttpStatus.InternalServerError, 'Some technical details'),
46
47
  resetErrorBoundary: mockReset,
47
48
  },
48
49
  parameters: {
49
50
  docs: {
50
51
  description: {
51
- story: 'Rendering when the error is an `ApiError` that includes extra details.',
52
+ story:
53
+ 'Rendering when the error is an `ApiError`. Shows reason, details in an Accordion, digest, and Copy/Retry buttons.',
52
54
  },
53
55
  },
54
56
  },
@@ -63,7 +65,7 @@ export const WithUnknownError: Story = {
63
65
  docs: {
64
66
  description: {
65
67
  story:
66
- 'If the error is not an `Error` or `ApiError`, a generic "Unknown" message is shown.',
68
+ 'If the error is not an `Error` or `ApiError`, a generic "Unknown error" message is shown, with Copy/Retry buttons.',
67
69
  },
68
70
  },
69
71
  },
@@ -1,54 +1,107 @@
1
1
  import { ErrorFallback } from './ErrorFallback';
2
2
  import { ApiError } from '../../errors';
3
- import { render, screen, userEvent } from '../../test/renderers';
4
- import { Http } from '../../utils/http';
3
+ import { HttpStatus } from '../../http';
4
+ import { render, screen, userEvent, within } from '../../test/renderers';
5
5
  import { identityFn } from '../../utils/utils';
6
6
 
7
+ const FAKE_UUID = 'fake-uuid';
8
+
9
+ Object.defineProperty(navigator, 'clipboard', {
10
+ value: {
11
+ writeText: vi.fn().mockResolvedValue(undefined),
12
+ },
13
+ configurable: true,
14
+ });
15
+
7
16
  describe('ErrorFallback', () => {
17
+ beforeEach(() => {
18
+ (navigator.clipboard.writeText as unknown) = vi.fn().mockResolvedValue(undefined);
19
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
8
26
  it('renders a generic error message for an unknown error type', () => {
9
27
  render(<ErrorFallback error={{} as unknown as Error} resetErrorBoundary={identityFn} />);
10
28
 
11
29
  const alert = screen.getByRole('alert');
12
30
 
13
- expect(alert).toHaveTextContent(/there was an error/i);
31
+ expect(alert).toHaveTextContent(/something went wrong/i);
14
32
  expect(alert).toHaveTextContent(/reason: unknown/i);
15
33
  });
16
34
 
17
35
  it('renders message for standard Error instance', () => {
18
- const error = new Error('Something went wrong');
36
+ const error = new Error('custom error message');
19
37
 
20
38
  render(<ErrorFallback error={error} resetErrorBoundary={identityFn} />);
21
39
 
22
- expect(screen.getByText(/reason: something went wrong/i)).toBeInTheDocument();
40
+ expect(screen.getByRole('alert')).toHaveTextContent(/reason: custom error message/i);
23
41
  });
24
42
 
25
- it('renders message and cause for ApiError instance', () => {
26
- const apiError = new ApiError('Fake Reason', Http.InternalServerError);
43
+ it('renders message, details and digest for ApiError instance', async () => {
44
+ const apiError = new ApiError('Fake Reason', HttpStatus.InternalServerError, 'Some details');
27
45
 
28
46
  render(<ErrorFallback error={apiError} resetErrorBoundary={identityFn} />);
29
47
 
30
- expect(screen.getByText(/reason: fake reason/i)).toBeInTheDocument();
48
+ // Message shown
49
+ expect(screen.getByRole('alert')).toHaveTextContent(/reason: fake reason/i);
50
+
51
+ // Accordion exists but details hidden until toggled
52
+ const accordionButton = screen.getByRole('button', { name: /show details/i });
53
+
54
+ expect(accordionButton).toBeInTheDocument();
55
+
56
+ // Expand and check details text
57
+ await userEvent.click(accordionButton);
58
+
59
+ const section = screen.getByRole('region', { hidden: false });
60
+
61
+ expect(within(section).getByText(/error id:/i)).toBeInTheDocument();
62
+ expect(within(section).getByText(/some details/i)).toBeInTheDocument();
31
63
  });
32
64
 
33
- it('renders cause only when it is a string', () => {
34
- const error = new Error('Has a cause', { cause: ["I won't display"] });
65
+ it('renders non-string causes', () => {
66
+ const error = new Error('Has a cause', { cause: ['I will display'] });
35
67
 
36
68
  render(<ErrorFallback error={error} resetErrorBoundary={identityFn} />);
37
69
 
38
- expect(screen.getByText(/reason: has a cause/i)).toBeInTheDocument();
39
- expect(screen.queryByText(/cause:/i)).not.toBeInTheDocument();
70
+ expect(screen.getByRole('alert')).toHaveTextContent(/reason: has a cause/i);
71
+ expect(screen.queryByRole('button', { name: /show details/i })).toBeInTheDocument();
40
72
  });
41
73
 
42
- it('calls resetErrorBoundary when Try Again is clicked', async () => {
74
+ it('calls resetErrorBoundary when Retry is clicked', async () => {
43
75
  const user = userEvent.setup();
44
76
  const resetSpy = vi.fn();
45
77
 
46
78
  render(<ErrorFallback error={new Error('Test error')} resetErrorBoundary={resetSpy} />);
47
79
 
48
- const button = screen.getByRole('button', { name: /try again/i });
80
+ const button = screen.getByRole('button', { name: /retry/i });
49
81
 
50
82
  await user.click(button);
51
83
 
52
84
  expect(resetSpy).toHaveBeenCalledTimes(1);
53
85
  });
86
+
87
+ it('copies error info to clipboard when Copy is clicked', async () => {
88
+ const user = userEvent.setup();
89
+ const apiError = new ApiError('Copy Reason', HttpStatus.BadRequest, 'Some details');
90
+
91
+ render(<ErrorFallback error={apiError} resetErrorBoundary={identityFn} />);
92
+
93
+ const copyButton = screen.getByRole('button', { name: /copy/i });
94
+
95
+ await user.click(copyButton);
96
+
97
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
98
+ expect.stringContaining('App Error: Copy Reason'),
99
+ );
100
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
101
+ expect.stringContaining('Details: Some details'),
102
+ );
103
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
104
+ expect.stringContaining(`Digest: ${FAKE_UUID}`),
105
+ );
106
+ });
54
107
  });
@@ -1,30 +1,84 @@
1
- import { type FallbackProps } from 'react-error-boundary';
1
+ import { useId } from 'react';
2
2
 
3
- import { Button, ErrorText } from '@tpzdsp/next-toolkit/components';
3
+ import { type FallbackProps } from 'react-error-boundary';
4
4
 
5
5
  import { ApiError } from '../../errors/ApiError';
6
+ import { Accordion } from '../accordion/Accordion';
7
+ import { Button } from '../Button/Button';
8
+ import { Heading } from '../Heading/Heading';
9
+ import { Paragraph } from '../Paragraph/Paragraph';
6
10
 
7
11
  export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
12
+ const id = useId();
13
+
8
14
  let message;
9
- let cause = undefined;
15
+ let details = undefined;
16
+ let digest = undefined;
10
17
 
11
18
  if (error instanceof ApiError) {
12
19
  message = error.message;
13
- cause = error.details;
20
+ details = error.details;
21
+ digest = error.digest;
14
22
  } else if (error instanceof Error) {
15
- ({ message, cause } = error);
23
+ message = error.message;
24
+ details = error.cause?.toString();
16
25
  } else {
17
- message = 'Unknown';
26
+ message = 'Unknown error';
18
27
  }
19
28
 
29
+ const copyToClipboard = () => {
30
+ navigator.clipboard.writeText(
31
+ // eslint-disable-next-line sonarjs/no-nested-template-literals
32
+ `App Error: ${message}, ${details ? `Details: ${details}, ` : ''}${digest ? `Digest: ${digest}` : ''}`,
33
+ );
34
+ };
35
+
20
36
  return (
21
- <div>
22
- <ErrorText>
23
- <p>There was an error. (Reason: {message})</p>
24
- {typeof cause === 'string' ? <p>Cause: {cause}</p> : null}
25
- </ErrorText>
37
+ <div
38
+ role="alert"
39
+ aria-labelledby={id}
40
+ className="grid gap-2 border-form border-transparent border-l-error max-w-full pl-2"
41
+ style={{ gridTemplateColumns: '1fr auto' }}
42
+ >
43
+ <div className="flex flex-col gap-0.5">
44
+ <Heading id={id} type="h3" className="text-error text-base font-semibold py-0">
45
+ Something went wrong
46
+ </Heading>
47
+
48
+ <Paragraph className="leading-snug pb-0">
49
+ <strong>Reason:</strong> {message}
50
+ </Paragraph>
51
+ </div>
52
+
53
+ <div className="flex items-start gap-1">
54
+ <Button onClick={copyToClipboard} variant="secondary" className="shrink-0">
55
+ Copy
56
+ </Button>
57
+
58
+ <Button onClick={resetErrorBoundary} variant="secondary" className="shrink-0">
59
+ Retry
60
+ </Button>
61
+ </div>
26
62
 
27
- <Button onClick={resetErrorBoundary}>Try Again</Button>
63
+ {details && (
64
+ <div className="col-span-2">
65
+ <Accordion title="Show details">
66
+ <div className="flex gap-0.5 flex-col">
67
+ {digest && (
68
+ <Paragraph className="leading-snug pb-0">
69
+ <strong>Error ID:</strong> {digest}
70
+ </Paragraph>
71
+ )}
72
+ <pre
73
+ className="text-base bg-white border border-gray-200 p-2 overflow-y-auto max-h-32
74
+ whitespace-pre-wrap"
75
+ >
76
+ {details}
77
+ </pre>
78
+ </div>
79
+ </Accordion>
80
+ </div>
81
+ )}
28
82
  </div>
29
83
  );
30
84
  };
@@ -5,13 +5,15 @@ import type { ExtendProps } from '../../types/utils';
5
5
  export type ErrorTextProps = ExtendProps<'p'>;
6
6
 
7
7
  export const ErrorText = ({ className, children, ...props }: ErrorTextProps) => {
8
+ const Component = typeof children === 'string' ? 'p' : 'div';
9
+
8
10
  return (
9
- <p
11
+ <Component
10
12
  role="alert"
11
13
  className={twMerge('mb-3 text-base text-error font-bold', className)}
12
14
  {...props}
13
15
  >
14
16
  {children}
15
- </p>
17
+ </Component>
16
18
  );
17
19
  };
@@ -1,11 +1,14 @@
1
1
  import { twMerge } from 'tailwind-merge';
2
2
 
3
- export type HeadingProps = {
3
+ import type { ExtendProps } from '../../types';
4
+
5
+ type Props = {
4
6
  type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
5
- className?: string;
6
- children: React.ReactNode;
7
7
  };
8
8
 
9
+ // h1 through h6 have identical props
10
+ export type HeadingProps = ExtendProps<'h1', Props>;
11
+
9
12
  export const Heading = ({ type, className, children }: HeadingProps) => {
10
13
  switch (type) {
11
14
  case 'h1':
@@ -36,7 +36,8 @@ export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
36
36
  // dialog elements do have a key handler as you can close them with `Escape`, so this can be ignored
37
37
  // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
38
38
  <dialog
39
- className="fixed inset-0 flex items-center justify-center w-full h-full m-0 bg-transparent backdrop:bg-black/50"
39
+ className="fixed inset-0 flex items-center justify-center w-full h-full m-0 bg-transparent
40
+ backdrop:bg-black/50"
40
41
  ref={modalRef}
41
42
  onCancel={(event) => {
42
43
  event.preventDefault();
@@ -1,10 +1,12 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+
1
3
  import type { ExtendProps } from '../../types/utils';
2
4
 
3
5
  export type ParagraphProps = ExtendProps<'p'>;
4
6
 
5
7
  export const Paragraph = ({ className, children, ...props }: ParagraphProps) => {
6
8
  return (
7
- <p className={`pb-4 text-sm text-text-primary ${className}`} {...props}>
9
+ <p className={twMerge('pb-4 text-sm text-text-primary', className)} {...props}>
8
10
  {children}
9
11
  </p>
10
12
  );
@@ -22,7 +22,8 @@ export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
22
22
  return (
23
23
  <nav aria-label="Skip navigation">
24
24
  <Link
25
- className="absolute w-full p-3 text-black bg-focus focus:relative focus:top-0 -top-full visited:text-black hover:text-black skip-link"
25
+ className="absolute w-full p-3 text-black bg-focus focus:relative focus:top-0 -top-full
26
+ visited:text-black hover:text-black skip-link"
26
27
  href={`#${mainContentId}`}
27
28
  onClick={handleActivate}
28
29
  onKeyDown={(event) => {