fix(search): keep a missed location query in the field so it can be corrected (#1420)

A location search that found nothing or failed now keeps its query, the open field and focus (text selected), and shows a persistent role=alert message under the search row instead of a toast. Typing, a new search or closing the emptied field clears it. HAL-727.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-26 16:44:34 -07:00
1 parent 3d9f153bc0
commit 6152f9f311
17 files changed
+207 -10

No files matched your search

@@ -0,0 +1,4 @@
- Keep a location search that found nothing, or failed, in the field so it can
be corrected: the query stays, focused and selected, and a persistent
announced message under the search row says what to do next, instead of a
toast followed by a cleared and closed field (samehkhamis, #1420).
+8
View File
@@ -5268,6 +5268,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe.
A location search that finds nothing, or fails, leaves the field open with the
query still in it, focused and selected, so it can be corrected and resubmitted
without reopening search. The miss is shown under the field as a persistent
`role="alert"` message ("Location not found." or "Search failed." with what to
do next) referenced by the input's `aria-describedby`, with `aria-invalid` set;
it is not a toast. Typing, a new search, or closing the emptied field clears it.
A search that arrives still clears and closes the field.
For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds
before computing their midpoint, then wraps that midpoint back into ±180 degrees.
Malformed viewport bounds add no proximity bias. Nominatim omits a crossing
+1 -1
View File
@@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
});
+2
View File
@@ -310,6 +310,8 @@
"Copy failed": "No se pudo copiar",
"Location not found": "Ubicación no encontrada",
"Search failed": "No se pudo completar la búsqueda",
"Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.",
"Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.",
"Fly to a POI first": "Ve primero a un punto de interés",
"Clearing selected data layers": "Limpiando las capas de datos seleccionadas",
"Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas",
+2
View File
@@ -530,6 +530,8 @@
"Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса",
"Location not found": "Место не найдено",
"Search failed": "Не удалось выполнить поиск",
"Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.",
"Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.",
"MAP": "КАРТА",
"panel": "панель",
"Expand {name}": "Развернуть {name}",
+2
View File
@@ -1819,6 +1819,8 @@
"ORBIT": "环绕",
"Location not found": "未找到位置",
"Search failed": "搜索失败",
"Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重试。",
"Detailed outline unavailable": "无法获取详细轮廓",
"Fly to a POI first": "请先飞往一个 POI",
"Reset to full globe view": "重置为完整地球视图",
+1
View File
@@ -348,6 +348,7 @@ export class StyleManager extends ShellFacade {
_poiRow: this._poiRow,
_locationBarDivider: this._locationBarDivider,
_locationSearch: this._locationSearch,
_locationSearchError: this._locationSearchError,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
+6
View File
@@ -22,6 +22,7 @@ export class LocationControls {
onCity,
onPoi,
onSearch,
onSearchEdit,
onReset,
doc = document,
requestFrame = (callback) => requestAnimationFrame(callback),
@@ -74,6 +75,11 @@ export class LocationControls {
this.bind(elements.search, 'keydown', (event) => {
if (event.key === 'Enter') void onSearch(elements.search.value);
});
// Typing corrects a failed query; closing the field (which empties it) drops its error.
this.bind(elements.search, 'input', () => onSearchEdit?.());
this.bind(elements.search, 'blur', () => {
if (!elements.search.value) onSearchEdit?.();
});
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
}
+11
View File
@@ -86,6 +86,7 @@ function fixture() {
onCity: (id) => calls.push(['city', id]),
onPoi: (id, index) => calls.push(['poi', id, index]),
onSearch: (query) => calls.push(['search', query]),
onSearchEdit: () => calls.push(['edit']),
onReset: () => calls.push(['reset']),
doc,
requestFrame: (fn) => {
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
f.elements.resetButtons[1].fire('click');
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
test('typing clears a failed search error and closing the emptied field drops it too', () => {
const f = fixture();
f.elements.search.value = 'Pariss';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
f.elements.search.fire('input');
f.elements.search.value = '';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [['edit'], ['edit']]);
});
+29 -5
View File
@@ -80,19 +80,27 @@ export class LocationNavigation {
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started')
if (change.type === 'started') {
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._setLocationSearchError(null);
} else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing')
this._showToast(t('Location not found'));
else if (change.type === 'failed') this._showToast(t('Search failed'));
this._setLocationSearchError(
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
);
else if (change.type === 'failed')
this._setLocationSearchError(
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
);
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
this._settleLocationSearchUi(change.generation, {
keepQuery: state.status === 'missing' || state.status === 'failed',
});
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
@@ -101,6 +109,21 @@ export class LocationNavigation {
}
}
/** Show, or with null clear, the announced error under the search field. */
_setLocationSearchError(message) {
const input = this._locationSearch;
const error = this._locationSearchError;
if (error) error.textContent = message || '';
if (!input) return;
if (message) {
input.setAttribute('aria-invalid', 'true');
if (error?.id) input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
_initLocationBar() {
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
this._locationControls?.destroy();
@@ -144,6 +167,7 @@ export class LocationNavigation {
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onSearchEdit: () => this._setLocationSearchError(null),
onReset: () => this.resetToGlobeView(),
});
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { NavigationController } from './navigationController.js';
import { LocationNavigation } from './locationNavigation.js';
function input() {
const classes = new Set(['expanded', 'searching']);
const attributes = new Map();
return {
id: 'location-search',
value: 'Pariss',
focused: false,
selected: false,
classList: {
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
},
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
select() {
this.selected = true;
},
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) ?? null,
};
}
function settle(searchInput, options) {
const owner = { _activeLocationSearchGeneration: 4, searchInput };
NavigationController.prototype._settleLocationSearchUi.call(
owner,
4,
options,
);
return owner;
}
test('a search that found nothing keeps its query, the open field and focus', () => {
const field = input();
const owner = settle(field, { keepQuery: true });
assert.equal(owner._activeLocationSearchGeneration, null);
assert.equal(field.value, 'Pariss');
assert.equal(field.classList.contains('expanded'), true);
assert.equal(field.classList.contains('searching'), false);
assert.equal(field.focused, true);
assert.equal(field.selected, true);
});
test('a search that arrived still clears and closes the field', () => {
const field = input();
field.focused = true;
settle(field);
assert.equal(field.value, '');
assert.equal(field.classList.contains('expanded'), false);
assert.equal(field.focused, false);
});
function navigation() {
const field = input();
const error = { id: 'location-search-error', textContent: '' };
const settled = [];
const toasts = [];
const owner = {
_disposed: false,
_locationSearch: field,
_locationSearchError: error,
_activeLocationSearchGeneration: null,
_settleLocationSearchUi: (generation, options) =>
settled.push([generation, options]),
_showToast: (message) => toasts.push(message),
_setLocationSearchError:
LocationNavigation.prototype._setLocationSearchError,
};
const handle = (status, type) =>
LocationNavigation.prototype._handleLocationSearchState.call(
owner,
{ status, query: field.value },
{ type, generation: 7 },
);
return { owner, field, error, settled, toasts, handle };
}
for (const [status, text] of [
['missing', 'Location not found'],
['failed', 'Search failed'],
]) {
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
const n = navigation();
n.handle('searching', 'started');
n.handle(status, status);
n.handle(status, 'settled');
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
assert.equal(
n.field.getAttribute('aria-describedby'),
'location-search-error',
);
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
assert.deepEqual(n.toasts, []);
n.handle('searching', 'started');
assert.equal(n.error.textContent, '');
assert.equal(n.field.getAttribute('aria-invalid'), null);
assert.equal(n.field.getAttribute('aria-describedby'), null);
});
}
+9 -2
View File
@@ -119,10 +119,17 @@ export class NavigationController {
return this._navigationGeneration;
}
_settleLocationSearchUi(generation) {
/** A search that found nothing keeps its query and focus so it can be corrected. */
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
if (this._activeLocationSearchGeneration !== generation) return;
this._activeLocationSearchGeneration = null;
this.searchInput?.classList.remove('searching', 'expanded');
this.searchInput?.classList.remove('searching');
if (keepQuery) {
this.searchInput?.focus();
this.searchInput?.select?.();
return;
}
this.searchInput?.classList.remove('expanded');
if (this.searchInput) this.searchInput.value = '';
this.searchInput?.blur();
}
+1
View File
@@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) {
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_locationSearchError: document.getElementById('location-search-error'),
_searchToggle: document.getElementById('search-toggle'),
_locationPills: document.getElementById('location-pills'),
_poiRow: document.getElementById('poi-row'),
+1 -1
View File
@@ -550,7 +550,7 @@ export class ShellFacade {
}
/** Settle only the search generation that still owns the shared input UI. */
_settleLocationSearchUi(generation) {
_settleLocationSearchUi(generation, options) {
return this._navigation._settleLocationSearchUi(...arguments);
}
+17
View File
@@ -298,3 +298,20 @@
#location-search.searching {
opacity: 0.5;
}
.location-search-error {
/* Its own line under the search row, which clips overflow in the dock. */
margin: 6px 0 0;
padding: 6px 10px;
border: 1px solid rgba(255, 99, 99, 0.45);
border-radius: 6px;
background: rgba(30, 8, 10, 0.92);
color: #ffb3b3;
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.35;
}
.location-search-error:empty {
display: none;
}
+2 -1
View File
@@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#param-slider-panel.collapsed #param-sliders,
#location-bar.collapsed .poi-row-container,
#location-bar.collapsed .location-bar-divider,
#location-bar.collapsed .location-city-row {
#location-bar.collapsed .location-city-row,
#location-bar.collapsed .location-search-error {
display: none !important;
}
+1
View File
@@ -91,6 +91,7 @@
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
</div>
</div>
<p id="location-search-error" class="location-search-error" role="alert"></p>
</div>
</div>
</div>