@tpzdsp/next-toolkit 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -6
  3. package/src/assets/styles/globals.css +21 -0
  4. package/src/assets/styles/ol.css +147 -176
  5. package/src/components/InfoBox/InfoBox.stories.tsx +457 -0
  6. package/src/components/InfoBox/InfoBox.test.tsx +382 -0
  7. package/src/components/InfoBox/InfoBox.tsx +177 -0
  8. package/src/components/InfoBox/hooks/index.ts +3 -0
  9. package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +187 -0
  10. package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +69 -0
  11. package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +168 -0
  12. package/src/components/InfoBox/hooks/useInfoBoxState.ts +71 -0
  13. package/src/components/InfoBox/hooks/usePortalMount.test.ts +62 -0
  14. package/src/components/InfoBox/hooks/usePortalMount.ts +15 -0
  15. package/src/components/InfoBox/types.ts +6 -0
  16. package/src/components/InfoBox/utils/focusTrapConfig.test.ts +310 -0
  17. package/src/components/InfoBox/utils/focusTrapConfig.ts +59 -0
  18. package/src/components/InfoBox/utils/index.ts +2 -0
  19. package/src/components/InfoBox/utils/positionUtils.test.ts +170 -0
  20. package/src/components/InfoBox/utils/positionUtils.ts +89 -0
  21. package/src/components/index.ts +8 -0
  22. package/src/http/logger.ts +1 -1
  23. package/src/map/FullScreenControl.ts +126 -0
  24. package/src/map/LayerSwitcherControl.ts +87 -181
  25. package/src/map/LayerSwitcherPanel.tsx +173 -0
  26. package/src/map/MapComponent.tsx +6 -35
  27. package/src/map/createControlButton.ts +72 -0
  28. package/src/map/geocoder/Geocoder.test.tsx +115 -0
  29. package/src/map/geocoder/Geocoder.tsx +393 -0
  30. package/src/map/geocoder/groupResults.ts +12 -0
  31. package/src/map/geocoder/index.ts +4 -0
  32. package/src/map/geocoder/types.ts +11 -0
  33. package/src/map/geometries.ts +7 -1
  34. package/src/map/index.ts +4 -1
  35. package/src/map/osOpenNamesSearch.ts +112 -57
  36. package/src/map/useKeyboardDrawing.ts +2 -2
  37. package/src/map/utils.ts +2 -1
  38. package/src/test/renderers.tsx +9 -20
  39. package/src/map/geocoder.ts +0 -61
  40. package/src/ol-geocoder.d.ts +0 -1
