Skip to content

Commit 215cf64

Browse files
committed
Add localization messages for search button.
Add localization and non-Latin character search tests.
1 parent c79a91a commit 215cf64

10 files changed

Lines changed: 320 additions & 59 deletions

File tree

index.html

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
</head>
118118
<body>
119119

120-
<mapml-viewer projection="CBMTILE" controls controlslist="geolocation search" zoom="16" lat="45.406314" lon="-75.6883335">
120+
<mapml-viewer lang="en" projection="CBMTILE" controls controlslist="geolocation search" zoom="16" lat="45.406314" lon="-75.6883335">
121121
<map-layer label="Canada Base Map - Transportation" checked>
122122
<map-link rel="suggestions" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}*&num=20"></map-link>
123123
<map-link rel="search" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}&num=20"></map-link>
@@ -150,15 +150,19 @@
150150
</map-extent>
151151
</map-layer>
152152
</mapml-viewer>
153-
<map is="web-map" projection="OSMTILE" zoom="14" lat="45.406314" lon="-75.6883335" controls controlslist="geolocation search">
153+
<map lang="fr" is="web-map" projection="OSMTILE" zoom="14" lat="45.406314" lon="-75.6883335" controls controlslist="geolocation search">
154154
<map-layer data-testid="osm-layer" label="OpenStreetMap" checked >
155155

156156
<map-link rel="license" title="© OpenStreetMap contributors CC BY-SA" href="https://www.openstreetmap.org/copyright"></map-link>
157157
<!-- Register your own geonames.org account at https://www.geonames.org/login
158158
and enable web services at https://www.geonames.org/manageaccount
159-
then replace username=demo with your username -->
160-
<map-link rel="search" tref="https://secure.geonames.org/searchJSON?q={searchTerms}&maxRows=25&inclBbox=true&username=demo"></map-link>
161-
<map-link rel="suggestions" tref="https://secure.geonames.org/searchJSON?name_startsWith={searchTerms}&maxRows=25&inclBbox=true&orderby=relevance&username=demo"></map-link>
159+
then replace username=demo with your username
160+
<map-link rel="search" tref="https://secure.geonames.org/searchJSON?q={searchTerms}&maxRows=25&inclBbox=true&username=prushforth"></map-link>
161+
<map-link rel="suggestions" tref="https://secure.geonames.org/searchJSON?name_startsWith={searchTerms}&maxRows=25&inclBbox=true&orderby=relevance&username=prushforth"></map-link>
162+
-->
163+
<!-- default search handler uses GeoJSON responses that have a quasi-standard 'schema' -->
164+
<map-link rel="suggestions" tref="https://photon.komoot.io/api?q={searchTerms}"></map-link>
165+
<map-link rel="search" tref="https://photon.komoot.io/api?q={searchTerms}"></map-link>
162166
<map-extent units="OSMTILE" checked="checked">
163167
<map-input name="z" type="zoom" value="18" min="0" max="18"></map-input>
164168
<map-input name="x" type="location" units="tilematrix" axis="column" min="0" max="262144"></map-input>
@@ -225,47 +229,47 @@
225229
});
226230

227231
// --- geonames.org custom handler for the <map is="web-map"> element ---
228-
const webMap = document.querySelector('map[is=web-map]');
232+
// const webMap = document.querySelector('map[is=web-map]');
229233

