@tpzdsp/next-toolkit 1.4.3 → 1.4.5
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 +166 -57
- package/src/components/SlidingPanel/SlidingPanel.tsx +105 -2
- package/src/components/accordion/Accordion.tsx +2 -1
- package/src/components/skipLink/SkipLink.tsx +20 -0
- package/src/map/LayerSwitcherControl.ts +179 -49
- package/src/map/MapComponent.tsx +20 -9
package/package.json
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
|
|
24
24
|
/* Component-specific styles */
|
|
25
25
|
@layer components {
|
|
26
|
+
.focus-yellow {
|
|
27
|
+
@apply z-20 focus:border-[#ffbf47] focus:outline focus:outline-[3px] focus:outline-[#ffbf47];
|
|
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,170 @@
|
|
|
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:
|
|
36
|
+
inset 0 0 0 1px #ffbf47,
|
|
37
|
+
inset 0 0 6px #ffbf47;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Zoom control container */
|
|
4
41
|
.ol-zoom {
|
|
42
|
+
top: 1.5rem;
|
|
43
|
+
right: 1rem;
|
|
5
44
|
left: unset;
|
|
6
|
-
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
z-index: 10;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Zoom buttons */
|
|
51
|
+
.ol-zoom button,
|
|
52
|
+
.ol-control button {
|
|
53
|
+
width: 40px;
|
|
54
|
+
height: 40px;
|
|
55
|
+
background: #fff;
|
|
56
|
+
color: #0b0c0c;
|
|
57
|
+
font-size: 2rem;
|
|
58
|
+
font-weight: bold;
|
|
59
|
+
line-height: 1;
|
|
60
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
transition:
|
|
63
|
+
border-color 0.2s,
|
|
64
|
+
box-shadow 0.2s,
|
|
65
|
+
background 0.2s;
|
|
66
|
+
margin: 0;
|
|
67
|
+
padding: 0;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ol-zoom button:hover,
|
|
74
|
+
.ol-control button:hover {
|
|
75
|
+
background: #f3f2f1;
|
|
76
|
+
border-color: #1d70b8;
|
|
77
|
+
outline: unset;
|
|
7
78
|
}
|
|
8
79
|
|
|
80
|
+
/* Layer switcher styles */
|
|
9
81
|
.ol-layer-switcher {
|
|
10
|
-
top:
|
|
11
|
-
right:
|
|
82
|
+
top: 0px;
|
|
83
|
+
right: 1rem;
|
|
12
84
|
}
|
|
13
85
|
|
|
14
|
-
.ol-
|
|
15
|
-
|
|
16
|
-
|
|
86
|
+
.ol-layer-switcher-toggle {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 110px;
|
|
89
|
+
right: 0px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ol-layer-switcher-toggle button {
|
|
93
|
+
width: 40px;
|
|
94
|
+
height: 40px;
|
|
95
|
+
background: #fff;
|
|
96
|
+
border: 2px solid #505a5f;
|
|
97
|
+
color: #0b0c0c;
|
|
98
|
+
font-size: 1.5rem;
|
|
99
|
+
font-weight: bold;
|
|
100
|
+
line-height: 1;
|
|
101
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
transition:
|
|
104
|
+
border-color 0.2s,
|
|
105
|
+
box-shadow 0.2s,
|
|
106
|
+
background 0.2s;
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding: 0;
|
|
109
|
+
display: flex;
|
|
17
110
|
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
18
112
|
}
|
|
19
113
|
|
|
20
114
|
.ol-layer-switcher-panel {
|
|
21
115
|
position: absolute;
|
|
22
|
-
top:
|
|
23
|
-
right: -100%;
|
|
24
|
-
min-width: fit-content;
|
|
25
|
-
max-width: 80vw;
|
|
26
|
-
background-color: #008938;
|
|
116
|
+
top: 110px;
|
|
117
|
+
right: -100%;
|
|
27
118
|
display: flex;
|
|
28
|
-
flex-direction:
|
|
29
|
-
align-items:
|
|
30
|
-
padding:
|
|
31
|
-
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
align-items: stretch;
|
|
121
|
+
padding: 1rem 1rem;
|
|
122
|
+
min-width: 220px;
|
|
123
|
+
background: #fff;
|
|
124
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
125
|
+
gap: 0.5rem;
|
|
32
126
|
transition: right 0.3s ease-in-out;
|
|
33
|
-
overflow: hidden;
|
|
34
|
-
white-space: nowrap;
|
|
35
127
|
}
|
|
36
128
|
|
|
37
129
|
.ol-layer-switcher-panel.open {
|
|
38
|
-
right:
|
|
130
|
+
right: 3rem;
|
|
39
131
|
}
|
|
40
132
|
|
|
41
|
-
|
|
133
|
+
/* Header for the close button, aligns it to the top-right */
|
|
134
|
+
.ol-layer-switcher-header {
|
|
135
|
+
display: flex;
|
|
136
|
+
justify-content: flex-end;
|
|
137
|
+
align-items: center;
|
|
138
|
+
width: 100%;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Small, circular close button */
|
|
142
|
+
.ol-layer-switcher-close {
|
|
143
|
+
font-size: 1.25rem;
|
|
144
|
+
width: 2rem;
|
|
145
|
+
height: 2rem;
|
|
146
|
+
padding: 0;
|
|
147
|
+
background: none;
|
|
148
|
+
border: none;
|
|
149
|
+
color: #505a5f;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
border-radius: 50%;
|
|
152
|
+
transition: background 0.2s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.ol-layer-switcher-close:hover,
|
|
156
|
+
.ol-layer-switcher-close:focus {
|
|
157
|
+
background: #e1e1e1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* Content area for basemap buttons */
|
|
161
|
+
.ol-layer-switcher-content {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-direction: row;
|
|
164
|
+
gap: 10px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.ol-layer-switcher-content button {
|
|
42
168
|
display: flex;
|
|
43
169
|
flex-direction: column;
|
|
44
170
|
align-items: center;
|
|
@@ -51,41 +177,42 @@
|
|
|
51
177
|
color: #000;
|
|
52
178
|
}
|
|
53
179
|
|
|
54
|
-
.ol-layer-switcher-
|
|
180
|
+
.ol-layer-switcher-content button:hover {
|
|
55
181
|
background-color: #ddd;
|
|
56
182
|
}
|
|
57
183
|
|
|
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;
|
|
184
|
+
.ol-layer-switcher-content button.active {
|
|
185
|
+
background-color: #007bff;
|
|
186
|
+
color: white;
|
|
187
|
+
border-color: #0056b3;
|
|
188
|
+
box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3);
|
|
189
|
+
transform: scale(1.05);
|
|
190
|
+
transition: all 0.2s ease;
|
|
65
191
|
}
|
|
66
192
|
|
|
67
|
-
.ol-layer-switcher-
|
|
68
|
-
filter: brightness(1.2);
|
|
193
|
+
.ol-layer-switcher-content button.active img {
|
|
194
|
+
filter: brightness(1.2);
|
|
69
195
|
}
|
|
70
196
|
|
|
71
|
-
.ol-layer-switcher-
|
|
72
|
-
filter: grayscale(1);
|
|
197
|
+
.ol-layer-switcher-content button:not(.active) img {
|
|
198
|
+
filter: grayscale(1);
|
|
73
199
|
}
|
|
74
200
|
|
|
75
|
-
.ol-layer-switcher-
|
|
201
|
+
.ol-layer-switcher-content button img {
|
|
76
202
|
max-width: 80px;
|
|
77
203
|
height: auto;
|
|
78
204
|
border: 2px solid #000;
|
|
79
|
-
border-radius: 0.3rem;
|
|
80
205
|
}
|
|
81
206
|
|
|
82
|
-
.ol-layer-switcher-
|
|
207
|
+
.ol-layer-switcher-content button div {
|
|
83
208
|
font-size: 14px;
|
|
84
209
|
padding-top: 0.5rem;
|
|
85
210
|
}
|
|
86
211
|
|
|
212
|
+
/* Geocoder tweaks */
|
|
87
213
|
|
|
88
|
-
#gcd-container,
|
|
214
|
+
#gcd-container,
|
|
215
|
+
.ol-geocoder .gcd-txt-control {
|
|
89
216
|
height: unset !important;
|
|
90
217
|
}
|
|
91
218
|
|
|
@@ -94,30 +221,12 @@
|
|
|
94
221
|
top: unset !important;
|
|
95
222
|
}
|
|
96
223
|
|
|
97
|
-
.ol-geocoder ul.gcd-txt-result > li:nth-child(odd)
|
|
98
|
-
background-color: #008938;
|
|
99
|
-
}
|
|
100
|
-
|
|
224
|
+
.ol-geocoder ul.gcd-txt-result > li:nth-child(odd),
|
|
101
225
|
.ol-geocoder ul.gcd-txt-result > li:nth-child(even) {
|
|
102
|
-
background-color: #
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover {
|
|
226
|
+
background-color: #fff;
|
|
106
227
|
background-color: #fff;
|
|
107
228
|
}
|
|
108
229
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-road,
|
|
112
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-city,
|
|
113
|
-
.ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-country {
|
|
114
|
-
color: #000;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/* Ensure the text color when not hovering */
|
|
118
|
-
.ol-geocoder ul.gcd-txt-result .gcd-address,
|
|
119
|
-
.ol-geocoder ul.gcd-txt-result .gcd-road,
|
|
120
|
-
.ol-geocoder ul.gcd-txt-result .gcd-city,
|
|
121
|
-
.ol-geocoder ul.gcd-txt-result .gcd-country {
|
|
122
|
-
color: #fff;
|
|
230
|
+
.ol-geocoder ul.gcd-txt-result > li > a:hover {
|
|
231
|
+
background-color: #f3f2f1;
|
|
123
232
|
}
|
|
@@ -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,3 +1,5 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
1
3
|
import { Link } from '../link/Link';
|
|
2
4
|
|
|
3
5
|
export type SkipLinkProps = {
|
|
@@ -5,12 +7,30 @@ export type SkipLinkProps = {
|
|
|
5
7
|
};
|
|
6
8
|
|
|
7
9
|
export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
|
|
10
|
+
const handleActivate = () => {
|
|
11
|
+
// Let the browser scroll first, then move focus
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
const main = document.getElementById(mainContentId);
|
|
14
|
+
|
|
15
|
+
if (main) {
|
|
16
|
+
main.focus();
|
|
17
|
+
}
|
|
18
|
+
}, 0);
|
|
19
|
+
};
|
|
20
|
+
|
|
8
21
|
return (
|
|
9
22
|
<nav aria-label="Skip navigation">
|
|
10
23
|
<Link
|
|
11
24
|
className="bg-focus focus:relative focus:top-0 w-full absolute -top-full text-black
|
|
12
25
|
visited:text-black hover:text-black p-3 skip-link"
|
|
13
26
|
href={`#${mainContentId}`}
|
|
27
|
+
onClick={handleActivate}
|
|
28
|
+
onKeyDown={(event) => {
|
|
29
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
30
|
+
event.preventDefault(); // Prevent default scroll/jump
|
|
31
|
+
handleActivate();
|
|
32
|
+
}
|
|
33
|
+
}}
|
|
14
34
|
>
|
|
15
35
|
Skip to main content
|
|
16
36
|
</Link>
|
|
@@ -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';
|
|
@@ -77,19 +77,24 @@ export const MapComponent = ({
|
|
|
77
77
|
// Initialise map's basemap layers.
|
|
78
78
|
const layers = initializeBasemapLayers();
|
|
79
79
|
|
|
80
|
-
const scaleLine = new ScaleLine({
|
|
81
|
-
units: 'metric',
|
|
82
|
-
});
|
|
83
|
-
|
|
84
80
|
const target = mapRef.current;
|
|
85
81
|
|
|
86
82
|
if (!target) {
|
|
87
83
|
return;
|
|
88
84
|
}
|
|
89
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
|
+
|
|
90
95
|
const newMap = new Map({
|
|
91
96
|
target,
|
|
92
|
-
controls
|
|
97
|
+
controls,
|
|
93
98
|
layers,
|
|
94
99
|
view: new View({
|
|
95
100
|
projection: 'EPSG:3857',
|
|
@@ -123,8 +128,6 @@ export const MapComponent = ({
|
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
newMap.addControl(new LayerSwitcherControl(layers));
|
|
127
|
-
|
|
128
131
|
// Setup popup overlay
|
|
129
132
|
const overlay = new Overlay({
|
|
130
133
|
element: document.getElementById('popup-container') ?? undefined,
|
|
@@ -170,7 +173,15 @@ export const MapComponent = ({
|
|
|
170
173
|
|
|
171
174
|
return (
|
|
172
175
|
<div className="flex flex-grow min-h-0">
|
|
173
|
-
<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
|
+
>
|
|
174
185
|
{isLoading ? (
|
|
175
186
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10">
|
|
176
187
|
<div
|