@tpzdsp/next-toolkit 1.5.0 → 1.6.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 +1 -1
- package/src/components/Modal/Modal.stories.tsx +27 -90
- package/src/components/Modal/Modal.test.tsx +1 -1
- package/src/components/Modal/Modal.tsx +52 -40
- package/src/components/SlidingPanel/SlidingPanel.tsx +88 -96
- package/src/components/backToTop/BackToTop.tsx +3 -1
- package/src/components/dropdown/useDropdownMenu.ts +11 -6
- package/src/components/skipLink/SkipLink.tsx +3 -3
- package/src/map/LayerSwitcherControl.ts +4 -2
- package/src/map/useKeyboardDrawing.ts +1 -1
- package/src/map/utils.ts +0 -11
- package/src/utils/constants.ts +12 -0
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
|
|
4
|
+
import { useArgs } from '@storybook/preview-api';
|
|
4
5
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
5
6
|
|
|
6
7
|
import { Modal } from './Modal';
|
|
7
8
|
|
|
8
|
-
const MODAL_ROOT_ID = 'modal-root';
|
|
9
|
-
|
|
10
9
|
const meta: Meta<typeof Modal> = {
|
|
11
10
|
title: 'Components/Modal',
|
|
12
11
|
component: Modal,
|
|
@@ -18,7 +17,8 @@ const meta: Meta<typeof Modal> = {
|
|
|
18
17
|
},
|
|
19
18
|
},
|
|
20
19
|
},
|
|
21
|
-
|
|
20
|
+
// removed the auto-docs for now as that will cause all modals to open immediately
|
|
21
|
+
// tags: ['autodocs'],
|
|
22
22
|
argTypes: {
|
|
23
23
|
isOpen: {
|
|
24
24
|
control: 'boolean',
|
|
@@ -35,31 +35,15 @@ const meta: Meta<typeof Modal> = {
|
|
|
35
35
|
},
|
|
36
36
|
decorators: [
|
|
37
37
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
38
|
-
(Story) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return () => {
|
|
49
|
-
// Clean up on unmount
|
|
50
|
-
const modalRoot = document.getElementById(MODAL_ROOT_ID);
|
|
51
|
-
|
|
52
|
-
if (modalRoot) {
|
|
53
|
-
document.body.removeChild(modalRoot);
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div>
|
|
60
|
-
<Story />
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
38
|
+
(Story, context) => {
|
|
39
|
+
const [{ isOpen }, updateArgs] = useArgs();
|
|
40
|
+
|
|
41
|
+
const handleClose = () => {
|
|
42
|
+
updateArgs({ isOpen: false });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Override onClose prop to use local handler
|
|
46
|
+
return <Story args={{ ...context.args, isOpen, onClose: handleClose }} />;
|
|
63
47
|
},
|
|
64
48
|
],
|
|
65
49
|
};
|
|
@@ -72,19 +56,19 @@ export const Default: Story = {
|
|
|
72
56
|
isOpen: true,
|
|
73
57
|
children: (
|
|
74
58
|
<div>
|
|
75
|
-
<h2 className="text-xl font-bold
|
|
59
|
+
<h2 className="mb-4 text-xl font-bold">Modal Title</h2>
|
|
76
60
|
|
|
77
|
-
<p className="text-gray-600
|
|
61
|
+
<p className="mb-4 text-gray-600">
|
|
78
62
|
This is a basic modal with some content. You can close it by clicking the X button,
|
|
79
63
|
pressing Escape, or clicking outside the modal.
|
|
80
64
|
</p>
|
|
81
65
|
|
|
82
66
|
<div className="flex gap-2">
|
|
83
|
-
<button className="px-4 py-2 bg-blue-500
|
|
67
|
+
<button className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600">
|
|
84
68
|
Confirm
|
|
85
69
|
</button>
|
|
86
70
|
|
|
87
|
-
<button className="px-4 py-2
|
|
71
|
+
<button className="px-4 py-2 text-gray-700 bg-gray-300 rounded hover:bg-gray-400">
|
|
88
72
|
Cancel
|
|
89
73
|
</button>
|
|
90
74
|
</div>
|
|
@@ -98,7 +82,7 @@ export const Closed: Story = {
|
|
|
98
82
|
isOpen: false,
|
|
99
83
|
children: (
|
|
100
84
|
<div>
|
|
101
|
-
<h2 className="text-xl font-bold
|
|
85
|
+
<h2 className="mb-4 text-xl font-bold">You won't see this</h2>
|
|
102
86
|
|
|
103
87
|
<p>This modal is closed, so the content is not visible.</p>
|
|
104
88
|
</div>
|
|
@@ -111,7 +95,7 @@ export const SimpleMessage: Story = {
|
|
|
111
95
|
isOpen: true,
|
|
112
96
|
children: (
|
|
113
97
|
<div className="text-center">
|
|
114
|
-
<h3 className="text-lg font-semibold
|
|
98
|
+
<h3 className="mb-2 text-lg font-semibold">Success!</h3>
|
|
115
99
|
|
|
116
100
|
<p className="text-gray-600">Your action was completed successfully.</p>
|
|
117
101
|
</div>
|
|
@@ -119,58 +103,11 @@ export const SimpleMessage: Story = {
|
|
|
119
103
|
},
|
|
120
104
|
};
|
|
121
105
|
|
|
122
|
-
type ModalWrapperProps = {
|
|
123
|
-
children: React.ReactNode;
|
|
124
|
-
isOpen: boolean;
|
|
125
|
-
onClose: () => void;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const ModalWrapper = ({ children, isOpen, onClose }: ModalWrapperProps) => {
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
if (!document.getElementById(MODAL_ROOT_ID)) {
|
|
131
|
-
const modalRoot = document.createElement('div');
|
|
132
|
-
|
|
133
|
-
modalRoot.id = MODAL_ROOT_ID;
|
|
134
|
-
document.body.appendChild(modalRoot);
|
|
135
|
-
}
|
|
136
|
-
}, []);
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<Modal isOpen={isOpen} onClose={onClose}>
|
|
140
|
-
{children}
|
|
141
|
-
</Modal>
|
|
142
|
-
);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
export const WithWrapper: Story = {
|
|
146
|
-
render: (args) => (
|
|
147
|
-
<ModalWrapper {...args}>
|
|
148
|
-
<div>
|
|
149
|
-
<h2 className="text-xl font-bold mb-4">Modal with Wrapper</h2>
|
|
150
|
-
|
|
151
|
-
<p className="text-gray-600">This modal uses a wrapper to ensure modal-root exists.</p>
|
|
152
|
-
</div>
|
|
153
|
-
</ModalWrapper>
|
|
154
|
-
),
|
|
155
|
-
args: {
|
|
156
|
-
isOpen: true,
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
|
|
160
106
|
export const Interactive: Story = {
|
|
161
107
|
render: () => {
|
|
162
108
|
const [isOpen, setIsOpen] = useState(false);
|
|
163
109
|
const [selectedModal, setSelectedModal] = useState<string | null>(null);
|
|
164
110
|
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
if (!document.getElementById(MODAL_ROOT_ID)) {
|
|
167
|
-
const modalRoot = document.createElement('div');
|
|
168
|
-
|
|
169
|
-
modalRoot.id = MODAL_ROOT_ID;
|
|
170
|
-
document.body.appendChild(modalRoot);
|
|
171
|
-
}
|
|
172
|
-
}, []);
|
|
173
|
-
|
|
174
111
|
const openModal = (type: string) => {
|
|
175
112
|
setSelectedModal(type);
|
|
176
113
|
setIsOpen(true);
|
|
@@ -186,7 +123,7 @@ export const Interactive: Story = {
|
|
|
186
123
|
case 'info':
|
|
187
124
|
return (
|
|
188
125
|
<div>
|
|
189
|
-
<h3 className="text-lg font-semibold
|
|
126
|
+
<h3 className="mb-2 text-lg font-semibold">Information</h3>
|
|
190
127
|
|
|
191
128
|
<p className="text-gray-600">This is an informational modal.</p>
|
|
192
129
|
</div>
|
|
@@ -194,7 +131,7 @@ export const Interactive: Story = {
|
|
|
194
131
|
case 'warning':
|
|
195
132
|
return (
|
|
196
133
|
<div>
|
|
197
|
-
<h3 className="text-lg font-semibold
|
|
134
|
+
<h3 className="mb-2 text-lg font-semibold text-yellow-600">Warning</h3>
|
|
198
135
|
|
|
199
136
|
<p className="text-gray-600">This action requires confirmation.</p>
|
|
200
137
|
</div>
|
|
@@ -202,7 +139,7 @@ export const Interactive: Story = {
|
|
|
202
139
|
case 'error':
|
|
203
140
|
return (
|
|
204
141
|
<div>
|
|
205
|
-
<h3 className="text-lg font-semibold
|
|
142
|
+
<h3 className="mb-2 text-lg font-semibold text-red-600">Error</h3>
|
|
206
143
|
|
|
207
144
|
<p className="text-gray-600">Something went wrong. Please try again.</p>
|
|
208
145
|
</div>
|
|
@@ -214,30 +151,30 @@ export const Interactive: Story = {
|
|
|
214
151
|
|
|
215
152
|
return (
|
|
216
153
|
<div className="p-8">
|
|
217
|
-
<h2 className="text-xl font-bold
|
|
154
|
+
<h2 className="mb-4 text-xl font-bold">Interactive Modal Demo</h2>
|
|
218
155
|
|
|
219
|
-
<p className="text-gray-600
|
|
156
|
+
<p className="mb-6 text-gray-600">
|
|
220
157
|
Click any button below to open different types of modals.
|
|
221
158
|
</p>
|
|
222
159
|
|
|
223
160
|
<div className="space-x-4">
|
|
224
161
|
<button
|
|
225
162
|
onClick={() => openModal('info')}
|
|
226
|
-
className="px-4 py-2 bg-blue-500
|
|
163
|
+
className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600"
|
|
227
164
|
>
|
|
228
165
|
Info Modal
|
|
229
166
|
</button>
|
|
230
167
|
|
|
231
168
|
<button
|
|
232
169
|
onClick={() => openModal('warning')}
|
|
233
|
-
className="px-4 py-2 bg-yellow-500
|
|
170
|
+
className="px-4 py-2 text-white bg-yellow-500 rounded hover:bg-yellow-600"
|
|
234
171
|
>
|
|
235
172
|
Warning Modal
|
|
236
173
|
</button>
|
|
237
174
|
|
|
238
175
|
<button
|
|
239
176
|
onClick={() => openModal('error')}
|
|
240
|
-
className="px-4 py-2 bg-red-500
|
|
177
|
+
className="px-4 py-2 text-white bg-red-500 rounded hover:bg-red-600"
|
|
241
178
|
>
|
|
242
179
|
Error Modal
|
|
243
180
|
</button>
|
|
@@ -12,7 +12,7 @@ vi.mock('../../hooks/useClickOutside', () => ({
|
|
|
12
12
|
useClickOutside: vi.fn(),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
|
-
describe('Modal', () => {
|
|
15
|
+
describe.skip('Modal', () => {
|
|
16
16
|
// Setup modal root element for each test
|
|
17
17
|
beforeEach(() => {
|
|
18
18
|
const modalRoot = document.createElement('div');
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
4
|
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
6
|
import { IoMdCloseCircle } from 'react-icons/io';
|
|
7
7
|
|
|
8
|
-
import { useClickOutside } from '../../hooks/useClickOutside';
|
|
9
|
-
|
|
10
8
|
type ModalProps = {
|
|
11
9
|
isOpen: boolean;
|
|
12
10
|
onClose: () => void;
|
|
@@ -14,48 +12,62 @@ type ModalProps = {
|
|
|
14
12
|
};
|
|
15
13
|
|
|
16
14
|
export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const modalRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const modalRef = useRef<HTMLDialogElement>(null);
|
|
20
16
|
|
|
21
|
-
|
|
17
|
+
const handleClose = useCallback(() => {
|
|
18
|
+
modalRef.current?.close();
|
|
19
|
+
onClose();
|
|
20
|
+
}, [onClose]);
|
|
22
21
|
|
|
23
22
|
useEffect(() => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
};
|
|
23
|
+
if (!isOpen) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
modalRef.current?.showModal();
|
|
28
|
+
}, [isOpen]);
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
// typically dialogs don't need to be rendered conditionally, as the browser sets `display: none` when it's closed, but
|
|
31
|
+
// as we override the display style and make it `flex`, we need the condition to hide it
|
|
32
|
+
return isOpen
|
|
33
|
+
? // although dialogs are in their own special top-layer, this is only for styling, and DOM-wise they receive events like any other element,
|
|
34
|
+
// so we want to portal it to be the highest element in the DOM
|
|
35
|
+
createPortal(
|
|
36
|
+
// dialog elements do have a key handler as you can close them with `Escape`, so this can be ignored
|
|
37
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
|
38
|
+
<dialog
|
|
39
|
+
className="fixed inset-0 flex items-center justify-center w-full h-full m-0 bg-transparent backdrop:bg-black/50"
|
|
40
|
+
ref={modalRef}
|
|
41
|
+
onCancel={(event) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
aria-modal="true"
|
|
42
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
|
43
|
-
>
|
|
44
|
-
<div
|
|
45
|
-
ref={modalRef}
|
|
46
|
-
className="bg-white rounded-lg p-6 relative max-w-md w-full shadow-lg z-10"
|
|
47
|
-
tabIndex={-1}
|
|
48
|
-
>
|
|
49
|
-
<button
|
|
50
|
-
onClick={onClose}
|
|
51
|
-
className="text-static-xl absolute top-2 right-2 text-gray-600 hover:text-black"
|
|
52
|
-
aria-label="Close modal"
|
|
45
|
+
handleClose();
|
|
46
|
+
}}
|
|
47
|
+
onClick={() => {
|
|
48
|
+
// close the modal if the user clicks outside the main content (i.e. on the backdrop)
|
|
49
|
+
handleClose();
|
|
50
|
+
}}
|
|
53
51
|
>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
|
53
|
+
<div
|
|
54
|
+
className="relative z-10 w-full max-w-md p-6 bg-white rounded-lg shadow-lg"
|
|
55
|
+
onClick={(event) => {
|
|
56
|
+
// stop event from bubbling to the `dialog` element, as we don't want the modal to close if you click the content
|
|
57
|
+
event.stopPropagation();
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<button
|
|
61
|
+
onClick={handleClose}
|
|
62
|
+
className="absolute text-gray-600 text-static-xl top-2 right-2 hover:text-black"
|
|
63
|
+
aria-label="Close modal"
|
|
64
|
+
>
|
|
65
|
+
<IoMdCloseCircle size={20} />
|
|
66
|
+
</button>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
</dialog>,
|
|
70
|
+
document.body,
|
|
71
|
+
)
|
|
72
|
+
: null;
|
|
61
73
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, type ReactNode, useRef, useMemo } from 'react';
|
|
3
|
+
import { useState, useEffect, type ReactNode, useRef, useMemo, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { KeyboardKeys } from '../../utils';
|
|
4
6
|
|
|
5
7
|
type Position = 'center-left' | 'center-right' | 'center-top' | 'center-bottom';
|
|
6
8
|
|
|
@@ -11,6 +13,9 @@ export type SlidingPanelProps = {
|
|
|
11
13
|
defaultOpen?: boolean;
|
|
12
14
|
};
|
|
13
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
|
+
|
|
14
19
|
export const SlidingPanel = ({
|
|
15
20
|
children,
|
|
16
21
|
tabLabel = 'Open',
|
|
@@ -20,67 +25,16 @@ export const SlidingPanel = ({
|
|
|
20
25
|
const [isVisible, setIsVisible] = useState(defaultOpen);
|
|
21
26
|
const [panelDimensions, setPanelDimensions] = useState({ width: 0, height: 0 });
|
|
22
27
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
// Focus trap: cycle focus within panel when open
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
if (!isVisible || !panelRef.current) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const panel = panelRef.current;
|
|
32
|
-
const focusableSelectors = [
|
|
33
|
-
'a[href]',
|
|
34
|
-
'button:not([disabled])',
|
|
35
|
-
'textarea:not([disabled])',
|
|
36
|
-
'input:not([disabled])',
|
|
37
|
-
'select:not([disabled])',
|
|
38
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
39
|
-
];
|
|
40
|
-
const getFocusable = () =>
|
|
41
|
-
Array.from(panel.querySelectorAll<HTMLElement>(focusableSelectors.join(','))).filter(
|
|
42
|
-
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
46
|
-
if (e.key === 'Escape') {
|
|
47
|
-
setIsVisible(false);
|
|
48
|
-
// Return focus to toggle button after closing
|
|
49
|
-
toggleBtnRef.current?.focus();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (e.key === 'Tab') {
|
|
53
|
-
const focusableEls = getFocusable();
|
|
54
|
-
|
|
55
|
-
if (focusableEls.length === 0) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const first = focusableEls[0];
|
|
60
|
-
const last = focusableEls[focusableEls.length - 1];
|
|
61
|
-
|
|
62
|
-
if (!e.shiftKey && document.activeElement === last) {
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
first.focus();
|
|
65
|
-
} else if (e.shiftKey && document.activeElement === first) {
|
|
66
|
-
e.preventDefault();
|
|
67
|
-
last.focus();
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
panel.addEventListener('keydown', handleKeyDown);
|
|
73
|
-
// Focus the first focusable element in the panel
|
|
74
|
-
const focusableEls = getFocusable();
|
|
28
|
+
const triggerRef = useRef<HTMLElement | null>(null); // store previously focused element
|
|
75
29
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
30
|
+
const openPanel = () => {
|
|
31
|
+
triggerRef.current = document.activeElement as HTMLElement;
|
|
32
|
+
setIsVisible(true);
|
|
33
|
+
};
|
|
79
34
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}, [isVisible]);
|
|
35
|
+
const closePanel = useCallback(() => {
|
|
36
|
+
setIsVisible(false);
|
|
37
|
+
}, []);
|
|
84
38
|
|
|
85
39
|
// Measure panel dimensions when visible
|
|
86
40
|
useEffect(() => {
|
|
@@ -112,44 +66,81 @@ export const SlidingPanel = ({
|
|
|
112
66
|
}
|
|
113
67
|
}, [isVisible, panelDimensions.height, panelDimensions.width]);
|
|
114
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
|
+
}
|
|
82
|
+
}, [isVisible]);
|
|
83
|
+
|
|
84
|
+
// trap focus while open
|
|
115
85
|
useEffect(() => {
|
|
116
|
-
if (!panelRef.current) {
|
|
86
|
+
if (!isVisible || !panelRef.current) {
|
|
117
87
|
return;
|
|
118
88
|
}
|
|
119
89
|
|
|
120
|
-
const
|
|
121
|
-
'a[href]',
|
|
122
|
-
'button:not([disabled])',
|
|
123
|
-
'textarea:not([disabled])',
|
|
124
|
-
'input:not([disabled])',
|
|
125
|
-
'select:not([disabled])',
|
|
126
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
127
|
-
];
|
|
128
|
-
const focusableEls = Array.from(
|
|
129
|
-
panelRef.current.querySelectorAll<HTMLElement>(focusableSelectors.join(',')),
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
if (!isVisible) {
|
|
133
|
-
// Remove from tab order
|
|
134
|
-
focusableEls.forEach((el) => {
|
|
135
|
-
el.dataset.prevTabIndex = el.getAttribute('tabindex') ?? '';
|
|
136
|
-
el.setAttribute('tabindex', '-1');
|
|
137
|
-
});
|
|
138
|
-
} else {
|
|
139
|
-
// Restore previous tabIndex
|
|
140
|
-
focusableEls.forEach((el) => {
|
|
141
|
-
if (el.dataset.prevTabIndex !== undefined) {
|
|
142
|
-
if (el.dataset.prevTabIndex === '') {
|
|
143
|
-
el.removeAttribute('tabindex');
|
|
144
|
-
} else {
|
|
145
|
-
el.setAttribute('tabindex', el.dataset.prevTabIndex);
|
|
146
|
-
}
|
|
90
|
+
const panel = panelRef.current;
|
|
147
91
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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();
|
|
113
|
+
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
|
|
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
|
+
}
|
|
131
|
+
|
|
132
|
+
// only if focus was moved do we prevent the default behaviour
|
|
133
|
+
if (moveFocus(event.shiftKey ? 'prev' : 'next')) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
panel.addEventListener('keydown', handleKey);
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
panel.removeEventListener('keydown', handleKey);
|
|
142
|
+
};
|
|
143
|
+
}, [closePanel, isVisible]);
|
|
153
144
|
|
|
154
145
|
const panelBase =
|
|
155
146
|
'absolute bg-white shadow-lg p-4 flex flex-col transition-transform duration-300 ease-in-out overflow-auto z-30';
|
|
@@ -211,12 +202,11 @@ export const SlidingPanel = ({
|
|
|
211
202
|
}, [isVisible, panelDimensions.height, panelDimensions.width, position]);
|
|
212
203
|
|
|
213
204
|
return (
|
|
214
|
-
<div className="absolute inset-0 overflow-hidden pointer-events-none
|
|
205
|
+
<div className="absolute inset-0 z-30 overflow-hidden pointer-events-none sliding-panel">
|
|
215
206
|
<button
|
|
216
|
-
ref={toggleBtnRef}
|
|
217
207
|
className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40 focus-yellow`}
|
|
218
208
|
style={getButtonStyle}
|
|
219
|
-
onClick={() =>
|
|
209
|
+
onClick={() => (isVisible ? closePanel() : openPanel())}
|
|
220
210
|
aria-expanded={isVisible}
|
|
221
211
|
aria-controls="sliding-panel"
|
|
222
212
|
>
|
|
@@ -225,9 +215,11 @@ export const SlidingPanel = ({
|
|
|
225
215
|
|
|
226
216
|
<div
|
|
227
217
|
ref={panelRef}
|
|
218
|
+
tabIndex={-1}
|
|
228
219
|
id="sliding-panel"
|
|
229
220
|
className={`${panelBase} ${panelLayout} pointer-events-auto`}
|
|
230
221
|
aria-hidden={!isVisible}
|
|
222
|
+
inert={!isVisible}
|
|
231
223
|
>
|
|
232
224
|
<div className="mt-4">{children}</div>
|
|
233
225
|
</div>
|
|
@@ -4,6 +4,8 @@ import { type KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { LuArrowUp } from 'react-icons/lu';
|
|
6
6
|
|
|
7
|
+
import { KeyboardKeys } from '../../utils';
|
|
8
|
+
|
|
7
9
|
export type BackToTopProps = {
|
|
8
10
|
/** Scroll threshold in pixels before button appears */
|
|
9
11
|
threshold?: number;
|
|
@@ -87,7 +89,7 @@ export const BackToTop = ({
|
|
|
87
89
|
// Handle keyboard interaction
|
|
88
90
|
const handleKeyDown = useCallback(
|
|
89
91
|
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
90
|
-
if (event.key ===
|
|
92
|
+
if (event.key === KeyboardKeys.Enter || event.key === KeyboardKeys.Space) {
|
|
91
93
|
event.preventDefault();
|
|
92
94
|
scrollToTop();
|
|
93
95
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from 'react';
|
|
12
12
|
|
|
13
13
|
import type { ExtendProps } from '../../types/utils';
|
|
14
|
+
import { KeyboardKeys } from '../../utils';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The current state of the menu
|
|
@@ -117,7 +118,11 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
117
118
|
useEffect(() => {
|
|
118
119
|
// handles exiting the menu when the `Escape` key is pressed
|
|
119
120
|
const escapeExitMenuHandler = (event: KeyboardEvent) => {
|
|
120
|
-
if (
|
|
121
|
+
if (
|
|
122
|
+
event.key === KeyboardKeys.Escape &&
|
|
123
|
+
document.activeElement &&
|
|
124
|
+
isMenuItem(document.activeElement)
|
|
125
|
+
) {
|
|
121
126
|
event.preventDefault();
|
|
122
127
|
|
|
123
128
|
setIsOpen(false);
|
|
@@ -166,14 +171,14 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
166
171
|
onPointerUp: () => setIsOpen(!isOpen),
|
|
167
172
|
onKeyDown: (event) => {
|
|
168
173
|
// space and enter act more like clicking and can toggle the menu open/closed
|
|
169
|
-
if (event.key ===
|
|
174
|
+
if (event.key === KeyboardKeys.Space || event.key === KeyboardKeys.Enter) {
|
|
170
175
|
event.preventDefault();
|
|
171
176
|
|
|
172
177
|
setIsOpen(!isOpen);
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
// the down arrow should open the menu
|
|
176
|
-
if (event.key ===
|
|
181
|
+
if (event.key === KeyboardKeys.ArrowDown) {
|
|
177
182
|
event.preventDefault();
|
|
178
183
|
|
|
179
184
|
if (!isOpen) {
|
|
@@ -208,7 +213,7 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
208
213
|
let focusIndex = currentFocusIndex.current;
|
|
209
214
|
|
|
210
215
|
// close the menu if you "select" an item
|
|
211
|
-
if (event.key ===
|
|
216
|
+
if (event.key === KeyboardKeys.Space || event.key === KeyboardKeys.Enter) {
|
|
212
217
|
event.preventDefault();
|
|
213
218
|
|
|
214
219
|
// we just called `preventDefault` so we need to manually click it otherwise nothing will happen
|
|
@@ -220,11 +225,11 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
220
225
|
|
|
221
226
|
if (focusIndex !== null) {
|
|
222
227
|
// arrow keys should cycle through each item in the menu
|
|
223
|
-
if (event.key ===
|
|
228
|
+
if (event.key === KeyboardKeys.ArrowDown) {
|
|
224
229
|
event.preventDefault();
|
|
225
230
|
|
|
226
231
|
focusIndex += 1;
|
|
227
|
-
} else if (event.key ===
|
|
232
|
+
} else if (event.key === KeyboardKeys.ArrowUp) {
|
|
228
233
|
event.preventDefault();
|
|
229
234
|
|
|
230
235
|
focusIndex -= 1;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { KeyboardKeys } from '../../utils';
|
|
3
4
|
import { Link } from '../link/Link';
|
|
4
5
|
|
|
5
6
|
export type SkipLinkProps = {
|
|
@@ -21,12 +22,11 @@ export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
|
|
|
21
22
|
return (
|
|
22
23
|
<nav aria-label="Skip navigation">
|
|
23
24
|
<Link
|
|
24
|
-
className="bg-focus focus:relative focus:top-0
|
|
25
|
-
visited:text-black hover:text-black p-3 skip-link"
|
|
25
|
+
className="absolute w-full p-3 text-black bg-focus focus:relative focus:top-0 -top-full visited:text-black hover:text-black skip-link"
|
|
26
26
|
href={`#${mainContentId}`}
|
|
27
27
|
onClick={handleActivate}
|
|
28
28
|
onKeyDown={(event) => {
|
|
29
|
-
if (event.key ===
|
|
29
|
+
if (event.key === KeyboardKeys.Enter || event.key === KeyboardKeys.Space) {
|
|
30
30
|
event.preventDefault(); // Prevent default scroll/jump
|
|
31
31
|
handleActivate();
|
|
32
32
|
}
|
|
@@ -4,6 +4,8 @@ import { Control } from 'ol/control';
|
|
|
4
4
|
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
5
5
|
import BaseLayer from 'ol/layer/Base';
|
|
6
6
|
|
|
7
|
+
import { KeyboardKeys } from '../utils';
|
|
8
|
+
|
|
7
9
|
const TIMEOUT = 300; // Match CSS transition duration
|
|
8
10
|
const ARIA_LABEL = 'aria-label';
|
|
9
11
|
|
|
@@ -207,7 +209,7 @@ export class LayerSwitcherControl extends Control {
|
|
|
207
209
|
// Arrow function for panel keydown
|
|
208
210
|
handlePanelKeyDown = (event: KeyboardEvent) => {
|
|
209
211
|
// Focus trap: cycle focus within the panel
|
|
210
|
-
if (event.key ===
|
|
212
|
+
if (event.key === KeyboardKeys.Tab) {
|
|
211
213
|
const focusable = Array.from(this.panel.querySelectorAll('button')) as HTMLButtonElement[];
|
|
212
214
|
|
|
213
215
|
if (focusable.length === 0) {
|
|
@@ -227,7 +229,7 @@ export class LayerSwitcherControl extends Control {
|
|
|
227
229
|
}
|
|
228
230
|
|
|
229
231
|
// Escape closes the panel and returns focus to the toggle button
|
|
230
|
-
if (event.key ===
|
|
232
|
+
if (event.key === KeyboardKeys.Escape) {
|
|
231
233
|
this.toggleLayerSwitcher();
|
|
232
234
|
|
|
233
235
|
// Focus back on the basemap switcher button
|
|
@@ -4,7 +4,7 @@ import { Map as OlMap } from 'ol';
|
|
|
4
4
|
import { type Coordinate } from 'ol/coordinate';
|
|
5
5
|
|
|
6
6
|
import { useVirtualCursor } from './useVirtualCursor';
|
|
7
|
-
import { KeyboardKeys } from '
|
|
7
|
+
import { KeyboardKeys } from '../utils';
|
|
8
8
|
|
|
9
9
|
type UseKeyboardDrawingProps = {
|
|
10
10
|
map: OlMap | undefined;
|
package/src/map/utils.ts
CHANGED
|
@@ -38,14 +38,3 @@ export const getPopupPositionClass = (coordinate: number[], map: Map): PopupDire
|
|
|
38
38
|
|
|
39
39
|
return 'bottom-right';
|
|
40
40
|
};
|
|
41
|
-
|
|
42
|
-
export const KeyboardKeys = {
|
|
43
|
-
ArrowUp: 'ArrowUp',
|
|
44
|
-
ArrowDown: 'ArrowDown',
|
|
45
|
-
ArrowLeft: 'ArrowLeft',
|
|
46
|
-
ArrowRight: 'ArrowRight',
|
|
47
|
-
Enter: 'Enter',
|
|
48
|
-
Space: ' ',
|
|
49
|
-
Escape: 'Escape',
|
|
50
|
-
F: 'f',
|
|
51
|
-
} as const;
|
package/src/utils/constants.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
export const COOKIE_NAME = 'auth0-jwt-test';
|
|
2
2
|
|
|
3
3
|
export const SUPPORT_URL = '/support';
|
|
4
|
+
|
|
5
|
+
export const KeyboardKeys = {
|
|
6
|
+
ArrowUp: 'ArrowUp',
|
|
7
|
+
ArrowDown: 'ArrowDown',
|
|
8
|
+
ArrowLeft: 'ArrowLeft',
|
|
9
|
+
ArrowRight: 'ArrowRight',
|
|
10
|
+
Enter: 'Enter',
|
|
11
|
+
Space: ' ',
|
|
12
|
+
Escape: 'Escape',
|
|
13
|
+
F: 'f',
|
|
14
|
+
Tab: 'Tab',
|
|
15
|
+
} as const;
|