@tpzdsp/next-toolkit 1.4.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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
  }
@@ -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
- right: 0.5rem;
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: 65px;
11
- right: 0.5rem;
77
+ top: 0px;
78
+ right: 1rem;
12
79
  }
13
80
 
14
- .ol-btn {
15
- display: flex !important;
16
- justify-content: center;
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: 0;
23
- right: -100%; /* Start off-screen */
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: row;
29
- align-items: center;
30
- padding: 10px;
31
- gap: 10px;
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: 1.5rem; /* Slide into view */
122
+ right: 3rem;
123
+ }
124
+
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;
39
157
  }
40
158
 
41
- .ol-layer-switcher-panel button {
159
+ .ol-layer-switcher-content button {
42
160
  display: flex;
43
161
  flex-direction: column;
44
162
  align-items: center;
@@ -51,41 +169,42 @@
51
169
  color: #000;
52
170
  }
53
171
 
54
- .ol-layer-switcher-panel button:hover {
172
+ .ol-layer-switcher-content button:hover {
55
173
  background-color: #ddd;
56
174
  }
57
175
 
58
- .ol-layer-switcher-panel button.active {
59
- background-color: #007bff; /* Bright blue background */
60
- color: white; /* White text */
61
- border-color: #0056b3; /* Darker border for contrast */
62
- box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3); /* Subtle shadow effect */
63
- transform: scale(1.05); /* Slightly scale the active button */
64
- transition: all 0.2s ease; /* Smooth transition for all styles */
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-panel button.active img {
68
- filter: brightness(1.2); /* Makes the image brighter */
185
+ .ol-layer-switcher-content button.active img {
186
+ filter: brightness(1.2);
69
187
  }
70
188
 
71
- .ol-layer-switcher-panel button:not(.active) img {
72
- filter: grayscale(1); /* Apply grayscale to inactive images */
189
+ .ol-layer-switcher-content button:not(.active) img {
190
+ filter: grayscale(1);
73
191
  }
74
192
 
75
- .ol-layer-switcher-panel button img {
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-panel button div {
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 */
87
205
 
88
- #gcd-container, .ol-geocoder .gcd-txt-control {
206
+ #gcd-container,
207
+ .ol-geocoder .gcd-txt-control {
89
208
  height: unset !important;
90
209
  }
91
210
 
@@ -94,30 +213,11 @@
94
213
  top: unset !important;
95
214
  }
96
215
 
97
- .ol-geocoder ul.gcd-txt-result > li:nth-child(odd) {
98
- background-color: #008938;
99
- }
100
-
101
- .ol-geocoder ul.gcd-txt-result > li:nth-child(even) {
102
- background-color: #bddabd;
103
- }
104
-
105
- .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;
106
218
  background-color: #fff;
107
219
  }
108
220
 
109
- /* Change the text color on hover */
110
- .ol-geocoder ul.gcd-txt-result > li > a:hover .gcd-address,
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;
221
+ .ol-geocoder ul.gcd-txt-result>li>a:hover {
222
+ background-color: #f3f2f1;
123
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
- className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40`}
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
- import OpenLayersLogo from './images/openlayers-logo.png';
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('aria-labelledby', 'Button to toggle layer switcher');
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.className = 'ol-layer-switcher ol-btn';
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 openLayersLogoData = OpenLayersLogo as unknown as StaticImageData;
27
+ const switcherIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
26
28
 
27
- switcherImage.src = openLayersLogoData.src;
28
- switcherImage.setAttribute('alt', 'Openlayers logo');
29
- switcherImage.setAttribute('width', '20px');
30
- switcherImage.setAttribute('height', '20px');
31
- button.appendChild(switcherImage);
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 = '&times;';
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
- this.panel.appendChild(btn);
118
+ content.appendChild(btn);
68
119
  });
69
120
 
70
- button.addEventListener('click', this.toggleLayerSwitcher.bind(this), false);
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.bind(this));
176
+ map.once('rendercomplete', this.setInitialActiveLayer);
79
177
  }
80
178
  }
81
179
 
82
- toggleLayerSwitcher() {
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
- }, 300); // Matches CSS transition time to prevent flickering
194
+ }, TIMEOUT); // Matches CSS transition time to prevent flickering
93
195
  }
94
196
 
95
197
  this.isCollapsed = !this.isCollapsed;
96
- }
97
198
 
98
- selectBasemap(layerName: string) {
99
- const currentBasemap = this.getMap()
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
- const newBasemap = this.getMap()
106
- ?.getLayers()
107
- .getArray()
108
- .find((layer: BaseLayer) => layer.get('name') === layerName);
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
- setInitialActiveLayer() {
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.getMap()
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
- if (btn.textContent?.trim() === layerName) {
141
- btn.classList.add('active');
142
- } else {
143
- btn.classList.remove('active');
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
  }
@@ -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, defaults as defaultControls } from 'ol/control';
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: defaultControls().extend([scaleLine]),
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 ref={mapRef} className="flex flex-grow relative z-10" id="map">
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