230-
function renderGeonamesOrgResults(container, geonames, map) {
231-
container.innerHTML = '';
232-
for (const item of geonames) {
233-
const btn = document.createElement('button');
234-
btn.className = 'mapml-search-result';
235-
btn.setAttribute('type', 'button');
236-
btn.textContent = item.name
237-
+ (item.adminName1 ? ', ' + item.adminName1 : '')
238-
+ (item.countryName ? ', ' + item.countryName : '');
239-
btn.addEventListener('click', () => {
240-
if (item.bbox) {
241-
map.fitBounds([
242-
[item.bbox.south, item.bbox.west],
243-
[item.bbox.north, item.bbox.east]
244-
]);
245-
} else {
246-
map.setView([parseFloat(item.lat), parseFloat(item.lng)], 10);
247-
}
248-
map._container
249-
.querySelector('.mapml-search-panel')
250-
.classList.remove('mapml-search-panel-open');
251-
});
252-
container.appendChild(btn);
253-
}
254-
}
234+
// function renderGeonamesOrgResults(container, geonames, map) {
235+
// container.innerHTML = '';
236+
// for (const item of geonames) {
237+
// const btn = document.createElement('button');
238+
// btn.className = 'mapml-search-result';
239+
// btn.setAttribute('type', 'button');
240+
// btn.textContent = item.name
241+
// + (item.adminName1 ? ', ' + item.adminName1 : '')
242+
// + (item.countryName ? ', ' + item.countryName : '');
243+
// btn.addEventListener('click', () => {
244+
// if (item.bbox) {
245+
// map.fitBounds([
246+
// [item.bbox.south, item.bbox.west],
247+
// [item.bbox.north, item.bbox.east]
248+
// ]);
249+
// } else {
250+
// map.setView([parseFloat(item.lat), parseFloat(item.lng)], 10);
251+
// }
252+
// map._container
253+
// .querySelector('.mapml-search-panel')
254+
// .classList.remove('mapml-search-panel-open');
255+
// });
256+
// container.appendChild(btn);
257+
// }
258+
// }
255259

256-
webMap.addEventListener('mapsuggestions', (e) => {
257-
e.preventDefault();
258-
const container = webMap._map._container.querySelector('.mapml-search-results');
259-
const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
260-
renderGeonamesOrgResults(container, allGeonames, webMap._map);
261-
});
260+
// webMap.addEventListener('mapsuggestions', (e) => {
261+
// e.preventDefault();
262+
// const container = webMap._map._container.querySelector('.mapml-search-results');
263+
// const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
264+
// renderGeonamesOrgResults(container, allGeonames, webMap._map);
265+
// });
262266

263-
webMap.addEventListener('mapsearch', (e) => {
264-
e.preventDefault();
265-
const container = webMap._map._container.querySelector('.mapml-search-results');
266-
const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
267-
renderGeonamesOrgResults(container, allGeonames, webMap._map);
268-
});
267+
// webMap.addEventListener('mapsearch', (e) => {
268+
// e.preventDefault();
269+
// const container = webMap._map._container.querySelector('.mapml-search-results');
270+
// const allGeonames = e.detail.responses.flatMap(r => (r.data && r.data.geonames) || []);
271+
// renderGeonamesOrgResults(container, allGeonames, webMap._map);
272+
// });
269273
});
270274
</script>
271275
</body>

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@
6666
"rollup": "^3.29.5",
6767
"serve-static": "^1.15.0"
6868
},
69-
"files": [
70-
"dist",
69+
"files": [
70+
"dist",
7171
"LICENSE.md",
7272
"README.md",
7373
"CONTRIBUTING.md"
74-
]
74+
]
7575
}

