@tpzdsp/next-toolkit 1.7.0 → 1.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "type": "module",
6
6
  "private": false,
@@ -150,6 +150,7 @@
150
150
  "autoprefixer": "^10.4.21",
151
151
  "buffer": "^6.0.3",
152
152
  "crypto-browserify": "^3.12.1",
153
+ "date-fns": "^4.1.0",
153
154
  "eslint": "^9.30.1",
154
155
  "eslint-config-prettier": "^10.1.8",
155
156
  "eslint-import-resolver-alias": "^1.1.2",
@@ -163,6 +164,8 @@
163
164
  "eslint-plugin-react-refresh": "^0.4.20",
164
165
  "eslint-plugin-sonarjs": "^3.0.4",
165
166
  "eslint-plugin-storybook": "^9.0.18",
167
+ "focus-trap": "^7.6.5",
168
+ "focus-trap-react": "^11.0.4",
166
169
  "geojson": "^0.5.0",
167
170
  "globals": "^16.3.0",
168
171
  "husky": "^9.1.7",
@@ -206,6 +209,9 @@
206
209
  "@turf/turf": "^7.2.0",
207
210
  "@types/geojson": "^7946.0.16",
208
211
  "@types/jsonwebtoken": "^9.0.10",
212
+ "date-fns": "^4.1.0",
213
+ "focus-trap": "^7.6.5",
214
+ "focus-trap-react": "^11.0.4",
209
215
  "geojson": "^0.5.0",
210
216
  "jsonwebtoken": "^9.0.2",
211
217
  "next": "^15.4.2",
@@ -220,6 +226,12 @@
220
226
  "zod": "^4.1.8"
221
227
  },
222
228
  "peerDependenciesMeta": {
229
+ "@tanstack/react-query": {
230
+ "optional": true
231
+ },
232
+ "@better-fetch/fetch": {
233
+ "optional": true
234
+ },
223
235
  "@turf/turf": {
224
236
  "optional": true
225
237
  },
@@ -229,6 +241,15 @@
229
241
  "@types/jsonwebtoken": {
230
242
  "optional": true
231
243
  },
244
+ "date-fns": {
245
+ "optional": true
246
+ },
247
+ "focus-trap": {
248
+ "optional": true
249
+ },
250
+ "focus-trap-react": {
251
+ "optional": true
252
+ },
232
253
  "geojson": {
233
254
  "optional": true
234
255
  },
@@ -249,6 +270,9 @@
249
270
  },
250
271
  "react-select": {
251
272
  "optional": true
273
+ },
274
+ "zod": {
275
+ "optional": true
252
276
  }
253
277
  },
