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

+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>