@@ -0,0 +1,126 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ import { Map } from 'ol';
3
+ import { Control } from 'ol/control';
4
+ import type { Options as ControlOptions } from 'ol/control/Control';
5
+
6
+ import { CONTROL_ICONS, createControlButton } from './createControlButton';
7
+
8
+ const FULL_SCREEN_CLASS = 'map-fullscreen';
9
+ const ARIA_LABEL_TOGGLE = 'Toggle full screen';
10
+ const ARIA_LABEL_EXIT = 'Exit full screen';
11
+
12
+ export class FullScreenControl extends Control {
13
+ private isFullScreen = false;
14
+ private readonly button!: HTMLButtonElement;
15
+ liveRegion!: HTMLElement;
16
+ private mapContainer: HTMLElement | null = null;
17
+
18
+ constructor(options?: ControlOptions) {
19
+ const button = createControlButton({
20
+ ariaLabel: ARIA_LABEL_TOGGLE,
21
+ title: ARIA_LABEL_TOGGLE,
22
+ iconSvg: CONTROL_ICONS.EXPAND,
23
+ className: 'ol-fullscreen-toggle',
24
+ });
25
+
26
+ const element = document.createElement('div');
27
+
28
+ element.className = 'ol-fullscreen ol-unselectable ol-control';
29
+ element.appendChild(button);
30
+
31
+ // Create a live region for screen reader announcements
32
+ const liveRegion = document.createElement('div');
33
+
34
+ liveRegion.setAttribute('aria-live', 'polite');
35
+ liveRegion.setAttribute('role', 'status');
36
+ liveRegion.className = 'ol-fullscreen-live-region';
37
+ liveRegion.style.position = 'absolute';
38
+ liveRegion.style.width = '1px';
39
+ liveRegion.style.height = '1px';
40
+ liveRegion.style.margin = '-1px';
41
+ liveRegion.style.border = '0';
42
+ liveRegion.style.padding = '0';
43
+ liveRegion.style.overflow = 'hidden';
44
+ liveRegion.style.clipPath = 'inset(50%)';
45
+ element.appendChild(liveRegion);
46
+
47
+ super({
48
+ element,
49
+ target: options?.target,
50
+ });
51
+
52
+ this.button = button;
53
+ this.liveRegion = liveRegion;
54
+
55
+ button.addEventListener('click', this.toggleFullScreen, false);
56
+ }
57
+
58
+ setMap(map: Map | null) {
59
+ super.setMap(map);
60
+
61
+ if (map) {
62
+ // Find the map container (the target element)
63
+ this.mapContainer = map.getTargetElement();
64
+ }
65
+ }
66
+
67
+ private readonly updateButtonIcon = (isFullScreen: boolean): void => {
68
+ // Update the SVG icon content
69
+ const svg = this.button.querySelector('svg');
70
+
71
+ if (svg) {
72
+ svg.innerHTML = isFullScreen ? CONTROL_ICONS.COLLAPSE : CONTROL_ICONS.EXPAND;
73
+ }
74
+ };
75
+
76
+ toggleFullScreen = (): void => {
77
+ if (!this.mapContainer) {
78
+ console.warn('Map container not found');
79
+
80
+ return;
81
+ }
82
+
83
+ this.isFullScreen = !this.isFullScreen;
84
+
85
+ if (this.isFullScreen) {
86
+ // Enter full screen mode
87
+ this.mapContainer.classList.add(FULL_SCREEN_CLASS);
88
+ this.updateButtonIcon(true);
89
+ this.button.setAttribute('aria-label', ARIA_LABEL_EXIT);
90
+ this.button.setAttribute('title', ARIA_LABEL_EXIT);
91
+ this.liveRegion.textContent = 'Full screen mode enabled';
92
+ } else {
93
+ // Exit full screen mode
94
+ this.mapContainer.classList.remove(FULL_SCREEN_CLASS);
95
+ this.updateButtonIcon(false);
96
+ this.button.setAttribute('aria-label', ARIA_LABEL_TOGGLE);
97
+ this.button.setAttribute('title', ARIA_LABEL_TOGGLE);
98
+ this.liveRegion.textContent = 'Full screen mode disabled';
99
+ }
100
+
101
+ // Force map to update its size after the transition
102
+ const map = this.getMap();
103
+
104
+ if (map) {
105
+ // Wait for CSS transition to complete before updating size
106
+ setTimeout(() => {
107
+ map.updateSize();
108
+ }, 300);
109
+ }
110
+ };
111
+
112
+ // Public method to exit full screen programmatically
113
+ exitFullScreen(): void {
114
+ if (this.isFullScreen) {
115
+ this.toggleFullScreen();
116
+ }
117
+ }
118
+
119
+ dispose(): void {
120
+ if (this.button) {
121
+ this.button.removeEventListener('click', this.toggleFullScreen);
122
+ }
123
+
124
+ super.dispose?.();
125
+ }
126
+ }
@@ -1,40 +1,36 @@
1
1
  /* eslint-disable no-restricted-syntax */
2
- import { createFocusTrap } from 'focus-trap';
3
- import type { FocusTrap } from 'focus-trap';
2
+ import { createElement } from 'react';
3
+
4
4
  import { Map } from 'ol';
5
5
  import { Control } from 'ol/control';
6
6
  import type { Options as ControlOptions } from 'ol/control/Control';
7
7
  import BaseLayer from 'ol/layer/Base';
8
+ import { createRoot } from 'react-dom/client';
9
+ import type { Root } from 'react-dom/client';
8
10
 
9
- const TIMEOUT = 300; // Match CSS transition duration
10
- const ARIA_LABEL = 'aria-label';
11
+ import { createControlButton } from './createControlButton';
12
+ import { LayerSwitcherPanel } from './LayerSwitcherPanel';
11
13
 
12
14
  const MAP_LOGO =
13
15
  '<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"/>';
14
16
 
