@tpzdsp/next-toolkit 1.8.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 +11 -1
- package/src/components/Card/Card.stories.tsx +68 -41
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +1 -1
- package/src/components/ErrorBoundary/ErrorFallback.tsx +2 -0
- package/src/components/ErrorText/ErrorText.stories.tsx +43 -24
- package/src/components/Hint/Hint.stories.tsx +46 -28
- package/src/components/Paragraph/Paragraph.stories.tsx +65 -21
- package/src/components/SlidingPanel/SlidingPanel.stories.tsx +99 -0
- package/src/components/SlidingPanel/SlidingPanel.tsx +120 -111
- package/src/components/chip/Chip.stories.tsx +48 -26
- package/src/errors/ApiError.ts +8 -2
- package/src/http/constants.ts +2 -0
- package/src/map/LayerSwitcherControl.ts +47 -59
- package/src/utils/utils.ts +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "1.
|
|
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,
|
|
@@ -164,6 +164,8 @@
|
|
|
164
164
|
"eslint-plugin-react-refresh": "^0.4.20",
|
|
165
165
|
"eslint-plugin-sonarjs": "^3.0.4",
|
|
166
166
|
"eslint-plugin-storybook": "^9.0.18",
|
|
167
|
+
"focus-trap": "^7.6.5",
|
|
168
|
+
"focus-trap-react": "^11.0.4",
|
|
167
169
|
"geojson": "^0.5.0",
|
|
168
170
|
"globals": "^16.3.0",
|
|
169
171
|
"husky": "^9.1.7",
|
|
@@ -208,6 +210,8 @@
|
|
|
208
210
|
"@types/geojson": "^7946.0.16",
|
|
209
211
|
"@types/jsonwebtoken": "^9.0.10",
|
|
210
212
|
"date-fns": "^4.1.0",
|
|
213
|
+
"focus-trap": "^7.6.5",
|
|
214
|
+
"focus-trap-react": "^11.0.4",
|
|
211
215
|
"geojson": "^0.5.0",
|
|
212
216
|
"jsonwebtoken": "^9.0.2",
|
|
213
217
|
"next": "^15.4.2",
|
|
@@ -240,6 +244,12 @@
|
|
|
240
244
|
"date-fns": {
|
|
241
245
|
"optional": true
|
|
242
246
|
},
|
|
247
|
+
"focus-trap": {
|
|
248
|
+
"optional": true
|
|
249
|
+
},
|
|
250
|
+
"focus-trap-react": {
|
|
251
|
+
"optional": true
|
|
252
|
+
},
|
|
243
253
|
"geojson": {
|
|
244
254
|
"optional": true
|
|
245
255
|
},
|
|
@@ -1,56 +1,83 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
1
2
|
import { FaChevronRight } from 'react-icons/fa6';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
import type { Meta, StoryFn } from '@storybook/react';
|
|
4
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
5
5
|
|
|
6
|
-
import { Card
|
|
6
|
+
import { Card } from './Card';
|
|
7
|
+
import { ExternalLink } from '../link/ExternalLink';
|
|
7
8
|
import { Paragraph } from '../Paragraph/Paragraph';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const meta: Meta<typeof Card> = {
|
|
11
|
+
title: 'Components/Card',
|
|
11
12
|
component: Card,
|
|
12
|
-
|
|
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
|
-
|
|
34
|
+
type Story = StoryObj<typeof Card>;
|
|
15
35
|
|
|
16
|
-
export const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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',
|
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
-
import type { Meta,
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
3
|
|
|
4
|
-
import { ErrorText
|
|
4
|
+
import { ErrorText } from './ErrorText';
|
|
5
|
+
import { ExternalLink } from '../link/ExternalLink';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const meta: Meta<typeof ErrorText> = {
|
|
8
|
+
title: 'Components/ErrorText',
|
|
8
9
|
component: ErrorText,
|
|
9
|
-
|
|
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
|
-
|
|
29
|
+
export default meta;
|
|
12
30
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
31
|
+
type Story = StoryObj<typeof ErrorText>;
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {};
|
|
17
34
|
|
|
18
|
-
export const CustomStyling =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
};
|
|
@@ -1,40 +1,58 @@
|
|
|
1
1
|
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
-
import type { Meta,
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
3
|
import { fn } from '@storybook/test';
|
|
4
4
|
|
|
5
|
-
import { Hint
|
|
5
|
+
import { Hint } from './Hint';
|
|
6
6
|
import { Button } from '../Button/Button';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const meta: Meta<typeof Hint> = {
|
|
9
|
+
title: 'Components/Hint',
|
|
10
10
|
component: Hint,
|
|
11
|
-
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'padded',
|
|
13
|
+
},
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
argTypes: {
|
|
16
|
+
children: {
|
|
17
|
+
control: false,
|
|
18
|
+
description: 'The content to display for the hint',
|
|
19
|
+
},
|
|
20
|
+
className: {
|
|
21
|
+
control: 'text',
|
|
22
|
+
description: 'Additional TailwindCSS classes to apply to the hint container',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
children: 'Hint message',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
12
29
|
|
|
13
|
-
|
|
30
|
+
export default meta;
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
32
|
+
type Story = StoryObj<typeof Hint>;
|
|
33
|
+
|
|
34
|
+
export const Default: Story = {};
|
|
19
35
|
|
|
20
|
-
export const CustomStyling =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
export const CustomStyling: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
className: 'text-3xl',
|
|
39
|
+
children: 'Hint message with extra large text',
|
|
40
|
+
},
|
|
24
41
|
};
|
|
25
42
|
|
|
26
|
-
export const ComplexChildren =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
export const ComplexChildren: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
children: (
|
|
46
|
+
<div>
|
|
47
|
+
Hint message with link and button{' '}
|
|
48
|
+
<a className="underline text-link" href="/">
|
|
49
|
+
Link
|
|
50
|
+
</a>
|
|
51
|
+
{}
|
|
52
|
+
<Button variant="primary" onClick={fn()}>
|
|
53
|
+
Click
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
},
|
|
40
58
|
};
|
|
@@ -1,30 +1,74 @@
|
|
|
1
1
|
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
-
import type { Meta,
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
3
|
|
|
4
|
-
import { Paragraph
|
|
4
|
+
import { Paragraph } from './Paragraph';
|
|
5
|
+
import { ExternalLink } from '../link/ExternalLink';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const meta: Meta<typeof Paragraph> = {
|
|
8
|
+
title: 'Components/Paragraph',
|
|
8
9
|
component: Paragraph,
|
|
9
|
-
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'padded',
|
|
12
|
+
},
|
|
13
|
+
tags: ['autodocs'],
|
|
14
|
+
argTypes: {
|
|
15
|
+
children: {
|
|
16
|
+
description: 'Content of the paragraph',
|
|
17
|
+
control: false,
|
|
18
|
+
},
|
|
19
|
+
className: {
|
|
20
|
+
description: 'Additional TailwindCSS classes to apply to the paragraph',
|
|
21
|
+
control: 'text',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
children: 'Hello, this is some simple text',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
|
|
31
|
+
type Story = StoryObj<typeof Paragraph>;
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {};
|
|
10
34
|
|
|
11
|
-
const
|
|
35
|
+
export const CustomStyling: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
className: 'text-yellow-800 p-2',
|
|
38
|
+
children: 'Hello, this is some simple text with custom styling',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const LongText: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
children:
|
|
45
|
+
'This is a very long paragraph. '.repeat(20) +
|
|
46
|
+
'It is used to test how the Paragraph component handles large amounts of text.',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
12
49
|
|
|
13
|
-
export const
|
|
14
|
-
|
|
15
|
-
|
|
50
|
+
export const WithLink: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
children: (
|
|
53
|
+
<span>
|
|
54
|
+
This paragraph contains a <ExternalLink href="https://storybook.js.org/">link</ExternalLink>
|
|
55
|
+
.
|
|
56
|
+
</span>
|
|
57
|
+
),
|
|
58
|
+
},
|
|
16
59
|
};
|
|
17
60
|
|
|
18
|
-
export const ImageAndText =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
export const ImageAndText: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
children: (
|
|
64
|
+
<div>
|
|
65
|
+
<img
|
|
66
|
+
src="https://images.unsplash.com/photo-1563991655280-cb95c90ca2fb?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8bm8lMjBjb3B5cmlnaHR8ZW58MHx8MHx8fDA%3D"
|
|
67
|
+
alt="Card"
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<p>This is text about the image</p>
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
},
|
|
30
74
|
};
|
|
@@ -29,3 +29,102 @@ export const AllSlidingPanels: StoryObj<typeof SlidingPanel> = {
|
|
|
29
29
|
</div>
|
|
30
30
|
),
|
|
31
31
|
};
|
|
32
|
+
|
|
33
|
+
export const WithAutoFocus: StoryObj<typeof SlidingPanel> = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<SlidingPanel tabLabel="Auto Focus Demo" position="center-left">
|
|
36
|
+
<div>
|
|
37
|
+
<h3>Panel with Auto Focus</h3>
|
|
38
|
+
|
|
39
|
+
<p>The input below should be focused when the panel opens:</p>
|
|
40
|
+
|
|
41
|
+
<input data-autofocus placeholder="I will be focused automatically" />
|
|
42
|
+
|
|
43
|
+
<button type="button">Button</button>
|
|
44
|
+
</div>
|
|
45
|
+
</SlidingPanel>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const DefaultOpen: StoryObj<typeof SlidingPanel> = {
|
|
50
|
+
render: () => (
|
|
51
|
+
<SlidingPanel tabLabel="Always Open" position="center-right" defaultOpen>
|
|
52
|
+
<div>
|
|
53
|
+
<h3>Default Open Panel</h3>
|
|
54
|
+
|
|
55
|
+
<p>This panel starts open by default.</p>
|
|
56
|
+
|
|
57
|
+
<button type="button">Button 1</button>
|
|
58
|
+
|
|
59
|
+
<button type="button">Button 2</button>
|
|
60
|
+
|
|
61
|
+
<input placeholder="Input field" />
|
|
62
|
+
</div>
|
|
63
|
+
</SlidingPanel>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const FormExample: StoryObj<typeof SlidingPanel> = {
|
|
68
|
+
render: () => (
|
|
69
|
+
<SlidingPanel tabLabel="Contact Form" position="center-top">
|
|
70
|
+
<div>
|
|
71
|
+
<h3>Contact Form</h3>
|
|
72
|
+
|
|
73
|
+
<form>
|
|
74
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
75
|
+
<label htmlFor="name">Name:</label>
|
|
76
|
+
|
|
77
|
+
<input id="name" type="text" placeholder="Your name" data-autofocus />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
81
|
+
<label htmlFor="email">Email:</label>
|
|
82
|
+
|
|
83
|
+
<input id="email" type="email" placeholder="your@email.com" />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
87
|
+
<label htmlFor="message">Message:</label>
|
|
88
|
+
|
|
89
|
+
<textarea id="message" placeholder="Your message"></textarea>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<button type="submit">Send Message</button>
|
|
93
|
+
|
|
94
|
+
<button type="button">Cancel</button>
|
|
95
|
+
</form>
|
|
96
|
+
</div>
|
|
97
|
+
</SlidingPanel>
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const NoFocusableElements: StoryObj<typeof SlidingPanel> = {
|
|
102
|
+
render: () => (
|
|
103
|
+
<SlidingPanel tabLabel="No Focus Elements" position="center-left">
|
|
104
|
+
<div>
|
|
105
|
+
<h3>Panel Without Focusable Elements</h3>
|
|
106
|
+
|
|
107
|
+
<p>This panel only contains text and headings.</p>
|
|
108
|
+
|
|
109
|
+
<p>
|
|
110
|
+
It should still work properly with focus trapping, even though there are no buttons,
|
|
111
|
+
inputs, or other interactive elements.
|
|
112
|
+
</p>
|
|
113
|
+
|
|
114
|
+
<div>
|
|
115
|
+
<strong>Key Features:</strong>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<ul>
|
|
119
|
+
<li>Opens and closes properly</li>
|
|
120
|
+
|
|
121
|
+
<li>Focus trap works correctly</li>
|
|
122
|
+
|
|
123
|
+
<li>Escape key closes the panel</li>
|
|
124
|
+
|
|
125
|
+
<li>Focus returns to trigger button</li>
|
|
126
|
+
</ul>
|
|
127
|
+
</div>
|
|
128
|
+
</SlidingPanel>
|
|
129
|
+
),
|
|
130
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, type ReactNode, useRef, useMemo,
|
|
3
|
+
import { useState, useEffect, type ReactNode, useRef, useMemo, useId } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { FocusTrap } from 'focus-trap-react';
|
|
6
6
|
|
|
7
7
|
type Position = 'center-left' | 'center-right' | 'center-top' | 'center-bottom';
|
|
8
8
|
|
|
@@ -13,134 +13,69 @@ export type SlidingPanelProps = {
|
|
|
13
13
|
defaultOpen?: boolean;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const TRAP_SELECTORS =
|
|
17
|
-
'a[href]:not([aria-hidden="true"]):not([hidden]), button:not([disabled]):not([aria-hidden="true"]):not([hidden]), input:not([disabled]):not([aria-hidden="true"]):not([hidden]), textarea:not([disabled]):not([aria-hidden="true"]):not([hidden]), select:not([disabled]):not([aria-hidden="true"]):not([hidden]), [tabindex]:not([tabindex="-1"]):not([aria-hidden="true"]):not([hidden])';
|
|
18
|
-
|
|
19
16
|
export const SlidingPanel = ({
|
|
20
17
|
children,
|
|
21
18
|
tabLabel = 'Open',
|
|
22
19
|
position = 'center-left',
|
|
23
20
|
defaultOpen = false,
|
|
24
21
|
}: SlidingPanelProps) => {
|
|
22
|
+
const id = useId();
|
|
23
|
+
|
|
25
24
|
const [isVisible, setIsVisible] = useState(defaultOpen);
|
|
25
|
+
const [isTrapActive, setIsTrapActive] = useState(defaultOpen);
|
|
26
26
|
const [panelDimensions, setPanelDimensions] = useState({ width: 0, height: 0 });
|
|
27
|
-
const panelRef = useRef<HTMLDivElement>(null);
|
|
28
|
-
const triggerRef = useRef<HTMLElement | null>(null); // store previously focused element
|
|
29
27
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
setIsVisible(true);
|
|
33
|
-
};
|
|
28
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
// Required for the `escapeDeactivates` function as these options are an object and only
|
|
32
|
+
// capture their environment once, so without a ref, `isVisible` would only ever be
|
|
33
|
+
// `false` inside the callback, even if it's change. A `useCallback` doesn't help because
|
|
34
|
+
// it creates a new function each time, so the event would still only capture the first created function.
|
|
35
|
+
const isVisibleRef = useRef(isVisible);
|
|
36
|
+
// tracks if the focus trap was deactivated by clicking outside the trap
|
|
37
|
+
const deactivatedByClick = useRef(false);
|
|
38
38
|
|
|
39
|
-
// Measure panel dimensions when visible
|
|
40
39
|
useEffect(() => {
|
|
41
|
-
|
|
42
|
-
const updateDimensions = () => {
|
|
43
|
-
if (panelRef.current) {
|
|
44
|
-
const rect = panelRef.current.getBoundingClientRect();
|
|
45
|
-
|
|
46
|
-
const newDimensions = { width: rect.width, height: rect.height };
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
newDimensions.width !== panelDimensions.width ||
|
|
50
|
-
newDimensions.height !== panelDimensions.height
|
|
51
|
-
) {
|
|
52
|
-
setPanelDimensions(newDimensions);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// Initial measurement
|
|
58
|
-
updateDimensions();
|
|
40
|
+
isVisibleRef.current = isVisible;
|
|
59
41
|
|
|
60
|
-
|
|
61
|
-
const resizeObserver = new ResizeObserver(updateDimensions);
|
|
42
|
+
deactivatedByClick.current = false;
|
|
62
43
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return () => resizeObserver.disconnect();
|
|
66
|
-
}
|
|
67
|
-
}, [isVisible, panelDimensions.height, panelDimensions.width]);
|
|
68
|
-
|
|
69
|
-
// move focus into panel when opened, restore focus to correct element when closed
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (isVisible && panelRef.current) {
|
|
72
|
-
const els = Array.from(panelRef.current.querySelectorAll<HTMLElement>(TRAP_SELECTORS));
|
|
73
|
-
|
|
74
|
-
if (els.length > 0) {
|
|
75
|
-
els[0].focus();
|
|
76
|
-
} else {
|
|
77
|
-
panelRef.current.focus();
|
|
78
|
-
}
|
|
79
|
-
} else if (!isVisible && triggerRef.current) {
|
|
80
|
-
triggerRef.current.focus();
|
|
81
|
-
}
|
|
44
|
+
setIsTrapActive(isVisible);
|
|
82
45
|
}, [isVisible]);
|
|
83
46
|
|
|
84
|
-
//
|
|
47
|
+
// Measure panel dimensions when visible or when panel content changes
|
|
85
48
|
useEffect(() => {
|
|
86
|
-
if (!
|
|
49
|
+
if (!panelRef.current) {
|
|
87
50
|
return;
|
|
88
51
|
}
|
|
89
52
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!els.length) {
|
|
96
|
-
panel.focus();
|
|
97
|
-
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const first = els[0];
|
|
102
|
-
const last = els[els.length - 1];
|
|
103
|
-
const active = document.activeElement;
|
|
104
|
-
|
|
105
|
-
if (direction === 'next' && active === last) {
|
|
106
|
-
first.focus();
|
|
107
|
-
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (direction === 'prev' && active === first) {
|
|
112
|
-
last.focus();
|
|
53
|
+
const updateDimensions = () => {
|
|
54
|
+
if (panelRef.current) {
|
|
55
|
+
const rect = panelRef.current.getBoundingClientRect();
|
|
56
|
+
const newDimensions = { width: rect.width, height: rect.height };
|
|
113
57
|
|
|
114
|
-
|
|
58
|
+
if (
|
|
59
|
+
newDimensions.width !== panelDimensions.width ||
|
|
60
|
+
newDimensions.height !== panelDimensions.height
|
|
61
|
+
) {
|
|
62
|
+
setPanelDimensions(newDimensions);
|
|
63
|
+
}
|
|
115
64
|
}
|
|
116
|
-
|
|
117
|
-
return false;
|
|
118
65
|
};
|
|
119
66
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
event.preventDefault();
|
|
123
|
-
closePanel();
|
|
124
|
-
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (event.key !== KeyboardKeys.Tab) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
67
|
+
// Initial measurement
|
|
68
|
+
updateDimensions();
|
|
131
69
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
event.preventDefault();
|
|
135
|
-
}
|
|
136
|
-
};
|
|
70
|
+
// Use ResizeObserver to detect content changes
|
|
71
|
+
const resizeObserver = new ResizeObserver(updateDimensions);
|
|
137
72
|
|
|
138
|
-
|
|
73
|
+
resizeObserver.observe(panelRef.current);
|
|
139
74
|
|
|
140
75
|
return () => {
|
|
141
|
-
|
|
76
|
+
resizeObserver.disconnect();
|
|
142
77
|
};
|
|
143
|
-
}, [
|
|
78
|
+
}, [isVisible, panelDimensions.height, panelDimensions.width]);
|
|
144
79
|
|
|
145
80
|
const panelBase =
|
|
146
81
|
'absolute bg-white shadow-lg p-4 flex flex-col transition-transform duration-300 ease-in-out overflow-auto z-30';
|
|
@@ -201,6 +136,14 @@ export const SlidingPanel = ({
|
|
|
201
136
|
return {};
|
|
202
137
|
}, [isVisible, panelDimensions.height, panelDimensions.width, position]);
|
|
203
138
|
|
|
139
|
+
const openPanel = () => {
|
|
140
|
+
setIsVisible(true);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const closePanel = () => {
|
|
144
|
+
setIsVisible(false);
|
|
145
|
+
};
|
|
146
|
+
|
|
204
147
|
return (
|
|
205
148
|
<div className="absolute inset-0 z-30 overflow-hidden pointer-events-none sliding-panel">
|
|
206
149
|
<button
|
|
@@ -208,21 +151,87 @@ export const SlidingPanel = ({
|
|
|
208
151
|
style={getButtonStyle}
|
|
209
152
|
onClick={() => (isVisible ? closePanel() : openPanel())}
|
|
210
153
|
aria-expanded={isVisible}
|
|
211
|
-
aria-controls=
|
|
154
|
+
aria-controls={id}
|
|
155
|
+
ref={buttonRef}
|
|
156
|
+
type="button"
|
|
212
157
|
>
|
|
213
158
|
{isVisible ? `Close ${tabLabel}` : `Open ${tabLabel}`}
|
|
214
159
|
</button>
|
|
215
160
|
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
161
|
+
<FocusTrap
|
|
162
|
+
active={isTrapActive}
|
|
163
|
+
focusTrapOptions={{
|
|
164
|
+
escapeDeactivates: () => {
|
|
165
|
+
deactivatedByClick.current = false;
|
|
166
|
+
|
|
167
|
+
if (isVisibleRef.current) {
|
|
168
|
+
closePanel();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return false;
|
|
172
|
+
},
|
|
173
|
+
clickOutsideDeactivates: () => {
|
|
174
|
+
deactivatedByClick.current = true; // mark as mouse-driven
|
|
175
|
+
|
|
176
|
+
return true; // still allow it to deactivate
|
|
177
|
+
},
|
|
178
|
+
// eslint-disable-next-line sonarjs/function-return-type
|
|
179
|
+
setReturnFocus: (target) => {
|
|
180
|
+
// if the user has clicked outside the focus trap we shouldn't return focus as it will steal focus from the element they clicked on
|
|
181
|
+
return deactivatedByClick.current ? false : target;
|
|
182
|
+
},
|
|
183
|
+
onDeactivate: () => {
|
|
184
|
+
setIsTrapActive(false);
|
|
185
|
+
},
|
|
186
|
+
onActivate: () => {
|
|
187
|
+
setIsTrapActive(true);
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Emergency fallback focus function for FocusTrap.
|
|
191
|
+
*
|
|
192
|
+
* This function is automatically called by FocusTrap whenever it cannot find
|
|
193
|
+
* any tabbable elements within the panel. This commonly occurs in scenarios like:
|
|
194
|
+
*
|
|
195
|
+
* - Panel opens but async content hasn't loaded yet
|
|
196
|
+
* - Panel is closing and content is being unmounted
|
|
197
|
+
* - User presses Escape and TableFilters content disappears
|
|
198
|
+
* - Dynamic content changes temporarily remove all focusable elements
|
|
199
|
+
*
|
|
200
|
+
* Without this fallback, FocusTrap throws the error:
|
|
201
|
+
* "Your focus-trap must have at least one container with at least one tabbable node in it at all times"
|
|
202
|
+
*
|
|
203
|
+
* The function ensures there's always a focusable element available by:
|
|
204
|
+
* 1. Looking for our guaranteed hidden fallback element with [data-fallback-focus]
|
|
205
|
+
* 2. Making it temporarily focusable (tabIndex = 0)
|
|
206
|
+
* 3. Returning it to FocusTrap as a safe focus target
|
|
207
|
+
* 4. Using document.body as ultimate fallback if something goes wrong
|
|
208
|
+
*
|
|
209
|
+
* This is especially crucial for complex content like ObservationTableManager
|
|
210
|
+
* where TableFilters has async operations that can cause content to disappear
|
|
211
|
+
* during panel lifecycle events.
|
|
212
|
+
*
|
|
213
|
+
* @returns HTMLElement - The element FocusTrap should focus on as a fallback
|
|
214
|
+
*/
|
|
215
|
+
fallbackFocus: () => {
|
|
216
|
+
return panelRef.current ?? buttonRef.current ?? document.body;
|
|
217
|
+
},
|
|
218
|
+
}}
|
|
223
219
|
>
|
|
224
|
-
<div
|
|
225
|
-
|
|
220
|
+
<div
|
|
221
|
+
ref={panelRef}
|
|
222
|
+
tabIndex={-1}
|
|
223
|
+
id={id}
|
|
224
|
+
role="dialog"
|
|
225
|
+
aria-modal="true"
|
|
226
|
+
aria-label={`Sliding panel for ${tabLabel}`}
|
|
227
|
+
className={`${panelBase} ${panelLayout} pointer-events-auto`}
|
|
228
|
+
aria-hidden={!isVisible}
|
|
229
|
+
inert={!isVisible}
|
|
230
|
+
onPointerDown={() => (!isTrapActive ? setIsTrapActive(true) : void 0)}
|
|
231
|
+
>
|
|
232
|
+
<div className="mt-4">{children}</div>
|
|
233
|
+
</div>
|
|
234
|
+
</FocusTrap>
|
|
226
235
|
</div>
|
|
227
236
|
);
|
|
228
237
|
};
|
|
@@ -1,40 +1,62 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
1
2
|
import { AiFillChrome } from 'react-icons/ai';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
import type { Meta, StoryFn } from '@storybook/react';
|
|
4
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
5
5
|
|
|
6
|
-
import { Chip
|
|
6
|
+
import { Chip } from './Chip';
|
|
7
7
|
import { Paragraph } from '../Paragraph/Paragraph';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const meta: Meta<typeof Chip> = {
|
|
10
|
+
title: 'Components/Chip',
|
|
11
11
|
component: Chip,
|
|
12
|
-
|
|
12
|
+
parameters: {
|
|
13
|
+
layout: 'padded',
|
|
14
|
+
},
|
|
15
|
+
tags: ['autodocs'],
|
|
16
|
+
argTypes: {
|
|
17
|
+
children: {
|
|
18
|
+
description: 'Content of the chip',
|
|
19
|
+
control: false,
|
|
20
|
+
},
|
|
21
|
+
className: {
|
|
22
|
+
description: 'Additional TailwindCSS classes to apply',
|
|
23
|
+
control: 'text',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
children: 'Hello, this is some simple Chip',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
13
30
|
|
|
14
|
-
|
|
31
|
+
export default meta;
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
Default.args = {
|
|
18
|
-
children: 'Chip',
|
|
19
|
-
};
|
|
33
|
+
type Story = StoryObj<typeof Chip>;
|
|
20
34
|
|
|
21
|
-
export const
|
|
22
|
-
JustText.args = {
|
|
23
|
-
children: 'Hello, this is some simple text',
|
|
24
|
-
};
|
|
35
|
+
export const Default: Story = {};
|
|
25
36
|
|
|
26
|
-
export const ParagraphOfText =
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
export const ParagraphOfText: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
children: <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>,
|
|
40
|
+
},
|
|
29
41
|
};
|
|
30
42
|
|
|
31
|
-
export const TextWithIcon =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
export const TextWithIcon: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
children: (
|
|
46
|
+
<div className="flex items-center justify-center gap-2">
|
|
47
|
+
<AiFillChrome className="text-base" />
|
|
48
|
+
|
|
49
|
+
<Paragraph className="pb-0">
|
|
50
|
+
Hello, this is a Chip container with a paragraph of text
|
|
51
|
+
</Paragraph>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
export const CustomStyling: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
className: 'bg-blue-100 text-blue-800 px-4 py-2 rounded-full',
|
|
60
|
+
children: 'Custom styled chip',
|
|
61
|
+
},
|
|
40
62
|
};
|
package/src/errors/ApiError.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import z from 'zod/v4';
|
|
4
4
|
|
|
5
|
-
import { HttpStatus, HttpStatusText } from '../http';
|
|
5
|
+
import { HttpStatus, HttpStatusText } from '../http/constants';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Schema defining the JSON shape of error responses returned by a server. (The proxy is where
|
|
@@ -53,7 +53,13 @@ export class ApiError extends Error implements z.output<typeof ApiErrorSchema> {
|
|
|
53
53
|
this.name = 'ApiError';
|
|
54
54
|
this.status = status;
|
|
55
55
|
this.details = details ?? null;
|
|
56
|
-
|
|
56
|
+
// Crypto is a NodeJS and browser global; use it if available, but fallback to a pseudo-random string otherwise.
|
|
57
|
+
// This is due to Storybook not providing the `crypto` global in some environments (e.g., Jest).
|
|
58
|
+
this.digest =
|
|
59
|
+
typeof crypto?.randomUUID === 'function'
|
|
60
|
+
? crypto.randomUUID()
|
|
61
|
+
: // eslint-disable-next-line sonarjs/pseudo-random
|
|
62
|
+
Math.random().toString(36).slice(2);
|
|
57
63
|
this.rehydrated = options?.rehydrated ?? false;
|
|
58
64
|
|
|
59
65
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
package/src/http/constants.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/* eslint-disable no-restricted-syntax */
|
|
2
|
+
import { createFocusTrap } from 'focus-trap';
|
|
3
|
+
import type { FocusTrap } from 'focus-trap';
|
|
2
4
|
import { Map } from 'ol';
|
|
3
5
|
import { Control } from 'ol/control';
|
|
4
6
|
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
5
7
|
import BaseLayer from 'ol/layer/Base';
|
|
6
8
|
|
|
7
|
-
import { KeyboardKeys } from '../utils';
|
|
8
|
-
|
|
9
9
|
const TIMEOUT = 300; // Match CSS transition duration
|
|
10
10
|
const ARIA_LABEL = 'aria-label';
|
|
11
11
|
|
|
@@ -16,7 +16,8 @@ export class LayerSwitcherControl extends Control {
|
|
|
16
16
|
map!: Map;
|
|
17
17
|
panel!: HTMLElement;
|
|
18
18
|
liveRegion!: HTMLElement;
|
|
19
|
-
|
|
19
|
+
isOpen = false;
|
|
20
|
+
private focusTrap: FocusTrap | null = null;
|
|
20
21
|
|
|
21
22
|
constructor(layers: BaseLayer[], options?: ControlOptions) {
|
|
22
23
|
const button = document.createElement('button');
|
|
@@ -70,9 +71,6 @@ export class LayerSwitcherControl extends Control {
|
|
|
70
71
|
this.panel.setAttribute('aria-modal', 'true');
|
|
71
72
|
this.panel.setAttribute(ARIA_LABEL, 'Basemap switcher');
|
|
72
73
|
|
|
73
|
-
// Add a keydown listener to the panel for Escape key
|
|
74
|
-
this.panel.addEventListener('keydown', this.handlePanelKeyDown);
|
|
75
|
-
|
|
76
74
|
// Create the header for the close button
|
|
77
75
|
const header = document.createElement('div');
|
|
78
76
|
|
|
@@ -87,7 +85,6 @@ export class LayerSwitcherControl extends Control {
|
|
|
87
85
|
closeBtn.type = 'button';
|
|
88
86
|
closeBtn.addEventListener('click', () => {
|
|
89
87
|
this.toggleLayerSwitcher();
|
|
90
|
-
this.focusToggleButton();
|
|
91
88
|
});
|
|
92
89
|
header.appendChild(closeBtn);
|
|
93
90
|
|
|
@@ -124,6 +121,29 @@ export class LayerSwitcherControl extends Control {
|
|
|
124
121
|
this.panel.appendChild(header);
|
|
125
122
|
this.panel.appendChild(content);
|
|
126
123
|
|
|
124
|
+
// Create focus trap once - will be activated/deactivated as needed
|
|
125
|
+
this.focusTrap = createFocusTrap(this.panel, {
|
|
126
|
+
clickOutsideDeactivates: (event) => {
|
|
127
|
+
// if the user happens to click on the open button again we let the button handle the click event, not the focus trap
|
|
128
|
+
if (button.contains(event.target as Node)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.toggleLayerSwitcher();
|
|
133
|
+
|
|
134
|
+
return false;
|
|
135
|
+
},
|
|
136
|
+
escapeDeactivates: () => {
|
|
137
|
+
this.toggleLayerSwitcher();
|
|
138
|
+
|
|
139
|
+
return false;
|
|
140
|
+
},
|
|
141
|
+
returnFocusOnDeactivate: true,
|
|
142
|
+
fallbackFocus: () => {
|
|
143
|
+
return this.panel;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
127
147
|
button.addEventListener('click', this.toggleLayerSwitcher, false);
|
|
128
148
|
}
|
|
129
149
|
|
|
@@ -142,22 +162,6 @@ export class LayerSwitcherControl extends Control {
|
|
|
142
162
|
.find((layer: BaseLayer) => layer.get('name') === layerName);
|
|
143
163
|
}
|
|
144
164
|
|
|
145
|
-
private focusFirstButton() {
|
|
146
|
-
const firstBtn = this.panel.querySelector(
|
|
147
|
-
'button:not(.ol-layer-switcher-close)',
|
|
148
|
-
) as HTMLButtonElement | null;
|
|
149
|
-
|
|
150
|
-
firstBtn?.focus();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private focusToggleButton() {
|
|
154
|
-
const toggleBtn = this.element.querySelector(
|
|
155
|
-
'button.ol-layer-switcher-toggle',
|
|
156
|
-
) as HTMLButtonElement | null;
|
|
157
|
-
|
|
158
|
-
toggleBtn?.focus();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
165
|
private announceBasemapChange(layerName: string) {
|
|
162
166
|
if (this.liveRegion) {
|
|
163
167
|
this.liveRegion.textContent = `Basemap changed to ${layerName}`;
|
|
@@ -181,59 +185,33 @@ export class LayerSwitcherControl extends Control {
|
|
|
181
185
|
|
|
182
186
|
// Arrow function: 'this' is always bound to the class instance
|
|
183
187
|
toggleLayerSwitcher = () => {
|
|
184
|
-
if (this.
|
|
188
|
+
if (!this.isOpen) {
|
|
185
189
|
this.element.appendChild(this.panel);
|
|
190
|
+
|
|
186
191
|
requestAnimationFrame(() => {
|
|
187
192
|
this.panel.classList.add('open'); // Ensure animation works after adding to DOM
|
|
188
193
|
|
|
189
|
-
//
|
|
190
|
-
this.
|
|
194
|
+
// Activate the existing focus trap
|
|
195
|
+
this.focusTrap?.activate();
|
|
191
196
|
});
|
|
192
197
|
} else {
|
|
193
198
|
this.panel.classList.remove('open');
|
|
199
|
+
|
|
200
|
+
// Deactivate focus trap but keep the instance
|
|
201
|
+
this.focusTrap?.deactivate();
|
|
202
|
+
|
|
194
203
|
setTimeout(() => {
|
|
195
204
|
this.element.removeChild(this.panel);
|
|
196
205
|
}, TIMEOUT); // Matches CSS transition time to prevent flickering
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
this.
|
|
208
|
+
this.isOpen = !this.isOpen;
|
|
200
209
|
|
|
201
210
|
// Update aria-expanded on the toggle button
|
|
202
211
|
const toggleBtn = this.element.querySelector('button.ol-layer-switcher-toggle');
|
|
203
212
|
|
|
204
213
|
if (toggleBtn) {
|
|
205
|
-
toggleBtn.setAttribute('aria-expanded',
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
// Arrow function for panel keydown
|
|
210
|
-
handlePanelKeyDown = (event: KeyboardEvent) => {
|
|
211
|
-
// Focus trap: cycle focus within the panel
|
|
212
|
-
if (event.key === KeyboardKeys.Tab) {
|
|
213
|
-
const focusable = Array.from(this.panel.querySelectorAll('button')) as HTMLButtonElement[];
|
|
214
|
-
|
|
215
|
-
if (focusable.length === 0) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const first = focusable[0];
|
|
220
|
-
const last = focusable[focusable.length - 1];
|
|
221
|
-
|
|
222
|
-
if (!event.shiftKey && document.activeElement === last) {
|
|
223
|
-
event.preventDefault();
|
|
224
|
-
first.focus();
|
|
225
|
-
} else if (event.shiftKey && document.activeElement === first) {
|
|
226
|
-
event.preventDefault();
|
|
227
|
-
last.focus();
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Escape closes the panel and returns focus to the toggle button
|
|
232
|
-
if (event.key === KeyboardKeys.Escape) {
|
|
233
|
-
this.toggleLayerSwitcher();
|
|
234
|
-
|
|
235
|
-
// Focus back on the basemap switcher button
|
|
236
|
-
this.focusToggleButton();
|
|
214
|
+
toggleBtn.setAttribute('aria-expanded', this.isOpen.toString());
|
|
237
215
|
}
|
|
238
216
|
};
|
|
239
217
|
|
|
@@ -276,4 +254,14 @@ export class LayerSwitcherControl extends Control {
|
|
|
276
254
|
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
277
255
|
});
|
|
278
256
|
};
|
|
257
|
+
|
|
258
|
+
// Cleanup method - call this when removing the control
|
|
259
|
+
destroy() {
|
|
260
|
+
// Deactivate and destroy focus trap
|
|
261
|
+
if (this.focusTrap) {
|
|
262
|
+
this.focusTrap.deactivate();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.focusTrap = null;
|
|
266
|
+
}
|
|
279
267
|
}
|
package/src/utils/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { verify } from 'jsonwebtoken';
|
|
2
2
|
import { twMerge } from 'tailwind-merge';
|
|
3
3
|
|
|
4
4
|
import type { Credentials, DecodedJWT } from '../types/auth';
|
|
@@ -9,10 +9,19 @@ export const decodeAuthToken = (token: string): Credentials | null => {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
try {
|
|
12
|
-
|
|
12
|
+
// eslint-disable-next-line no-undef
|
|
13
|
+
const secret = process.env.JWT_SECRET;
|
|
14
|
+
|
|
15
|
+
if (!secret) {
|
|
16
|
+
throw new Error('JWT secret not set');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const user = verify(token, secret) as DecodedJWT;
|
|
13
20
|
|
|
14
21
|
return { token, user };
|
|
15
|
-
} catch {
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('Error decoding auth token:', error);
|
|
24
|
+
|
|
16
25
|
return null;
|
|
17
26
|
}
|
|
18
27
|
};
|