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
`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
+3 -1
View File
@@ -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.
+3
View File
@@ -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": [
+18 -7
View File
@@ -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 }),
+5 -6
View File
@@ -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) =>
+100
View File
@@ -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
View File
@@ -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();
}
}
+60 -19
View File
@@ -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 } });
+5 -16
View File
@@ -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();
+7 -12
View File
@@ -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);
});
}
+4 -7
View File
@@ -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');
});
-6
View File
@@ -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'),
+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 { 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>&#x1F50E;</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>