src/mapml/control/SearchButton.js

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export var SearchButton = Control.extend({
1111
},
1212
onAdd: function (map) {
1313
let locale = this._getLocale(map);
14+
this._locale = locale;
1415
let container = DomUtil.create('div', 'mapml-search-control leaflet-bar');
1516

1617
let button = DomUtil.create(
@@ -83,10 +84,39 @@ export var SearchButton = Control.extend({
8384
// Debounced input handler for suggestions
8485
this._debounceTimer = null;
8586
this._abortController = null;
87+
// Track IME composition to avoid searching on intermediate input
88+
this._isComposing = false;
89+
DomEvent.on(
90+
input,
91+
'compositionstart',
92+
function () {
93+
this._isComposing = true;
94+
},
95+
this
96+
);
97+
DomEvent.on(
98+
input,
99+
'compositionend',
100+
function () {
101+
this._isComposing = false;
102+
// Trigger search after composition ends
103+
let query = this._input.value.trim();
104+
if (query.length < 2) {
105+
this._results.innerHTML = '';
106+
return;
107+
}
108+
if (this._debounceTimer) clearTimeout(this._debounceTimer);
109+
this._debounceTimer = setTimeout(() => {
110+
this._fetchSuggestions(query);
111+
}, 300);
112+
},
113+
this
114+
);
86115
DomEvent.on(
87116
input,
88117
'input',
89118
function () {
119+
if (this._isComposing) return;
90120
if (this._debounceTimer) clearTimeout(this._debounceTimer);
91121
let query = this._input.value.trim();
92122
if (query.length < 2) {
@@ -111,8 +141,11 @@ export var SearchButton = Control.extend({
111141
panel
112142
);
113143
closeBtn.setAttribute('type', 'button');
114-
closeBtn.setAttribute('aria-label', locale.btnClose || 'Close');
115-
closeBtn.title = locale.btnClose || 'Close';
144+
closeBtn.setAttribute(
145+
'aria-label',
146+
locale.btnSearchClose || 'Close search'
147+
);
148+
closeBtn.title = locale.btnSearchClose || 'Close search';
116149
closeBtn.innerHTML =
117150
'<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 0 24 24" fill="currentColor">' +
118151
'<path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z"/>' +
@@ -358,10 +391,7 @@ export var SearchButton = Control.extend({
358391
let btn = document.createElement('button');
359392
btn.className = 'mapml-search-result';
360393
btn.setAttribute('type', 'button');
361-
btn.textContent =
362-
feature.properties.display_name ||
363-
feature.properties.name ||
364-
'Unnamed';
394+
btn.textContent = this._formatResultName(feature.properties);
365395
btn.addEventListener(
366396
'click',
367397
(
@@ -374,10 +404,36 @@ export var SearchButton = Control.extend({
374404
}
375405
},
376406

407+
_formatResultName: function (props) {
408+
if (!props) return this._locale?.searchResultWithNoName || 'Unnamed';
409+
if (props.display_name) return props.display_name;
410+
let parts = [props.name];
411+
// Build context from common geocoder properties (Photon, etc.)
412+
for (let key of ['city', 'county', 'state', 'country']) {
413+
if (props[key] && props[key] !== props.name) parts.push(props[key]);
414+
}
415+
return (
416+
parts.filter(Boolean).join(', ') ||
417+
this._locale?.searchResultWithNoName ||
418+
'Unnamed'
419+
);
420+
},
421+
377422
_selectResult: function (feature, layer) {
378423
let map = this._map;
379-
if (feature.bbox && feature.bbox.length === 4) {
380-
let [west, south, east, north] = feature.bbox;
424+
// Standard GeoJSON bbox
425+
let bbox = feature.bbox;
426+
// Photon stores extent in properties.extent [west, south, east, north]
427+
if (
428+
(!bbox || bbox.length !== 4) &&
429+
feature.properties &&
430+
feature.properties.extent &&
431+
feature.properties.extent.length === 4
432+
) {
433+
bbox = feature.properties.extent;
434+
}
435+
if (bbox && bbox.length === 4) {
436+
let [west, south, east, north] = bbox;
381437
map.fitBounds(latLngBounds([south, west], [north, east]));
382438
} else if (
383439
feature.geometry &&

test/e2e/core/searchI18n.html

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!DOCTYPE html>
2+
<html lang="ja">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Search i18n Tests</title>
7+
<script type="module" src="mapml.js"></script>
8+
</head>
9+
<body>
10+
<mapml-viewer
11+
data-testid="viewer"
12+
projection="OSMTILE"
13+
zoom="5"
14+
lat="35.68"
15+
lon="139.69"
16+
style="width: 600px; height: 400px;"
17+
controls
18+
controlslist="search"
19+
>
20+
<map-layer
21+
data-testid="search-layer"
22+
label="Japanese Search Layer"
23+
src="search-japanese.mapml"
24+
checked
25+
></map-layer>
26+
</mapml-viewer>
27+
</body>
28+
</html>

test/e2e/core/searchI18n.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test, expect, chromium } from '@playwright/test';
2+
3+
test.describe('Search i18n tests (Japanese)', () => {
4+
let page;
5+
let context;
6+
test.beforeAll(async () => {
7+
context = await chromium.launchPersistentContext('');
8+
page =
9+
context.pages().find((page) => page.url() === 'about:blank') ||
10+
(await context.newPage());
11+
await page.goto('searchI18n.html', { waitUntil: 'networkidle' });
12+
await page.waitForTimeout(1000);
13+
});
14+
test.afterAll(async () => {
15+
await context.close();
16+
});
17+
18+
test('Japanese query in suggestions displays CJK results', async () => {
19+
await page.click('.mapml-search-button');
20+
await page.waitForTimeout(400);
21+
// Type Japanese characters directly (simulates committed IME input)
22+
await page.fill('.mapml-search-input', '東京');
23+
// Wait for debounce + fetch
24+
await page.waitForSelector('.mapml-search-result', { timeout: 5000 });
25+
let texts = await page.$$eval('.mapml-search-result', (btns) =>
26+
btns.map((b) => b.textContent)
27+
);
28+
expect(texts[0]).toBe('東京都, 日本');
29+
expect(texts[1]).toBe('東京タワー, 東京都, 日本');
30+
expect(texts[2]).toBe('東京駅, 東京都, 日本');
31+
});
32+
33+
test('Japanese query via Enter performs search', async () => {
34+
await page.fill('.mapml-search-input', '東京');
35+
await page.press('.mapml-search-input', 'Enter');
36+
await page.waitForSelector('.mapml-search-result', { timeout: 5000 });
37+
await page.waitForTimeout(300);
38+
let texts = await page.$$eval('.mapml-search-result', (btns) =>
39+
btns.map((b) => b.textContent)
40+
);
41+
expect(texts).toEqual(['東京都, 日本']);
42+
});
43+
44+
test('Clicking Japanese result moves map to correct location', async () => {
45+
await page.click('.mapml-search-result:first-child');
46+
await page.waitForTimeout(500);
47+
let center = await page.$eval('[data-testid=viewer]', (viewer) => {
48+
let map = viewer._map;
49+
return { lat: map.getCenter().lat, lng: map.getCenter().lng };
50+
});
51+
// Tokyo bbox center is roughly (35.7, 139.4)
52+
expect(center.lat).toBeCloseTo(35.7, 0);
53+
expect(center.lng).toBeCloseTo(139.4, 0);
54+
});
55+
56+
test('Search query is properly URL-encoded for non-Latin characters', async () => {
57+
// Intercept the network request to verify encoding
58+
let requestUrl = '';
59+
page.on('request', (request) => {
60+
if (request.url().includes('/search/ja/suggestions')) {
61+
requestUrl = request.url();
62+
}
63+
});
64+
await page.click('.mapml-search-button');
65+
await page.waitForTimeout(400);
66+
await page.fill('.mapml-search-input', '東京');
67+
await page.waitForSelector('.mapml-search-result', { timeout: 5000 });
68+
// The query should be percent-encoded
69+
expect(requestUrl).toContain(encodeURIComponent('東京'));
70+
});
71+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<mapml- xmlns="http://www.w3.org/1999/xhtml">
2+
<map-head>
3+
<map-title>Japanese search layer</map-title>
4+
<map-link rel="suggestions" tref="/search/ja/suggestions?q={searchTerms}"></map-link>
5+
<map-link rel="search" tref="/search/ja/results?q={searchTerms}"></map-link>
6+
</map-head>
7+
<map-body>
8+
<map-extent units="OSMTILE" checked="checked">
9+
<map-input name="z" type="zoom" value="17" min="0" max="17"></map-input>
10+
<map-input name="y" type="location" units="tilematrix" axis="row" min="0" max="131071"></map-input>
11+
<map-input name="x" type="location" units="tilematrix" axis="column" min="0" max="131071"></map-input>
12+
</map-extent>
13+
</map-body>
14+
</mapml->

0 commit comments

Comments
 (0)