@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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 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
  }
@@ -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
- right: 0.5rem;
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: 65px;
11
- right: 0.5rem;
82
+ top: 0px;
83
+ right: 1rem;
12
84
  }
13
85
 
14
- .ol-btn {
15
- display: flex !important;
16
- justify-content: center;
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: 0;
23
- right: -100%; /* Start off-screen */
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: row;
29
- align-items: center;
30
- padding: 10px;
31
- gap: 10px;
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: 1.5rem; /* Slide into view */
130
+ right: 3rem;
39
131
  }
40
132
 
41
- .ol-layer-switcher-panel button {
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-panel button:hover {
180
+ .ol-layer-switcher-content button:hover {
55
181
  background-color: #ddd;
56
182
  }
57
183
 
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 */
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-panel button.active img {
68
- filter: brightness(1.2); /* Makes the image brighter */
193
+ .ol-layer-switcher-content button.active img {
194
+ filter: brightness(1.2);
69
195
  }
70
196
 
71
- .ol-layer-switcher-panel button:not(.active) img {
72
- filter: grayscale(1); /* Apply grayscale to inactive images */
197
+ .ol-layer-switcher-content button:not(.active) img {
198
+ filter: grayscale(1);
73
199
  }
74
200
 
75
- .ol-layer-switcher-panel button img {
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-panel button div {
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, .ol-geocoder .gcd-txt-control {
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: #bddabd;
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
- /* 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;
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
- 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,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
- 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