Skip to content

Commit 27cb0e5

Browse files
committed
Simplify search events' API
1 parent f00e0d6 commit 27cb0e5

5 files changed

Lines changed: 238 additions & 158 deletions

File tree

index.html

Lines changed: 29 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@
116116
</noscript>
117117
</head>
118118
<body>
119-
119+
<!-- this map defines > 1 searchable layer each with different results format,
120+
so it requires a custom search handler (see below) to render the results -->
120121
<mapml-viewer lang="en" projection="CBMTILE" controls controlslist="geolocation search" zoom="16" lat="45.406314" lon="-75.6883335">
121122
<map-layer label="Canada Base Map - Transportation" checked>
122123
<map-link rel="suggestions" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}*&num=20"></map-link>
@@ -155,6 +156,7 @@
155156
<map-link rel="search" tref="https://photon.komoot.io/api?q={searchTerms}"></map-link>
156157
</map-layer>
157158
</mapml-viewer>
159+
<!-- this map uses the default search handler (supports GeoJSON search results only) -->
158160
<map lang="fr" is="web-map" projection="OSMTILE" zoom="14" lat="45.406314" lon="-75.6883335" controls controlslist="geolocation search">
159161
<map-layer data-testid="osm-layer" label="OpenStreetMap" checked >
160162

@@ -195,32 +197,24 @@
195197
document.addEventListener('DOMContentLoaded', () => {
196198
const viewer = document.querySelector('mapml-viewer');
197199

198-
function renderResults(container, responses, map) {
199-
container.innerHTML = '';
200+
function toItems(responses, includeValue) {
201+
const items = [];
200202
for (const r of responses) {
201203
const data = r.data;
202204
if (!data) continue;
203205
// geogratis format (items array)
204206
if (data.items) {
205207
for (const item of data.items) {
206-
const btn = document.createElement('button');
207-
btn.className = 'mapml-search-result';
208-
btn.setAttribute('type', 'button');
209-
btn.textContent = item.name
210-
+ (item.concise ? ' (' + item.concise.code + ')' : '')
211-
+ (item.province ? ', ' + item.province.code : '');
212-
btn.addEventListener('click', () => {
213-
if (item.bbox && item.bbox.length === 4) {
214-
const [west, south, east, north] = item.bbox;
215-
map.fitBounds([[south, west], [north, east]]);
216-
} else {
217-
map.setView([item.latitude, item.longitude], 10);
218-
}
219-
map._container
220-
.querySelector('.mapml-search-panel')
221-
.classList.remove('mapml-search-panel-open');
222-
});
223-
container.appendChild(btn);
208+
const entry = {
209+
text: item.name
210+
+ (item.concise ? ' (' + item.concise.code + ')' : '')
211+
+ (item.province ? ', ' + item.province.code : ''),
212+
lat: item.latitude,
213+
lng: item.longitude,
214+
bbox: item.bbox || undefined
215+
};
216+
if (includeValue) entry.value = item.name;
217+
items.push(entry);
224218
}
225219
}
226220
// GeoJSON format (Photon etc.)
@@ -231,84 +225,33 @@
231225
for (const key of ['city', 'county', 'state', 'country']) {
232226
if (props[key] && props[key] !== props.name) parts.push(props[key]);
233227
}
234-
const btn = document.createElement('button');
235-
btn.className = 'mapml-search-result';
236-
btn.setAttribute('type', 'button');
237-
btn.textContent = parts.filter(Boolean).join(', ') || 'Unnamed';
238-
btn.addEventListener('click', () => {
239-
const bbox = feature.bbox
240-
|| (props.extent && props.extent.length === 4 ? props.extent : null);
241-
if (bbox && bbox.length === 4) {
242-
const [west, south, east, north] = bbox;
243-
map.fitBounds([[south, west], [north, east]]);
244-
} else if (feature.geometry && feature.geometry.coordinates) {
245-
const [lon, lat] = feature.geometry.coordinates;
246-
map.setView([lat, lon], props.zoom || 14);
247-
}
248-
map._container
249-
.querySelector('.mapml-search-panel')
250-
.classList.remove('mapml-search-panel-open');
251-
});
252-
container.appendChild(btn);
228+
const coords = feature.geometry?.coordinates || [];
229+
const bbox = feature.bbox
230+
|| (props.extent?.length === 4 ? props.extent : undefined);
231+
const entry = {
232+
text: parts.filter(Boolean).join(', ') || 'Unnamed',
233+
lat: coords[1],
234+
lng: coords[0],
235+
bbox: bbox || undefined
236+
};
237+
if (includeValue) entry.value = props.name || entry.text;
238+
items.push(entry);
253239
}
254240
}
255241
}
242+
return items;
256243
}
257244

258245
viewer.addEventListener('mapsuggestions', (e) => {
259246
e.preventDefault();
260-
const container = viewer._map._container.querySelector('.mapml-search-results');
261-
renderResults(container, e.detail.responses, viewer._map);
247+
e.detail.setResults(toItems(e.detail.responses, true));
262248
});
263249

264250
viewer.addEventListener('mapsearch', (e) => {
265251
e.preventDefault();
266-
const container = viewer._map._container.querySelector('.mapml-search-results');
267-
renderResults(container, e.detail.responses, viewer._map);
252+
e.detail.setResults(toItems(e.detail.responses, false));
268253
});
269254

270-
// --- geonames.org custom handler for the <map is="web-map"> element ---
271-
// const webMap = document.querySelector('map[is=web-map]');
272-
273-
// function renderGeonamesOrgResults(container, geonames, map) {
274-
// container.innerHTML = '';
275-
// for (const item of geonames) {
276-
// const btn = document.createElement('button');
277-
// btn.className = 'mapml-search-result';
278-
// btn.setAttribute('type', 'button');
279-
// btn.textContent = item.name
280-
// + (item.adminName1 ? ', ' + item.adminName1 : '')
281-
// + (item.countryName ? ', ' + item.countryName : '');
282-
// btn.addEventListener('click', () => {
283-
// if (item.bbox) {
284-
// map.fitBounds([
285-
// [item.bbox.south, item.bbox.west],
286-
// [item.bbox.north, item.bbox.east]
287-
// ]);
288-
// } else {
289-
// map.setView([parseFloat(item.lat), parseFloat(item.lng)], 10);
290-
// }
291-
// map._container
292-
// .querySelector('.mapml-search-panel')
293-
// .classList.remove('mapml-search-panel-open');
294-
// });
295-
// container.appendChild(btn);
296-
// }
297-
// }
298-
299-
// webMap.addEventListener('mapsuggestions', (e) => {
300-
// e.preventDefault();
301-
// const container = webMap._map._container.querySelector('.mapml-search-results');
302-
// const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
303-
// renderGeonamesOrgResults(container, allGeonames, webMap._map);
304-
// });
305-
306-
// webMap.addEventListener('mapsearch', (e) => {
307-
// e.preventDefault();
308-
// const container = webMap._map._container.querySelector('.mapml-search-results');
309-
// const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
310-
// renderGeonamesOrgResults(container, allGeonames, webMap._map);
311-
// });
312255
});
313256
</script>
314257
</body>

