From 5f8f9bb9216acd907d860af22cc118424fd1c522 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 12:19:44 -0700 Subject: [PATCH] feat(ui): render the location tray's pills, mini status and search error from a store LocationControls (./ui/location) no longer builds the city and landmark pills or writes the mini status and the search error: it presents the tray and hands it to its publish option, so the entry stays Vue-free. The shell publishes it to a new gev-location store and connects the controls, and the dock renders it and calls chooseCity() and choosePoi(). The landmark row still expands a frame after its pills render. The search field, its toggle and the reset buttons keep their listeners. The location QA harness reads the rendered elements. --- CHANGELOG.md | 7 ++ docs/VUE-MIGRATION.md | 4 +- scripts/package-boundaries.json | 3 + scripts/qa-location-controls.mjs | 25 +++-- src/ui/applicationShell.js | 11 +- src/ui/commandDock.test.mjs | 100 +++++++++++++++++ src/ui/locationControls.js | 143 ++++++++++++++----------- src/ui/locationControls.test.mjs | 79 ++++++++++---- src/ui/locationNavigation.js | 21 +--- src/ui/locationSearchRecovery.test.mjs | 19 ++-- src/ui/placeNames.test.mjs | 11 +- src/ui/shellElements.js | 6 -- src/ui/stores/location.js | 27 +++++ src/ui/views/CommandDockView.vue | 18 ++-- 14 files changed, 333 insertions(+), 141 deletions(-) create mode 100644 src/ui/stores/location.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d19f068..b3b279902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ dock renders them and calls `select()`. `renderMapStackChips` and `syncMapStackChips` are deprecated (samehkhamis). +- `LocationControls` (`./ui/location`) no longer builds the city and + 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`, `searchToggle` and + `resetButtons` (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 8b3641cb2..edf6fe905 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -120,7 +120,9 @@ They contain no application state. VisualSettings publishes to the `gev-display` store; the buttons keep their owner's listeners; the map source chips and status badge render what `createMapSourceControls()` publishes beside it, so `./ui/maps` - needs no Vue), + needs no Vue; the location tray's city and landmark pills, mini status + and search error render what `LocationControls` publishes to the + `gev-location` store, and the search field keeps its listeners), the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 207a45157..2a5f1d2a5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -58,6 +58,7 @@ "src/ui/stores/cctv.js", "src/ui/stores/radio.js", "src/ui/stores/context.js", + "src/ui/stores/location.js", "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue", "src/ui/views/panels/DisplayPanel.vue", @@ -973,6 +974,7 @@ "src/ui/stores/cctv.js", "src/ui/stores/radio.js", "src/ui/stores/context.js", + "src/ui/stores/location.js", "src/ui/stores/display.js" ], "external": ["cesium", "vue", "pinia"] @@ -2943,6 +2945,7 @@ "src/ui/stores/cctv.js", "src/ui/stores/radio.js", "src/ui/stores/context.js", + "src/ui/stores/location.js", "src/ui/stores/display.js" ], "external": [ diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 46da4fe37..060521d93 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -120,8 +120,11 @@ try { const ui = window.__godsEyeView.styleManager; return ( ui._searchedLocationLabel === '39.7392° N, 104.9903° W' && - ui._locationMiniCity.textContent.includes('39.7392° N') && - ui._locationMiniPoi.textContent === '104.9903° W' + document + .getElementById('location-mini-city') + .textContent.includes('39.7392° N') && + document.getElementById('location-mini-poi').textContent === + '104.9903° W' ); }), ); @@ -194,8 +197,11 @@ try { const ui = window.__godsEyeView.styleManager; return ( ui._searchedLocationLabel === 'Second landmark, Test city' && - ui._locationMiniCity.textContent.includes('Second landmark') && - ui._locationMiniPoi.textContent === 'Test city' && + document + .getElementById('location-mini-city') + .textContent.includes('Second landmark') && + document.getElementById('location-mini-poi').textContent === + 'Test city' && ui.activeStyle === 'normal' && !ui._locationSearch.classList.contains('searching') ); @@ -227,8 +233,10 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ); return ( - !ui._poiRow.classList.contains('expanded') && - !ui._locationBarDivider.classList.contains('visible') + !document.getElementById('poi-row').classList.contains('expanded') && + !document + .getElementById('location-bar-divider') + .classList.contains('visible') ); }, cityId), ); @@ -301,7 +309,10 @@ try { ui._locationControls.destroy(); ui._locationLookup.destroy(); const before = ui._navigationGeneration; - ui._locationPills.querySelector('button').click(); + document + .getElementById('location-pills') + .querySelector('button') + ?.click(); ui._resetGlobeBtn.click(); ui._locationSearch.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 47c37bcf5..04d08329b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -55,6 +55,7 @@ import { PanelNavigation } from './panelNavigation.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { useContextStore } from './stores/context.js'; import { useDisplayStore } from './stores/display.js'; +import { useLocationStore } from './stores/location.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -311,18 +312,16 @@ export class StyleManager extends ShellFacade { rocketLaunchesLayer: services.rocketLaunchesLayer, }, elements: { - _locationPills: this._locationPills, - _poiRow: this._poiRow, - _locationBarDivider: this._locationBarDivider, _locationSearch: this._locationSearch, - _locationSearchError: this._locationSearchError, _searchToggle: this._searchToggle, _resetGlobeBtn: this._resetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, - _locationMiniCity: this._locationMiniCity, - _locationMiniPoi: this._locationMiniPoi, }, operations: { + _publishLocation: (view) => + useLocationStore(applicationPinia()).publish(view), + _connectLocation: (controls) => + useLocationStore(applicationPinia()).connect(controls), _beginDeferredNavigation: (...args) => this._beginDeferredNavigation(...args), _reassertNavigationHandoff: (...args) => diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index 5ebec996f..ff8f802da 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -7,6 +7,8 @@ import { createPinia } from 'pinia'; import { createApp, h, nextTick } from 'vue'; import { setLocale } from '../i18n/index.js'; import { useDisplayStore } from './stores/display.js'; +import { useLocationStore } from './stores/location.js'; +import { LocationControls } from './locationControls.js'; import CommandDockView from './views/CommandDockView.vue'; import SceneChromeView from './views/SceneChromeView.vue'; import { VisualSettings } from './visualSettings.js'; @@ -152,3 +154,101 @@ test('the map source row renders the published chips and selects through the con assert.equal($('#map-stack-status').classList.contains('warn'), false); assert.equal(document.querySelectorAll('.map-stack-chip').length, 0); }); + +test('the location tray renders what LocationControls publishes', async (t) => { + const { $, pinia } = fixture(t); + assert.equal($('#location-mini-city').textContent, '📍 Location: --'); + assert.equal($('#location-mini-poi').textContent, 'Landmark: --'); + const store = useLocationStore(pinia); + const calls = []; + const frames = []; + const controls = new LocationControls({ + elements: { + search: $('#location-search'), + searchToggle: $('#search-toggle'), + resetButtons: [], + }, + cities: { + paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] }, + rome: { name: 'Rome', pois: [{ name: 'Colosseum' }] }, + }, + getExpandedCity: () => null, + onCity: (id) => calls.push(['city', id]), + onPoi: (id, index) => calls.push(['poi', id, index]), + onSearch() {}, + onReset() {}, + publish: (view) => store.publish(view), + requestFrame: (fn) => frames.push(fn), + cancelFrame() {}, + }); + t.after(() => controls.destroy()); + store.connect(controls); + await nextTick(); + const pills = [ + ...document.querySelectorAll('#location-pills .location-pill'), + ]; + assert.deepEqual( + pills.map((pill) => `${pill.dataset.locationId}:${pill.textContent}`), + ['paris:Paris', 'rome:Rome'], + ); + pills[1].click(); + assert.deepEqual(calls, [['city', 'rome']]); + pills[1].classList.add('voice-highlight'); + controls.highlightCity('rome'); + controls.showPois('paris'); + await nextTick(); + assert.equal(pills[1].classList.contains('active'), true); + assert.equal(pills[1].classList.contains('voice-highlight'), true); + const row = $('#poi-row'); + const pois = [...row.querySelectorAll('.poi-pill')]; + assert.deepEqual( + pois.map((poi) => poi.textContent), + ['QLouvre', 'WEiffel'], + ); + assert.equal(row.classList.contains('expanded'), false); + frames.shift()(); + await nextTick(); + assert.equal(row.classList.contains('expanded'), true); + assert.equal($('#location-bar-divider').classList.contains('visible'), true); + controls.highlightPoi(1); + await nextTick(); + assert.equal(pois[1].classList.contains('active'), true); + pois[1].click(); + assert.deepEqual(calls.at(-1), ['poi', 'paris', 1]); + // Switching cities while the row is open swaps its pills and keeps it open. + controls.showPois('rome'); + await nextTick(); + assert.equal(row.classList.contains('expanded'), true); + const romePois = [...row.querySelectorAll('.poi-pill')]; + assert.deepEqual( + romePois.map((poi) => poi.textContent), + ['QColosseum'], + ); + assert.equal(romePois[0].classList.contains('active'), false); + romePois[0].click(); + assert.deepEqual(calls.at(-1), ['poi', 'rome', 0]); + frames.shift()(); + controls.renderStatus({ + city: { name: 'Paris' }, + currentPoi: { name: 'Eiffel' }, + }); + controls.setSearchError('No match'); + await nextTick(); + assert.match($('#location-mini-city').textContent, /Paris/); + assert.match($('#location-mini-poi').textContent, /Eiffel/); + assert.equal($('#location-search-error').textContent, 'No match'); + const search = $('#location-search'); + assert.equal(search.getAttribute('aria-invalid'), 'true'); + assert.equal( + search.getAttribute('aria-describedby'), + 'location-search-error', + ); + controls.setSearchError(null); + controls.hidePois(); + await nextTick(); + assert.equal(search.hasAttribute('aria-invalid'), false); + assert.equal(search.hasAttribute('aria-describedby'), false); + assert.equal($('#location-search-error').textContent, ''); + assert.equal(row.classList.contains('expanded'), false); + assert.equal($('#location-bar-divider').classList.contains('visible'), false); +}); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 27363c1ac..4b53e8f60 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -13,7 +13,13 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze( locationMiniStatus({ searchedLabel: '·' }), ); -/** Location DOM, keyboard handling and pending row animation over supplied actions. */ +/** + * The location tray's presentation, keyboard handling and pending row + * animation over supplied actions. The city pills, the landmark row, the + * mini status and the search error are presented as a plain view and handed + * to `publish`; Core's dock renders them and calls `chooseCity()` and + * `choosePoi()`. The search field and reset buttons keep their listeners. + */ export class LocationControls { constructor({ elements, @@ -24,6 +30,7 @@ export class LocationControls { onSearch, onSearchEdit, onReset, + publish = () => {}, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), cancelFrame = (id) => cancelAnimationFrame(id), @@ -36,25 +43,24 @@ export class LocationControls { onPoi, onSearch, onReset, + publish, doc, requestFrame, cancelFrame, }); this.removers = []; - this.poiRemovers = []; this.frame = null; this.destroyed = false; this.rowGeneration = 0; - elements.pills.replaceChildren(); - for (const [id, city] of Object.entries(cities)) { - const pill = doc.createElement('button'); - pill.type = 'button'; - pill.className = 'location-pill'; - pill.dataset.locationId = id; - pill.textContent = localizePlaceName(city.name); - this.bind(pill, 'click', () => onCity(id)); - elements.pills.appendChild(pill); - } + this.activeCity = null; + this.poiCity = null; + this.activePoi = null; + this.poisExpanded = false; + this.status = { + city: t('📍 Location: --'), + poi: t('Landmark: --'), + }; + this.searchError = null; this.bind(doc, 'keydown', (event) => { const cityId = getExpandedCity(); if ( @@ -82,6 +88,7 @@ export class LocationControls { }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); + this.present(); } bind(element, event, handler, removers = this.removers) { if (!element) return; @@ -91,6 +98,37 @@ export class LocationControls { element.addEventListener(event, listener); removers.push(() => element.removeEventListener(event, listener)); } + /** Publish the tray as it stands. */ + present() { + if (this.destroyed) return; + const pois = this.cities[this.poiCity]?.pois || []; + this.publish({ + cities: Object.entries(this.cities).map(([id, city]) => ({ + id, + label: localizePlaceName(city.name), + active: id === this.activeCity, + })), + poiCity: this.poiCity, + pois: pois.map((poi, index) => ({ + index, + key: String(POI_KEYS[index] || index + 1), + name: String(poi.name ?? ''), + active: index === this.activePoi, + })), + poisExpanded: this.poisExpanded, + status: { ...this.status }, + searchError: this.searchError, + }); + } + /** A city pill was clicked. */ + chooseCity(id) { + if (!this.destroyed) this.onCity(id); + } + /** A landmark pill of the shown city was clicked. */ + choosePoi(index) { + if (!this.destroyed && this.poiCity !== null) + this.onPoi(this.poiCity, index); + } cancelExpansion() { this.rowGeneration++; if (this.frame !== null) this.cancelFrame(this.frame); @@ -102,54 +140,35 @@ export class LocationControls { if (!city) return; this.cancelExpansion(); const generation = this.rowGeneration; - for (const remove of this.poiRemovers.splice(0)) remove(); - this.elements.poiRow.replaceChildren(); - city.pois.forEach((poi, index) => { - const pill = this.doc.createElement('button'); - pill.type = 'button'; - pill.className = 'poi-pill'; - pill.dataset.poiIndex = index; - const key = this.doc.createElement('span'); - key.className = 'poi-pill-key'; - key.textContent = POI_KEYS[index] || index + 1; - const label = this.doc.createElement('span'); - label.className = 'poi-pill-name'; - label.textContent = poi.name; - pill.append(key, label); - this.bind( - pill, - 'click', - () => this.onPoi(cityId, index), - this.poiRemovers, - ); - this.elements.poiRow.appendChild(pill); - }); + this.poiCity = cityId; + this.activePoi = null; + this.present(); + // The row expands a frame after its pills render, so the transition runs. this.frame = this.requestFrame(() => { if (this.destroyed || generation !== this.rowGeneration) return; this.frame = null; - this.elements.poiRow.classList.add('expanded'); - this.elements.divider.classList.add('visible'); + this.poisExpanded = true; + this.present(); }); } hidePois() { if (this.destroyed) return; this.cancelExpansion(); - this.elements.poiRow.classList.remove('expanded'); - this.elements.divider.classList.remove('visible'); + this.poisExpanded = false; + this.present(); } highlightPoi(index) { if (this.destroyed) return; - for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) - pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); + this.activePoi = index; + this.present(); } highlightCity(id) { if (this.destroyed) return; - for (const pill of this.elements.pills.querySelectorAll('.location-pill')) - pill.classList.toggle('active', pill.dataset.locationId === id); + this.activeCity = id; + this.present(); } renderStatus(record) { - if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) - return; + if (this.destroyed) return; // A preset city shows its name in the UI language; the record stays English. const state = record?.city?.name ? { @@ -158,17 +177,25 @@ export class LocationControls { } : record; const lines = locationMiniStatus(state); - this.elements.statusCity.textContent = - lines.city === LOCATION_MINI_EMPTY.city - ? t('📍 Location: --') - : lines.city; - if (lines.poi === LOCATION_MINI_EMPTY.poi) { - this.elements.statusPoi.textContent = t('Landmark: --'); - } else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) { - this.elements.statusPoi.textContent = t('Searched location'); - } else { - this.elements.statusPoi.textContent = lines.poi; - } + this.status = { + city: + lines.city === LOCATION_MINI_EMPTY.city + ? t('📍 Location: --') + : lines.city, + poi: + lines.poi === LOCATION_MINI_EMPTY.poi + ? t('Landmark: --') + : lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi + ? t('Searched location') + : lines.poi, + }; + this.present(); + } + /** Show, or with null clear, the announced error under the search field. */ + setSearchError(message) { + if (this.destroyed) return; + this.searchError = message || null; + this.present(); } createOrbitIndicator() { if (this.destroyed) return null; @@ -187,11 +214,7 @@ export class LocationControls { if (this.destroyed) return; this.destroyed = true; this.cancelExpansion(); - for (const remove of [ - ...this.poiRemovers.splice(0), - ...this.removers.splice(0), - ]) - remove(); + for (const remove of this.removers.splice(0)) remove(); this.orbitIndicator?.remove(); } } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 33f5eaa7f..baddaa896 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -59,14 +59,9 @@ function node() { } function fixture() { const elements = { - pills: node(), - poiRow: node(), - divider: node(), search: node(), searchToggle: node(), resetButtons: [node(), node()], - statusCity: node(), - statusPoi: node(), }; const doc = node(); doc.createElement = node; @@ -79,6 +74,7 @@ function fixture() { const frames = new Map(); const cancelled = []; let next = 0; + const views = []; const controls = new LocationControls({ elements, cities, @@ -88,6 +84,7 @@ function fixture() { onSearch: (query) => calls.push(['search', query]), onSearchEdit: () => calls.push(['edit']), onReset: () => calls.push(['reset']), + publish: (view) => views.push(view), doc, requestFrame: (fn) => { const id = next++; @@ -96,8 +93,32 @@ function fixture() { }, cancelFrame: (id) => cancelled.push(id), }); - return { elements, doc, controls, calls, frames, cancelled }; + const view = () => views.at(-1); + return { elements, doc, controls, calls, frames, cancelled, views, view }; } +test('the tray presents its cities, empty status and no landmarks at first', () => { + const f = fixture(); + assert.deepEqual(f.view(), { + cities: [ + { id: 'a', label: 'City A', active: false }, + { id: 'b', label: 'City B', active: false }, + ], + poiCity: null, + pois: [], + poisExpanded: false, + status: { city: '📍 Location: --', poi: 'Landmark: --' }, + searchError: null, + }); + f.controls.highlightCity('b'); + assert.deepEqual( + f.view().cities.map((city) => city.active), + [false, true], + ); + f.controls.setSearchError('Not found'); + assert.equal(f.view().searchError, 'Not found'); + f.controls.setSearchError(null); + assert.equal(f.view().searchError, null); +}); test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => { const f = fixture(); f.controls.showPois('a'); @@ -105,42 +126,62 @@ test('hiding a POI row cancels frame zero and rejects an already queued expansio f.controls.hidePois(); callback(); assert.deepEqual(f.cancelled, [0]); - assert.equal(f.elements.poiRow.classList.contains('expanded'), false); - assert.equal(f.elements.divider.classList.contains('visible'), false); + assert.equal(f.view().poisExpanded, false); }); -test('replacing POIs removes old click actions and presents the final row', () => { +test('replacing POIs routes clicks to the final row and expands it a frame later', () => { const f = fixture(); f.controls.showPois('a'); - const old = f.elements.poiRow.children[0]; + f.controls.highlightPoi(1); + assert.deepEqual( + f.view().pois.map((poi) => [poi.key, poi.name, poi.active]), + [ + ['Q', 'First', false], + ['W', 'Second', true], + ], + ); f.controls.showPois('b'); - old.fire('click'); - assert.deepEqual(f.calls, []); - f.elements.poiRow.children[0].fire('click'); + assert.equal(f.view().poiCity, 'b'); + assert.deepEqual( + f.view().pois.map((poi) => [poi.name, poi.active]), + [['Elsewhere', false]], + ); + f.controls.choosePoi(0); assert.deepEqual(f.calls, [['poi', 'b', 0]]); f.frames.get(0)(); - assert.equal(f.elements.poiRow.classList.contains('expanded'), false); + assert.equal(f.view().poisExpanded, false); f.frames.get(1)(); - assert.equal(f.elements.poiRow.classList.contains('expanded'), true); + assert.equal(f.view().poisExpanded, true); }); test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => { const f = fixture(); f.controls.showPois('a'); f.controls.createOrbitIndicator(); assert.equal(f.doc.body.children.length, 1); - const city = f.elements.pills.children[0]; - const poi = f.elements.poiRow.children[0]; f.controls.destroy(); f.controls.destroy(); - city.fire('click'); - poi.fire('click'); + const published = f.views.length; + f.controls.chooseCity('a'); + f.controls.choosePoi(0); + f.controls.highlightCity('a'); f.elements.search.value = 'Place'; f.elements.search.fire('keydown', { key: 'Enter' }); f.doc.fire('keydown', { key: 'Q' }); f.elements.resetButtons[0].fire('click'); f.frames.get(0)(); assert.deepEqual(f.calls, []); + assert.equal(f.views.length, published); assert.equal(f.doc.body.children.length, 0); }); +test('the mini status shows the city, a landmark or a searched place', () => { + const f = fixture(); + f.controls.renderStatus({ searchedLabel: 'Somewhere' }); + assert.equal(f.view().status.poi, 'Searched location'); + f.controls.renderStatus({}); + assert.deepEqual(f.view().status, { + city: '📍 Location: --', + poi: 'Landmark: --', + }); +}); test('location and POI keys route once while form controls retain typing', () => { const f = fixture(); f.doc.fire('keydown', { key: 'W', target: { matches: () => false } }); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index d0664509f..07aa9db32 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -111,17 +111,7 @@ 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'); - } + this._locationControls?.setSearchError(message); } _initLocationBar() { @@ -153,15 +143,11 @@ export class LocationNavigation { ); this._locationControls = new LocationControls({ elements: { - pills: this._locationPills, - poiRow: this._poiRow, - divider: this._locationBarDivider, search: this._locationSearch, searchToggle: this._searchToggle, resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn], - statusCity: this._locationMiniCity, - statusPoi: this._locationMiniPoi, }, + publish: (view) => this._publishLocation?.(view), cities: CITY_POIS, getExpandedCity: () => this._expandedCityId, onCity: (id) => this._onCityPillClick(id), @@ -170,6 +156,7 @@ export class LocationNavigation { onSearchEdit: () => this._setLocationSearchError(null), onReset: () => this.resetToGlobeView(), }); + this._connectLocation?.(this._locationControls); } _beginWorldJumpTransition() { @@ -449,6 +436,8 @@ export class LocationNavigation { this._locationLookupUnsubscribe = null; this._locationLookup?.destroy(); this._locationControls?.destroy(); + this._connectLocation?.(null); + this._publishLocation?.(null); this._cancelGlobeReset?.(); this._cancelGlobeReset = null; if (this._worldJumpActive) this._endWorldJumpTransition(); diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs index 19a310203..7372db8eb 100644 --- a/src/ui/locationSearchRecovery.test.mjs +++ b/src/ui/locationSearchRecovery.test.mjs @@ -62,13 +62,15 @@ test('a search that arrived still clears and closes the field', () => { function navigation() { const field = input(); - const error = { id: 'location-search-error', textContent: '' }; + // The tray renders the error and the field's aria-invalid from the + // controls' published view (src/ui/commandDock.test.mjs). + const errors = []; const settled = []; const toasts = []; const owner = { _disposed: false, _locationSearch: field, - _locationSearchError: error, + _locationControls: { setSearchError: (message) => errors.push(message) }, _activeLocationSearchGeneration: null, _settleLocationSearchUi: (generation, options) => settled.push([generation, options]), @@ -82,7 +84,7 @@ function navigation() { { status, query: field.value }, { type, generation: 7 }, ); - return { owner, field, error, settled, toasts, handle }; + return { owner, field, errors, settled, toasts, handle }; } for (const [status, text] of [ @@ -94,17 +96,10 @@ for (const [status, text] of [ 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.match(n.errors.at(-1), new RegExp(`^${text}\\. `)); 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); + assert.equal(n.errors.at(-1), null); }); } diff --git a/src/ui/placeNames.test.mjs b/src/ui/placeNames.test.mjs index eff49b942..0c224eaee 100644 --- a/src/ui/placeNames.test.mjs +++ b/src/ui/placeNames.test.mjs @@ -52,17 +52,13 @@ function element() { test('ru: location pills and the mini-status show the translated city', () => { setLocale('ru'); const elements = { - pills: element(), - poiRow: element(), - divider: element(), search: element(), searchToggle: element(), resetButtons: [], - statusCity: element(), - statusPoi: element(), }; const doc = { ...element(), createElement: element }; const cities = { tokyo: CITY_POIS.tokyo }; + const views = []; const controls = new LocationControls({ elements, cities, @@ -71,12 +67,13 @@ test('ru: location pills and the mini-status show the translated city', () => { onPoi() {}, onSearch() {}, onReset() {}, + publish: (view) => views.push(view), doc, requestFrame: () => 0, cancelFrame() {}, }); - assert.equal(elements.pills.children[0].textContent, 'Токио'); + assert.equal(views.at(-1).cities[0].label, 'Токио'); controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null }); - assert.equal(elements.statusCity.textContent, '📍 Токио'); + assert.equal(views.at(-1).status.city, '📍 Токио'); assert.equal(CITY_POIS.tokyo.name, 'Tokyo'); }); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 93b4895f9..569a5645f 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -124,13 +124,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'), - _locationBarDivider: document.getElementById('location-bar-divider'), - _locationMiniCity: document.getElementById('location-mini-city'), - _locationMiniPoi: document.getElementById('location-mini-poi'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), _safeFrameBox: document.getElementById('safe-frame-box'), _detectionBtn: document.getElementById('detection-toggle'), diff --git a/src/ui/stores/location.js b/src/ui/stores/location.js new file mode 100644 index 000000000..ac1344ca7 --- /dev/null +++ b/src/ui/stores/location.js @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; +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 and the search + * error and publishes them here; it keeps the search field's listeners. + */ +export const useLocationStore = defineStore('gev-location', () => { + /** The presented tray; null until the controls first render. */ + const view = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + view.value = next && markRaw(next); + } + + /** The controls whose `chooseCity(id)` and `choosePoi(index)` pills call. */ + const controls = shallowRef(null); + + /** @param {?object} next The location controls, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + return { view, publish, controls, connect }; +}); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index a4d351e94..0db92e252 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -3,11 +3,15 @@ import { storeToRefs } from 'pinia'; import { t } from '../../i18n/index.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useDisplayStore } from '../stores/display.js'; +import { useLocationStore } from '../stores/location.js'; // VisualSettings publishes the active style and the map source controls // their chip row; until they do, the dock shows its initial markup. The // style buttons keep their owner's listeners; a chip calls the controls. const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore()); +// LocationControls publishes the location tray; pills call the controls, +// and the search field keeps their listeners. +const { view: loc, controls: locationControls } = storeToRefs(useLocationStore()); function selectMapSource(chip) { if (!chip.available) return; @@ -88,23 +92,23 @@ function selectMapSource(chip) {
-
📍 Location: --
-
Landmark: --
+
{{ loc ? loc.status.city : t('📍 Location: --') }}
+
{{ loc ? loc.status.poi : t('Landmark: --') }}
-
-
+
+
-
+
- +
- +