From 6152f9f3110a91d2198d2d3fd642b51855b91561 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:44:34 -0700 Subject: [PATCH] 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. --- .../1420-keep-missed-location-query.md | 4 + docs/CURRENT-STATE.md | 8 ++ src/cameraHandoff.test.mjs | 2 +- src/i18n/locales/es.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/ui/applicationShell.js | 1 + src/ui/locationControls.js | 6 + src/ui/locationControls.test.mjs | 11 ++ src/ui/locationNavigation.js | 34 +++++- src/ui/locationSearchRecovery.test.mjs | 110 ++++++++++++++++++ src/ui/navigationController.js | 11 +- src/ui/shellElements.js | 1 + src/ui/shellFacade.js | 2 +- src/ui/styles/location.css | 17 +++ src/ui/styles/scenes.css | 3 +- src/ui/templates/command-dock.html | 1 + 17 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1420-keep-missed-location-query.md create mode 100644 src/ui/locationSearchRecovery.test.mjs diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md new file mode 100644 index 000000000..233abf495 --- /dev/null +++ b/changelog.d/1420-keep-missed-location-query.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be5972034..0cb46940c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 1fe30ff06..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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/); }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cfa47a69..0285917b0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b3bdd165f..b1c1be229 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a44973167..9bf76700c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "重置为完整地球视图", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e3ed24867..4b999a75f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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, diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index f2b67c7db..27363c1ac 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -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); } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 928e48554..33f5eaa7f 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -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']]); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index af8fb95b8..d0664509f 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -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(), }); } diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs new file mode 100644 index 000000000..19a310203 --- /dev/null +++ b/src/ui/locationSearchRecovery.test.mjs @@ -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); + }); +} diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index e2a135142..3c0bc7684 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -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(); } diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9134b9045..df7fcbc16 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -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'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 2c1861b31..01962f8f9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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); } diff --git a/src/ui/styles/location.css b/src/ui/styles/location.css index 281e4b08f..5c10f20b5 100644 --- a/src/ui/styles/location.css +++ b/src/ui/styles/location.css @@ -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; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index b048c5bd2..29250a636 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -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; } diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index e6e2735ea..3dac977ae 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -91,6 +91,7 @@ +