@tpzdsp/next-toolkit 1.12.1 → 1.14.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.
@@ -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
+ };
@@ -0,0 +1,4 @@
1
+ export { Geocoder } from './Geocoder';
2
+ export type { GeocoderProps } from './Geocoder';
3
+ export { groupResults } from './groupResults';
4
+ export type { GeocoderResult, GroupedResults } from './types';
@@ -0,0 +1,11 @@
1
+ export type GeocoderResult = {
2
+ id: string;
3
+ label: string;
4
+ group?: string;
5
+ extent?: [number, number, number, number];
6
+ center?: [number, number];
7
+ /** Optional zoom level for this result (e.g., city=12, street=16, building=18) */
8
+ zoom?: number;
9
+ };
10
+
11
+ export type GroupedResults = Record<string, GeocoderResult[]>;
package/src/map/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export * from './basemaps';
2
- export * from './geocoder';
2
+ export * from './geocoder/index';
3
3
  export * from './geometries';
4
+ export * from './createControlButton';
4
5
  export * from './LayerSwitcherControl';
6
+ export * from './LayerSwitcherPanel';
7
+ export * from './FullScreenControl';
5
8
  export * from './utils';
6
9
  export * from './MapComponent';
7
10
  export * from './MapContext';