15
17
  export class LayerSwitcherControl extends Control {
16
18
  map!: Map;
17
- panel!: HTMLElement;
18
19
  liveRegion!: HTMLElement;
19
20
  isOpen = false;
20
- private focusTrap: FocusTrap | null = null;
21
+ private readonly layers: BaseLayer[];
22
+ private reactRoot: Root | null = null;
23
+ private reactContainer: HTMLDivElement | null = null;
24
+ private readonly button!: HTMLButtonElement;
25
+ private isClosingFromPanel = false;
21
26
 
22
27
  constructor(layers: BaseLayer[], options?: ControlOptions) {
23
- const button = document.createElement('button');
24
-
25
- button.setAttribute(ARIA_LABEL, 'Button to toggle layer switcher');
26
- button.setAttribute('title', 'Basemap switcher');
27
- button.setAttribute('aria-expanded', 'false');
28
- button.className = 'ol-layer-switcher-toggle ol-btn';
29
-
30
- const switcherIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
31
-
32
- switcherIcon.setAttribute('width', '20');
33
- switcherIcon.setAttribute('height', '20');
34
- switcherIcon.setAttribute('viewBox', '0 0 24 24');
35
- switcherIcon.setAttribute('fill', 'currentColor');
36
- switcherIcon.innerHTML = MAP_LOGO;
37
- button.appendChild(switcherIcon);
28
+ const button = createControlButton({
29
+ ariaLabel: 'Toggle basemap selector',
30
+ title: 'Basemap selector',
31
+ iconSvg: MAP_LOGO,
32
+ hasPopup: true,
33
+ });
38
34
 
39
35
  const element = document.createElement('div');
40
36
 
@@ -62,206 +58,116 @@ export class LayerSwitcherControl extends Control {
62
58
  target: options?.target,
63
59
  });
64
60
 
65
- // Assign the live region to the class property so it can be used in other functions.
61
+ this.layers = layers;
62
+ this.button = button;
66
63
  this.liveRegion = liveRegion;
67
64
 
68
- this.panel = document.createElement('div');
69
- this.panel.className = 'ol-layer-switcher-panel';
70
- this.panel.setAttribute('role', 'dialog');
71
- this.panel.setAttribute('aria-modal', 'true');
72
- this.panel.setAttribute(ARIA_LABEL, 'Basemap switcher');
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
- });
89
- header.appendChild(closeBtn);
90
-
91
- // Create the content area for basemap buttons
92
- const content = document.createElement('div');
93
-
94
- content.className = 'ol-layer-switcher-content';
95
-
96
- // Add a button for each basemap layer
97
- layers.forEach((layer) => {
98
- const img = document.createElement('img');
99
-
100
- img.src = layer.get('image') as string;
101
- img.setAttribute('title', layer.get('name'));
102
- img.setAttribute('alt', `Preview of ${layer.get('name')}`);
103
-
104
- const switcherText = document.createElement('div');
105
-
106
- switcherText.textContent = layer.get('name') as string;
107
-
108
- const btn = document.createElement('button');
109
-
110
- btn.setAttribute('type', 'button');
111
- btn.setAttribute('aria-pressed', btn.classList.contains('active') ? 'true' : 'false');
112
- btn.appendChild(img);
113
- btn.appendChild(switcherText);
114
-
115
- btn.addEventListener('click', () => this.selectBasemap(layer.get('name') as string), false);
116
-
117
- content.appendChild(btn);
118
- });
119
-
120
- // Assemble the panel
121
- this.panel.appendChild(header);
122
- this.panel.appendChild(content);
123
-
124
- // Create focus trap once - will be activated/deactivated as needed
125
- this.focusTrap = createFocusTrap(this.panel, {
126
- clickOutsideDeactivates: (event) => {
127
- // if the user happens to click on the open button again we let the button handle the click event, not the focus trap
128
- if (button.contains(event.target as Node)) {
129
- return true;
130
- }
131
-
132
- this.toggleLayerSwitcher();
133
-
134
- return false;
135
- },
136
- escapeDeactivates: () => {
137
- this.toggleLayerSwitcher();
138
-
139
- return false;
140
- },
141
- returnFocusOnDeactivate: true,
142
- fallbackFocus: () => {
143
- return this.panel;
144
- },
145
- });
65
+ // Create React container
66
+ this.reactContainer = document.createElement('div');
67
+ this.reactRoot = createRoot(this.reactContainer);
146
68
 
147
69
  button.addEventListener('click', this.toggleLayerSwitcher, false);
148
70
  }
149
71
 
