@tpzdsp/next-toolkit 2.0.0 → 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.0",
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
+ };
@@ -3,73 +3,13 @@
3
3
  import { useCallback, useEffect, useId, useRef, useState } from 'react';
4
4
 
5
5
  import type Map from 'ol/Map';
6
+ import { ImSpinner2 } from 'react-icons/im';
7
+ import { IoClose, IoSearch } from 'react-icons/io5';
6
8
 
7
9
  import { groupResults } from './groupResults';
8
10
  import type { GeocoderResult } from './types';
9
11
  import { useDebounce } from '../../hooks/useDebounce';
10
12
 
11
- // SVG Icons matching Mapbox GL Geocoder style
12
- const SearchIcon = () => (
13
- <svg
14
- className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
15
- width="20"
16
- height="20"
17
- viewBox="0 0 18 18"
18
- fill="none"
19
- xmlns="http://www.w3.org/2000/svg"
20
- >
21
- <path
22
- d="M7.5 13.5C10.8137 13.5 13.5 10.8137 13.5 7.5C13.5 4.18629 10.8137 1.5 7.5 1.5C4.18629 1.5 1.5 4.18629 1.5 7.5C1.5 10.8137 4.18629 13.5 7.5 13.5Z"
23
- stroke="#757575"
24
- strokeWidth="1.5"
25
- strokeLinecap="round"
26
- strokeLinejoin="round"
27
- />
28
-
29
- <path
30
- d="M16.5 16.5L11.625 11.625"
31
- stroke="#757575"
32
- strokeWidth="1.5"
33
- strokeLinecap="round"
34
- strokeLinejoin="round"
35
- />
36
- </svg>
37
- );
38
-
39
- const ClearIcon = () => (
40
- <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
41
- <path
42
- d="M15 5L5 15M5 5L15 15"
43
- stroke="#757575"
44
- strokeWidth="2"
45
- strokeLinecap="round"
46
- strokeLinejoin="round"
47
- />
48
- </svg>
49
- );
50
-
51
- const LoadingIcon = () => (
52
- <svg
53
- className="animate-spin"
54
- width="26"
55
- height="26"
56
- viewBox="0 0 26 26"
57
- fill="none"
58
- xmlns="http://www.w3.org/2000/svg"
59
- >
60
- <circle
61
- cx="13"
62
- cy="13"
63
- r="10"
64
- stroke="#757575"
65
- strokeWidth="3"
66
- strokeLinecap="round"
67
- strokeDasharray="60"
68
- strokeDashoffset="40"
69
- />
70
- </svg>
71
- );
72
-
73
13
  // Typeahead defaults
74
14
  const DEFAULT_TYPEAHEAD_DELAY_MS = 500;
75
15
 
@@ -217,19 +157,11 @@ export const Geocoder = ({
217
157
  if (shouldZoomOut) {
218
158
  // Step 1: Zoom out, then combined pan + zoom in (like ol-geocoder)
219
159
  view.animate({ zoom: flyZoom, duration: zoomOutDuration }, () => {
220
- view.animate({
221
- center: result.center,
222
- zoom: finalZoom,
223
- duration: flyToDuration,
224
- });
160
+ view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
225
161
  });
226
162
  } else {
227
163
  // Combined pan + zoom in one smooth animation
228
- view.animate({
229
- center: result.center,
230
- zoom: finalZoom,
231
- duration: flyToDuration,
232
- });
164
+ view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
233
165
  }
234
166
  }
235
167
 
@@ -278,12 +210,14 @@ export const Geocoder = ({
278
210
  Search for a place
279
211
  </label>
280
212
 
281
- {/* Mapbox-style geocoder container */}
282
213
  <div
283
- className="relative bg-white rounded shadow-lg transition-all duration-200
284
- 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"
285
216
  >
286
- <SearchIcon />
217
+ <IoSearch
218
+ size={20}
219
+ className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
220
+ />
287
221
 
288
222
  <input
289
223
  ref={inputRef}
@@ -336,13 +270,12 @@ export const Geocoder = ({
336
270
  placeholder:text-gray-500 focus:outline-none focus:ring-0"
337
271
  />
338
272
 
339
- {/* Clear or Loading button */}
340
273
  <div className="absolute right-2 top-1/2 -translate-y-1/2">
341
274
  {(() => {
342
275
  if (isSearching) {
343
276
  return (
344
277
  <div className="p-1">
345
- <LoadingIcon />
278
+ <ImSpinner2 size={22} className="animate-spin text-gray-500" />
346
279
  </div>
347
280
  );
348
281
  }
@@ -355,7 +288,7 @@ export const Geocoder = ({
355
288
  aria-label="Clear search"
356
289
  className="p-1 hover:bg-gray-100 rounded transition-colors"
357
290
  >
358
- <ClearIcon />
291
+ <IoClose size={20} className="text-gray-500" />
359
292
  </button>
360
293
  );
361
294
  }
@@ -369,7 +302,7 @@ export const Geocoder = ({
369
302
  {open ? (
370
303
  <div className="absolute z-1000 mt-1.5 w-full bg-white rounded shadow-lg overflow-hidden">
371
304
  {results.length > 0 ? (
372
- <div id={listboxId} role="listbox" className="max-h-60 overflow-auto">
305
+ <div id={listboxId} role="listbox" tabIndex={-1} className="max-h-60 overflow-auto">
373
306
  {Object.entries(grouped).map(([group, items]) => (
374
307
  <div key={group} role="presentation">
375
308
  <div
@@ -382,21 +315,22 @@ export const Geocoder = ({
382
315
  <div role="group">
383
316
  {items.map((item) => {
384
317
  flatIndex += 1;
385
- const isActive = flatIndex === activeIndex;
318
+ const itemIndex = flatIndex;
319
+ const isActive = itemIndex === activeIndex;
386
320
  const isSelected = item.id === selectedId;
387
321
 
388
322
  return (
389
323
  <div
390
- id={`${listboxId}-option-${flatIndex}`}
324
+ id={`${listboxId}-option-${itemIndex}`}
391
325
  key={item.id}
392
326
  role="option"
393
327
  aria-selected={isSelected}
394
328
  tabIndex={-1}
395
- className={`cursor-pointer px-3 py-2 text-sm text-gray-800
329
+ className={`scroll-mt-8 cursor-pointer px-3 py-2 text-sm text-gray-800
396
330
  transition-colors ${isSelected ? 'bg-blue-50 font-semibold' : ''}
397
331
  ${isActive && !isSelected ? 'bg-gray-100' : ''}
398
332
  ${!isSelected && !isActive ? 'hover:bg-gray-50' : ''}`}
399
- onMouseEnter={() => setActiveIndex(flatIndex)}
333
+ onMouseEnter={() => setActiveIndex(itemIndex)}
400
334
  onMouseDown={() => selectResult(item)}
401
335
  >
402
336
  <div className="truncate">{item.label}</div>
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
- };