mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
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).
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "重置为完整地球视图",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user