mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
4966877859
commit
5f8f9bb921
14 files changed
+333
-141
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+83
-60
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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) {
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<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-poi" class="location-mini-poi" data-i18n>Landmark: --</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">{{ loc ? loc.status.poi : t('Landmark: --') }}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></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" v-class-toggle="{ visible: !!loc?.poisExpanded }"></div>
|
||||
<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">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title>
|
||||
<span>🔎</span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user