search-implementation-plan.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,78 @@ Reference design: search.md. Example service response: geonames.json.
8888
- Default handler does NOT run.
8989
- Click result → map moves.
9090

91+
---
92+
93+
### Addendum: Expose `e.detail.setResults()` on both search events
94+
95+
Before implementing custom handler documentation/examples, add a
96+
`setResults(items)` method to the `e.detail` object of **both** the
97+
`mapsuggestions` and `mapsearch` custom events. This avoids forcing users to
98+
reach into the viewer's shadow DOM to populate the results dropdown.
99+
100+
#### Unified item shape: `{ text, value?, lat?, lng?, bbox? }`
101+
102+
A single method with a single item shape serves both events. Click behaviour is
103+
inferred from which optional fields are present:
104+
105+
| Fields present | On click | Typical use |
106+
|---------------|----------|-------------|
107+
| `value` (with or without location fields) | Sets input to `value`, calls `_doSearch(value)` | Suggestions — refine the query |
108+
| `lat`/`lng` (no `value`) | Navigates map to location | Search results — go to the place |
109+
110+
- `text`**required**. Display string shown in the dropdown button.
111+
- `value` — optional. If present, clicking triggers a full search using this
112+
string. Takes precedence over location fields.
113+
- `lat`, `lng` — optional. Geographic coordinates. Used for navigation when
114+
`value` is absent.
115+
- `bbox` — optional. `[west, south, east, north]`. Used for navigation
116+
(preferred over point coordinates when available).
117+
118+
#### Why a unified shape
119+
120+
Most geocoding services (Geolocator API, Photon, Nominatim, etc.) return the
121+
same data for both suggestions and search results. Forcing users to construct
122+
different item shapes for the two events when the underlying data is identical
123+
would be unnecessary friction. The presence or absence of `value` is a natural
124+
signal for intent: suggestions carry a query string to refine; final results
125+
carry a location to navigate to.
126+
127+
#### Usage pattern
128+
129+
```js
130+
// mapsuggestions — include `value` so clicking re-searches
131+
e.detail.setResults(items.map(r => ({
132+
text: `${r.name}, ${r.province} (${r.category})`,
133+
value: r.name,
134+
lat: r.lat, lng: r.lng, bbox: r.bbox
135+
})));
136+
137+
// mapsearch — omit `value` so clicking navigates
138+
e.detail.setResults(items.map(r => ({
139+
text: `${r.name}, ${r.province} (${r.category})`,
140+
lat: r.lat, lng: r.lng, bbox: r.bbox
141+
})));
142+
```
143+
144+
#### Implementation notes
145+
146+
- Keeps users out of the shadow DOM entirely and preserves the control's
147+
keyboard/a11y behaviour for free.
148+
- **Location:** `src/mapml/control/SearchButton.js` — attach `setResults` as a
149+
bound function on the `detail` object when constructing both the
150+
`mapsuggestions` and `mapsearch` `CustomEvent`s.
151+
- When an item has `value`, the click handler calls `_doSearch(value)`.
152+
- When an item lacks `value` but has `lat`/`lng`/`bbox`, the click handler
153+
calls `_navigateToFeature` (or a thin wrapper), constructing a minimal
154+
feature-like object from the item's location fields.
155+
156+
**Reference implementation target:** The API should conform to the usage
157+
described in
158+
[revised-custom-handler-instructions.md](../../../experiments/api/search/revised-custom-handler-instructions.md).
159+
That document describes the Geolocator API integration walkthrough updated to
160+
use `setSuggestions()` and `setResults()` instead of direct shadow DOM access.
161+
162+
91163
---
92164

