mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(location): render the search field's open and searching state from the tray
LocationControls presents whether the search field is open and whether a lookup runs, and the dock renders its expanded and searching classes and calls toggleSearch(). The lookup, the navigation's settle, Escape and the location tray's collapse reach the field through the controls (setSearching, settleSearch, closeSearch) instead of writing its classes. LocationControls no longer takes searchToggle, LocationSearch takes setSearching and NavigationController takes settleSearch in place of searchInput.
This commit is contained in:
1 parent
b4ee2321cf
commit
0949f6a776
18 files changed
+238
-83
No files matched your search
+9
-2
@@ -113,8 +113,15 @@
|
|||||||
landmark pills or writes the mini status: it presents the tray, including
|
landmark pills or writes the mini status: it presents the tray, including
|
||||||
the search error (`setSearchError()`), and hands it to its `publish`
|
the search error (`setSearchError()`), and hands it to its `publish`
|
||||||
option, and Core's dock renders it and calls `chooseCity()` and
|
option, and Core's dock renders it and calls `chooseCity()` and
|
||||||
`choosePoi()`. Its `elements` are now only `search` and `searchToggle`:
|
`choosePoi()`. Its `elements` are now only `search`: `resetButtons` and
|
||||||
`resetButtons` is gone, and the reset buttons call its new `reset()`.
|
`searchToggle` are gone, and the reset buttons and the search toggle call
|
||||||
|
its new `reset()` and `toggleSearch()`. It presents the search field's
|
||||||
|
open and searching state too (`searchExpanded`, `searching`; a
|
||||||
|
`searchExpanded` option starts it open), set through `setSearching()`,
|
||||||
|
`settleSearch()` and `closeSearch()`, and a host renders the field's
|
||||||
|
`expanded` and `searching` classes from it; `LocationSearch`
|
||||||
|
takes a `setSearching` option in place of writing the field's class, and
|
||||||
|
`NavigationController` a `settleSearch` option in place of `searchInput`.
|
||||||
`createOrbitIndicator()` is replaced by
|
`createOrbitIndicator()` is replaced by
|
||||||
`showOrbit(active)`, which adds the ORBIT indicator to the presented tray
|
`showOrbit(active)`, which adds the ORBIT indicator to the presented tray
|
||||||
as `orbit: { active }` (null until offered) (samehkhamis).
|
as `orbit: { active }` (null until offered) (samehkhamis).
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
|
|||||||
'if (!query || this.destroyed) return;',
|
'if (!query || this.destroyed) return;',
|
||||||
'const authority = this.begin();',
|
'const authority = this.begin();',
|
||||||
'if (authority === false)',
|
'if (authority === false)',
|
||||||
'this.input.blur();',
|
'this.input?.blur();',
|
||||||
'this.onStart?.(authority);',
|
'this.onStart?.(authority);',
|
||||||
'await this.search(query',
|
'await this.search(query',
|
||||||
], 'disposed search refusal');
|
], 'disposed search refusal');
|
||||||
|
|||||||
@@ -151,17 +151,12 @@ test('a collapsed nested panel does not block the next Escape from closing its p
|
|||||||
|
|
||||||
test('Location Escape clears a hidden draft search before restoring disclosure focus', () => {
|
test('Location Escape clears a hidden draft search before restoring disclosure focus', () => {
|
||||||
const fixture = panelFixture({ id: 'location-bar' });
|
const fixture = panelFixture({ id: 'location-bar' });
|
||||||
const removed = [];
|
// The location controls close the field and drop its draft
|
||||||
let blurred = false;
|
// (src/ui/locationControls.test.mjs).
|
||||||
fixture.manager._locationSearch = {
|
let closed = 0;
|
||||||
classList: { remove: (...names) => removed.push(...names) },
|
fixture.manager._closeLocationSearch = () => { closed += 1; };
|
||||||
value: 'focus cleanup',
|
|
||||||
blur: () => { blurred = true; },
|
|
||||||
};
|
|
||||||
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true);
|
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true);
|
||||||
assert.deepEqual(removed, ['expanded']);
|
assert.equal(closed, 1);
|
||||||
assert.equal(fixture.manager._locationSearch.value, '');
|
|
||||||
assert.equal(blurred, true);
|
|
||||||
assert.equal(fixture.focused(), true);
|
assert.equal(fixture.focused(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -134,11 +134,11 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._panelChrome = new PanelChrome({
|
this._panelChrome = new PanelChrome({
|
||||||
elements: {
|
elements: {
|
||||||
_leftPanelStack: this._leftPanelStack,
|
_leftPanelStack: this._leftPanelStack,
|
||||||
_locationSearch: this._locationSearch,
|
|
||||||
_ppToggles: this._ppToggles,
|
_ppToggles: this._ppToggles,
|
||||||
_rightPanelStack: this._rightPanelStack,
|
_rightPanelStack: this._rightPanelStack,
|
||||||
},
|
},
|
||||||
operations: {
|
operations: {
|
||||||
|
_closeLocationSearch: () => this._locationNavigation?.closeSearch(),
|
||||||
_syncCctvPanelViewport: (...args) =>
|
_syncCctvPanelViewport: (...args) =>
|
||||||
this._syncCctvPanelViewport(...args),
|
this._syncCctvPanelViewport(...args),
|
||||||
_showToast: (...args) => this._showToast(...args),
|
_showToast: (...args) => this._showToast(...args),
|
||||||
@@ -171,7 +171,8 @@ export class StyleManager extends ShellFacade {
|
|||||||
militaryAwarenessLayer,
|
militaryAwarenessLayer,
|
||||||
rocketLaunchesLayer: services.rocketLaunchesLayer,
|
rocketLaunchesLayer: services.rocketLaunchesLayer,
|
||||||
},
|
},
|
||||||
searchInput: this._locationSearch,
|
settleSearch: (options) =>
|
||||||
|
this._locationNavigation?.settleSearch(options),
|
||||||
interruptCameraMotion: services.interruptCameraMotion,
|
interruptCameraMotion: services.interruptCameraMotion,
|
||||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||||
isCockpitActive: () => !!this.cockpitView?.active,
|
isCockpitActive: () => !!this.cockpitView?.active,
|
||||||
@@ -321,7 +322,6 @@ export class StyleManager extends ShellFacade {
|
|||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
_locationSearch: this._locationSearch,
|
_locationSearch: this._locationSearch,
|
||||||
_searchToggle: this._searchToggle,
|
|
||||||
},
|
},
|
||||||
operations: {
|
operations: {
|
||||||
_publishLocation: (view) =>
|
_publishLocation: (view) =>
|
||||||
@@ -528,6 +528,8 @@ export class StyleManager extends ShellFacade {
|
|||||||
_locationSearch: this._locationSearch,
|
_locationSearch: this._locationSearch,
|
||||||
},
|
},
|
||||||
operations: {
|
operations: {
|
||||||
|
_closeLocationSearch: (options) =>
|
||||||
|
this._locationNavigation?.closeSearch(options),
|
||||||
setStyle: (...args) => this.setStyle(...args),
|
setStyle: (...args) => this.setStyle(...args),
|
||||||
_updateHudButtonState: (...args) => this._updateHudButtonState(...args),
|
_updateHudButtonState: (...args) => this._updateHudButtonState(...args),
|
||||||
_syncShareState: (...args) => this._syncShareState(...args),
|
_syncShareState: (...args) => this._syncShareState(...args),
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ test('the ORBIT indicator sits on the body while offered and lights while orbiti
|
|||||||
const controls = new LocationControls({
|
const controls = new LocationControls({
|
||||||
elements: {
|
elements: {
|
||||||
search: $('#location-search'),
|
search: $('#location-search'),
|
||||||
searchToggle: $('#search-toggle'),
|
|
||||||
},
|
},
|
||||||
cities: {},
|
cities: {},
|
||||||
getExpandedCity: () => null,
|
getExpandedCity: () => null,
|
||||||
@@ -209,7 +208,6 @@ test('the location tray renders what LocationControls publishes', async (t) => {
|
|||||||
const controls = new LocationControls({
|
const controls = new LocationControls({
|
||||||
elements: {
|
elements: {
|
||||||
search: $('#location-search'),
|
search: $('#location-search'),
|
||||||
searchToggle: $('#search-toggle'),
|
|
||||||
},
|
},
|
||||||
cities: {
|
cities: {
|
||||||
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
|
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
|
||||||
@@ -455,3 +453,36 @@ test('closing a sheet returns focus to its tab once the tab bar shows it closed'
|
|||||||
);
|
);
|
||||||
assert.equal($('#mobile-sheet-backdrop').hidden, false);
|
assert.equal($('#mobile-sheet-backdrop').hidden, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the search toggle opens the field, and a lookup marks it', async (t) => {
|
||||||
|
const { $, pinia } = fixture(t);
|
||||||
|
const store = useLocationStore(pinia);
|
||||||
|
const controls = new LocationControls({
|
||||||
|
elements: { search: $('#location-search') },
|
||||||
|
cities: {},
|
||||||
|
getExpandedCity: () => null,
|
||||||
|
onCity() {},
|
||||||
|
onPoi() {},
|
||||||
|
onSearch() {},
|
||||||
|
onReset() {},
|
||||||
|
publish: (view) => store.publish(view),
|
||||||
|
requestFrame() {},
|
||||||
|
cancelFrame() {},
|
||||||
|
});
|
||||||
|
t.after(() => controls.destroy());
|
||||||
|
store.connect(controls);
|
||||||
|
await nextTick();
|
||||||
|
const field = $('#location-search');
|
||||||
|
assert.equal(field.classList.contains('expanded'), false);
|
||||||
|
$('#search-toggle').click();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(field.classList.contains('expanded'), true);
|
||||||
|
assert.equal(document.activeElement, field);
|
||||||
|
controls.setSearching(true);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(field.classList.contains('searching'), true);
|
||||||
|
controls.settleSearch();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(field.classList.contains('searching'), false);
|
||||||
|
assert.equal(field.classList.contains('expanded'), false);
|
||||||
|
});
|
||||||
@@ -55,13 +55,7 @@ export class DisplayBindings {
|
|||||||
searchInput: this._locationSearch,
|
searchInput: this._locationSearch,
|
||||||
actions: {
|
actions: {
|
||||||
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
||||||
dismissSearch: () => {
|
dismissSearch: () => this._closeLocationSearch?.({ ifOpen: true }),
|
||||||
if (this._locationSearch.classList.contains('expanded')) {
|
|
||||||
this._locationSearch.classList.remove('expanded');
|
|
||||||
this._locationSearch.value = '';
|
|
||||||
this._locationSearch.blur();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleHud: () => {
|
toggleHud: () => {
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
this.hud.toggle();
|
this.hud.toggle();
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
|
|||||||
* mini status, the search error and the ORBIT indicator are presented as a
|
* mini status, the search error and the ORBIT indicator are presented as a
|
||||||
* plain view and handed to `publish`; Core's dock renders them and calls
|
* plain view and handed to `publish`; Core's dock renders them and calls
|
||||||
* `chooseCity()` and `choosePoi()`, and the scene chrome shows the indicator.
|
* `chooseCity()` and `choosePoi()`, and the scene chrome shows the indicator.
|
||||||
* The globe actions and the cockpit call `reset()`. The search field keeps its
|
* The globe actions and the cockpit call `reset()`. The search field's open
|
||||||
* listeners.
|
* and searching state is presented too, and the dock's toggle calls
|
||||||
|
* `toggleSearch()`; the field keeps its own key, input and blur listeners.
|
||||||
*/
|
*/
|
||||||
export class LocationControls {
|
export class LocationControls {
|
||||||
constructor({
|
constructor({
|
||||||
@@ -32,6 +33,7 @@ export class LocationControls {
|
|||||||
onSearch,
|
onSearch,
|
||||||
onSearchEdit,
|
onSearchEdit,
|
||||||
onReset,
|
onReset,
|
||||||
|
searchExpanded = false,
|
||||||
publish = () => {},
|
publish = () => {},
|
||||||
doc = document,
|
doc = document,
|
||||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||||
@@ -63,6 +65,9 @@ export class LocationControls {
|
|||||||
poi: t('Landmark: --'),
|
poi: t('Landmark: --'),
|
||||||
};
|
};
|
||||||
this.searchError = null;
|
this.searchError = null;
|
||||||
|
// The search field: whether it is open, and whether a lookup runs.
|
||||||
|
this.searchExpanded = Boolean(searchExpanded);
|
||||||
|
this.searching = false;
|
||||||
// The ORBIT indicator: null until the shell offers it, then whether the
|
// The ORBIT indicator: null until the shell offers it, then whether the
|
||||||
// camera orbits.
|
// camera orbits.
|
||||||
this.orbit = null;
|
this.orbit = null;
|
||||||
@@ -78,11 +83,6 @@ export class LocationControls {
|
|||||||
if (index !== -1 && index < (cities[cityId]?.pois.length || 0))
|
if (index !== -1 && index < (cities[cityId]?.pois.length || 0))
|
||||||
onPoi(cityId, index);
|
onPoi(cityId, index);
|
||||||
});
|
});
|
||||||
this.bind(elements.searchToggle, 'click', () => {
|
|
||||||
elements.search.classList.toggle('expanded');
|
|
||||||
if (elements.search.classList.contains('expanded'))
|
|
||||||
elements.search.focus();
|
|
||||||
});
|
|
||||||
this.bind(elements.search, 'keydown', (event) => {
|
this.bind(elements.search, 'keydown', (event) => {
|
||||||
if (event.key === 'Enter') void onSearch(elements.search.value);
|
if (event.key === 'Enter') void onSearch(elements.search.value);
|
||||||
});
|
});
|
||||||
@@ -124,6 +124,8 @@ export class LocationControls {
|
|||||||
poisExpanded: this.poisExpanded,
|
poisExpanded: this.poisExpanded,
|
||||||
status: { ...this.status },
|
status: { ...this.status },
|
||||||
searchError: this.searchError,
|
searchError: this.searchError,
|
||||||
|
searchExpanded: this.searchExpanded,
|
||||||
|
searching: this.searching,
|
||||||
orbit: this.orbit && { ...this.orbit },
|
orbit: this.orbit && { ...this.orbit },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -208,6 +210,43 @@ export class LocationControls {
|
|||||||
this.searchError = message || null;
|
this.searchError = message || null;
|
||||||
this.present();
|
this.present();
|
||||||
}
|
}
|
||||||
|
/** The search toggle was clicked: open the field and focus it, or close it. */
|
||||||
|
toggleSearch() {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.searchExpanded = !this.searchExpanded;
|
||||||
|
this.present();
|
||||||
|
if (this.searchExpanded) this.elements.search?.focus();
|
||||||
|
}
|
||||||
|
/** Mark the search field while a lookup runs. */
|
||||||
|
setSearching(active) {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.searching = Boolean(active);
|
||||||
|
this.present();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A lookup settled. One that found nothing keeps its query, the open field
|
||||||
|
* and focus so it can be corrected; any other clears and closes the field.
|
||||||
|
*/
|
||||||
|
settleSearch({ keepQuery = false } = {}) {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.searching = false;
|
||||||
|
if (!keepQuery) {
|
||||||
|
this.closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.present();
|
||||||
|
this.elements.search?.focus();
|
||||||
|
this.elements.search?.select?.();
|
||||||
|
}
|
||||||
|
/** Close the search field and drop its query. */
|
||||||
|
closeSearch() {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.searchExpanded = false;
|
||||||
|
this.present();
|
||||||
|
const field = this.elements.search;
|
||||||
|
if (field) field.value = '';
|
||||||
|
field?.blur?.();
|
||||||
|
}
|
||||||
/** Offer the ORBIT indicator, lit while the camera orbits. */
|
/** Offer the ORBIT indicator, lit while the camera orbits. */
|
||||||
showOrbit(active = false) {
|
showOrbit(active = false) {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
|
|||||||
@@ -57,10 +57,9 @@ function node() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function fixture() {
|
function fixture(options = {}) {
|
||||||
const elements = {
|
const elements = {
|
||||||
search: node(),
|
search: node(),
|
||||||
searchToggle: node(),
|
|
||||||
};
|
};
|
||||||
const doc = node();
|
const doc = node();
|
||||||
doc.createElement = node;
|
doc.createElement = node;
|
||||||
@@ -90,6 +89,7 @@ function fixture() {
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
cancelFrame: (id) => cancelled.push(id),
|
cancelFrame: (id) => cancelled.push(id),
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
const view = () => views.at(-1);
|
const view = () => views.at(-1);
|
||||||
return { elements, doc, controls, calls, frames, cancelled, views, view };
|
return { elements, doc, controls, calls, frames, cancelled, views, view };
|
||||||
@@ -106,6 +106,8 @@ test('the tray presents its cities, empty status and no landmarks at first', ()
|
|||||||
poisExpanded: false,
|
poisExpanded: false,
|
||||||
status: { city: '📍 Location: --', poi: 'Landmark: --' },
|
status: { city: '📍 Location: --', poi: 'Landmark: --' },
|
||||||
searchError: null,
|
searchError: null,
|
||||||
|
searchExpanded: false,
|
||||||
|
searching: false,
|
||||||
orbit: null,
|
orbit: null,
|
||||||
});
|
});
|
||||||
f.controls.highlightCity('b');
|
f.controls.highlightCity('b');
|
||||||
@@ -199,3 +201,66 @@ test('typing clears a failed search error and closing the emptied field drops it
|
|||||||
f.elements.search.fire('blur');
|
f.elements.search.fire('blur');
|
||||||
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
||||||
});
|
});
|
||||||
|
test('the search field opens and closes through the toggle and marks a lookup', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const field = f.elements.search;
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
assert.equal(f.view().searchExpanded, true);
|
||||||
|
assert.equal(field.focused, true);
|
||||||
|
f.controls.setSearching(true);
|
||||||
|
assert.equal(f.view().searching, true);
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
assert.equal(f.view().searchExpanded, false);
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
field.value = 'Paris';
|
||||||
|
f.controls.closeSearch();
|
||||||
|
assert.equal(f.view().searchExpanded, false);
|
||||||
|
assert.equal(field.value, '');
|
||||||
|
});
|
||||||
|
test('a lookup that found nothing keeps its query, the open field and focus', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const field = f.elements.search;
|
||||||
|
let selected = false;
|
||||||
|
field.select = () => {
|
||||||
|
selected = true;
|
||||||
|
};
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
f.controls.setSearching(true);
|
||||||
|
field.value = 'Pariss';
|
||||||
|
field.focused = false;
|
||||||
|
f.controls.settleSearch({ keepQuery: true });
|
||||||
|
assert.equal(f.view().searching, false);
|
||||||
|
assert.equal(f.view().searchExpanded, true);
|
||||||
|
assert.equal(field.value, 'Pariss');
|
||||||
|
assert.equal(field.focused, true);
|
||||||
|
assert.equal(selected, true);
|
||||||
|
});
|
||||||
|
test('a lookup that arrived clears and closes the field', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const field = f.elements.search;
|
||||||
|
let blurred = false;
|
||||||
|
field.blur = () => {
|
||||||
|
blurred = true;
|
||||||
|
};
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
f.controls.setSearching(true);
|
||||||
|
field.value = 'Paris';
|
||||||
|
f.controls.settleSearch();
|
||||||
|
assert.equal(f.view().searching, false);
|
||||||
|
assert.equal(f.view().searchExpanded, false);
|
||||||
|
assert.equal(field.value, '');
|
||||||
|
assert.equal(blurred, true);
|
||||||
|
// Destroyed controls change nothing.
|
||||||
|
f.controls.destroy();
|
||||||
|
const after = f.views.length;
|
||||||
|
f.controls.toggleSearch();
|
||||||
|
f.controls.setSearching(true);
|
||||||
|
f.controls.settleSearch();
|
||||||
|
f.controls.closeSearch();
|
||||||
|
assert.equal(f.views.length, after);
|
||||||
|
});
|
||||||
|
test('controls that replace others can start with the field open', () => {
|
||||||
|
const f = fixture({ searchExpanded: true });
|
||||||
|
assert.equal(f.controls.presentation().searchExpanded, true);
|
||||||
|
assert.equal(f.elements.search.focused, undefined);
|
||||||
|
});
|
||||||
@@ -115,11 +115,14 @@ export class LocationNavigation {
|
|||||||
|
|
||||||
_initLocationBar() {
|
_initLocationBar() {
|
||||||
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||||
|
// An open field stays open in the new controls.
|
||||||
|
const searchExpanded = Boolean(this._locationControls?.searchExpanded);
|
||||||
this._locationControls?.destroy();
|
this._locationControls?.destroy();
|
||||||
this._locationLookupUnsubscribe?.();
|
this._locationLookupUnsubscribe?.();
|
||||||
this._locationLookup?.destroy();
|
this._locationLookup?.destroy();
|
||||||
this._locationLookup = new LocationSearch({
|
this._locationLookup = new LocationSearch({
|
||||||
input: this._locationSearch,
|
input: this._locationSearch,
|
||||||
|
setSearching: (active) => this._locationControls?.setSearching(active),
|
||||||
begin: () => this._beginDeferredNavigation('location'),
|
begin: () => this._beginDeferredNavigation('location'),
|
||||||
isCurrent: (generation) =>
|
isCurrent: (generation) =>
|
||||||
!this._disposed && generation === this._navigationGeneration,
|
!this._disposed && generation === this._navigationGeneration,
|
||||||
@@ -135,15 +138,9 @@ export class LocationNavigation {
|
|||||||
},
|
},
|
||||||
onError: (error) => console.error('[Search] Geocoding failed:', error),
|
onError: (error) => console.error('[Search] Geocoding failed:', error),
|
||||||
});
|
});
|
||||||
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
|
|
||||||
({ initial, change }) => {
|
|
||||||
this._locationState.publish(initial ? { type: 'reset' } : change);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this._locationControls = new LocationControls({
|
this._locationControls = new LocationControls({
|
||||||
elements: {
|
elements: {
|
||||||
search: this._locationSearch,
|
search: this._locationSearch,
|
||||||
searchToggle: this._searchToggle,
|
|
||||||
},
|
},
|
||||||
publish: (view) => this._publishLocation?.(view),
|
publish: (view) => this._publishLocation?.(view),
|
||||||
cities: CITY_POIS,
|
cities: CITY_POIS,
|
||||||
@@ -153,7 +150,15 @@ export class LocationNavigation {
|
|||||||
onSearch: (query) => this._locationLookup.run(query),
|
onSearch: (query) => this._locationLookup.run(query),
|
||||||
onSearchEdit: () => this._setLocationSearchError(null),
|
onSearchEdit: () => this._setLocationSearchError(null),
|
||||||
onReset: () => this.resetToGlobeView(),
|
onReset: () => this.resetToGlobeView(),
|
||||||
|
searchExpanded,
|
||||||
});
|
});
|
||||||
|
// The new lookup's reset settles a replaced lookup's search through the
|
||||||
|
// new controls, so it subscribes once they are in place.
|
||||||
|
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
|
||||||
|
({ initial, change }) => {
|
||||||
|
this._locationState.publish(initial ? { type: 'reset' } : change);
|
||||||
|
},
|
||||||
|
);
|
||||||
this._connectLocation?.(this._locationControls);
|
this._connectLocation?.(this._locationControls);
|
||||||
this._connectGlobeReset?.(this._locationControls);
|
this._connectGlobeReset?.(this._locationControls);
|
||||||
}
|
}
|
||||||
@@ -412,6 +417,18 @@ export class LocationNavigation {
|
|||||||
return resetPromise;
|
return resetPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settle the search field once its lookup has (LocationControls.settleSearch). */
|
||||||
|
settleSearch(options) {
|
||||||
|
this._locationControls?.settleSearch(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the search field; with `ifOpen`, only when it is open. */
|
||||||
|
closeSearch({ ifOpen = false } = {}) {
|
||||||
|
const controls = this._locationControls;
|
||||||
|
if (ifOpen && !controls?.searchExpanded) return;
|
||||||
|
controls?.closeSearch();
|
||||||
|
}
|
||||||
|
|
||||||
/** Revoke callbacks and settle owned camera work before the viewer is released. */
|
/** Revoke callbacks and settle owned camera work before the viewer is released. */
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this._disposed) return;
|
if (this._disposed) return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createStateChannel } from '../app/stateChannel.js';
|
|||||||
export class LocationSearch {
|
export class LocationSearch {
|
||||||
constructor({
|
constructor({
|
||||||
input,
|
input,
|
||||||
|
setSearching = () => {},
|
||||||
begin,
|
begin,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
beforeFly,
|
beforeFly,
|
||||||
@@ -16,6 +17,7 @@ export class LocationSearch {
|
|||||||
}) {
|
}) {
|
||||||
Object.assign(this, {
|
Object.assign(this, {
|
||||||
input,
|
input,
|
||||||
|
setSearching,
|
||||||
begin,
|
begin,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
beforeFly,
|
beforeFly,
|
||||||
@@ -51,8 +53,8 @@ export class LocationSearch {
|
|||||||
if (!query || this.destroyed) return;
|
if (!query || this.destroyed) return;
|
||||||
const authority = this.begin();
|
const authority = this.begin();
|
||||||
if (authority === false) {
|
if (authority === false) {
|
||||||
this.input.classList.remove('searching');
|
this.setSearching(false);
|
||||||
this.input.blur();
|
this.input?.blur();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.controller?.abort();
|
this.controller?.abort();
|
||||||
@@ -81,7 +83,7 @@ export class LocationSearch {
|
|||||||
try {
|
try {
|
||||||
this.onStart?.(authority);
|
this.onStart?.(authority);
|
||||||
if (!current() || controller.signal.aborted) return;
|
if (!current() || controller.signal.aborted) return;
|
||||||
this.input.classList.add('searching');
|
this.setSearching(true);
|
||||||
this.channel.publish(change('started'));
|
this.channel.publish(change('started'));
|
||||||
if (!current() || controller.signal.aborted) return;
|
if (!current() || controller.signal.aborted) return;
|
||||||
const destination = await this.search(query, {
|
const destination = await this.search(query, {
|
||||||
|
|||||||
@@ -30,34 +30,32 @@ function input() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function settle(searchInput, options) {
|
function settle(options) {
|
||||||
const owner = { _activeLocationSearchGeneration: 4, searchInput };
|
const asked = [];
|
||||||
|
const owner = {
|
||||||
|
_activeLocationSearchGeneration: 4,
|
||||||
|
settleSearch: (settled) => asked.push(settled),
|
||||||
|
};
|
||||||
NavigationController.prototype._settleLocationSearchUi.call(
|
NavigationController.prototype._settleLocationSearchUi.call(
|
||||||
owner,
|
owner,
|
||||||
4,
|
4,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
return owner;
|
return { owner, asked };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a search that found nothing keeps its query, the open field and focus', () => {
|
// The location controls keep or clear the field (src/ui/locationControls.test.mjs).
|
||||||
const field = input();
|
test('a search that found nothing asks the field to keep its query', () => {
|
||||||
const owner = settle(field, { keepQuery: true });
|
const { owner, asked } = settle({ keepQuery: true });
|
||||||
assert.equal(owner._activeLocationSearchGeneration, null);
|
assert.equal(owner._activeLocationSearchGeneration, null);
|
||||||
assert.equal(field.value, 'Pariss');
|
assert.deepEqual(asked, [{ keepQuery: true }]);
|
||||||
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', () => {
|
test('a search that arrived asks the field to clear, once', () => {
|
||||||
const field = input();
|
const { owner, asked } = settle();
|
||||||
field.focused = true;
|
assert.deepEqual(asked, [{ keepQuery: false }]);
|
||||||
settle(field);
|
NavigationController.prototype._settleLocationSearchUi.call(owner, 4);
|
||||||
assert.equal(field.value, '');
|
assert.deepEqual(asked, [{ keepQuery: false }]);
|
||||||
assert.equal(field.classList.contains('expanded'), false);
|
|
||||||
assert.equal(field.focused, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function navigation() {
|
function navigation() {
|
||||||
@@ -103,3 +101,21 @@ for (const [status, text] of [
|
|||||||
assert.equal(n.errors.at(-1), null);
|
assert.equal(n.errors.at(-1), null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Escape closes the search field only while it is open; panel collapse always', () => {
|
||||||
|
let closed = 0;
|
||||||
|
const owner = {
|
||||||
|
_locationControls: {
|
||||||
|
searchExpanded: false,
|
||||||
|
closeSearch: () => (closed += 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
LocationNavigation.prototype.closeSearch.call(owner, { ifOpen: true });
|
||||||
|
assert.equal(closed, 0);
|
||||||
|
owner._locationControls.searchExpanded = true;
|
||||||
|
LocationNavigation.prototype.closeSearch.call(owner, { ifOpen: true });
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
owner._locationControls.searchExpanded = false;
|
||||||
|
LocationNavigation.prototype.closeSearch.call(owner);
|
||||||
|
assert.equal(closed, 2);
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ export class NavigationController {
|
|||||||
constructor({
|
constructor({
|
||||||
viewer,
|
viewer,
|
||||||
tracking,
|
tracking,
|
||||||
searchInput,
|
settleSearch = () => {},
|
||||||
interruptCameraMotion,
|
interruptCameraMotion,
|
||||||
interruptGlobeRotation = () => {},
|
interruptGlobeRotation = () => {},
|
||||||
isCockpitActive,
|
isCockpitActive,
|
||||||
@@ -26,7 +26,7 @@ export class NavigationController {
|
|||||||
Object.assign(this, {
|
Object.assign(this, {
|
||||||
viewer,
|
viewer,
|
||||||
tracking,
|
tracking,
|
||||||
searchInput,
|
settleSearch,
|
||||||
interruptCameraMotion,
|
interruptCameraMotion,
|
||||||
interruptGlobeRotation,
|
interruptGlobeRotation,
|
||||||
isCockpitActive,
|
isCockpitActive,
|
||||||
@@ -123,15 +123,7 @@ export class NavigationController {
|
|||||||
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
|
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
|
||||||
if (this._activeLocationSearchGeneration !== generation) return;
|
if (this._activeLocationSearchGeneration !== generation) return;
|
||||||
this._activeLocationSearchGeneration = null;
|
this._activeLocationSearchGeneration = null;
|
||||||
this.searchInput?.classList.remove('searching');
|
this.settleSearch({ keepQuery });
|
||||||
if (keepQuery) {
|
|
||||||
this.searchInput?.focus();
|
|
||||||
this.searchInput?.select?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searchInput?.classList.remove('expanded');
|
|
||||||
if (this.searchInput) this.searchInput.value = '';
|
|
||||||
this.searchInput?.blur();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_releaseFollowCamera({
|
_releaseFollowCamera({
|
||||||
|
|||||||
@@ -176,10 +176,7 @@ export class PanelChrome {
|
|||||||
onChange: (collapsed, options) =>
|
onChange: (collapsed, options) =>
|
||||||
this.setPanelCollapsed(panelId, collapsed, options),
|
this.setPanelCollapsed(panelId, collapsed, options),
|
||||||
beforeCollapse: () => {
|
beforeCollapse: () => {
|
||||||
if (panelId !== 'location-bar' || !this._locationSearch) return;
|
if (panelId === 'location-bar') this._closeLocationSearch?.();
|
||||||
this._locationSearch.classList.remove('expanded');
|
|
||||||
this._locationSearch.value = '';
|
|
||||||
this._locationSearch.blur();
|
|
||||||
},
|
},
|
||||||
// A desktop side panel hides entirely once collapsed; its rail item
|
// A desktop side panel hides entirely once collapsed; its rail item
|
||||||
// takes the focus its own disclosure can no longer hold.
|
// takes the focus its own disclosure can no longer hold.
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ test('ru: location pills and the mini-status show the translated city', () => {
|
|||||||
setLocale('ru');
|
setLocale('ru');
|
||||||
const elements = {
|
const elements = {
|
||||||
search: element(),
|
search: element(),
|
||||||
searchToggle: element(),
|
|
||||||
};
|
};
|
||||||
const doc = { ...element(), createElement: element };
|
const doc = { ...element(), createElement: element };
|
||||||
const cities = { tokyo: CITY_POIS.tokyo };
|
const cities = { tokyo: CITY_POIS.tokyo };
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function readShellElements(document = globalThis.document) {
|
|||||||
_cctvMeta: document.getElementById('cctv-meta'),
|
_cctvMeta: document.getElementById('cctv-meta'),
|
||||||
_cctvSummary: document.getElementById('cctv-summary'),
|
_cctvSummary: document.getElementById('cctv-summary'),
|
||||||
_locationSearch: document.getElementById('location-search'),
|
_locationSearch: document.getElementById('location-search'),
|
||||||
_searchToggle: document.getElementById('search-toggle'),
|
|
||||||
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
|
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
|
||||||
_safeFrameBox: document.getElementById('safe-frame-box'),
|
_safeFrameBox: document.getElementById('safe-frame-box'),
|
||||||
_detectionBtn: document.getElementById('detection-toggle'),
|
_detectionBtn: document.getElementById('detection-toggle'),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function navigation() {
|
|||||||
return new NavigationController({
|
return new NavigationController({
|
||||||
viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } },
|
viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } },
|
||||||
tracking,
|
tracking,
|
||||||
searchInput: null,
|
|
||||||
interruptCameraMotion() {},
|
interruptCameraMotion() {},
|
||||||
isCockpitActive: () => false,
|
isCockpitActive: () => false,
|
||||||
clearLocation() {},
|
clearLocation() {},
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
/**
|
/**
|
||||||
* The dock's location tray as its view renders it. LocationControls
|
* The dock's location tray as its view renders it. LocationControls
|
||||||
* presents the city pills, the landmark row, the mini status, the search
|
* presents the city pills, the landmark row, the mini status, the search
|
||||||
* error and the ORBIT indicator and publishes them here; it keeps the search
|
* error, the search field's open and searching state and the ORBIT indicator
|
||||||
* field's listeners.
|
* and publishes them here; it keeps the search field's key, input and blur
|
||||||
|
* listeners.
|
||||||
*/
|
*/
|
||||||
export const useLocationStore = defineStore('gev-location', () => {
|
export const useLocationStore = defineStore('gev-location', () => {
|
||||||
/** The presented tray; null until the controls first render. */
|
/** The presented tray; null until the controls first render. */
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ function selectMapSource(chip) {
|
|||||||
<div class="location-city-row">
|
<div class="location-city-row">
|
||||||
<div id="location-pills" class="location-pills"><UiButton v-for="city in loc?.cities" :key="city.id" class="location-pill" :pressed="Boolean(city.active)" pressed-class="active" :data-location-id="city.id" @click="locationControls?.chooseCity(city.id)">{{ city.label }}</UiButton></div>
|
<div id="location-pills" class="location-pills"><UiButton v-for="city in loc?.cities" :key="city.id" class="location-pill" :pressed="Boolean(city.active)" pressed-class="active" :data-location-id="city.id" @click="locationControls?.chooseCity(city.id)">{{ city.label }}</UiButton></div>
|
||||||
<div class="location-search-wrap">
|
<div class="location-search-wrap">
|
||||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title>
|
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title @click="locationControls?.toggleSearch()">
|
||||||
<span>🔎</span>
|
<span>🔎</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" :aria-invalid="loc?.searchError ? 'true' : null" :aria-describedby="loc?.searchError ? 'location-search-error' : null" data-i18n-aria-label data-i18n-placeholder />
|
<input type="text" id="location-search" v-class-toggle="{ expanded: !!loc?.searchExpanded, searching: !!loc?.searching }" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" :aria-invalid="loc?.searchError ? 'true' : null" :aria-describedby="loc?.searchError ? 'location-search-error' : null" data-i18n-aria-label data-i18n-placeholder />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="location-search-error" class="location-search-error" role="alert">{{ loc?.searchError }}</p>
|
<p id="location-search-error" class="location-search-error" role="alert">{{ loc?.searchError }}</p>
|
||||||
|
|||||||
Reference in new issue
Block a user