150
72
  private getCurrentBasemap(): BaseLayer | undefined {
151
- return this.getMap()
152
- ?.getLayers()
153
- .getArray()
154
- .filter((layer: BaseLayer) => layer.get('basemap') === true)
155
- .find((layer: BaseLayer) => layer.getVisible() === true);
73
+ return this.layers.find((layer: BaseLayer) => layer.getVisible() === true);
156
74
  }
157
75
 
158
76
  private getBasemapByName(layerName: string): BaseLayer | undefined {
159
- return this.getMap()
160
- ?.getLayers()
161
- .getArray()
162
- .find((layer: BaseLayer) => layer.get('name') === layerName);
77
+ return this.layers.find((layer: BaseLayer) => layer.get('name') === layerName);
163
78
  }
164
79
 
165
- private announceBasemapChange(layerName: string) {
166
- if (this.liveRegion) {
167
- this.liveRegion.textContent = `Basemap changed to ${layerName}`;
168
- // Optionally clear the message after a short delay to allow repeated announcements
169
- setTimeout(() => {
170
- if (this.liveRegion) {
171
- this.liveRegion.textContent = '';
172
- }
173
- }, 1000);
174
- }
175
- }
176
-
177
- setMap(map: Map) {
80
+ setMap(map: Map | null) {
178
81
  super.setMap(map);
179
82
 
180
83
  if (map) {
181
- // Ensure we only set the initial active layer when the map is assigned
182
- map.once('rendercomplete', this.setInitialActiveLayer);
84
+ this.map = map;
183
85
  }
184
86
  }
185
87
 
186
- // Arrow function: 'this' is always bound to the class instance
187
- toggleLayerSwitcher = () => {
188
- if (!this.isOpen) {
189
- this.element.appendChild(this.panel);
190
-
191
- requestAnimationFrame(() => {
192
- this.panel.classList.add('open'); // Ensure animation works after adding to DOM
193
-
194
- // Activate the existing focus trap
195
- this.focusTrap?.activate();
196
- });
197
- } else {
198
- this.panel.classList.remove('open');
199
-
200
- // Deactivate focus trap but keep the instance
201
- this.focusTrap?.deactivate();
88
+ toggleLayerSwitcher = (): void => {
89
+ // Prevent double-toggle when panel closes via focus trap
90
+ if (this.isClosingFromPanel) {
91
+ this.isClosingFromPanel = false;
202
92
 
203
- setTimeout(() => {
204
- this.element.removeChild(this.panel);
205
- }, TIMEOUT); // Matches CSS transition time to prevent flickering
93
+ return;
206
94
  }
207
95
 
208
96
  this.isOpen = !this.isOpen;
97
+ this.button.setAttribute('aria-expanded', this.isOpen.toString());
209
98
 
210
- // Update aria-expanded on the toggle button
211
- const toggleBtn = this.element.querySelector('button.ol-layer-switcher-toggle');
212
-
213
- if (toggleBtn) {
214
- toggleBtn.setAttribute('aria-expanded', this.isOpen.toString());
99
+ if (this.isOpen) {
100
+ this.liveRegion.textContent = 'Basemap switcher opened';
101
+ this.renderPanel();
102
+ } else {
103
+ this.liveRegion.textContent = 'Basemap switcher closed';
104
+ this.closePanel();
215
105
  }
216
106
  };
217
107
 
218
- selectBasemap = (layerName: string) => {
219
- const currentBasemap = this.getCurrentBasemap();
220
-
221
- const newBasemap = this.getBasemapByName(layerName);
222
-
223
- currentBasemap?.setVisible(false);
224
- newBasemap?.setVisible(true);
225
-
226
- this.updateActiveButton(layerName); // Update the active button style
227
-
228
- this.announceBasemapChange(layerName);
108
+ private readonly handlePanelClose = (): void => {
109
+ if (this.isOpen) {
110
+ this.isClosingFromPanel = true;
111
+ this.isOpen = false;
112
+ this.button.setAttribute('aria-expanded', 'false');
113
+ this.liveRegion.textContent = 'Basemap switcher closed';
114
+ this.closePanel();
115
+ }
229
116
  };
230
117
 
231
- setInitialActiveLayer = () => {
232
- const map = this.getMap();
233
-
234
- if (!map) {
118
+ private readonly renderPanel = (): void => {
119
+ if (!this.reactRoot || !this.reactContainer) {
235
120
  return;
236
121
  }
237
122
 
238
- const currentBasemap = this.getCurrentBasemap();
239
-
240
- if (currentBasemap) {
241
- const activeLayerName = currentBasemap.get('name');
123
+ const buttonRect = this.button.getBoundingClientRect();
124
+ const activeLayer = this.getCurrentBasemap();
125
+ const activeLayerName = activeLayer ? (activeLayer.get('name') as string) : '';
126
+
127
+ this.reactRoot.render(
128
+ createElement(LayerSwitcherPanel, {
129
+ isOpen: this.isOpen,
130
+ onClose: this.handlePanelClose,
131
+ layers: this.layers,
132
+ activeLayerName,
133
+ onSelectLayer: this.selectBasemap,
134
+ buttonRect,
135
+ }),
136
+ );
137
+ };
242
138
 
243
- this.updateActiveButton(activeLayerName);
139
+ private readonly closePanel = (): void => {
140
+ if (!this.reactRoot) {
141
+ return;
244
142
  }
143
+
144
+ this.reactRoot.render(null);
245
145
  };
246
146
 
247
- updateActiveButton = (layerName: string) => {
248
- const buttons = this.panel.querySelectorAll('button');
147
+ selectBasemap = (layerName: string): void => {
148
+ const currentBasemap = this.getCurrentBasemap();
149
+ const newBasemap = this.getBasemapByName(layerName);
150
+
151
+ currentBasemap?.setVisible(false);
152
+ newBasemap?.setVisible(true);
249
153
 
250
- buttons.forEach((btn: HTMLButtonElement) => {
251
- const isActive = btn.textContent?.trim() === layerName;
154
+ this.liveRegion.textContent = `${layerName} basemap selected`;
252
155
 
253
- btn.classList.toggle('active', isActive);
254
- btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
255
- });
156
+ // Re-render to update active state
157
+ if (this.isOpen) {
158
+ this.renderPanel();
159
+ }
256
160
  };
257
161
 
258
- // Cleanup method - call this when removing the control
259
- destroy() {
260
- // Deactivate and destroy focus trap
261
- if (this.focusTrap) {
262
- this.focusTrap.deactivate();
162
+ dispose(): void {
163
+ // Clean up React root
164
+ if (this.reactRoot) {
165
+ this.reactRoot.unmount();
166
+ this.reactRoot = null;
263
167
  }
264
168
 
265
- this.focusTrap = null;
169
+ this.reactContainer = null;
170
+
171
+ super.dispose?.();
266
172
  }
267
173
  }
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ import { FocusTrap } from 'focus-trap-react';
6
+ import BaseLayer from 'ol/layer/Base';
7
+ import { createPortal } from 'react-dom';
8
+
9
+ import { cn } from '../utils';
10
+
11
+ type LayerSwitcherPanelProps = {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ layers: BaseLayer[];
15
+ activeLayerName: string | null;
16
+ onSelectLayer: (layerName: string) => void;
17
+ buttonRect: DOMRect | null;
18
+ };
19
+
20
+ export const LayerSwitcherPanel = ({
21
+ isOpen,
22
+ onClose,
23
+ layers,
24
+ activeLayerName,
25
+ onSelectLayer,
26
+ buttonRect,
27
+ }: LayerSwitcherPanelProps) => {
28
+ const [isMounted, setIsMounted] = useState(false);
29
+ const [isTrapActive, setIsTrapActive] = useState(false);
30
+ const panelRef = useRef<HTMLDivElement>(null);
31
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
32
+
33
+ const handleClose = () => {
34
+ setIsTrapActive(false);
35
+ onClose();
36
+ };
37
+
38
+ useEffect(() => {
39
+ setIsMounted(true);
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ if (isOpen) {
44
+ setIsTrapActive(true);
45
+ } else {
46
+ setIsTrapActive(false);
47
+ }
48
+ }, [isOpen]);
49
+
50
+ if (!isMounted || !isOpen) {
51
+ return null;
52
+ }
53
+
54
+ // Calculate position based on button position
55
+ const top = buttonRect ? buttonRect.bottom + 8 : 118;
56
+ const right = 48; // 3rem = 48px
57
+
58
+ // Calculate max-height to prevent panel from going off-screen
59
+ // Leave 16px padding at the bottom
60
+ const maxHeight = buttonRect
61
+ ? `${window.innerHeight - buttonRect.bottom - 24}px`
62
+ : 'calc(100vh - 150px)';
63
+
64
+ const panelClasses = cn(
65
+ 'fixed bg-white rounded-lg shadow-lg border border-gray-200',
66
+ 'flex flex-col',
67
+ 'p-2 gap-2',
68
+ 'w-[180px] max-w-[250px]',
69
+ 'transition-all duration-300 ease-in-out',
70
+ 'z-[9999]',
71
+ isOpen ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full pointer-events-none',
72
+ );
73
+
74
+ const contentClasses = cn(
75
+ 'grid grid-cols-1 gap-2',
76
+ 'flex-1 min-h-0',
77
+ 'overflow-y-auto overflow-x-hidden',
78
+ );
79
+
80
+ return createPortal(
81
+ <FocusTrap
82
+ active={isTrapActive}
83
+ focusTrapOptions={{
84
+ clickOutsideDeactivates: true,
85
+ escapeDeactivates: true,
86
+ onDeactivate: handleClose,
87
+ returnFocusOnDeactivate: true,
88
+ initialFocus: false, // Don't auto-focus close button
89
+ fallbackFocus: () => panelRef.current ?? document.body,
90
+ }}
91
+ >
92
+ {/* We use role="dialog" instead of <dialog> tag for better portal positioning control */}
93
+ <div
94
+ ref={panelRef}
95
+ role="dialog"
96
+ aria-modal="true"
97
+ aria-label="Basemap switcher"
98
+ aria-describedby="layer-switcher-description"
99
+ style={{ top, right, maxHeight }}
100
+ className={panelClasses}
101
+ >
102
+ {/* Screen reader description */}
103
+ <div id="layer-switcher-description" className="sr-only">
104
+ Select a basemap layer. Use arrow keys to navigate between options, Enter or Space to
105
+ select. Press Escape to close.
106
+ </div>
107
+ {/* Header with close button */}
108
+ <div className="flex justify-end flex-shrink-0">
109
+ <button
110
+ ref={closeButtonRef}
111
+ type="button"
112
+ onClick={handleClose}
113
+ aria-label="Close basemap switcher"
114
+ tabIndex={0}
115
+ className="w-8 h-8 flex items-center justify-center rounded text-gray-600 hover:bg-focus
116
+ focus:outline focus:outline-2 focus:outline-focus focus:bg-focus transition-colors"
117
+ >
118
+ <span className="text-xl leading-none" aria-hidden="true">
119
+ &times;
120
+ </span>
121
+ </button>
122
+ </div>
123
+
124
+ {/* Content with basemap buttons */}
125
+ <div className={contentClasses} role="radiogroup" aria-label="Basemap options">
126
+ {layers.map((layer) => {
127
+ const name = layer.get('name') as string;
128
+ const image = layer.get('image') as string;
129
+ const isActive = name === activeLayerName;
130
+
131
+ return (
132
+ <button
133
+ key={name}
134
+ type="button"
135
+ role="radio"
136
+ aria-checked={isActive}
137
+ aria-label={`${name} basemap${isActive ? ', currently selected' : ''}`}
138
+ onClick={() => onSelectLayer(name)}
139
+ className={cn(
140
+ 'flex flex-col items-center justify-start gap-2',
141
+ 'w-full p-2',
142
+ 'border-2 rounded',
143
+ 'transition-all duration-200',
144
+ 'focus:outline focus:outline-2 focus:outline-focus',
145
+ isActive
146
+ ? 'bg-focus text-black border-focus'
147
+ : 'bg-white text-black border-gray-300 hover:bg-focus',
148
+ )}
149
+ >
150
+ <div className="flex items-center justify-center w-full flex-shrink-0">
151
+ <img
152
+ src={image}
153
+ alt={`Preview of ${name}`}
154
+ title={name}
155
+ className={cn(
156
+ 'max-w-[60px] max-h-[60px] w-auto h-auto object-contain border',
157
+ isActive ? 'border-black' : 'grayscale border-black',
158
+ )}
159
+ />
160
+ </div>
161
+
162
+ <div className="text-xs leading-tight text-center w-full flex-shrink-0 px-1">
163
+ {name}
164
+ </div>
165
+ </button>
166
+ );
167
+ })}
168
+ </div>
169
+ </div>
170
+ </FocusTrap>,
171
+ document.body,
172
+ );
173
+ };