@tpzdsp/next-toolkit 1.4.2 → 1.4.4
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/assets/styles/globals.css +4 -0
- package/src/assets/styles/ol.css +158 -57
- package/src/components/SlidingPanel/SlidingPanel.tsx +105 -2
- package/src/components/accordion/Accordion.tsx +2 -1
- package/src/map/LayerSwitcherControl.ts +179 -49
- package/src/map/MapComponent.tsx +20 -12
- package/src/types/api.ts +1 -1
- package/src/utils/http.ts +1 -12
package/package.json
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
|
|
24
24
|
/* Component-specific styles */
|
|
25
25
|
@layer components {
|
|
26
|
+
.focus-yellow {
|
|
27
|
+
@apply focus:outline focus:outline-[3px] focus:outline-[#ffbf47] focus:border-[#ffbf47] z-20;
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
.library-button {
|
|
27
31
|
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors;
|
|
28
32
|
}
|
package/src/assets/styles/ol.css
CHANGED
|
@@ -1,44 +1,162 @@
|
|
|
1
1
|
@import 'ol/ol.css';
|
|
2
2
|
@import 'ol-geocoder/dist/ol-geocoder.min.css';
|
|
3
3
|
|
|
4
|
+
/* Universal OpenLayers control focus style */
|
|
5
|
+
#map:focus,
|
|
6
|
+
.ol-viewport:focus,
|
|
7
|
+
.ol-control:focus,
|
|
8
|
+
.ol-control button:focus,
|
|
9
|
+
.ol-control button:focus-visible,
|
|
10
|
+
.ol-attribution:focus,
|
|
11
|
+
.ol-attribution button:focus,
|
|
12
|
+
.ol-zoom:focus,
|
|
13
|
+
.ol-zoom button:focus,
|
|
14
|
+
.ol-layer-switcher-toggle:focus,
|
|
15
|
+
.ol-layer-switcher-close:focus,
|
|
16
|
+
.ol-layer-switcher-panel button:focus,
|
|
17
|
+
.ol-attribution ul li a:focus,
|
|
18
|
+
.ol-attribution ul li a:focus-visible,
|
|
19
|
+
.ol-geocoder ul.gcd-txt-result>li>a:focus,
|
|
20
|
+
.ol-geocoder ul.gcd-txt-result>li>a:focus-visible {
|
|
21
|
+
outline: 3px solid #ffbf47 !important;
|
|
22
|
+
border-color: #ffbf47 !important;
|
|
23
|
+
z-index: 2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ol-geocoder #gcd-input-search:focus,
|
|
27
|
+
.ol-geocoder .gcd-txt-search:focus,
|
|
28
|
+
.ol-geocoder .gcd-txt-search:focus-visible {
|
|
29
|
+
background-color: #ffbf47 !important;
|
|
30
|
+
z-index: 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ol-geocoder .gcd-txt-input:focus,
|
|
34
|
+
.ol-geocoder .gcd-txt-input:focus-visible {
|
|
35
|
+
box-shadow: inset 0 0 0 1px #ffbf47, inset 0 0 6px #ffbf47;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Zoom control container */
|
|
4
39
|
.ol-zoom {
|
|
40
|
+
top: 1.5rem;
|
|
41
|
+
right: 1rem;
|
|
5
42
|
left: unset;
|
|
6
|
-
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
z-index: 10;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Zoom buttons */
|
|
49
|
+
.ol-zoom button,
|
|
50
|
+
.ol-control button {
|
|
51
|
+
width: 40px;
|
|
52
|
+
height: 40px;
|
|
53
|
+
background: #fff;
|
|
54
|
+
color: #0b0c0c;
|
|
55
|
+
font-size: 2rem;
|
|
56
|
+
font-weight: bold;
|
|
57
|
+
line-height: 1;
|
|
58
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ol-zoom button:hover,
|
|
69
|
+
.ol-control button:hover {
|
|
70
|
+
background: #f3f2f1;
|
|
71
|
+
border-color: #1d70b8;
|
|
72
|
+
outline: unset;
|
|
7
73
|
}
|
|
8
74
|
|
|
75
|
+
/* Layer switcher styles */
|
|
9
76
|
.ol-layer-switcher {
|
|
10
|
-
top:
|
|
11
|
-
right:
|
|
77
|
+
top: 0px;
|
|
78
|
+
right: 1rem;
|
|
12
79
|
}
|
|
13
80
|
|
|
14
|
-
.ol-
|
|
15
|
-
|
|
16
|
-
|
|
81
|
+
.ol-layer-switcher-toggle {
|
|
82
|
+
position: absolute;
|
|
83
|
+
top: 110px;
|
|
84
|
+
right: 0px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ol-layer-switcher-toggle button {
|
|
88
|
+
width: 40px;
|
|
89
|
+
height: 40px;
|
|
90
|
+
background: #fff;
|
|
91
|
+
border: 2px solid #505a5f;
|
|
92
|
+
color: #0b0c0c;
|
|
93
|
+
font-size: 1.5rem;
|
|
94
|
+
font-weight: bold;
|
|
95
|
+
line-height: 1;
|
|
96
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
|
99
|
+
margin: 0;
|
|
100
|
+
padding: 0;
|
|
101
|
+
display: flex;
|
|
17
102
|
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
18
104
|
}
|
|
19
105
|
|
|
20
106
|
.ol-layer-switcher-panel {
|
|
21
107
|
position: absolute;
|
|
22
|
-
top:
|
|
23
|
-
right: -100%;
|
|
24
|
-
min-width: fit-content;
|
|
25
|
-
max-width: 80vw;
|
|
26
|
-
background-color: #008938;
|
|
108
|
+
top: 110px;
|
|
109
|
+
right: -100%;
|
|
27
110
|
display: flex;
|
|
28
|
-
flex-direction:
|
|
29
|
-
align-items:
|
|
30
|
-
padding:
|
|
31
|
-
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
align-items: stretch;
|
|
113
|
+
padding: 1rem 1rem;
|
|
114
|
+
min-width: 220px;
|
|
115
|
+
background: #fff;
|
|
116
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
117
|
+
gap: 0.5rem;
|
|
32
118
|
transition: right 0.3s ease-in-out;
|
|
33
|
-
overflow: hidden;
|
|
34
|
-
white-space: nowrap;
|
|
35
119
|
}
|
|
36
120
|
|
|
37
121
|
.ol-layer-switcher-panel.open {
|
|
38
|
-
right:
|
|
122
|
+
right: 3rem;
|
|
39
123
|
}
|
|
40
124
|
|
|
41
|
-
|
|
125
|
+
/* Header for the close button, aligns it to the top-right */
|
|
126
|
+
.ol-layer-switcher-header {
|
|
127
|
+
display: flex;
|
|
128
|
+
justify-content: flex-end;
|
|
129
|
+
align-items: center;
|
|
130
|
+
width: 100%;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Small, circular close button */
|
|
134
|
+
.ol-layer-switcher-close {
|
|
135
|
+
font-size: 1.25rem;
|
|
136
|
+
width: 2rem;
|
|
137
|
+
height: 2rem;
|
|
138
|
+
padding: 0;
|
|
139
|
+
background: none;
|
|
140
|
+
border: none;
|
|
141
|
+
color: #505a5f;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
border-radius: 50%;
|
|
144
|
+
transition: background 0.2s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.ol-layer-switcher-close:hover,
|
|
148
|
+
.ol-layer-switcher-close:focus {
|
|
149
|
+
background: #e1e1e1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Content area for basemap buttons */
|
|
153
|
+
.ol-layer-switcher-content {
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-direction: row;
|
|
156
|
+
gap: 10px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.ol-layer-switcher-content button {
|
|
42
160
|
display: flex;
|
|
43
161
|
flex-direction: column;
|
|
44
162
|
align-items: center;
|
|
@@ -51,39 +169,41 @@
|
|
|
51
169
|
color: #000;
|
|
52
170
|
}
|
|
53
171
|
|
|
54
|
-
.ol-layer-switcher-
|
|
172
|
+
.ol-layer-switcher-content button:hover {
|
|
55
173
|
background-color: #ddd;
|
|
56
174
|
}
|
|
57
175
|
|
|
58
|
-
.ol-layer-switcher-
|
|
59
|
-
background-color: #007bff;
|
|
60
|
-
color: white;
|
|
61
|
-
border-color: #0056b3;
|
|
62
|
-
box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3);
|
|
63
|
-
transform: scale(1.05);
|
|
64
|
-
transition: all 0.2s ease;
|
|
176
|
+
.ol-layer-switcher-content button.active {
|
|
177
|
+
background-color: #007bff;
|
|
178
|
+
color: white;
|
|
179
|
+
border-color: #0056b3;
|
|
180
|
+
box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3);
|
|
181
|
+
transform: scale(1.05);
|
|
182
|
+
transition: all 0.2s ease;
|
|
65
183
|
}
|
|
66
184
|
|
|
67
|
-
.ol-layer-switcher-
|
|
68
|
-
filter: brightness(1.2);
|
|
185
|
+
.ol-layer-switcher-content button.active img {
|
|
186
|
+
filter: brightness(1.2);
|
|
69
187
|
}
|
|
70
188
|
|
|
71
|
-
.ol-layer-switcher-
|
|
72
|
-
filter: grayscale(1);
|
|
189
|
+
.ol-layer-switcher-content button:not(.active) img {
|
|
190
|
+
filter: grayscale(1);
|
|
73
191
|
}
|
|
74
192
|
|
|
75
|
-
.ol-layer-switcher-
|
|
193
|
+
.ol-layer-switcher-content button img {
|
|
76
194
|
max-width: 80px;
|
|
77
195
|
height: auto;
|
|
78
196
|
border: 2px solid #000;
|
|
79
|
-
border-radius: 0.3rem;
|
|
80
197
|
}
|
|
81
198
|
|
|
82
|
-
.ol-layer-switcher-
|
|
199
|
+
.ol-layer-switcher-content button div {
|
|
83
200
|
font-size: 14px;
|
|
84
201
|
padding-top: 0.5rem;
|
|
85
202
|
}
|
|
86
203
|
|
|
204
|
+
/* Geocoder tweaks */
|
|
205
|
+
|
|
206
|
+
#gcd-container,
|
|
87
207
|
.ol-geocoder .gcd-txt-control {
|
|
88
208
|
height: unset !important;
|
|
89
209
|
}
|
|
@@ -93,30 +213,11 @@
|
|
|
93
213
|
top: unset !important;
|
|
94
214
|
}
|
|
95
215
|
|
|
96
|
-
.ol-geocoder ul.gcd-txt-result
|
|
97
|
-
background-color: #
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
.ol-geocoder ul.gcd-txt-result > li:nth-child(even) {
|
|
101
|
-
background-color: #bddabd;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover {
|
|
216
|
+
.ol-geocoder ul.gcd-txt-result>li:nth-child(odd),
|
|
217
|
+
.ol-geocoder ul.gcd-txt-result>li:nth-child(even) { background-color: #fff;
|
|
105
218
|
background-color: #fff;
|
|
106
219
|
}
|
|
107
220
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-road,
|
|
111
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-city,
|
|
112
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-country {
|
|
113
|
-
color: #000;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/* Ensure the text color when not hovering */
|
|
117
|
-
.ol-geocoder ul.gcd-txt-result .gcd-address,
|
|
118
|
-
.ol-geocoder ul.gcd-txt-result .gcd-road,
|
|
119
|
-
.ol-geocoder ul.gcd-txt-result .gcd-city,
|
|
120
|
-
.ol-geocoder ul.gcd-txt-result .gcd-country {
|
|
121
|
-
color: #fff;
|
|
221
|
+
.ol-geocoder ul.gcd-txt-result>li>a:hover {
|
|
222
|
+
background-color: #f3f2f1;
|
|
122
223
|
}
|
|
@@ -20,6 +20,67 @@ export const SlidingPanel = ({
|
|
|
20
20
|
const [isVisible, setIsVisible] = useState(defaultOpen);
|
|
21
21
|
const [panelDimensions, setPanelDimensions] = useState({ width: 0, height: 0 });
|
|
22
22
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const toggleBtnRef = useRef<HTMLButtonElement>(null);
|
|
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();
|
|
75
|
+
|
|
76
|
+
if (focusableEls.length) {
|
|
77
|
+
focusableEls[0].focus();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
panel.removeEventListener('keydown', handleKeyDown);
|
|
82
|
+
};
|
|
83
|
+
}, [isVisible]);
|
|
23
84
|
|
|
24
85
|
// Measure panel dimensions when visible
|
|
25
86
|
useEffect(() => {
|
|
@@ -51,6 +112,45 @@ export const SlidingPanel = ({
|
|
|
51
112
|
}
|
|
52
113
|
}, [isVisible, panelDimensions.height, panelDimensions.width]);
|
|
53
114
|
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!panelRef.current) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const focusableSelectors = [
|
|
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
|
+
}
|
|
147
|
+
|
|
148
|
+
delete el.dataset.prevTabIndex;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}, [isVisible]);
|
|
153
|
+
|
|
54
154
|
const panelBase =
|
|
55
155
|
'absolute bg-white shadow-lg p-4 flex flex-col transition-transform duration-300 ease-in-out overflow-auto z-30';
|
|
56
156
|
|
|
@@ -113,18 +213,21 @@ export const SlidingPanel = ({
|
|
|
113
213
|
return (
|
|
114
214
|
<div className="absolute inset-0 overflow-hidden pointer-events-none z-30">
|
|
115
215
|
<button
|
|
116
|
-
|
|
216
|
+
ref={toggleBtnRef}
|
|
217
|
+
className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40 focus-yellow`}
|
|
117
218
|
style={getButtonStyle}
|
|
118
219
|
onClick={() => setIsVisible((prev) => !prev)}
|
|
220
|
+
aria-expanded={isVisible}
|
|
221
|
+
aria-controls="sliding-panel"
|
|
119
222
|
>
|
|
120
223
|
{isVisible ? `Close ${tabLabel}` : `Open ${tabLabel}`}
|
|
121
224
|
</button>
|
|
122
225
|
|
|
123
226
|
<div
|
|
124
227
|
ref={panelRef}
|
|
228
|
+
id="sliding-panel"
|
|
125
229
|
className={`${panelBase} ${panelLayout} pointer-events-auto`}
|
|
126
230
|
aria-hidden={!isVisible}
|
|
127
|
-
inert={!isVisible}
|
|
128
231
|
>
|
|
129
232
|
<div className="mt-4">{children}</div>
|
|
130
233
|
</div>
|
|
@@ -22,7 +22,8 @@ export const Accordion = ({ title, children, defaultOpen = false }: AccordionPro
|
|
|
22
22
|
<button
|
|
23
23
|
aria-expanded={isOpen}
|
|
24
24
|
aria-controls={contentId}
|
|
25
|
-
className="flex justify-between items-center px-2 py-1 bg-neutral-100 rounded-md
|
|
25
|
+
className="flex justify-between items-center px-2 py-1 bg-neutral-100 rounded-md
|
|
26
|
+
focus-yellow"
|
|
26
27
|
id={buttonId}
|
|
27
28
|
onClick={() => setIsOpen(!isOpen)}
|
|
28
29
|
type="button"
|
|
@@ -1,53 +1,106 @@
|
|
|
1
1
|
/* eslint-disable no-restricted-syntax */
|
|
2
|
-
import type { StaticImageData } from 'next/image';
|
|
3
2
|
import { Map } from 'ol';
|
|
4
3
|
import { Control } from 'ol/control';
|
|
5
4
|
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
6
5
|
import BaseLayer from 'ol/layer/Base';
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
const TIMEOUT = 300; // Match CSS transition duration
|
|
8
|
+
const ARIA_LABEL = 'aria-label';
|
|
9
|
+
|
|
10
|
+
const MAP_LOGO =
|
|
11
|
+
'<path d="M20.5 3l-5.7 2.1-6-2L3.5 5c-.3.1-.5.5-.5.8v15.2c0 .3.2.6.5.7l5.7-2.1 6 2 5.3-1.9c.3-.1.5-.4.5-.7V3.8c0-.3-.2-.6-.5-.8zM10 5.2l4 1.3v12.3l-4-1.3V5.2zm-6 1.1l4-1.4v12.3l-4 1.4V6.3zm16 12.4l-4 1.4V7.8l4-1.4v12.3z"/>';
|
|
9
12
|
|
|
10
13
|
export class LayerSwitcherControl extends Control {
|
|
11
14
|
map!: Map;
|
|
12
15
|
panel!: HTMLElement;
|
|
16
|
+
liveRegion!: HTMLElement;
|
|
13
17
|
isCollapsed = true;
|
|
14
18
|
|
|
15
19
|
constructor(layers: BaseLayer[], options?: ControlOptions) {
|
|
16
20
|
const button = document.createElement('button');
|
|
17
21
|
|
|
18
|
-
button.setAttribute(
|
|
19
|
-
button.setAttribute('aria-label', 'Button to toggle layer switcher');
|
|
22
|
+
button.setAttribute(ARIA_LABEL, 'Button to toggle layer switcher');
|
|
20
23
|
button.setAttribute('title', 'Basemap switcher');
|
|
21
|
-
button.
|
|
22
|
-
|
|
23
|
-
const switcherImage = document.createElement('img');
|
|
24
|
+
button.setAttribute('aria-expanded', 'false');
|
|
25
|
+
button.className = 'ol-layer-switcher-toggle ol-btn';
|
|
24
26
|
|
|
25
|
-
const
|
|
27
|
+
const switcherIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
switcherIcon.setAttribute('width', '20');
|
|
30
|
+
switcherIcon.setAttribute('height', '20');
|
|
31
|
+
switcherIcon.setAttribute('viewBox', '0 0 24 24');
|
|
32
|
+
switcherIcon.setAttribute('fill', 'currentColor');
|
|
33
|
+
switcherIcon.innerHTML = MAP_LOGO;
|
|
34
|
+
button.appendChild(switcherIcon);
|
|
32
35
|
|
|
33
36
|
const element = document.createElement('div');
|
|
34
37
|
|
|
35
38
|
element.className = 'ol-layer-switcher ol-unselectable ol-control';
|
|
36
39
|
element.appendChild(button);
|
|
37
40
|
|
|
41
|
+
// Create a live region for screen reader announcements
|
|
42
|
+
const liveRegion = document.createElement('div');
|
|
43
|
+
|
|
44
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
45
|
+
liveRegion.setAttribute('role', 'status');
|
|
46
|
+
liveRegion.className = 'ol-layer-switcher-live-region';
|
|
47
|
+
liveRegion.style.position = 'absolute';
|
|
48
|
+
liveRegion.style.width = '1px';
|
|
49
|
+
liveRegion.style.height = '1px';
|
|
50
|
+
liveRegion.style.margin = '-1px';
|
|
51
|
+
liveRegion.style.border = '0';
|
|
52
|
+
liveRegion.style.padding = '0';
|
|
53
|
+
liveRegion.style.overflow = 'hidden';
|
|
54
|
+
liveRegion.style.clipPath = 'inset(50%)';
|
|
55
|
+
element.appendChild(liveRegion);
|
|
56
|
+
|
|
38
57
|
super({
|
|
39
58
|
element,
|
|
40
59
|
target: options?.target,
|
|
41
60
|
});
|
|
42
61
|
|
|
62
|
+
// Assign the live region to the class property so it can be used in other functions.
|
|
63
|
+
this.liveRegion = liveRegion;
|
|
64
|
+
|
|
43
65
|
this.panel = document.createElement('div');
|
|
44
66
|
this.panel.className = 'ol-layer-switcher-panel';
|
|
67
|
+
this.panel.setAttribute('role', 'dialog');
|
|
68
|
+
this.panel.setAttribute('aria-modal', 'true');
|
|
69
|
+
this.panel.setAttribute(ARIA_LABEL, 'Basemap switcher');
|
|
70
|
+
|
|
71
|
+
// Add a keydown listener to the panel for Escape key
|
|
72
|
+
this.panel.addEventListener('keydown', this.handlePanelKeyDown);
|
|
45
73
|
|
|
74
|
+
// Create the header for the close button
|
|
75
|
+
const header = document.createElement('div');
|
|
76
|
+
|
|
77
|
+
header.className = 'ol-layer-switcher-header';
|
|
78
|
+
|
|
79
|
+
// Create the close button
|
|
80
|
+
const closeBtn = document.createElement('button');
|
|
81
|
+
|
|
82
|
+
closeBtn.className = 'ol-layer-switcher-close ol-btn';
|
|
83
|
+
closeBtn.setAttribute(ARIA_LABEL, 'Close basemap switcher');
|
|
84
|
+
closeBtn.innerHTML = '×';
|
|
85
|
+
closeBtn.type = 'button';
|
|
86
|
+
closeBtn.addEventListener('click', () => {
|
|
87
|
+
this.toggleLayerSwitcher();
|
|
88
|
+
this.focusToggleButton();
|
|
89
|
+
});
|
|
90
|
+
header.appendChild(closeBtn);
|
|
91
|
+
|
|
92
|
+
// Create the content area for basemap buttons
|
|
93
|
+
const content = document.createElement('div');
|
|
94
|
+
|
|
95
|
+
content.className = 'ol-layer-switcher-content';
|
|
96
|
+
|
|
97
|
+
// Add a button for each basemap layer
|
|
46
98
|
layers.forEach((layer) => {
|
|
47
99
|
const img = document.createElement('img');
|
|
48
100
|
|
|
49
101
|
img.src = layer.get('image') as string;
|
|
50
102
|
img.setAttribute('title', layer.get('name'));
|
|
103
|
+
img.setAttribute('alt', `Preview of ${layer.get('name')}`);
|
|
51
104
|
|
|
52
105
|
const switcherText = document.createElement('div');
|
|
53
106
|
|
|
@@ -55,19 +108,64 @@ export class LayerSwitcherControl extends Control {
|
|
|
55
108
|
|
|
56
109
|
const btn = document.createElement('button');
|
|
57
110
|
|
|
111
|
+
btn.setAttribute('type', 'button');
|
|
112
|
+
btn.setAttribute('aria-pressed', btn.classList.contains('active') ? 'true' : 'false');
|
|
58
113
|
btn.appendChild(img);
|
|
59
114
|
btn.appendChild(switcherText);
|
|
60
115
|
|
|
61
|
-
btn.addEventListener(
|
|
62
|
-
'click',
|
|
63
|
-
this.selectBasemap.bind(this, layer.get('name') as string),
|
|
64
|
-
false,
|
|
65
|
-
);
|
|
116
|
+
btn.addEventListener('click', () => this.selectBasemap(layer.get('name') as string), false);
|
|
66
117
|
|
|
67
|
-
|
|
118
|
+
content.appendChild(btn);
|
|
68
119
|
});
|
|
69
120
|
|
|
70
|
-
|
|
121
|
+
// Assemble the panel
|
|
122
|
+
this.panel.appendChild(header);
|
|
123
|
+
this.panel.appendChild(content);
|
|
124
|
+
|
|
125
|
+
button.addEventListener('click', this.toggleLayerSwitcher, false);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private getCurrentBasemap(): BaseLayer | undefined {
|
|
129
|
+
return this.getMap()
|
|
130
|
+
?.getLayers()
|
|
131
|
+
.getArray()
|
|
132
|
+
.filter((layer: BaseLayer) => layer.get('basemap') === true)
|
|
133
|
+
.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private getBasemapByName(layerName: string): BaseLayer | undefined {
|
|
137
|
+
return this.getMap()
|
|
138
|
+
?.getLayers()
|
|
139
|
+
.getArray()
|
|
140
|
+
.find((layer: BaseLayer) => layer.get('name') === layerName);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private focusFirstButton() {
|
|
144
|
+
const firstBtn = this.panel.querySelector(
|
|
145
|
+
'button:not(.ol-layer-switcher-close)',
|
|
146
|
+
) as HTMLButtonElement | null;
|
|
147
|
+
|
|
148
|
+
firstBtn?.focus();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private focusToggleButton() {
|
|
152
|
+
const toggleBtn = this.element.querySelector(
|
|
153
|
+
'button.ol-layer-switcher-toggle',
|
|
154
|
+
) as HTMLButtonElement | null;
|
|
155
|
+
|
|
156
|
+
toggleBtn?.focus();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private announceBasemapChange(layerName: string) {
|
|
160
|
+
if (this.liveRegion) {
|
|
161
|
+
this.liveRegion.textContent = `Basemap changed to ${layerName}`;
|
|
162
|
+
// Optionally clear the message after a short delay to allow repeated announcements
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
if (this.liveRegion) {
|
|
165
|
+
this.liveRegion.textContent = '';
|
|
166
|
+
}
|
|
167
|
+
}, 1000);
|
|
168
|
+
}
|
|
71
169
|
}
|
|
72
170
|
|
|
73
171
|
setMap(map: Map) {
|
|
@@ -75,73 +173,105 @@ export class LayerSwitcherControl extends Control {
|
|
|
75
173
|
|
|
76
174
|
if (map) {
|
|
77
175
|
// Ensure we only set the initial active layer when the map is assigned
|
|
78
|
-
map.once('rendercomplete', this.setInitialActiveLayer
|
|
176
|
+
map.once('rendercomplete', this.setInitialActiveLayer);
|
|
79
177
|
}
|
|
80
178
|
}
|
|
81
179
|
|
|
82
|
-
|
|
180
|
+
// Arrow function: 'this' is always bound to the class instance
|
|
181
|
+
toggleLayerSwitcher = () => {
|
|
83
182
|
if (this.isCollapsed) {
|
|
84
183
|
this.element.appendChild(this.panel);
|
|
85
184
|
requestAnimationFrame(() => {
|
|
86
185
|
this.panel.classList.add('open'); // Ensure animation works after adding to DOM
|
|
186
|
+
|
|
187
|
+
// Focus the first basemap button (skip the close button)
|
|
188
|
+
this.focusFirstButton();
|
|
87
189
|
});
|
|
88
190
|
} else {
|
|
89
191
|
this.panel.classList.remove('open');
|
|
90
192
|
setTimeout(() => {
|
|
91
193
|
this.element.removeChild(this.panel);
|
|
92
|
-
},
|
|
194
|
+
}, TIMEOUT); // Matches CSS transition time to prevent flickering
|
|
93
195
|
}
|
|
94
196
|
|
|
95
197
|
this.isCollapsed = !this.isCollapsed;
|
|
96
|
-
}
|
|
97
198
|
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
?.getLayers()
|
|
101
|
-
.getArray()
|
|
102
|
-
.filter((layer: BaseLayer) => layer.get('basemap') === true)
|
|
103
|
-
.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
199
|
+
// Update aria-expanded on the toggle button
|
|
200
|
+
const toggleBtn = this.element.querySelector('button.ol-layer-switcher-toggle');
|
|
104
201
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
202
|
+
if (toggleBtn) {
|
|
203
|
+
toggleBtn.setAttribute('aria-expanded', (!this.isCollapsed).toString());
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Arrow function for panel keydown
|
|
208
|
+
handlePanelKeyDown = (event: KeyboardEvent) => {
|
|
209
|
+
// Focus trap: cycle focus within the panel
|
|
210
|
+
if (event.key === 'Tab') {
|
|
211
|
+
const focusable = Array.from(this.panel.querySelectorAll('button')) as HTMLButtonElement[];
|
|
212
|
+
|
|
213
|
+
if (focusable.length === 0) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const first = focusable[0];
|
|
218
|
+
const last = focusable[focusable.length - 1];
|
|
219
|
+
|
|
220
|
+
if (!event.shiftKey && document.activeElement === last) {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
first.focus();
|
|
223
|
+
} else if (event.shiftKey && document.activeElement === first) {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
last.focus();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Escape closes the panel and returns focus to the toggle button
|
|
230
|
+
if (event.key === 'Escape') {
|
|
231
|
+
this.toggleLayerSwitcher();
|
|
232
|
+
|
|
233
|
+
// Focus back on the basemap switcher button
|
|
234
|
+
this.focusToggleButton();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
selectBasemap = (layerName: string) => {
|
|
239
|
+
const currentBasemap = this.getCurrentBasemap();
|
|
240
|
+
|
|
241
|
+
const newBasemap = this.getBasemapByName(layerName);
|
|
109
242
|
|
|
110
243
|
currentBasemap?.setVisible(false);
|
|
111
244
|
newBasemap?.setVisible(true);
|
|
112
245
|
|
|
113
246
|
this.updateActiveButton(layerName); // Update the active button style
|
|
114
|
-
}
|
|
115
247
|
|
|
116
|
-
|
|
248
|
+
this.announceBasemapChange(layerName);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
setInitialActiveLayer = () => {
|
|
117
252
|
const map = this.getMap();
|
|
118
253
|
|
|
119
254
|
if (!map) {
|
|
120
255
|
return;
|
|
121
256
|
}
|
|
122
257
|
|
|
123
|
-
const currentBasemap = this.
|
|
124
|
-
?.getLayers()
|
|
125
|
-
.getArray()
|
|
126
|
-
.filter((layer: BaseLayer) => layer.get('basemap') === true)
|
|
127
|
-
.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
258
|
+
const currentBasemap = this.getCurrentBasemap();
|
|
128
259
|
|
|
129
260
|
if (currentBasemap) {
|
|
130
261
|
const activeLayerName = currentBasemap.get('name');
|
|
131
262
|
|
|
132
263
|
this.updateActiveButton(activeLayerName);
|
|
133
264
|
}
|
|
134
|
-
}
|
|
265
|
+
};
|
|
135
266
|
|
|
136
|
-
updateActiveButton(layerName: string) {
|
|
267
|
+
updateActiveButton = (layerName: string) => {
|
|
137
268
|
const buttons = this.panel.querySelectorAll('button');
|
|
138
269
|
|
|
139
270
|
buttons.forEach((btn: HTMLButtonElement) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
271
|
+
const isActive = btn.textContent?.trim() === layerName;
|
|
272
|
+
|
|
273
|
+
btn.classList.toggle('active', isActive);
|
|
274
|
+
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
145
275
|
});
|
|
146
|
-
}
|
|
276
|
+
};
|
|
147
277
|
}
|
package/src/map/MapComponent.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Map, Overlay, View } from 'ol';
|
|
6
|
-
import { Control, ScaleLine,
|
|
6
|
+
import { Attribution, Control, ScaleLine, Zoom } from 'ol/control';
|
|
7
7
|
import { fromLonLat } from 'ol/proj';
|
|
8
8
|
|
|
9
9
|
import { initializeBasemapLayers } from './basemaps';
|
|
@@ -19,7 +19,6 @@ export type MapComponentProps = {
|
|
|
19
19
|
geocoderUrl: string;
|
|
20
20
|
basePath: string;
|
|
21
21
|
isLoading?: boolean;
|
|
22
|
-
error?: Error;
|
|
23
22
|
};
|
|
24
23
|
|
|
25
24
|
const positionTransforms: Record<PopupDirection, string> = {
|
|
@@ -48,9 +47,7 @@ export const MapComponent = ({
|
|
|
48
47
|
geocoderUrl,
|
|
49
48
|
basePath,
|
|
50
49
|
isLoading,
|
|
51
|
-
error,
|
|
52
50
|
}: MapComponentProps) => {
|
|
53
|
-
console.log('ERROR: ', error);
|
|
54
51
|
const [popupFeatures, setPopupFeatures] = useState([]);
|
|
55
52
|
const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
|
|
56
53
|
const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
|
|
@@ -80,19 +77,24 @@ export const MapComponent = ({
|
|
|
80
77
|
// Initialise map's basemap layers.
|
|
81
78
|
const layers = initializeBasemapLayers();
|
|
82
79
|
|
|
83
|
-
const scaleLine = new ScaleLine({
|
|
84
|
-
units: 'metric',
|
|
85
|
-
});
|
|
86
|
-
|
|
87
80
|
const target = mapRef.current;
|
|
88
81
|
|
|
89
82
|
if (!target) {
|
|
90
83
|
return;
|
|
91
84
|
}
|
|
92
85
|
|
|
86
|
+
// Create controls individually
|
|
87
|
+
const mapZoom = new Zoom();
|
|
88
|
+
const scaleLine = new ScaleLine({ units: 'metric' });
|
|
89
|
+
const attribution = new Attribution();
|
|
90
|
+
const layerSwitcher = new LayerSwitcherControl(layers);
|
|
91
|
+
|
|
92
|
+
// Add controls in the desired order
|
|
93
|
+
const controls = [mapZoom, layerSwitcher, scaleLine, attribution];
|
|
94
|
+
|
|
93
95
|
const newMap = new Map({
|
|
94
96
|
target,
|
|
95
|
-
controls
|
|
97
|
+
controls,
|
|
96
98
|
layers,
|
|
97
99
|
view: new View({
|
|
98
100
|
projection: 'EPSG:3857',
|
|
@@ -126,8 +128,6 @@ export const MapComponent = ({
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
newMap.addControl(new LayerSwitcherControl(layers));
|
|
130
|
-
|
|
131
131
|
// Setup popup overlay
|
|
132
132
|
const overlay = new Overlay({
|
|
133
133
|
element: document.getElementById('popup-container') ?? undefined,
|
|
@@ -173,7 +173,15 @@ export const MapComponent = ({
|
|
|
173
173
|
|
|
174
174
|
return (
|
|
175
175
|
<div className="flex flex-grow min-h-0">
|
|
176
|
-
<div
|
|
176
|
+
<div
|
|
177
|
+
ref={mapRef}
|
|
178
|
+
className="flex flex-grow relative z-10"
|
|
179
|
+
id="map"
|
|
180
|
+
role="application"
|
|
181
|
+
aria-label="Map"
|
|
182
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
183
|
+
tabIndex={0}
|
|
184
|
+
>
|
|
177
185
|
{isLoading ? (
|
|
178
186
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10">
|
|
179
187
|
<div
|
package/src/types/api.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type PaginatedResponse<T = unknown> = {
|
|
|
19
19
|
|
|
20
20
|
// Since we only ever fetch a single API we don't need multiple failure types,
|
|
21
21
|
// so defining a global one as the default is fine. This can be changed per-app.
|
|
22
|
-
export type ApiFailure = { message: string; status?: number; code?: string;
|
|
22
|
+
export type ApiFailure = { message: string; status?: number; code?: string; detail: string };
|
|
23
23
|
|
|
24
24
|
type GenericResponse<Ok, Error> =
|
|
25
25
|
| ({
|
package/src/utils/http.ts
CHANGED
|
@@ -128,21 +128,10 @@ const post = <Ok, Error = ApiFailure>(
|
|
|
128
128
|
body: unknown,
|
|
129
129
|
options?: RequestInit,
|
|
130
130
|
): Promise<Response<Ok, Error>> => {
|
|
131
|
-
let requestBody: string;
|
|
132
|
-
|
|
133
|
-
// If body is an empty object, send an empty string
|
|
134
|
-
if (body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === 0) {
|
|
135
|
-
requestBody = '';
|
|
136
|
-
} else if (body === undefined || body === null) {
|
|
137
|
-
requestBody = '{}';
|
|
138
|
-
} else {
|
|
139
|
-
requestBody = JSON.stringify(body);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
131
|
const requestOptions = {
|
|
143
132
|
...options,
|
|
144
133
|
method: HttpMethod.Post,
|
|
145
|
-
body:
|
|
134
|
+
body: JSON.stringify(body),
|
|
146
135
|
headers: {
|
|
147
136
|
'Content-Type': MimeTypes.Json,
|
|
148
137
|
...options?.headers,
|