93165
## Implementation Notes

src/mapml/control/SearchButton.js

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,11 @@ export var SearchButton = Control.extend({
341341
let event = new CustomEvent('mapsuggestions', {
342342
bubbles: true,
343343
cancelable: true,
344-
detail: { query, responses }
344+
detail: {
345+
query,
346+
responses,
347+
setResults: this._setResults.bind(this)
348+
}
345349
});
346350
let cancelled = !this._mapEl.dispatchEvent(event);
347351
if (!cancelled) {
@@ -375,7 +379,11 @@ export var SearchButton = Control.extend({
375379
let event = new CustomEvent('mapsearch', {
376380
bubbles: true,
377381
cancelable: true,
378-
detail: { query, responses }
382+
detail: {
383+
query,
384+
responses,
385+
setResults: this._setResults.bind(this)
386+
}
379387
});
380388
let cancelled = !this._mapEl.dispatchEvent(event);
381389
if (!cancelled) {
@@ -504,6 +512,51 @@ export var SearchButton = Control.extend({
504512
let name = this._formatResultName(feature.properties);
505513
this._input.value = name;
506514
this._doSearch(name);
515+
},
516+
517+
/**
518+
* Public API for custom event handlers.
519+
* Renders items as buttons in the results dropdown.
520+
*
521+
* Item shape: { text, value?, lat?, lng?, bbox? }
522+
* - text (required): display string
523+
* - value: if present, clicking triggers _doSearch(value)
524+
* - lat/lng/bbox: if present and value is absent, clicking navigates
525+
*/
526+
_setResults: function (items) {
527+
this._results.innerHTML = '';
528+
if (!items || !Array.isArray(items)) return;
529+
for (let item of items) {
530+
let btn = document.createElement('button');
531+
btn.className = 'mapml-search-result';
532+
btn.setAttribute('type', 'button');
533+
btn.textContent = item.text || '';
534+
btn.addEventListener(
535+
'click',
536+
((it) => () => {
537+
if (it.value != null) {
538+
// Suggestion mode: refine the search
539+
this._input.value = it.value;
540+
this._doSearch(it.value);
541+
} else {
542+
// Result mode: navigate to location
543+
this._navigateToItem(it);
544+
}
545+
})(item)
546+
);
547+
btn.addEventListener('keydown', this._resultKeydown.bind(this));
548+
this._results.appendChild(btn);
549+
}
550+
},
551+
552+
_navigateToItem: function (item) {
553+
let map = this._map;
554+
if (item.bbox && item.bbox.length === 4) {
555+
let [west, south, east, north] = item.bbox;
556+
map.fitBounds(latLngBounds([south, west], [north, east]));
557+
} else if (item.lat != null && item.lng != null) {
558+
map.setView([item.lat, item.lng], 14);
559+
}
507560
}
508561
});
509562

0 commit comments

Comments
 (0)