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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 12:19:44 -07:00
1 parent 4966877859
commit 5f8f9bb921
14 files changed
+333 -141

No files matched your search

+7
View File
@@ -50,6 +50,13 @@
dock renders them and calls `select()`. `renderMapStackChips` and dock renders them and calls `select()`. `renderMapStackChips` and
`syncMapStackChips` are deprecated (samehkhamis). `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 ## [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 - Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+3 -1
View File
@@ -120,7 +120,9 @@ They contain no application state.
VisualSettings publishes to the `gev-display` store; the buttons keep VisualSettings publishes to the `gev-display` store; the buttons keep
their owner's listeners; the map source chips and status badge render their owner's listeners; the map source chips and status badge render
what `createMapSourceControls()` publishes beside it, so `./ui/maps` 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 the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+3
View File
@@ -58,6 +58,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/location.js",
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/DisplayPanel.vue",
@@ -973,6 +974,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
"external": ["cesium", "vue", "pinia"] "external": ["cesium", "vue", "pinia"]
@@ -2943,6 +2945,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
"external": [ "external": [
+18 -7
View File
@@ -120,8 +120,11 @@ try {
const ui = window.__godsEyeView.styleManager; const ui = window.__godsEyeView.styleManager;
return ( return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' && ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') && document
ui._locationMiniPoi.textContent === '104.9903° W' .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; const ui = window.__godsEyeView.styleManager;
return ( return (
ui._searchedLocationLabel === 'Second landmark, Test city' && ui._searchedLocationLabel === 'Second landmark, Test city' &&
ui._locationMiniCity.textContent.includes('Second landmark') && document
ui._locationMiniPoi.textContent === 'Test city' && .getElementById('location-mini-city')
.textContent.includes('Second landmark') &&
document.getElementById('location-mini-poi').textContent ===
'Test city' &&
ui.activeStyle === 'normal' && ui.activeStyle === 'normal' &&
!ui._locationSearch.classList.contains('searching') !ui._locationSearch.classList.contains('searching')
); );
@@ -227,8 +233,10 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)), requestAnimationFrame(() => requestAnimationFrame(resolve)),
); );
return ( return (
!ui._poiRow.classList.contains('expanded') && !document.getElementById('poi-row').classList.contains('expanded') &&
!ui._locationBarDivider.classList.contains('visible') !document
.getElementById('location-bar-divider')
.classList.contains('visible')
); );
}, cityId), }, cityId),
); );
@@ -301,7 +309,10 @@ try {
ui._locationControls.destroy(); ui._locationControls.destroy();
ui._locationLookup.destroy(); ui._locationLookup.destroy();
const before = ui._navigationGeneration; const before = ui._navigationGeneration;
ui._locationPills.querySelector('button').click(); document
.getElementById('location-pills')
.querySelector('button')
?.click();
ui._resetGlobeBtn.click(); ui._resetGlobeBtn.click();
ui._locationSearch.dispatchEvent( ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
+5 -6
View File
@@ -55,6 +55,7 @@ import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js'; import { useContextStore } from './stores/context.js';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import { useLocationStore } from './stores/location.js';
/** /**
* Central UI orchestrator for the God's Eye View application. * Central UI orchestrator for the God's Eye View application.
@@ -311,18 +312,16 @@ export class StyleManager extends ShellFacade {
rocketLaunchesLayer: services.rocketLaunchesLayer, rocketLaunchesLayer: services.rocketLaunchesLayer,
}, },
elements: { elements: {
_locationPills: this._locationPills,
_poiRow: this._poiRow,
_locationBarDivider: this._locationBarDivider,
_locationSearch: this._locationSearch, _locationSearch: this._locationSearch,
_locationSearchError: this._locationSearchError,
_searchToggle: this._searchToggle, _searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn, _resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
_locationMiniCity: this._locationMiniCity,
_locationMiniPoi: this._locationMiniPoi,
}, },
operations: { operations: {
_publishLocation: (view) =>
useLocationStore(applicationPinia()).publish(view),
_connectLocation: (controls) =>
useLocationStore(applicationPinia()).connect(controls),
_beginDeferredNavigation: (...args) => _beginDeferredNavigation: (...args) =>
this._beginDeferredNavigation(...args), this._beginDeferredNavigation(...args),
_reassertNavigationHandoff: (...args) => _reassertNavigationHandoff: (...args) =>
+100
View File
@@ -7,6 +7,8 @@ import { createPinia } from 'pinia';
import { createApp, h, nextTick } from 'vue'; import { createApp, h, nextTick } from 'vue';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
import { useDisplayStore } from './stores/display.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 CommandDockView from './views/CommandDockView.vue';
import SceneChromeView from './views/SceneChromeView.vue'; import SceneChromeView from './views/SceneChromeView.vue';
import { VisualSettings } from './visualSettings.js'; 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($('#map-stack-status').classList.contains('warn'), false);
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0); 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);
});
+83 -60
View File
@@ -13,7 +13,13 @@ const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze(
locationMiniStatus({ searchedLabel: '·' }), 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 { export class LocationControls {
constructor({ constructor({
elements, elements,
@@ -24,6 +30,7 @@ export class LocationControls {
onSearch, onSearch,
onSearchEdit, onSearchEdit,
onReset, onReset,
publish = () => {},
doc = document, doc = document,
requestFrame = (callback) => requestAnimationFrame(callback), requestFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (id) => cancelAnimationFrame(id), cancelFrame = (id) => cancelAnimationFrame(id),
@@ -36,25 +43,24 @@ export class LocationControls {
onPoi, onPoi,
onSearch, onSearch,
onReset, onReset,
publish,
doc, doc,
requestFrame, requestFrame,
cancelFrame, cancelFrame,
}); });
this.removers = []; this.removers = [];
this.poiRemovers = [];
this.frame = null; this.frame = null;
this.destroyed = false; this.destroyed = false;
this.rowGeneration = 0; this.rowGeneration = 0;
elements.pills.replaceChildren(); this.activeCity = null;
for (const [id, city] of Object.entries(cities)) { this.poiCity = null;
const pill = doc.createElement('button'); this.activePoi = null;
pill.type = 'button'; this.poisExpanded = false;
pill.className = 'location-pill'; this.status = {
pill.dataset.locationId = id; city: t('📍 Location: --'),
pill.textContent = localizePlaceName(city.name); poi: t('Landmark: --'),
this.bind(pill, 'click', () => onCity(id)); };
elements.pills.appendChild(pill); this.searchError = null;
}
this.bind(doc, 'keydown', (event) => { this.bind(doc, 'keydown', (event) => {
const cityId = getExpandedCity(); const cityId = getExpandedCity();
if ( if (
@@ -82,6 +88,7 @@ export class LocationControls {
}); });
for (const button of elements.resetButtons) for (const button of elements.resetButtons)
this.bind(button, 'click', onReset); this.bind(button, 'click', onReset);
this.present();
} }
bind(element, event, handler, removers = this.removers) { bind(element, event, handler, removers = this.removers) {
if (!element) return; if (!element) return;
@@ -91,6 +98,37 @@ export class LocationControls {
element.addEventListener(event, listener); element.addEventListener(event, listener);
removers.push(() => element.removeEventListener(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() { cancelExpansion() {
this.rowGeneration++; this.rowGeneration++;
if (this.frame !== null) this.cancelFrame(this.frame); if (this.frame !== null) this.cancelFrame(this.frame);
@@ -102,54 +140,35 @@ export class LocationControls {
if (!city) return; if (!city) return;
this.cancelExpansion(); this.cancelExpansion();
const generation = this.rowGeneration; const generation = this.rowGeneration;
for (const remove of this.poiRemovers.splice(0)) remove(); this.poiCity = cityId;
this.elements.poiRow.replaceChildren(); this.activePoi = null;
city.pois.forEach((poi, index) => { this.present();
const pill = this.doc.createElement('button'); // The row expands a frame after its pills render, so the transition runs.
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.frame = this.requestFrame(() => { this.frame = this.requestFrame(() => {
if (this.destroyed || generation !== this.rowGeneration) return; if (this.destroyed || generation !== this.rowGeneration) return;
this.frame = null; this.frame = null;
this.elements.poiRow.classList.add('expanded'); this.poisExpanded = true;
this.elements.divider.classList.add('visible'); this.present();
}); });
} }
hidePois() { hidePois() {
if (this.destroyed) return; if (this.destroyed) return;
this.cancelExpansion(); this.cancelExpansion();
this.elements.poiRow.classList.remove('expanded'); this.poisExpanded = false;
this.elements.divider.classList.remove('visible'); this.present();
} }
highlightPoi(index) { highlightPoi(index) {
if (this.destroyed) return; if (this.destroyed) return;
for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) this.activePoi = index;
pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); this.present();
} }
highlightCity(id) { highlightCity(id) {
if (this.destroyed) return; if (this.destroyed) return;
for (const pill of this.elements.pills.querySelectorAll('.location-pill')) this.activeCity = id;
pill.classList.toggle('active', pill.dataset.locationId === id); this.present();
} }
renderStatus(record) { renderStatus(record) {
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) if (this.destroyed) return;
return;
// A preset city shows its name in the UI language; the record stays English. // A preset city shows its name in the UI language; the record stays English.
const state = record?.city?.name const state = record?.city?.name
? { ? {
@@ -158,17 +177,25 @@ export class LocationControls {
} }
: record; : record;
const lines = locationMiniStatus(state); const lines = locationMiniStatus(state);
this.elements.statusCity.textContent = this.status = {
lines.city === LOCATION_MINI_EMPTY.city city:
? t('📍 Location: --') lines.city === LOCATION_MINI_EMPTY.city
: lines.city; ? t('📍 Location: --')
if (lines.poi === LOCATION_MINI_EMPTY.poi) { : lines.city,
this.elements.statusPoi.textContent = t('Landmark: --'); poi:
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) { lines.poi === LOCATION_MINI_EMPTY.poi
this.elements.statusPoi.textContent = t('Searched location'); ? t('Landmark: --')
} else { : lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi
this.elements.statusPoi.textContent = lines.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() { createOrbitIndicator() {
if (this.destroyed) return null; if (this.destroyed) return null;
@@ -187,11 +214,7 @@ export class LocationControls {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
this.cancelExpansion(); this.cancelExpansion();
for (const remove of [ for (const remove of this.removers.splice(0)) remove();
...this.poiRemovers.splice(0),
...this.removers.splice(0),
])
remove();
this.orbitIndicator?.remove(); this.orbitIndicator?.remove();
} }
} }
+60 -19
View File
@@ -59,14 +59,9 @@ function node() {
} }
function fixture() { function fixture() {
const elements = { const elements = {
pills: node(),
poiRow: node(),
divider: node(),
search: node(), search: node(),
searchToggle: node(), searchToggle: node(),
resetButtons: [node(), node()], resetButtons: [node(), node()],
statusCity: node(),
statusPoi: node(),
}; };
const doc = node(); const doc = node();
doc.createElement = node; doc.createElement = node;
@@ -79,6 +74,7 @@ function fixture() {
const frames = new Map(); const frames = new Map();
const cancelled = []; const cancelled = [];
let next = 0; let next = 0;
const views = [];
const controls = new LocationControls({ const controls = new LocationControls({
elements, elements,
cities, cities,
@@ -88,6 +84,7 @@ function fixture() {
onSearch: (query) => calls.push(['search', query]), onSearch: (query) => calls.push(['search', query]),
onSearchEdit: () => calls.push(['edit']), onSearchEdit: () => calls.push(['edit']),
onReset: () => calls.push(['reset']), onReset: () => calls.push(['reset']),
publish: (view) => views.push(view),
doc, doc,
requestFrame: (fn) => { requestFrame: (fn) => {
const id = next++; const id = next++;
@@ -96,8 +93,32 @@ function fixture() {
}, },
cancelFrame: (id) => cancelled.push(id), 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', () => { test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => {
const f = fixture(); const f = fixture();
f.controls.showPois('a'); 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(); f.controls.hidePois();
callback(); callback();
assert.deepEqual(f.cancelled, [0]); assert.deepEqual(f.cancelled, [0]);
assert.equal(f.elements.poiRow.classList.contains('expanded'), false); assert.equal(f.view().poisExpanded, false);
assert.equal(f.elements.divider.classList.contains('visible'), 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(); const f = fixture();
f.controls.showPois('a'); 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'); f.controls.showPois('b');
old.fire('click'); assert.equal(f.view().poiCity, 'b');
assert.deepEqual(f.calls, []); assert.deepEqual(
f.elements.poiRow.children[0].fire('click'); f.view().pois.map((poi) => [poi.name, poi.active]),
[['Elsewhere', false]],
);
f.controls.choosePoi(0);
assert.deepEqual(f.calls, [['poi', 'b', 0]]); assert.deepEqual(f.calls, [['poi', 'b', 0]]);
f.frames.get(0)(); f.frames.get(0)();
assert.equal(f.elements.poiRow.classList.contains('expanded'), false); assert.equal(f.view().poisExpanded, false);
f.frames.get(1)(); 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', () => { test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
const f = fixture(); const f = fixture();
f.controls.showPois('a'); f.controls.showPois('a');
f.controls.createOrbitIndicator(); f.controls.createOrbitIndicator();
assert.equal(f.doc.body.children.length, 1); 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();
f.controls.destroy(); f.controls.destroy();
city.fire('click'); const published = f.views.length;
poi.fire('click'); f.controls.chooseCity('a');
f.controls.choosePoi(0);
f.controls.highlightCity('a');
f.elements.search.value = 'Place'; f.elements.search.value = 'Place';
f.elements.search.fire('keydown', { key: 'Enter' }); f.elements.search.fire('keydown', { key: 'Enter' });
f.doc.fire('keydown', { key: 'Q' }); f.doc.fire('keydown', { key: 'Q' });
f.elements.resetButtons[0].fire('click'); f.elements.resetButtons[0].fire('click');
f.frames.get(0)(); f.frames.get(0)();
assert.deepEqual(f.calls, []); assert.deepEqual(f.calls, []);
assert.equal(f.views.length, published);
assert.equal(f.doc.body.children.length, 0); 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', () => { test('location and POI keys route once while form controls retain typing', () => {
const f = fixture(); const f = fixture();
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } }); f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
+5 -16
View File
@@ -111,17 +111,7 @@ export class LocationNavigation {
/** Show, or with null clear, the announced error under the search field. */ /** Show, or with null clear, the announced error under the search field. */
_setLocationSearchError(message) { _setLocationSearchError(message) {
const input = this._locationSearch; this._locationControls?.setSearchError(message);
const error = this._locationSearchError;
if (error) error.textContent = message || '';
if (!input) return;
if (message) {
input.setAttribute('aria-invalid', 'true');
if (error?.id) input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
} }
_initLocationBar() { _initLocationBar() {
@@ -153,15 +143,11 @@ export class LocationNavigation {
); );
this._locationControls = new LocationControls({ this._locationControls = new LocationControls({
elements: { elements: {
pills: this._locationPills,
poiRow: this._poiRow,
divider: this._locationBarDivider,
search: this._locationSearch, search: this._locationSearch,
searchToggle: this._searchToggle, searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn], resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
statusCity: this._locationMiniCity,
statusPoi: this._locationMiniPoi,
}, },
publish: (view) => this._publishLocation?.(view),
cities: CITY_POIS, cities: CITY_POIS,
getExpandedCity: () => this._expandedCityId, getExpandedCity: () => this._expandedCityId,
onCity: (id) => this._onCityPillClick(id), onCity: (id) => this._onCityPillClick(id),
@@ -170,6 +156,7 @@ export class LocationNavigation {
onSearchEdit: () => this._setLocationSearchError(null), onSearchEdit: () => this._setLocationSearchError(null),
onReset: () => this.resetToGlobeView(), onReset: () => this.resetToGlobeView(),
}); });
this._connectLocation?.(this._locationControls);
} }
_beginWorldJumpTransition() { _beginWorldJumpTransition() {
@@ -449,6 +436,8 @@ export class LocationNavigation {
this._locationLookupUnsubscribe = null; this._locationLookupUnsubscribe = null;
this._locationLookup?.destroy(); this._locationLookup?.destroy();
this._locationControls?.destroy(); this._locationControls?.destroy();
this._connectLocation?.(null);
this._publishLocation?.(null);
this._cancelGlobeReset?.(); this._cancelGlobeReset?.();
this._cancelGlobeReset = null; this._cancelGlobeReset = null;
if (this._worldJumpActive) this._endWorldJumpTransition(); if (this._worldJumpActive) this._endWorldJumpTransition();
+7 -12
View File
@@ -62,13 +62,15 @@ test('a search that arrived still clears and closes the field', () => {
function navigation() { function navigation() {
const field = input(); 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 settled = [];
const toasts = []; const toasts = [];
const owner = { const owner = {
_disposed: false, _disposed: false,
_locationSearch: field, _locationSearch: field,
_locationSearchError: error, _locationControls: { setSearchError: (message) => errors.push(message) },
_activeLocationSearchGeneration: null, _activeLocationSearchGeneration: null,
_settleLocationSearchUi: (generation, options) => _settleLocationSearchUi: (generation, options) =>
settled.push([generation, options]), settled.push([generation, options]),
@@ -82,7 +84,7 @@ function navigation() {
{ status, query: field.value }, { status, query: field.value },
{ type, generation: 7 }, { type, generation: 7 },
); );
return { owner, field, error, settled, toasts, handle }; return { owner, field, errors, settled, toasts, handle };
} }
for (const [status, text] of [ for (const [status, text] of [
@@ -94,17 +96,10 @@ for (const [status, text] of [
n.handle('searching', 'started'); n.handle('searching', 'started');
n.handle(status, status); n.handle(status, status);
n.handle(status, 'settled'); n.handle(status, 'settled');
assert.match(n.error.textContent, new RegExp(`^${text}\\. `)); assert.match(n.errors.at(-1), new RegExp(`^${text}\\. `));
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
assert.equal(
n.field.getAttribute('aria-describedby'),
'location-search-error',
);
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]); assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
assert.deepEqual(n.toasts, []); assert.deepEqual(n.toasts, []);
n.handle('searching', 'started'); n.handle('searching', 'started');
assert.equal(n.error.textContent, ''); assert.equal(n.errors.at(-1), null);
assert.equal(n.field.getAttribute('aria-invalid'), null);
assert.equal(n.field.getAttribute('aria-describedby'), null);
}); });
} }
+4 -7
View File
@@ -52,17 +52,13 @@ function element() {
test('ru: location pills and the mini-status show the translated city', () => { test('ru: location pills and the mini-status show the translated city', () => {
setLocale('ru'); setLocale('ru');
const elements = { const elements = {
pills: element(),
poiRow: element(),
divider: element(),
search: element(), search: element(),
searchToggle: element(), searchToggle: element(),
resetButtons: [], resetButtons: [],
statusCity: element(),
statusPoi: element(),
}; };
const doc = { ...element(), createElement: element }; const doc = { ...element(), createElement: element };
const cities = { tokyo: CITY_POIS.tokyo }; const cities = { tokyo: CITY_POIS.tokyo };
const views = [];
const controls = new LocationControls({ const controls = new LocationControls({
elements, elements,
cities, cities,
@@ -71,12 +67,13 @@ test('ru: location pills and the mini-status show the translated city', () => {
onPoi() {}, onPoi() {},
onSearch() {}, onSearch() {},
onReset() {}, onReset() {},
publish: (view) => views.push(view),
doc, doc,
requestFrame: () => 0, requestFrame: () => 0,
cancelFrame() {}, cancelFrame() {},
}); });
assert.equal(elements.pills.children[0].textContent, 'Токио'); assert.equal(views.at(-1).cities[0].label, 'Токио');
controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null }); 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'); assert.equal(CITY_POIS.tokyo.name, 'Tokyo');
}); });
-6
View File
@@ -124,13 +124,7 @@ export function readShellElements(document = globalThis.document) {
_cctvSyncProgress: document.getElementById('cctv-sync-progress'), _cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'), _toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'), _locationSearch: document.getElementById('location-search'),
_locationSearchError: document.getElementById('location-search-error'),
_searchToggle: document.getElementById('search-toggle'), _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'), _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'),
+27
View File
@@ -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 };
});
+11 -7
View File
@@ -3,11 +3,15 @@ import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js'; import { useDisplayStore } from '../stores/display.js';
import { useLocationStore } from '../stores/location.js';
// VisualSettings publishes the active style and the map source controls // VisualSettings publishes the active style and the map source controls
// their chip row; until they do, the dock shows its initial markup. The // 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. // style buttons keep their owner's listeners; a chip calls the controls.
const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore()); 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) { function selectMapSource(chip) {
if (!chip.available) return; if (!chip.available) return;
@@ -88,23 +92,23 @@ function selectMapSource(chip) {
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button> <button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
</div> </div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite"> <div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city" data-i18n>📍 Location: --</div> <div id="location-mini-city" class="location-mini-city">{{ loc ? loc.status.city : t('📍 Location: --') }}</div>
<div id="location-mini-poi" class="location-mini-poi" data-i18n>Landmark: --</div> <div id="location-mini-poi" class="location-mini-poi">{{ loc ? loc.status.poi : t('Landmark: --') }}</div>
</div> </div>
<div id="location-bar-popover" class="dock-popover-content"> <div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button> <button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div> <div id="poi-row" class="poi-row-container" v-class-toggle="{ expanded: !!loc?.poisExpanded }"><button v-for="poi in loc?.pois" :key="`${loc.poiCity}:${poi.index}`" type="button" class="poi-pill" v-class-toggle="{ active: poi.active }" :data-poi-index="poi.index" @click="locationControls?.choosePoi(poi.index)"><span class="poi-pill-key">{{ poi.key }}</span><span class="poi-pill-name">{{ poi.name }}</span></button></div>
<div class="location-bar-divider" id="location-bar-divider"></div> <div class="location-bar-divider" id="location-bar-divider" v-class-toggle="{ visible: !!loc?.poisExpanded }"></div>
<div class="location-city-row"> <div class="location-city-row">
<div id="location-pills" class="location-pills"></div> <div id="location-pills" class="location-pills"><button v-for="city in loc?.cities" :key="city.id" type="button" class="location-pill" v-class-toggle="{ active: city.active }" :data-location-id="city.id" @click="locationControls?.chooseCity(city.id)">{{ city.label }}</button></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>
<span>&#x1F50E;</span> <span>&#x1F50E;</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" data-i18n-aria-label data-i18n-placeholder /> <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 />
</div> </div>
</div> </div>
<p id="location-search-error" class="location-search-error" role="alert"></p> <p id="location-search-error" class="location-search-error" role="alert">{{ loc?.searchError }}</p>
</div> </div>
</div> </div>
</div> </div>