diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5f0e6d4..17abe6f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,8 +113,15 @@ landmark pills or writes the mini status: it presents the tray, including the search error (`setSearchError()`), and hands it to its `publish` option, and Core's dock renders it and calls `chooseCity()` and - `choosePoi()`. Its `elements` are now only `search` and `searchToggle`: - `resetButtons` is gone, and the reset buttons call its new `reset()`. + `choosePoi()`. Its `elements` are now only `search`: `resetButtons` and + `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 `showOrbit(active)`, which adds the ORBIT indicator to the presented tray as `orbit: { active }` (null until offered) (samehkhamis). diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 15a316366..2c0d356b2 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -353,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => { 'if (!query || this.destroyed) return;', 'const authority = this.begin();', 'if (authority === false)', - 'this.input.blur();', + 'this.input?.blur();', 'this.onStart?.(authority);', 'await this.search(query', ], 'disposed search refusal'); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 75eba753d..1177804d0 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -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', () => { const fixture = panelFixture({ id: 'location-bar' }); - const removed = []; - let blurred = false; - fixture.manager._locationSearch = { - classList: { remove: (...names) => removed.push(...names) }, - value: 'focus cleanup', - blur: () => { blurred = true; }, - }; + // The location controls close the field and drop its draft + // (src/ui/locationControls.test.mjs). + let closed = 0; + fixture.manager._closeLocationSearch = () => { closed += 1; }; assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true); - assert.deepEqual(removed, ['expanded']); - assert.equal(fixture.manager._locationSearch.value, ''); - assert.equal(blurred, true); + assert.equal(closed, 1); assert.equal(fixture.focused(), true); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index eed23487c..1fc2c1f05 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -134,11 +134,11 @@ export class StyleManager extends ShellFacade { this._panelChrome = new PanelChrome({ elements: { _leftPanelStack: this._leftPanelStack, - _locationSearch: this._locationSearch, _ppToggles: this._ppToggles, _rightPanelStack: this._rightPanelStack, }, operations: { + _closeLocationSearch: () => this._locationNavigation?.closeSearch(), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), _showToast: (...args) => this._showToast(...args), @@ -171,7 +171,8 @@ export class StyleManager extends ShellFacade { militaryAwarenessLayer, rocketLaunchesLayer: services.rocketLaunchesLayer, }, - searchInput: this._locationSearch, + settleSearch: (options) => + this._locationNavigation?.settleSearch(options), interruptCameraMotion: services.interruptCameraMotion, interruptGlobeRotation: services.interruptGlobeRotation, isCockpitActive: () => !!this.cockpitView?.active, @@ -321,7 +322,6 @@ export class StyleManager extends ShellFacade { }, elements: { _locationSearch: this._locationSearch, - _searchToggle: this._searchToggle, }, operations: { _publishLocation: (view) => @@ -528,6 +528,8 @@ export class StyleManager extends ShellFacade { _locationSearch: this._locationSearch, }, operations: { + _closeLocationSearch: (options) => + this._locationNavigation?.closeSearch(options), setStyle: (...args) => this.setStyle(...args), _updateHudButtonState: (...args) => this._updateHudButtonState(...args), _syncShareState: (...args) => this._syncShareState(...args), diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index 742cea1e6..c70007a17 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -168,7 +168,6 @@ test('the ORBIT indicator sits on the body while offered and lights while orbiti const controls = new LocationControls({ elements: { search: $('#location-search'), - searchToggle: $('#search-toggle'), }, cities: {}, getExpandedCity: () => null, @@ -209,7 +208,6 @@ test('the location tray renders what LocationControls publishes', async (t) => { const controls = new LocationControls({ elements: { search: $('#location-search'), - searchToggle: $('#search-toggle'), }, cities: { 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); }); + +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); +}); diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index 7c67f65c4..73c4fbee6 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -55,13 +55,7 @@ export class DisplayBindings { searchInput: this._locationSearch, actions: { setStyle: (style) => this.setStyle(style, { userInitiated: true }), - dismissSearch: () => { - if (this._locationSearch.classList.contains('expanded')) { - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); - } - }, + dismissSearch: () => this._closeLocationSearch?.({ ifOpen: true }), toggleHud: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this.hud.toggle(); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 8977c6fc2..5b5f06d5e 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -19,8 +19,9 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze( * 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 * `chooseCity()` and `choosePoi()`, and the scene chrome shows the indicator. - * The globe actions and the cockpit call `reset()`. The search field keeps its - * listeners. + * The globe actions and the cockpit call `reset()`. The search field's open + * 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 { constructor({ @@ -32,6 +33,7 @@ export class LocationControls { onSearch, onSearchEdit, onReset, + searchExpanded = false, publish = () => {}, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), @@ -63,6 +65,9 @@ export class LocationControls { poi: t('Landmark: --'), }; 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 // camera orbits. this.orbit = null; @@ -78,11 +83,6 @@ export class LocationControls { if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) 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) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); @@ -124,6 +124,8 @@ export class LocationControls { poisExpanded: this.poisExpanded, status: { ...this.status }, searchError: this.searchError, + searchExpanded: this.searchExpanded, + searching: this.searching, orbit: this.orbit && { ...this.orbit }, }; } @@ -208,6 +210,43 @@ export class LocationControls { this.searchError = message || null; 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. */ showOrbit(active = false) { if (this.destroyed) return; diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index b62478fc8..ccae6454b 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -57,10 +57,9 @@ function node() { }, }; } -function fixture() { +function fixture(options = {}) { const elements = { search: node(), - searchToggle: node(), }; const doc = node(); doc.createElement = node; @@ -90,6 +89,7 @@ function fixture() { return id; }, cancelFrame: (id) => cancelled.push(id), + ...options, }); const view = () => views.at(-1); 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, status: { city: '📍 Location: --', poi: 'Landmark: --' }, searchError: null, + searchExpanded: false, + searching: false, orbit: null, }); 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'); 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); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index f06515c6e..6f9ae4158 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -115,11 +115,14 @@ export class LocationNavigation { _initLocationBar() { 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._locationLookupUnsubscribe?.(); this._locationLookup?.destroy(); this._locationLookup = new LocationSearch({ input: this._locationSearch, + setSearching: (active) => this._locationControls?.setSearching(active), begin: () => this._beginDeferredNavigation('location'), isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration, @@ -135,15 +138,9 @@ export class LocationNavigation { }, 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({ elements: { search: this._locationSearch, - searchToggle: this._searchToggle, }, publish: (view) => this._publishLocation?.(view), cities: CITY_POIS, @@ -153,7 +150,15 @@ export class LocationNavigation { onSearch: (query) => this._locationLookup.run(query), onSearchEdit: () => this._setLocationSearchError(null), 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._connectGlobeReset?.(this._locationControls); } @@ -412,6 +417,18 @@ export class LocationNavigation { 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. */ destroy() { if (this._disposed) return; diff --git a/src/ui/locationSearch.js b/src/ui/locationSearch.js index 09669ca56..aeed9320e 100644 --- a/src/ui/locationSearch.js +++ b/src/ui/locationSearch.js @@ -4,6 +4,7 @@ import { createStateChannel } from '../app/stateChannel.js'; export class LocationSearch { constructor({ input, + setSearching = () => {}, begin, isCurrent, beforeFly, @@ -16,6 +17,7 @@ export class LocationSearch { }) { Object.assign(this, { input, + setSearching, begin, isCurrent, beforeFly, @@ -51,8 +53,8 @@ export class LocationSearch { if (!query || this.destroyed) return; const authority = this.begin(); if (authority === false) { - this.input.classList.remove('searching'); - this.input.blur(); + this.setSearching(false); + this.input?.blur(); return; } this.controller?.abort(); @@ -81,7 +83,7 @@ export class LocationSearch { try { this.onStart?.(authority); if (!current() || controller.signal.aborted) return; - this.input.classList.add('searching'); + this.setSearching(true); this.channel.publish(change('started')); if (!current() || controller.signal.aborted) return; const destination = await this.search(query, { diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs index 7372db8eb..0be6e7602 100644 --- a/src/ui/locationSearchRecovery.test.mjs +++ b/src/ui/locationSearchRecovery.test.mjs @@ -30,34 +30,32 @@ function input() { }; } -function settle(searchInput, options) { - const owner = { _activeLocationSearchGeneration: 4, searchInput }; +function settle(options) { + const asked = []; + const owner = { + _activeLocationSearchGeneration: 4, + settleSearch: (settled) => asked.push(settled), + }; NavigationController.prototype._settleLocationSearchUi.call( owner, 4, options, ); - return owner; + return { owner, asked }; } -test('a search that found nothing keeps its query, the open field and focus', () => { - const field = input(); - const owner = settle(field, { keepQuery: true }); +// The location controls keep or clear the field (src/ui/locationControls.test.mjs). +test('a search that found nothing asks the field to keep its query', () => { + const { owner, asked } = settle({ 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); + assert.deepEqual(asked, [{ keepQuery: 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); +test('a search that arrived asks the field to clear, once', () => { + const { owner, asked } = settle(); + assert.deepEqual(asked, [{ keepQuery: false }]); + NavigationController.prototype._settleLocationSearchUi.call(owner, 4); + assert.deepEqual(asked, [{ keepQuery: false }]); }); function navigation() { @@ -103,3 +101,21 @@ for (const [status, text] of [ 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); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 3c0bc7684..3ea54948b 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -12,7 +12,7 @@ export class NavigationController { constructor({ viewer, tracking, - searchInput, + settleSearch = () => {}, interruptCameraMotion, interruptGlobeRotation = () => {}, isCockpitActive, @@ -26,7 +26,7 @@ export class NavigationController { Object.assign(this, { viewer, tracking, - searchInput, + settleSearch, interruptCameraMotion, interruptGlobeRotation, isCockpitActive, @@ -123,15 +123,7 @@ export class NavigationController { _settleLocationSearchUi(generation, { keepQuery = false } = {}) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; - 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(); + this.settleSearch({ keepQuery }); } _releaseFollowCamera({ diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index f389ff97c..97dcaa737 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -176,10 +176,7 @@ export class PanelChrome { onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), beforeCollapse: () => { - if (panelId !== 'location-bar' || !this._locationSearch) return; - this._locationSearch.classList.remove('expanded'); - this._locationSearch.value = ''; - this._locationSearch.blur(); + if (panelId === 'location-bar') this._closeLocationSearch?.(); }, // A desktop side panel hides entirely once collapsed; its rail item // takes the focus its own disclosure can no longer hold. diff --git a/src/ui/placeNames.test.mjs b/src/ui/placeNames.test.mjs index 0e1536ffa..5ddf3152c 100644 --- a/src/ui/placeNames.test.mjs +++ b/src/ui/placeNames.test.mjs @@ -53,7 +53,6 @@ test('ru: location pills and the mini-status show the translated city', () => { setLocale('ru'); const elements = { search: element(), - searchToggle: element(), }; const doc = { ...element(), createElement: element }; const cities = { tokyo: CITY_POIS.tokyo }; diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index fa24026aa..c512532b2 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -64,7 +64,6 @@ export function readShellElements(document = globalThis.document) { _cctvMeta: document.getElementById('cctv-meta'), _cctvSummary: document.getElementById('cctv-summary'), _locationSearch: document.getElementById('location-search'), - _searchToggle: document.getElementById('search-toggle'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), _safeFrameBox: document.getElementById('safe-frame-box'), _detectionBtn: document.getElementById('detection-toggle'), diff --git a/src/ui/stateOwners.test.mjs b/src/ui/stateOwners.test.mjs index b3708ea73..dd9339721 100644 --- a/src/ui/stateOwners.test.mjs +++ b/src/ui/stateOwners.test.mjs @@ -17,7 +17,6 @@ function navigation() { return new NavigationController({ viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } }, tracking, - searchInput: null, interruptCameraMotion() {}, isCockpitActive: () => false, clearLocation() {}, diff --git a/src/ui/stores/location.js b/src/ui/stores/location.js index 3ef58aa9a..1ef2f51b3 100644 --- a/src/ui/stores/location.js +++ b/src/ui/stores/location.js @@ -4,8 +4,9 @@ import { markRaw, shallowRef } from 'vue'; /** * The dock's location tray as its view renders it. LocationControls * 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 - * field's listeners. + * error, the search field's open and searching state and the ORBIT indicator + * and publishes them here; it keeps the search field's key, input and blur + * listeners. */ export const useLocationStore = defineStore('gev-location', () => { /** The presented tray; null until the controls first render. */ diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 552d0e739..10f3463ca 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -150,10 +150,10 @@ function selectMapSource(chip) {
{{ city.label }}
- - +