mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 { 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>🔎</span>
|
<span>🔎</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" 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>
|
||||||
|
|||||||
Reference in new issue
Block a user