@tpzdsp/next-toolkit 1.12.1 → 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.
- package/README.md +4 -4
- package/package.json +1 -6
- package/src/assets/styles/ol.css +147 -176
- package/src/components/InfoBox/InfoBox.stories.tsx +457 -0
- package/src/components/InfoBox/InfoBox.test.tsx +382 -0
- package/src/components/InfoBox/InfoBox.tsx +177 -0
- package/src/components/InfoBox/hooks/index.ts +3 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +187 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +69 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +168 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.ts +71 -0
- package/src/components/InfoBox/hooks/usePortalMount.test.ts +62 -0
- package/src/components/InfoBox/hooks/usePortalMount.ts +15 -0
- package/src/components/InfoBox/types.ts +6 -0
- package/src/components/InfoBox/utils/focusTrapConfig.test.ts +310 -0
- package/src/components/InfoBox/utils/focusTrapConfig.ts +59 -0
- package/src/components/InfoBox/utils/index.ts +2 -0
- package/src/components/InfoBox/utils/positionUtils.test.ts +170 -0
- package/src/components/InfoBox/utils/positionUtils.ts +89 -0
- package/src/components/index.ts +8 -0
- package/src/map/FullScreenControl.ts +126 -0
- package/src/map/LayerSwitcherControl.ts +87 -181
- package/src/map/LayerSwitcherPanel.tsx +173 -0
- package/src/map/MapComponent.tsx +6 -35
- package/src/map/createControlButton.ts +72 -0
- package/src/map/geocoder/Geocoder.test.tsx +115 -0
- package/src/map/geocoder/Geocoder.tsx +393 -0
- package/src/map/geocoder/groupResults.ts +12 -0
- package/src/map/geocoder/index.ts +4 -0
- package/src/map/geocoder/types.ts +11 -0
- package/src/map/index.ts +4 -1
- package/src/map/osOpenNamesSearch.ts +112 -57
- package/src/test/renderers.tsx +9 -20
- package/src/map/geocoder.ts +0 -61
- 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 {
|
|
3
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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 =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
61
|
+
this.layers = layers;
|
|
62
|
+
this.button = button;
|
|
66
63
|
this.liveRegion = liveRegion;
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
this.
|
|
70
|
-
this.
|
|
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 = '×';
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
map.once('rendercomplete', this.setInitialActiveLayer);
|
|
84
|
+
this.map = map;
|
|
183
85
|
}
|
|
184
86
|
}
|
|
185
87
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!map) {
|
|
118
|
+
private readonly renderPanel = (): void => {
|
|
119
|
+
if (!this.reactRoot || !this.reactContainer) {
|
|
235
120
|
return;
|
|
236
121
|
}
|
|
237
122
|
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
139
|
+
private readonly closePanel = (): void => {
|
|
140
|
+
if (!this.reactRoot) {
|
|
141
|
+
return;
|
|
244
142
|
}
|
|
143
|
+
|
|
144
|
+
this.reactRoot.render(null);
|
|
245
145
|
};
|
|
246
146
|
|
|
247
|
-
|
|
248
|
-
const
|
|
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
|
-
|
|
251
|
-
const isActive = btn.textContent?.trim() === layerName;
|
|
154
|
+
this.liveRegion.textContent = `${layerName} basemap selected`;
|
|
252
155
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
156
|
+
// Re-render to update active state
|
|
157
|
+
if (this.isOpen) {
|
|
158
|
+
this.renderPanel();
|
|
159
|
+
}
|
|
256
160
|
};
|
|
257
161
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
this.
|
|
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.
|
|
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
|
+
×
|
|
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
|
+
};
|