@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
package/src/map/MapComponent.tsx
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Map, Overlay, View } from 'ol';
|
|
6
|
-
import { Attribution,
|
|
6
|
+
import { Attribution, ScaleLine, Zoom } from 'ol/control';
|
|
7
7
|
import { fromLonLat } from 'ol/proj';
|
|
8
8
|
|
|
9
9
|
import { initializeBasemapLayers } from './basemaps';
|
|
10
|
-
import {
|
|
10
|
+
import { FullScreenControl } from './FullScreenControl';
|
|
11
11
|
import { LayerSwitcherControl } from './LayerSwitcherControl';
|
|
12
12
|
import { useMap } from './MapContext';
|
|
13
13
|
import { Popup } from './Popup';
|
|
@@ -16,7 +16,6 @@ import type { PopupDirection } from '../types/map';
|
|
|
16
16
|
|
|
17
17
|
export type MapComponentProps = {
|
|
18
18
|
osMapsApiKey?: string;
|
|
19
|
-
geocoderUrl: string;
|
|
20
19
|
basePath: string;
|
|
21
20
|
isLoading?: boolean;
|
|
22
21
|
};
|
|
@@ -42,12 +41,7 @@ const arrowStyles: Record<PopupDirection, string> = {
|
|
|
42
41
|
*
|
|
43
42
|
* @return {*}
|
|
44
43
|
*/
|
|
45
|
-
export const MapComponent = ({
|
|
46
|
-
osMapsApiKey,
|
|
47
|
-
geocoderUrl,
|
|
48
|
-
basePath,
|
|
49
|
-
isLoading,
|
|
50
|
-
}: MapComponentProps) => {
|
|
44
|
+
export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponentProps) => {
|
|
51
45
|
const [popupFeatures, setPopupFeatures] = useState([]);
|
|
52
46
|
const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
|
|
53
47
|
const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
|
|
@@ -88,9 +82,10 @@ export const MapComponent = ({
|
|
|
88
82
|
const scaleLine = new ScaleLine({ units: 'metric' });
|
|
89
83
|
const attribution = new Attribution();
|
|
90
84
|
const layerSwitcher = new LayerSwitcherControl(layers);
|
|
85
|
+
const fullScreenControl = new FullScreenControl();
|
|
91
86
|
|
|
92
87
|
// Add controls in the desired order
|
|
93
|
-
const controls = [mapZoom, layerSwitcher, scaleLine, attribution];
|
|
88
|
+
const controls = [mapZoom, layerSwitcher, fullScreenControl, scaleLine, attribution];
|
|
94
89
|
|
|
95
90
|
const newMap = new Map({
|
|
96
91
|
target,
|
|
@@ -98,36 +93,14 @@ export const MapComponent = ({
|
|
|
98
93
|
layers,
|
|
99
94
|
view: new View({
|
|
100
95
|
projection: 'EPSG:3857',
|
|
101
|
-
// extent: transformExtent(
|
|
102
|
-
// [-8.371582, 49.852152, 2.021484, 59.445075],
|
|
103
|
-
// 'EPSG:4326',
|
|
104
|
-
// 'EPSG:3857',
|
|
105
|
-
// ),
|
|
106
96
|
center: fromLonLat(center),
|
|
107
97
|
zoom,
|
|
108
98
|
}),
|
|
109
99
|
});
|
|
110
100
|
|
|
111
101
|
// Mark the map as initialized to prevent re-initialization
|
|
112
|
-
// This is a workaround to avoid re-initializing the map when the component
|
|
113
|
-
// re-renders. The map is only initialized once when the component mounts.
|
|
114
|
-
// This is important because the map is a singleton and should not be
|
|
115
|
-
// re-initialized.
|
|
116
102
|
mapInitializedRef.current = true;
|
|
117
103
|
|
|
118
|
-
// Create an instance of the custom provider, passing any options that are
|
|
119
|
-
// required
|
|
120
|
-
// Only initialize geocoder if API key is available
|
|
121
|
-
if (osMapsApiKey) {
|
|
122
|
-
try {
|
|
123
|
-
const geocoder = initializeGeocoder(osMapsApiKey, geocoderUrl, newMap);
|
|
124
|
-
|
|
125
|
-
newMap.addControl(geocoder as Control);
|
|
126
|
-
} catch (error) {
|
|
127
|
-
console.error('Failed to initialize geocoder:', error);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
104
|
// Setup popup overlay
|
|
132
105
|
const overlay = new Overlay({
|
|
133
106
|
element: document.getElementById('popup-container') ?? undefined,
|
|
@@ -151,7 +124,7 @@ export const MapComponent = ({
|
|
|
151
124
|
|
|
152
125
|
setPopupFeatures(features);
|
|
153
126
|
setPopupCoordinate(coordinate);
|
|
154
|
-
setPopupPositionClass(direction);
|
|
127
|
+
setPopupPositionClass(direction);
|
|
155
128
|
overlay.setPosition(event.coordinate);
|
|
156
129
|
}
|
|
157
130
|
});
|
|
@@ -191,8 +164,6 @@ export const MapComponent = ({
|
|
|
191
164
|
</div>
|
|
192
165
|
) : null}
|
|
193
166
|
|
|
194
|
-
<div className="absolute top-36 z-20" id="geocoder" />
|
|
195
|
-
|
|
196
167
|
<div
|
|
197
168
|
className={`absolute z-20 ${positionTransforms[popupPositionClass]}`}
|
|
198
169
|
id="popup-container"
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import Map from 'ol/Map';
|
|
2
|
+
import View from 'ol/View';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { Geocoder } from './Geocoder';
|
|
6
|
+
import { render, screen, userEvent } from '../../test/renderers';
|
|
7
|
+
|
|
8
|
+
class MockView extends View {
|
|
9
|
+
animate = vi.fn((options, callback) => {
|
|
10
|
+
// Immediately execute callback if provided (simulating instant animation)
|
|
11
|
+
if (typeof callback === 'function') {
|
|
12
|
+
callback(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
fit = vi.fn();
|
|
16
|
+
getZoom = vi.fn(() => 5);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockView = new MockView({ center: [0, 0], zoom: 5 });
|
|
20
|
+
const mockMap = new Map({ view: mockView });
|
|
21
|
+
|
|
22
|
+
describe('Geocoder', () => {
|
|
23
|
+
it('renders results and allows keyboard selection', async () => {
|
|
24
|
+
const search = vi.fn().mockResolvedValue([{ id: '1', label: 'London', center: [0, 0] }]);
|
|
25
|
+
|
|
26
|
+
render(<Geocoder map={mockMap} search={search} />);
|
|
27
|
+
|
|
28
|
+
const input = screen.getByRole('combobox');
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
|
|
31
|
+
// Type into the input
|
|
32
|
+
await user.type(input, 'Lon');
|
|
33
|
+
|
|
34
|
+
// Press Enter to trigger search
|
|
35
|
+
await user.keyboard('{Enter}');
|
|
36
|
+
|
|
37
|
+
// Wait for the result to appear
|
|
38
|
+
const option = await screen.findByText('London');
|
|
39
|
+
|
|
40
|
+
expect(option).toBeInTheDocument();
|
|
41
|
+
|
|
42
|
+
// Navigate to the option using arrow keys and select
|
|
43
|
+
await user.keyboard('{ArrowDown}{Enter}');
|
|
44
|
+
|
|
45
|
+
// Verify the map animation was called with center and zoom (default zoom is 14)
|
|
46
|
+
expect(mockView.animate).toHaveBeenCalledWith(
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
center: [0, 0],
|
|
49
|
+
zoom: 14,
|
|
50
|
+
duration: 1500,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('fits to extent when result has extent', async () => {
|
|
56
|
+
const search = vi
|
|
57
|
+
.fn()
|
|
58
|
+
.mockResolvedValue([{ id: '1', label: 'Greater London', extent: [-0.5, 51.3, 0.3, 51.7] }]);
|
|
59
|
+
|
|
60
|
+
render(<Geocoder map={mockMap} search={search} />);
|
|
61
|
+
|
|
62
|
+
const input = screen.getByRole('combobox');
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
|
|
65
|
+
await user.type(input, 'Greater{Enter}');
|
|
66
|
+
|
|
67
|
+
const option = await screen.findByText('Greater London');
|
|
68
|
+
|
|
69
|
+
await user.click(option);
|
|
70
|
+
|
|
71
|
+
// Should call fit with extent and padding (no zoom-out animation since currentZoom=5 < flyOutThreshold=8)
|
|
72
|
+
expect(mockView.fit).toHaveBeenCalledWith(
|
|
73
|
+
[-0.5, 51.3, 0.3, 51.7],
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
padding: [40, 40, 40, 40],
|
|
76
|
+
duration: 1500,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not search when query is too short', async () => {
|
|
82
|
+
const search = vi.fn().mockResolvedValue([]);
|
|
83
|
+
|
|
84
|
+
render(<Geocoder map={mockMap} search={search} minChars={3} />);
|
|
85
|
+
|
|
86
|
+
const input = screen.getByRole('combobox');
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
|
|
89
|
+
await user.type(input, 'Lo{Enter}');
|
|
90
|
+
|
|
91
|
+
// Search should not be called because query is too short (2 chars < minChars 3)
|
|
92
|
+
expect(search).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('closes dropdown on Escape key', async () => {
|
|
96
|
+
const search = vi.fn().mockResolvedValue([{ id: '1', label: 'London', center: [0, 0] }]);
|
|
97
|
+
|
|
98
|
+
render(<Geocoder map={mockMap} search={search} />);
|
|
99
|
+
|
|
100
|
+
const input = screen.getByRole('combobox');
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
|
|
103
|
+
await user.type(input, 'London{Enter}');
|
|
104
|
+
|
|
105
|
+
const option = await screen.findByText('London');
|
|
106
|
+
|
|
107
|
+
expect(option).toBeInTheDocument();
|
|
108
|
+
|
|
109
|
+
await user.keyboard('{Escape}');
|
|
110
|
+
|
|
111
|
+
// Results should be cleared and dropdown closed
|
|
112
|
+
expect(screen.queryByText('London')).not.toBeInTheDocument();
|
|
113
|
+
expect(input).toHaveValue('');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type Map from 'ol/Map';
|
|
6
|
+
|
|
7
|
+
import { groupResults } from './groupResults';
|
|
8
|
+
import type { GeocoderResult } from './types';
|
|
9
|
+
|
|
10
|
+
// SVG Icons matching Mapbox GL Geocoder style
|
|
11
|
+
const SearchIcon = () => (
|
|
12
|
+
<svg
|
|
13
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
|
14
|
+
width="20"
|
|
15
|
+
height="20"
|
|
16
|
+
viewBox="0 0 18 18"
|
|
17
|
+
fill="none"
|
|
18
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
19
|
+
>
|
|
20
|
+
<path
|
|
21
|
+
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"
|
|
22
|
+
stroke="#757575"
|
|
23
|
+
strokeWidth="1.5"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<path
|
|
29
|
+
d="M16.5 16.5L11.625 11.625"
|
|
30
|
+
stroke="#757575"
|
|
31
|
+
strokeWidth="1.5"
|
|
32
|
+
strokeLinecap="round"
|
|
33
|
+
strokeLinejoin="round"
|
|
34
|
+
/>
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const ClearIcon = () => (
|
|
39
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
40
|
+
<path
|
|
41
|
+
d="M15 5L5 15M5 5L15 15"
|
|
42
|
+
stroke="#757575"
|
|
43
|
+
strokeWidth="2"
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const LoadingIcon = () => (
|
|
51
|
+
<svg
|
|
52
|
+
className="animate-spin"
|
|
53
|
+
width="26"
|
|
54
|
+
height="26"
|
|
55
|
+
viewBox="0 0 26 26"
|
|
56
|
+
fill="none"
|
|
57
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
+
>
|
|
59
|
+
<circle
|
|
60
|
+
cx="13"
|
|
61
|
+
cy="13"
|
|
62
|
+
r="10"
|
|
63
|
+
stroke="#757575"
|
|
64
|
+
strokeWidth="3"
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeDasharray="60"
|
|
67
|
+
strokeDashoffset="40"
|
|
68
|
+
/>
|
|
69
|
+
</svg>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Animation defaults - can be overridden via props
|
|
73
|
+
const DEFAULT_TARGET_ZOOM = 14;
|
|
74
|
+
const DEFAULT_FLY_OUT_ZOOM_MIN = 6;
|
|
75
|
+
const DEFAULT_FLY_OUT_THRESHOLD = 8; // Only zoom out if current zoom > this
|
|
76
|
+
const DEFAULT_ZOOM_OUT_DURATION = 800;
|
|
77
|
+
const DEFAULT_FLY_TO_DURATION = 1500;
|
|
78
|
+
const DEFAULT_FIT_PADDING = 40;
|
|
79
|
+
|
|
80
|
+
export type GeocoderProps = {
|
|
81
|
+
map: Map;
|
|
82
|
+
search: (query: string) => Promise<GeocoderResult[]>;
|
|
83
|
+
placeholder?: string;
|
|
84
|
+
minChars?: number;
|
|
85
|
+
id?: string;
|
|
86
|
+
/** Target zoom level when flying to a location (default: 14) */
|
|
87
|
+
targetZoom?: number;
|
|
88
|
+
/** Minimum zoom level when zooming out for fly effect (default: 6) */
|
|
89
|
+
flyOutZoomMin?: number;
|
|
90
|
+
/** Only zoom out if current zoom is above this threshold (default: 8) */
|
|
91
|
+
flyOutThreshold?: number;
|
|
92
|
+
/** Duration of zoom out animation in ms (default: 800) */
|
|
93
|
+
zoomOutDuration?: number;
|
|
94
|
+
/** Duration of fly-to animation in ms (default: 1500) */
|
|
95
|
+
flyToDuration?: number;
|
|
96
|
+
/** Padding around extent when fitting (default: 40) */
|
|
97
|
+
fitPadding?: number;
|
|
98
|
+
/** Debug callback for logging results and selections */
|
|
99
|
+
onDebug?: (event: { type: 'results' | 'select' | 'clear'; data?: unknown }) => void;
|
|
100
|
+
// To enable auto-search on typing (debounced), import useDebounce from '../../hooks/useDebounce'
|
|
101
|
+
// and add: debounceDelay?: number; prop, then use debouncedQuery in a useEffect
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const Geocoder = ({
|
|
105
|
+
map,
|
|
106
|
+
search,
|
|
107
|
+
placeholder = 'Search for a place',
|
|
108
|
+
minChars = 3,
|
|
109
|
+
id: providedId,
|
|
110
|
+
targetZoom = DEFAULT_TARGET_ZOOM,
|
|
111
|
+
flyOutZoomMin = DEFAULT_FLY_OUT_ZOOM_MIN,
|
|
112
|
+
flyOutThreshold = DEFAULT_FLY_OUT_THRESHOLD,
|
|
113
|
+
zoomOutDuration = DEFAULT_ZOOM_OUT_DURATION,
|
|
114
|
+
flyToDuration = DEFAULT_FLY_TO_DURATION,
|
|
115
|
+
fitPadding = DEFAULT_FIT_PADDING,
|
|
116
|
+
onDebug,
|
|
117
|
+
}: GeocoderProps) => {
|
|
118
|
+
const generatedId = useId();
|
|
119
|
+
const id = providedId ?? generatedId;
|
|
120
|
+
const listboxId = `${id}-listbox`;
|
|
121
|
+
|
|
122
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
123
|
+
|
|
124
|
+
const [query, setQuery] = useState('');
|
|
125
|
+
const [results, setResults] = useState<GeocoderResult[]>([]);
|
|
126
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
127
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
128
|
+
const [open, setOpen] = useState(false);
|
|
129
|
+
const [status, setStatus] = useState('');
|
|
130
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
131
|
+
|
|
132
|
+
const performSearch = async () => {
|
|
133
|
+
if (query.length < minChars) {
|
|
134
|
+
setStatus(`Enter at least ${minChars} characters`);
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setIsSearching(true);
|
|
140
|
+
setStatus('Searching...');
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const data = await search(query);
|
|
144
|
+
|
|
145
|
+
setResults(data);
|
|
146
|
+
setSelectedId(null);
|
|
147
|
+
setOpen(true);
|
|
148
|
+
setActiveIndex(-1);
|
|
149
|
+
setStatus(data.length ? `${data.length} results available` : 'No results found');
|
|
150
|
+
|
|
151
|
+
// Debug callback for results
|
|
152
|
+
onDebug?.({ type: 'results', data: { query, results: data, count: data.length } });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('Search error:', error);
|
|
155
|
+
setStatus('There was an error reaching the server');
|
|
156
|
+
setResults([]);
|
|
157
|
+
} finally {
|
|
158
|
+
setIsSearching(false);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const selectResult = (result: GeocoderResult) => {
|
|
163
|
+
const view = map.getView();
|
|
164
|
+
const currentZoom = view.getZoom() ?? 10;
|
|
165
|
+
const shouldZoomOut = currentZoom > flyOutThreshold;
|
|
166
|
+
const flyZoom = shouldZoomOut ? Math.max(currentZoom - 4, flyOutZoomMin) : currentZoom;
|
|
167
|
+
const padding = [fitPadding, fitPadding, fitPadding, fitPadding] as [
|
|
168
|
+
number,
|
|
169
|
+
number,
|
|
170
|
+
number,
|
|
171
|
+
number,
|
|
172
|
+
];
|
|
173
|
+
// Use result-specific zoom if provided, otherwise fall back to targetZoom prop
|
|
174
|
+
const finalZoom = result.zoom ?? targetZoom;
|
|
175
|
+
|
|
176
|
+
if (result.extent) {
|
|
177
|
+
if (shouldZoomOut) {
|
|
178
|
+
// Step 1: Zoom out, then fit to extent (combined pan + zoom)
|
|
179
|
+
view.animate({ zoom: flyZoom, duration: zoomOutDuration }, () => {
|
|
180
|
+
view.fit(result.extent!, { padding, duration: flyToDuration });
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
view.fit(result.extent, { padding, duration: flyToDuration });
|
|
184
|
+
}
|
|
185
|
+
} else if (result.center) {
|
|
186
|
+
if (shouldZoomOut) {
|
|
187
|
+
// Step 1: Zoom out, then combined pan + zoom in (like ol-geocoder)
|
|
188
|
+
view.animate({ zoom: flyZoom, duration: zoomOutDuration }, () => {
|
|
189
|
+
view.animate({
|
|
190
|
+
center: result.center,
|
|
191
|
+
zoom: finalZoom,
|
|
192
|
+
duration: flyToDuration,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
// Combined pan + zoom in one smooth animation
|
|
197
|
+
view.animate({
|
|
198
|
+
center: result.center,
|
|
199
|
+
zoom: finalZoom,
|
|
200
|
+
duration: flyToDuration,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setSelectedId(result.id);
|
|
206
|
+
setOpen(false);
|
|
207
|
+
setStatus(`Selected ${result.label}`);
|
|
208
|
+
|
|
209
|
+
// Debug callback for selection
|
|
210
|
+
onDebug?.({
|
|
211
|
+
type: 'select',
|
|
212
|
+
data: { result, zoom: finalZoom, currentZoom, shouldZoomOut },
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const clearSearch = () => {
|
|
217
|
+
setQuery('');
|
|
218
|
+
setResults([]);
|
|
219
|
+
setOpen(false);
|
|
220
|
+
setActiveIndex(-1);
|
|
221
|
+
setSelectedId(null);
|
|
222
|
+
setStatus('');
|
|
223
|
+
inputRef.current?.focus();
|
|
224
|
+
|
|
225
|
+
// Debug callback for clear
|
|
226
|
+
onDebug?.({ type: 'clear' });
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const handleFocus = () => {
|
|
230
|
+
// Show previous results when focusing the input
|
|
231
|
+
if (results.length > 0) {
|
|
232
|
+
setOpen(true);
|
|
233
|
+
setActiveIndex(-1);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const grouped = groupResults(results);
|
|
238
|
+
let flatIndex = -1;
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div className="relative w-full max-w-sm">
|
|
242
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
243
|
+
{status}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<label htmlFor={id} className="sr-only">
|
|
247
|
+
Search for a place
|
|
248
|
+
</label>
|
|
249
|
+
|
|
250
|
+
{/* Mapbox-style geocoder container */}
|
|
251
|
+
<div className="relative bg-white rounded shadow-lg transition-all duration-200">
|
|
252
|
+
<SearchIcon />
|
|
253
|
+
|
|
254
|
+
<input
|
|
255
|
+
ref={inputRef}
|
|
256
|
+
id={id}
|
|
257
|
+
type="text"
|
|
258
|
+
role="combobox"
|
|
259
|
+
aria-autocomplete="list"
|
|
260
|
+
aria-expanded={open}
|
|
261
|
+
aria-controls={listboxId}
|
|
262
|
+
aria-activedescendant={
|
|
263
|
+
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined
|
|
264
|
+
}
|
|
265
|
+
value={query}
|
|
266
|
+
placeholder={placeholder}
|
|
267
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
268
|
+
onFocus={handleFocus}
|
|
269
|
+
onKeyDown={(e) => {
|
|
270
|
+
if (e.key === 'ArrowDown') {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
|
|
273
|
+
if (!open && results.length > 0) {
|
|
274
|
+
setOpen(true);
|
|
275
|
+
} else {
|
|
276
|
+
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (e.key === 'ArrowUp') {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (e.key === 'Enter') {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
const activeResult = results[activeIndex];
|
|
288
|
+
|
|
289
|
+
if (activeResult && open) {
|
|
290
|
+
selectResult(activeResult);
|
|
291
|
+
} else {
|
|
292
|
+
performSearch();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (e.key === 'Escape') {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
clearSearch();
|
|
299
|
+
}
|
|
300
|
+
}}
|
|
301
|
+
className="w-full h-12 pl-11 pr-11 border-0 bg-transparent text-base text-gray-800
|
|
302
|
+
placeholder:text-gray-500 focus:outline-none focus:ring-0"
|
|
303
|
+
/>
|
|
304
|
+
|
|
305
|
+
{/* Clear or Loading button */}
|
|
306
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
307
|
+
{(() => {
|
|
308
|
+
if (isSearching) {
|
|
309
|
+
return (
|
|
310
|
+
<div className="p-1">
|
|
311
|
+
<LoadingIcon />
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (query) {
|
|
317
|
+
return (
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={clearSearch}
|
|
321
|
+
aria-label="Clear search"
|
|
322
|
+
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
323
|
+
>
|
|
324
|
+
<ClearIcon />
|
|
325
|
+
</button>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return null;
|
|
330
|
+
})()}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{/* Results dropdown with Mapbox styling */}
|
|
335
|
+
{open ? (
|
|
336
|
+
<div className="absolute z-1000 mt-1.5 w-full bg-white rounded shadow-lg overflow-hidden">
|
|
337
|
+
{results.length > 0 ? (
|
|
338
|
+
<div id={listboxId} role="listbox" className="max-h-60 overflow-auto">
|
|
339
|
+
{Object.entries(grouped).map(([group, items]) => (
|
|
340
|
+
<div key={group} role="presentation">
|
|
341
|
+
<div
|
|
342
|
+
className="px-3 py-1.5 text-xs font-semibold text-gray-600 bg-gray-50 sticky
|
|
343
|
+
top-0"
|
|
344
|
+
>
|
|
345
|
+
{group}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div role="group">
|
|
349
|
+
{items.map((item) => {
|
|
350
|
+
flatIndex += 1;
|
|
351
|
+
const isActive = flatIndex === activeIndex;
|
|
352
|
+
const isSelected = item.id === selectedId;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
id={`${listboxId}-option-${flatIndex}`}
|
|
357
|
+
key={item.id}
|
|
358
|
+
role="option"
|
|
359
|
+
aria-selected={isSelected}
|
|
360
|
+
tabIndex={-1}
|
|
361
|
+
className={`cursor-pointer px-3 py-2 text-sm text-gray-800
|
|
362
|
+
transition-colors ${isSelected ? 'bg-blue-50 font-semibold' : ''}
|
|
363
|
+
${isActive && !isSelected ? 'bg-gray-100' : ''}
|
|
364
|
+
${!isSelected && !isActive ? 'hover:bg-gray-50' : ''}`}
|
|
365
|
+
onMouseEnter={() => setActiveIndex(flatIndex)}
|
|
366
|
+
onMouseDown={() => selectResult(item)}
|
|
367
|
+
>
|
|
368
|
+
<div className="truncate">{item.label}</div>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
) : (
|
|
377
|
+
(() => {
|
|
378
|
+
if (query && !isSearching) {
|
|
379
|
+
return (
|
|
380
|
+
<div className="px-3 py-6 text-center text-sm text-gray-500">
|
|
381
|
+
{status || 'No results found'}
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return null;
|
|
387
|
+
})()
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
) : null}
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GeocoderResult, GroupedResults } from './types';
|
|
2
|
+
|
|
3
|
+
export const groupResults = (results: GeocoderResult[]): GroupedResults => {
|
|
4
|
+
return results.reduce<GroupedResults>((acc, result) => {
|
|
5
|
+
const key = result.group ?? 'Results';
|
|
6
|
+
|
|
7
|
+
acc[key] ??= [];
|
|
8
|
+
acc[key].push(result);
|
|
9
|
+
|
|
10
|
+
return acc;
|
|
11
|
+
}, {});
|
|
12
|
+
};
|