254
278
  "optionalDependencies": {
@@ -1,56 +1,83 @@
1
+ /* eslint-disable storybook/no-renderer-packages */
1
2
  import { FaChevronRight } from 'react-icons/fa6';
2
3
 
3
- /* eslint-disable storybook/no-renderer-packages */
4
- import type { Meta, StoryFn } from '@storybook/react';
4
+ import type { Meta, StoryObj } from '@storybook/react';
5
5
 
6
- import { Card, type CardProps } from './Card';
6
+ import { Card } from './Card';
7
+ import { ExternalLink } from '../link/ExternalLink';
7
8
  import { Paragraph } from '../Paragraph/Paragraph';
8
9
 
9
- export default {
10
- children: 'Card',
10
+ const meta: Meta<typeof Card> = {
11
+ title: 'Components/Card',
11
12
  component: Card,
12
- } as Meta;
13
+ parameters: {
14
+ layout: 'padded',
15
+ },
16
+ tags: ['autodocs'],
17
+ argTypes: {
18
+ children: {
19
+ description: 'Content of the card',
20
+ control: false,
21
+ },
22
+ className: {
23
+ description: 'Additional TailwindCSS classes to apply',
24
+ control: 'text',
25
+ },
26
+ },
27
+ args: {
28
+ children: 'Hello, this is some simple text',
29
+ },
30
+ };
31
+
32
+ export default meta;
13
33
 
14
- const Template: StoryFn<CardProps> = (args) => <Card {...args} />;
34
+ type Story = StoryObj<typeof Card>;
15
35
 
16
- export const JustText = Template.bind({});
17
- JustText.args = {
18
- children: 'Hello, this is some simple text',
36
+ export const Default: Story = {};
37
+
38
+ export const ParagraphOfText: Story = {
39
+ args: {
40
+ children: <Paragraph>Hello, this is some simple text</Paragraph>,
41
+ },
19
42
  };
20
43
 
21
- export const ParagraphOfText = Template.bind({});
22
- ParagraphOfText.args = {
23
- children: <p>Hello, this is some simple text</p>,
44
+ export const ImageAndText: Story = {
45
+ args: {
46
+ children: (
47
+ <div>
48
+ <img
49
+ src="https://images.unsplash.com/photo-1563991655280-cb95c90ca2fb?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8bm8lMjBjb3B5cmlnaHR8ZW58MHx8MHx8fDA%3D"
50
+ alt="Card"
51
+ />
52
+
53
+ <p>This is text about the image</p>
54
+ </div>
55
+ ),
56
+ },
24
57
  };
25
58
 
26
- export const ImageAndText = Template.bind({});
27
- ImageAndText.args = {
28
- children: (
29
- <div>
30
- <img
31
- src="https://images.unsplash.com/photo-1563991655280-cb95c90ca2fb?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8bm8lMjBjb3B5cmlnaHR8ZW58MHx8MHx8fDA%3D"
32
- alt="Card"
33
- />
34
-
35
- <p>This is text about the image</p>
36
- </div>
37
- ),
59
+ export const ComplexChildren: Story = {
60
+ args: {
61
+ children: (
62
+ <>
63
+ <ExternalLink
64
+ href="https://google.co.uk"
65
+ className="mb-4 flex flex-nowrap items-center gap-2 justify-between font-bold"
66
+ >
67
+ <strong>title</strong>
68
+
69
+ <FaChevronRight className="text-base" />
70
+ </ExternalLink>
71
+
72
+ <Paragraph>Some descriptive text</Paragraph>
73
+ </>
74
+ ),
75
+ },
38
76
  };
39
77
 
40
- export const WithLink = Template.bind({});
41
- WithLink.args = {
42
- children: (
43
- <>
44
- <a
45
- href="https://google.co.uk"
46
- className="mb-4 flex flex-nowrap items-center gap-2 justify-between font-bold"
47
- >
48
- <strong>title</strong>
49
-
50
- <FaChevronRight className="text-base" />
51
- </a>
52
-
53
- <Paragraph>Some descriptive text</Paragraph>
54
- </>
55
- ),
78
+ export const CustomStyling: Story = {
79
+ args: {
80
+ className: 'bg-blue-100 text-blue-800 p-4 rounded-lg shadow-lg',
81
+ children: 'Custom styled card',
82
+ },
56
83
  };
@@ -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 { HttpStatus } from '../../http';
6
+ import { HttpStatus } from '../../http/constants';
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', HttpStatus.InternalServerError, '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
3
  import { HttpStatus } from '../../http';
4
- import { render, screen, userEvent } from '../../test/renderers';
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', HttpStatus.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,10 +1,18 @@
1
- import { type FallbackProps } from 'react-error-boundary';
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
2
4
 
3
- import { Button, ErrorText } from '@tpzdsp/next-toolkit/components';
5
+ import { type FallbackProps } from 'react-error-boundary';
4
6
 
5
7
  import { ApiError } from '../../errors/ApiError';
8
+ import { Accordion } from '../accordion/Accordion';
9
+ import { Button } from '../Button/Button';
10
+ import { Heading } from '../Heading/Heading';
11
+ import { Paragraph } from '../Paragraph/Paragraph';
6
12
 
7
13
  export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
14
+ const id = useId();
15
+
8
16
  let message;
9
17
  let details = undefined;
10
18
  let digest = undefined;
@@ -12,35 +20,67 @@ export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
12
20
  if (error instanceof ApiError) {
13
21
  message = error.message;
14
22
  details = error.details;
23
+ digest = error.digest;
15
24
  } else if (error instanceof Error) {
16
25
  message = error.message;
17
26
  details = error.cause?.toString();
18
27
  } else {
19
- message = 'Unknown';
28
+ message = 'Unknown error';
20
29
  }
21
30
 
31
+ const copyToClipboard = () => {
32
+ navigator.clipboard.writeText(
33
+ // eslint-disable-next-line sonarjs/no-nested-template-literals
34
+ `App Error: ${message}, ${details ? `Details: ${details}, ` : ''}${digest ? `Digest: ${digest}` : ''}`,
35
+ );
36
+ };
37
+
22
38
  return (
23
- <div>
24
- <ErrorText>
25
- There was an error. <br />
26
- Reason: {message}
27
- </ErrorText>
28
- {details ? (
29
- <span>
30
- <br />
31
-
32
- <details>
33
- <summary>Details</summary>
34
-
35
- <pre>{details}</pre>
36
- </details>
37
- </span>
38
- ) : (
39
- <></>
39
+ <div
40
+ role="alert"
41
+ aria-labelledby={id}
42
+ className="grid gap-2 border-form border-transparent border-l-error max-w-full pl-2"
43
+ style={{ gridTemplateColumns: '1fr auto' }}
44
+ >
45
+ <div className="flex flex-col gap-0.5">
46
+ <Heading id={id} type="h3" className="text-error text-base font-semibold py-0">
47
+ Something went wrong
48
+ </Heading>
49
+
50
+ <Paragraph className="leading-snug pb-0">
51
+ <strong>Reason:</strong> {message}
52
+ </Paragraph>
53
+ </div>
54
+
55
+ <div className="flex items-start gap-1">
56
+ <Button onClick={copyToClipboard} variant="secondary" className="shrink-0">
57
+ Copy
58
+ </Button>
59
+
60
+ <Button onClick={resetErrorBoundary} variant="secondary" className="shrink-0">
61
+ Retry
62
+ </Button>
63
+ </div>
64
+
65
+ {details && (
66
+ <div className="col-span-2">
67
+ <Accordion title="Show details">
68
+ <div className="flex gap-0.5 flex-col">
69
+ {digest && (
70
+ <Paragraph className="leading-snug pb-0">
71
+ <strong>Error ID:</strong> {digest}
72
+ </Paragraph>
73
+ )}
74
+ <pre
75
+ className="text-base bg-white border border-gray-200 p-2 overflow-y-auto max-h-32
76
+ whitespace-pre-wrap"
77
+ >
78
+ {details}
79
+ </pre>
80
+ </div>
81
+ </Accordion>
82
+ </div>
40
83
  )}
41
- <br />
42
- Digest: {digest ?? 'No digest provided'}
43
- <Button onClick={resetErrorBoundary}>Try Again</Button>
44
84
  </div>
45
85
  );
46
86
  };
@@ -1,34 +1,53 @@
1
1
  /* eslint-disable storybook/no-renderer-packages */
2
- import type { Meta, StoryFn } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
3
 
4
- import { ErrorText, type ErrorTextProps } from './ErrorText';
4
+ import { ErrorText } from './ErrorText';
5
+ import { ExternalLink } from '../link/ExternalLink';
5
6
 
6
- export default {
7
- children: 'Error text',
7
+ const meta: Meta<typeof ErrorText> = {
8
+ title: 'Components/ErrorText',
8
9
  component: ErrorText,
9
- } as Meta;
10
+ parameters: {
11
+ layout: 'padded',
12
+ },
13
+ tags: ['autodocs'],
14
+ argTypes: {
15
+ children: {
16
+ description: 'The content to display for the error',
17
+ control: false,
18
+ },
19
+ className: {
20
+ description: 'Additional TailwindCSS classes to apply',
21
+ control: 'text',
22
+ },
23
+ },
24
+ args: {
25
+ children: 'Error message',
26
+ },
27
+ };
10
28
 
11
- const Template: StoryFn<ErrorTextProps> = (args) => <ErrorText {...args} />;
29
+ export default meta;
12
30
 
13
- export const DefaultError = Template.bind({});
14
- DefaultError.args = {
15
- children: 'Error message',
16
- };
31
+ type Story = StoryObj<typeof ErrorText>;
32
+
33
+ export const Default: Story = {};
17
34
 
18
- export const CustomStyling = Template.bind({});
19
- CustomStyling.args = {
20
- className: 'text-3xl',
21
- children: 'Error message with large text',
35
+ export const CustomStyling: Story = {
36
+ args: {
37
+ className: 'text-3xl',
38
+ children: 'Custom styled error message',
39
+ },
22
40
  };
23
41
 
24
- export const ComplexChildren = Template.bind({});
25
- ComplexChildren.args = {
26
- children: (
27
- <div>
28
- Error message with link{' '}
29
- <a className="underline text-link" href="/">
30
- Link
31
- </a>
32
- </div>
33
- ),
42
+ export const ComplexChildren: Story = {
43
+ args: {
44
+ children: (
45
+ <div>
46
+ Error message with{' '}
47
+ <ExternalLink className="underline text-link" href="/">
48
+ Link
49
+ </ExternalLink>
50
+ </div>
51
+ ),
52
+ },
34
53
  };
@@ -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':