@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.8.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,
@@ -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
- /* 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',
@@ -1,3 +1,5 @@
1
+ 'use client';
2
+
1
3
  import { useId } from 'react';
2
4
 
3
5
  import { type FallbackProps } from 'react-error-boundary';
@@ -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
  };
@@ -1,40 +1,58 @@
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
  import { fn } from '@storybook/test';
4
4
 
5
- import { Hint, type HintProps } from './Hint';
5
+ import { Hint } from './Hint';
6
6
  import { Button } from '../Button/Button';
7
7
 
8
- export default {
9
- children: 'Hint',
8
+ const meta: Meta<typeof Hint> = {
9
+ title: 'Components/Hint',
10
10
  component: Hint,
11
- } as Meta;
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
- const Template: StoryFn<HintProps> = (args) => <Hint {...args} />;
30
+ export default meta;
14
31
 
15
- export const DefaultHint = Template.bind({});
16
- DefaultHint.args = {
17
- children: 'Hint message',
18
- };
32
+ type Story = StoryObj<typeof Hint>;
33
+
34
+ export const Default: Story = {};
19
35
 
20
- export const CustomStyling = Template.bind({});
21
- CustomStyling.args = {
22
- className: 'text-3xl',
23
- children: 'Error message with extra large text',
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 = Template.bind({});
27
- ComplexChildren.args = {
28
- children: (
29
- <div>
30
- Hint message with link and button{' '}
31
- <a className="underline text-link" href="/">
32
- Link
33
- </a>
34
- {/* // eslint-disable-next-line custom/padding-between-jsx-elements */}
35
- <Button variant="primary" onClick={fn()}>
36
- Click
37
- </Button>
38
- </div>
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, StoryFn } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
3
 
4
- import { Paragraph, type ParagraphProps } from './Paragraph';
4
+ import { Paragraph } from './Paragraph';
5
+ import { ExternalLink } from '../link/ExternalLink';
5
6
 
6
- export default {
7
- children: 'Paragraph',
7
+ const meta: Meta<typeof Paragraph> = {
8
+ title: 'Components/Paragraph',
8
9
  component: Paragraph,
9
- } as Meta;
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 Template: StoryFn<ParagraphProps> = (args) => <Paragraph {...args} />;
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 JustText = Template.bind({});
14
- JustText.args = {
15
- children: 'Hello, this is some simple text',
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 = Template.bind({});
19
- ImageAndText.args = {
20
- children: (
21
- <div>
22
- <img
23
- src="https://images.unsplash.com/photo-1563991655280-cb95c90ca2fb?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8bm8lMjBjb3B5cmlnaHR8ZW58MHx8MHx8fDA%3D"
24
- alt="Card"
25
- />
26
-
27
- <p>This is text about the image</p>
28
- </div>
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, useCallback } from 'react';
3
+ import { useState, useEffect, type ReactNode, useRef, useMemo, useId } from 'react';
4
4
 
5
- import { KeyboardKeys } from '../../utils';
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 openPanel = () => {
31
- triggerRef.current = document.activeElement as HTMLElement;
32
- setIsVisible(true);
33
- };
28
+ const panelRef = useRef<HTMLDivElement>(null);
29
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
34
30
 
35
- const closePanel = useCallback(() => {
36
- setIsVisible(false);
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
- if (isVisible && panelRef.current) {
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
- // Use ResizeObserver to detect changes
61
- const resizeObserver = new ResizeObserver(updateDimensions);
42
+ deactivatedByClick.current = false;
62
43
 
63
- resizeObserver.observe(panelRef.current);
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
- // trap focus while open
47
+ // Measure panel dimensions when visible or when panel content changes
85
48
  useEffect(() => {
86
- if (!isVisible || !panelRef.current) {
49
+ if (!panelRef.current) {
87
50
  return;
88
51
  }
89
52
 
90
- const panel = panelRef.current;
91
-
92
- const moveFocus = (direction: 'next' | 'prev') => {
93
- const els = Array.from(panel.querySelectorAll<HTMLElement>(TRAP_SELECTORS));
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
- return true;
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
- const handleKey = (event: KeyboardEvent) => {
121
- if (event.key === KeyboardKeys.Escape) {
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
- // only if focus was moved do we prevent the default behaviour
133
- if (moveFocus(event.shiftKey ? 'prev' : 'next')) {
134
- event.preventDefault();
135
- }
136
- };
70
+ // Use ResizeObserver to detect content changes
71
+ const resizeObserver = new ResizeObserver(updateDimensions);
137
72
 
138
- panel.addEventListener('keydown', handleKey);
73
+ resizeObserver.observe(panelRef.current);
139
74
 
140
75
  return () => {
141
- panel.removeEventListener('keydown', handleKey);
76
+ resizeObserver.disconnect();
142
77
  };
143
- }, [closePanel, isVisible]);
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="sliding-panel"
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
- <div
217
- ref={panelRef}
218
- tabIndex={-1}
219
- id="sliding-panel"
220
- className={`${panelBase} ${panelLayout} pointer-events-auto`}
221
- aria-hidden={!isVisible}
222
- inert={!isVisible}
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 className="mt-4">{children}</div>
225
- </div>
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
- /* 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 { Chip, type ChipProps } from './Chip';
6
+ import { Chip } from './Chip';
7
7
  import { Paragraph } from '../Paragraph/Paragraph';
8
8
 
9
- export default {
10
- children: 'Chip',
9
+ const meta: Meta<typeof Chip> = {
10
+ title: 'Components/Chip',
11
11
  component: Chip,
12
- } as Meta;
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
- const Template: StoryFn<ChipProps> = (args) => <Chip {...args} />;
31
+ export default meta;
15
32
 
16
- export const Default = Template.bind({});
17
- Default.args = {
18
- children: 'Chip',
19
- };
33
+ type Story = StoryObj<typeof Chip>;
20
34
 
21
- export const JustText = Template.bind({});
22
- JustText.args = {
23
- children: 'Hello, this is some simple text',
24
- };
35
+ export const Default: Story = {};
25
36
 
26
- export const ParagraphOfText = Template.bind({});
27
- ParagraphOfText.args = {
28
- children: <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>,
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 = Template.bind({});
32
- TextWithIcon.args = {
33
- children: (
34
- <div className="flex items-center justify-center gap-2">
35
- <AiFillChrome className="text-base" />
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
- <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>
38
- </div>
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
  };
@@ -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
- this.digest = crypto.randomUUID();
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)
@@ -99,6 +99,8 @@ export const Header = {
99
99
  CsvHeader: 'CSV-Header',
100
100
  XTotalItems: 'X-Total-Items',
101
101
  XRequestId: 'X-Request-ID',
102
+ ApiVersion: 'API-Version',
103
+ CacheControl: 'Cache-Control',
102
104
  } as const;
103
105
 
104
106
  /**
@@ -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
- isCollapsed = true;
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.isCollapsed) {
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
- // Focus the first basemap button (skip the close button)
190
- this.focusFirstButton();
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.isCollapsed = !this.isCollapsed;
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', (!this.isCollapsed).toString());
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
  }
@@ -1,4 +1,4 @@
1
- import { decode } from 'jsonwebtoken';
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
- const user = decode(token) as unknown as DecodedJWT;
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
  };