@tpzdsp/next-toolkit 2.0.1 → 2.1.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/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
+ "packageManager": "pnpm@11.5.3",
5
6
  "engines": {
6
- "node": "^24.12.0",
7
- "pnpm": "^10.0.0"
7
+ "node": "24.12.0",
8
+ "pnpm": "11.5.3"
8
9
  },
9
10
  "type": "module",
10
11
  "private": false,
@@ -33,8 +33,8 @@
33
33
  .ol-btn:focus-visible {
34
34
  width: 40px !important;
35
35
  height: 40px !important;
36
- border: none !important;
37
36
  outline: none !important;
37
+ border: 2px solid #0b0c0c !important;
38
38
  }
39
39
 
40
40
  .ol-btn {
@@ -47,7 +47,8 @@
47
47
  cursor: pointer;
48
48
  transition:
49
49
  box-shadow 0.2s,
50
- background 0.2s;
50
+ background 0.2s,
51
+ border-color 0.2s;
51
52
  margin: 0;
52
53
  padding: 0;
53
54
  display: flex;
@@ -71,11 +72,16 @@
71
72
 
72
73
  .ol-btn:focus,
73
74
  .ol-btn:focus-visible {
74
- outline: 3px solid var(--focus-outline-color) !important;
75
+ border-color: var(--focus-outline-color) !important;
75
76
  background: var(--focus-color) !important;
76
77
  z-index: 2;
77
78
  }
78
79
 
80
+ .ol-btn:disabled {
81
+ opacity: 0.4;
82
+ cursor: not-allowed;
83
+ }
84
+
79
85
  /* Universal OpenLayers control focus style for non-button elements */
80
86
  #map:focus,
81
87
  .ol-viewport:focus,
@@ -106,8 +112,8 @@
106
112
  .ol-zoom button:focus-visible {
107
113
  width: 40px !important;
108
114
  height: 40px !important;
109
- border: none !important;
110
115
  outline: none !important;
116
+ border: 2px solid #0b0c0c !important;
111
117
  }
112
118
 
113
119
  .ol-zoom button {
@@ -136,7 +142,7 @@
136
142
 
137
143
  .ol-zoom button:focus,
138
144
  .ol-zoom button:focus-visible {
139
- outline: 3px solid var(--focus-outline-color) !important;
145
+ border-color: var(--focus-outline-color) !important;
140
146
  background: var(--focus-color) !important;
141
147
  z-index: 2;
142
148
  }
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
4
+
5
+ import { createPortal } from 'react-dom';
6
+
7
+ import { cn } from '../utils';
8
+
9
+ type LegendPanelProps = {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ buttonRect: DOMRect | null;
13
+ children?: ReactNode;
14
+ };
15
+
16
+ export const LegendPanel = ({ isOpen, onClose, buttonRect, children }: LegendPanelProps) => {
17
+ const [isMounted, setIsMounted] = useState(false);
18
+ const panelRef = useRef<HTMLDivElement>(null);
19
+
20
+ useEffect(() => {
21
+ setIsMounted(true);
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ if (!isOpen) {
26
+ return;
27
+ }
28
+
29
+ const handleKeyDown = (e: KeyboardEvent) => {
30
+ if (e.key === 'Escape') {
31
+ onClose();
32
+ }
33
+ };
34
+
35
+ document.addEventListener('keydown', handleKeyDown);
36
+
37
+ return () => document.removeEventListener('keydown', handleKeyDown);
38
+ }, [isOpen, onClose]);
39
+
40
+ if (!isMounted || !isOpen) {
41
+ return <></>;
42
+ }
43
+
44
+ const top = buttonRect ? buttonRect.top : 200;
45
+ const right = buttonRect ? window.innerWidth - buttonRect.left + 8 : 96;
46
+ const maxHeight = buttonRect
47
+ ? `${window.innerHeight - buttonRect.top - 24}px`
48
+ : 'calc(100vh - 224px)';
49
+
50
+ return createPortal(
51
+ <div
52
+ ref={panelRef}
53
+ role="dialog"
54
+ aria-modal="true"
55
+ aria-label="Map legend"
56
+ style={{ top, right, maxHeight }}
57
+ className={cn(
58
+ 'fixed bg-white rounded-lg shadow-lg border border-gray-200',
59
+ 'flex flex-col',
60
+ 'p-4 gap-3',
61
+ 'w-[220px]',
62
+ 'z-[9999]',
63
+ )}
64
+ >
65
+ <div className="flex items-center justify-between flex-shrink-0">
66
+ <h2 className="text-sm font-semibold text-gray-800">Legend</h2>
67
+
68
+ <button
69
+ type="button"
70
+ onClick={onClose}
71
+ aria-label="Close legend"
72
+ className="w-8 h-8 flex items-center justify-center rounded text-gray-600 hover:bg-focus
73
+ focus:outline focus:outline-2 focus:outline-focus focus:bg-focus transition-colors"
74
+ >
75
+ <span className="text-xl leading-none" aria-hidden="true">
76
+ &times;
77
+ </span>
78
+ </button>
79
+ </div>
80
+
81
+ <div className="overflow-y-auto">{children}</div>
82
+ </div>,
83
+ document.body,
84
+ );
85
+ };
@@ -3,12 +3,10 @@
3
3
  import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
4
4
 
5
5
  import { Feature, Map, Overlay, View } from 'ol';
6
- import { Attribution, ScaleLine, Zoom } from 'ol/control';
6
+ import { Attribution, ScaleLine } from 'ol/control';
7
7
  import { fromLonLat } from 'ol/proj';
8
8
 
9
9
  import { initializeBasemapLayers } from './basemaps';
10
- import { FullScreenControl } from './FullScreenControl';
11
- import { LayerSwitcherControl } from './LayerSwitcherControl';
12
10
  import { useMap } from './MapContext';
13
11
  import { Popup } from './Popup';
14
12
  import { getPopupPositionClass, LAYER_NAMES } from './utils';
@@ -81,15 +79,9 @@ const MapComponentBase = ({ osMapsApiKey, basePath, children }: MapComponentProp
81
79
  return;
82
80
  }
83
81
 
84
- // Create controls individually
85
- const mapZoom = new Zoom();
86
82
  const scaleLine = new ScaleLine({ units: 'metric' });
87
83
  const attribution = new Attribution();
88
- const layerSwitcher = new LayerSwitcherControl(layers);
89
- const fullScreenControl = new FullScreenControl();
90
-
91
- // Add controls in the desired order
92
- const controls = [mapZoom, layerSwitcher, fullScreenControl, scaleLine, attribution];
84
+ const controls = [scaleLine, attribution];
93
85
 
94
86
  const newMap = new Map({
95
87
  target,
@@ -0,0 +1,231 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
4
+
5
+ import BaseLayer from 'ol/layer/Base';
6
+ import { LuLayers, LuList, LuMaximize2, LuMinus, LuMinimize2, LuPlus } from 'react-icons/lu';
7
+
8
+ import { LayerSwitcherPanel } from './LayerSwitcherPanel';
9
+ import { LegendPanel } from './LegendPanel';
10
+ import { useMap } from './MapContext';
11
+
12
+ type MapControlsOverlayProps = {
13
+ mapContainerRef: RefObject<HTMLDivElement | null>;
14
+ isLegendVisible?: boolean;
15
+ isLegendEnabled?: boolean;
16
+ legendContent?: ReactNode;
17
+ };
18
+
19
+ type ControlButtonProps = {
20
+ ariaLabel: string;
21
+ title: string;
22
+ icon: ReactNode;
23
+ onClick: () => void;
24
+ isPressed?: boolean;
25
+ isDisabled?: boolean;
26
+ hasPopup?: boolean;
27
+ isExpanded?: boolean;
28
+ buttonRef?: RefObject<HTMLButtonElement | null>;
29
+ };
30
+
31
+ const ControlButton = ({
32
+ ariaLabel,
33
+ title,
34
+ icon,
35
+ onClick,
36
+ isPressed,
37
+ isDisabled = false,
38
+ hasPopup,
39
+ isExpanded,
40
+ buttonRef,
41
+ }: ControlButtonProps) => (
42
+ <button
43
+ ref={buttonRef}
44
+ type="button"
45
+ className="ol-btn"
46
+ aria-label={ariaLabel}
47
+ title={title}
48
+ onClick={onClick}
49
+ disabled={isDisabled}
50
+ aria-pressed={isPressed}
51
+ aria-haspopup={hasPopup ? 'dialog' : undefined}
52
+ aria-expanded={isExpanded}
53
+ >
54
+ <span aria-hidden="true">{icon}</span>
55
+ </button>
56
+ );
57
+
58
+ export const MapControlsOverlay = ({
59
+ mapContainerRef,
60
+ isLegendVisible = false,
61
+ isLegendEnabled = false,
62
+ legendContent,
63
+ }: MapControlsOverlayProps) => {
64
+ const { map, getLayers } = useMap();
65
+ const [isFullScreen, setIsFullScreen] = useState(false);
66
+ const [isLayerSwitcherOpen, setIsLayerSwitcherOpen] = useState(false);
67
+ const [layerSwitcherButtonRect, setLayerSwitcherButtonRect] = useState<DOMRect | null>(null);
68
+ const layerSwitcherButtonRef = useRef<HTMLButtonElement>(null);
69
+ const [isLegendOpen, setIsLegendOpen] = useState(false);
70
+ const [legendButtonRect, setLegendButtonRect] = useState<DOMRect | null>(null);
71
+ const legendButtonRef = useRef<HTMLButtonElement>(null);
72
+
73
+ const basemapLayers = useMemo(
74
+ () => (getLayers()?.filter((l: BaseLayer) => l.get('basemap') === true) ?? []) as BaseLayer[],
75
+ [getLayers],
76
+ );
77
+
78
+ const activeLayerName =
79
+ (basemapLayers.find((l: BaseLayer) => l.getVisible())?.get('name') as string | undefined) ??
80
+ null;
81
+
82
+ const handleFullScreen = useCallback(() => {
83
+ const container = mapContainerRef.current;
84
+
85
+ if (!container) {
86
+ return;
87
+ }
88
+
89
+ const entering = !isFullScreen;
90
+
91
+ setIsFullScreen(entering);
92
+ container.classList.toggle('map-fullscreen', entering);
93
+
94
+ if (map) {
95
+ setTimeout(() => map.updateSize(), 300);
96
+ }
97
+ }, [isFullScreen, map, mapContainerRef]);
98
+
99
+ const handleLayerSwitcherToggle = useCallback(() => {
100
+ const rect = layerSwitcherButtonRef.current?.getBoundingClientRect() ?? null;
101
+
102
+ setLayerSwitcherButtonRect(rect);
103
+ setIsLayerSwitcherOpen((prev) => !prev);
104
+ }, []);
105
+
106
+ const handleLayerSwitcherClose = useCallback(() => {
107
+ setIsLayerSwitcherOpen(false);
108
+ layerSwitcherButtonRef.current?.focus();
109
+ }, []);
110
+
111
+ const handleLegendToggle = useCallback(() => {
112
+ const rect = legendButtonRef.current?.getBoundingClientRect() ?? null;
113
+
114
+ setLegendButtonRect(rect);
115
+ setIsLegendOpen((prev) => !prev);
116
+ }, []);
117
+
118
+ const handleLegendClose = useCallback(() => {
119
+ setIsLegendOpen(false);
120
+ legendButtonRef.current?.focus();
121
+ }, []);
122
+
123
+ const handleSelectLayer = useCallback(
124
+ (layerName: string) => {
125
+ const current = basemapLayers.find((l: BaseLayer) => l.getVisible());
126
+ const next = basemapLayers.find((l: BaseLayer) => l.get('name') === layerName);
127
+
128
+ current?.setVisible(false);
129
+ next?.setVisible(true);
130
+ },
131
+ [basemapLayers],
132
+ );
133
+
134
+ const handleZoomIn = useCallback(() => {
135
+ if (!map) {
136
+ return;
137
+ }
138
+
139
+ const view = map.getView();
140
+ const zoom = view.getZoom();
141
+
142
+ if (zoom !== undefined) {
143
+ view.animate({ zoom: zoom + 1, duration: 200 });
144
+ }
145
+ }, [map]);
146
+
147
+ const handleZoomOut = useCallback(() => {
148
+ if (!map) {
149
+ return;
150
+ }
151
+
152
+ const view = map.getView();
153
+ const zoom = view.getZoom();
154
+
155
+ if (zoom !== undefined) {
156
+ view.animate({ zoom: zoom - 1, duration: 200 });
157
+ }
158
+ }, [map]);
159
+
160
+ const fullScreenLabel = isFullScreen ? 'Exit full screen' : 'Toggle full screen';
161
+ const legendLabel = isLegendOpen ? 'Hide legend' : 'Show legend';
162
+
163
+ return (
164
+ <>
165
+ <div className="flex flex-col" role="group" aria-label="Map controls">
166
+ <ControlButton
167
+ ariaLabel={fullScreenLabel}
168
+ title={fullScreenLabel}
169
+ icon={isFullScreen ? <LuMinimize2 size={20} /> : <LuMaximize2 size={20} />}
170
+ onClick={handleFullScreen}
171
+ />
172
+
173
+ <ControlButton
174
+ buttonRef={layerSwitcherButtonRef}
175
+ ariaLabel="Toggle basemap selector"
176
+ title="Basemap selector"
177
+ icon={<LuLayers size={20} />}
178
+ onClick={handleLayerSwitcherToggle}
179
+ hasPopup
180
+ isExpanded={isLayerSwitcherOpen}
181
+ />
182
+
183
+ <ControlButton
184
+ ariaLabel="Zoom in"
185
+ title="Zoom in"
186
+ icon={<LuPlus size={20} />}
187
+ onClick={handleZoomIn}
188
+ />
189
+
190
+ <ControlButton
191
+ ariaLabel="Zoom out"
192
+ title="Zoom out"
193
+ icon={<LuMinus size={20} />}
194
+ onClick={handleZoomOut}
195
+ />
196
+
197
+ {isLegendVisible ? (
198
+ <ControlButton
199
+ buttonRef={legendButtonRef}
200
+ ariaLabel={legendLabel}
201
+ title={legendLabel}
202
+ icon={<LuList size={20} />}
203
+ onClick={handleLegendToggle}
204
+ hasPopup
205
+ isExpanded={isLegendOpen}
206
+ isDisabled={!isLegendEnabled}
207
+ />
208
+ ) : null}
209
+ </div>
210
+
211
+ <LayerSwitcherPanel
212
+ isOpen={isLayerSwitcherOpen}
213
+ onClose={handleLayerSwitcherClose}
214
+ layers={basemapLayers}
215
+ activeLayerName={activeLayerName}
216
+ onSelectLayer={handleSelectLayer}
217
+ buttonRect={layerSwitcherButtonRect}
218
+ />
219
+
220
+ {isLegendVisible ? (
221
+ <LegendPanel
222
+ isOpen={isLegendOpen}
223
+ onClose={handleLegendClose}
224
+ buttonRect={legendButtonRect}
225
+ >
226
+ {legendContent}
227
+ </LegendPanel>
228
+ ) : null}
229
+ </>
230
+ );
231
+ };
@@ -211,8 +211,8 @@ export const Geocoder = ({
211
211
  </label>
212
212
 
213
213
  <div
214
- className="relative bg-white rounded shadow-lg transition-all duration-200
215
- focus-within:ring-2 focus-within:ring-focus"
214
+ className="relative bg-white rounded shadow-lg border-2 border-[#0b0c0c] transition-all
215
+ duration-200 focus-within:ring-2 focus-within:ring-focus"
216
216
  >
217
217
  <IoSearch
218
218
  size={20}
package/src/map/index.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  export * from './basemaps';
2
2
  export * from './geocoder/index';
3
3
  export * from './geometries';
4
- export * from './createControlButton';
5
- export * from './LayerSwitcherControl';
6
4
  export * from './LayerSwitcherPanel';
7
- export * from './FullScreenControl';
5
+ export * from './LegendPanel';
6
+ export * from './MapControlsOverlay';
8
7
  export * from './utils';
9
8
  export * from './MapComponent';
10
9
  export * from './MapContext';
@@ -1,3 +1,5 @@
1
+ 'use client';
2
+
1
3
  import { useCallback, useEffect, useState } from 'react';
2
4
 
3
5
  import { Map as OlMap } from 'ol';
@@ -1,3 +1,5 @@
1
+ 'use client';
2
+
1
3
  import { useEffect, useRef } from 'react';
2
4
 
3
5
  import { Map as OlMap } from 'ol';
@@ -1,126 +0,0 @@
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,173 +0,0 @@
1
- /* eslint-disable no-restricted-syntax */
2
- import { createElement } from 'react';
3
-
4
- import { Map } from 'ol';
5
- import { Control } from 'ol/control';
6
- import type { Options as ControlOptions } from 'ol/control/Control';
7
- import BaseLayer from 'ol/layer/Base';
8
- import { createRoot } from 'react-dom/client';
9
- import type { Root } from 'react-dom/client';
10
-
11
- import { createControlButton } from './createControlButton';
12
- import { LayerSwitcherPanel } from './LayerSwitcherPanel';
13
-
14
- const MAP_LOGO =
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"/>';
16
-
17
- export class LayerSwitcherControl extends Control {
18
- map!: Map;
19
- liveRegion!: HTMLElement;
20
- isOpen = false;
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;
26
-
27
- constructor(layers: BaseLayer[], options?: ControlOptions) {
28
- const button = createControlButton({
29
- ariaLabel: 'Toggle basemap selector',
30
- title: 'Basemap selector',
31
- iconSvg: MAP_LOGO,
32
- hasPopup: true,
33
- });
34
-
35
- const element = document.createElement('div');
36
-
37
- element.className = 'ol-layer-switcher ol-unselectable ol-control';
38
- element.appendChild(button);
39
-
40
- // Create a live region for screen reader announcements
41
- const liveRegion = document.createElement('div');
42
-
43
- liveRegion.setAttribute('aria-live', 'polite');
44
- liveRegion.setAttribute('role', 'status');
45
- liveRegion.className = 'ol-layer-switcher-live-region';
46
- liveRegion.style.position = 'absolute';
47
- liveRegion.style.width = '1px';
48
- liveRegion.style.height = '1px';
49
- liveRegion.style.margin = '-1px';
50
- liveRegion.style.border = '0';
51
- liveRegion.style.padding = '0';
52
- liveRegion.style.overflow = 'hidden';
53
- liveRegion.style.clipPath = 'inset(50%)';
54
- element.appendChild(liveRegion);
55
-
56
- super({
57
- element,
58
- target: options?.target,
59
- });
60
-
61
- this.layers = layers;
62
- this.button = button;
63
- this.liveRegion = liveRegion;
64
-
65
- // Create React container
66
- this.reactContainer = document.createElement('div');
67
- this.reactRoot = createRoot(this.reactContainer);
68
-
69
- button.addEventListener('click', this.toggleLayerSwitcher, false);
70
- }
71
-
72
- private getCurrentBasemap(): BaseLayer | undefined {
73
- return this.layers.find((layer: BaseLayer) => layer.getVisible() === true);
74
- }
75
-
76
- private getBasemapByName(layerName: string): BaseLayer | undefined {
77
- return this.layers.find((layer: BaseLayer) => layer.get('name') === layerName);
78
- }
79
-
80
- setMap(map: Map | null) {
81
- super.setMap(map);
82
-
83
- if (map) {
84
- this.map = map;
85
- }
86
- }
87
-
88
- toggleLayerSwitcher = (): void => {
89
- // Prevent double-toggle when panel closes via focus trap
90
- if (this.isClosingFromPanel) {
91
- this.isClosingFromPanel = false;
92
-
93
- return;
94
- }
95
-
96
- this.isOpen = !this.isOpen;
97
- this.button.setAttribute('aria-expanded', this.isOpen.toString());
98
-
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();
105
- }
106
- };
107
-
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
- }
116
- };
117
-
118
- private readonly renderPanel = (): void => {
119
- if (!this.reactRoot || !this.reactContainer) {
120
- return;
121
- }
122
-
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
- };
138
-
139
- private readonly closePanel = (): void => {
140
- if (!this.reactRoot) {
141
- return;
142
- }
143
-
144
- this.reactRoot.render(null);
145
- };
146
-
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);
153
-
154
- this.liveRegion.textContent = `${layerName} basemap selected`;
155
-
156
- // Re-render to update active state
157
- if (this.isOpen) {
158
- this.renderPanel();
159
- }
160
- };
161
-
162
- dispose(): void {
163
- // Clean up React root
164
- if (this.reactRoot) {
165
- this.reactRoot.unmount();
166
- this.reactRoot = null;
167
- }
168
-
169
- this.reactContainer = null;
170
-
171
- super.dispose?.();
172
- }
173
- }
@@ -1,72 +0,0 @@
1
- /**
2
- * Utility function to create a standardized OpenLayers control button
3
- * with consistent styling and accessibility attributes.
4
- *
5
- * @example
6
- * const button = createControlButton({
7
- * ariaLabel: 'Toggle feature',
8
- * title: 'Feature control',
9
- * iconSvg: '<path d="M..." />',
10
- * });
11
- */
12
-
13
- // Common SVG icons for map controls
14
- export const CONTROL_ICONS = {
15
- PLUS: '<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
16
- MINUS: '<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
17
- MAP_LAYERS:
18
- '<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"/>',
19
- EXPAND:
20
- '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
21
- COLLAPSE:
22
- '<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
23
- };
24
-
25
- export type ControlButtonOptions = {
26
- /** Accessible label for screen readers */
27
- ariaLabel: string;
28
- /** Tooltip text shown on hover */
29
- title: string;
30
- /** SVG path data or element for the button icon */
31
- iconSvg: string;
32
- /** Additional CSS classes to apply */
33
- className?: string;
34
- /** Button type, defaults to 'button' */
35
- type?: 'button' | 'submit' | 'reset';
36
- /** Whether this button controls a popup/dialog */
37
- hasPopup?: boolean;
38
- };
39
-
40
- /**
41
- * Creates a standardized control button with proper accessibility attributes
42
- * and consistent styling via the `ol-btn` base class.
43
- */
44
- export const createControlButton = (options: ControlButtonOptions): HTMLButtonElement => {
45
- const { ariaLabel, title, iconSvg, className = '', type = 'button', hasPopup = false } = options;
46
-
47
- const button = document.createElement('button');
48
-
49
- button.setAttribute('aria-label', ariaLabel);
50
- button.setAttribute('title', title);
51
- button.type = type;
52
- button.className = `ol-btn ${className}`.trim();
53
-
54
- if (hasPopup) {
55
- button.setAttribute('aria-haspopup', 'dialog');
56
- button.setAttribute('aria-expanded', 'false');
57
- }
58
-
59
- // Create SVG icon
60
- const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
61
-
62
- icon.setAttribute('width', '20');
63
- icon.setAttribute('height', '20');
64
- icon.setAttribute('viewBox', '0 0 24 24');
65
- icon.setAttribute('fill', 'currentColor');
66
- icon.setAttribute('aria-hidden', 'true');
67
- icon.innerHTML = iconSvg;
68
-
69
- button.appendChild(icon);
70
-
71
- return button;
72
- };