feat(ui): render the Context mode switch from a store

ContextControls presents the selected mode, the transition busy state and the nearby sites search through a publish option, and ContextView renders them from the gev-context store. ./ui/context stays free of Vue. The Radio QA reads the search button's aria-busy, the state it actually carries.
This commit is contained in:
Sameh Khamis committed 2026-10-31 00:51:29 -07:00
1 parent b6c7c130f6
commit 749bfd5cd4
13 files changed
+273 -143

No files matched your search

+4 -1
View File
@@ -108,7 +108,10 @@ They contain no application state.
card that LocalSdrControls publishes beside it; fields a person edits take card that LocalSdrControls publishes beside it; fields a person edits take
their value through `v-field-value`, which never writes a focused field; their value through `v-field-value`, which never writes a focused field;
the tuner and the Cockpit radio controls keep their owners' element the tuner and the Cockpit radio controls keep their owners' element
bindings), the command dock, bindings; `ContextView` renders the mode switch and the nearby sites
search from the `gev-context` store, which ContextControls reaches only
through its `publish` option, so `./ui/context` needs no Vue), the
command dock,
the phone shell, then Cockpit. Layers that write into the right rail the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+3
View File
@@ -53,6 +53,7 @@
"src/ui/cctv/CctvPicker.vue", "src/ui/cctv/CctvPicker.vue",
"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/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",
@@ -967,6 +968,7 @@
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"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/display.js" "src/ui/stores/display.js"
], ],
"external": ["cesium", "vue", "pinia"] "external": ["cesium", "vue", "pinia"]
@@ -2936,6 +2938,7 @@
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"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/display.js" "src/ui/stores/display.js"
], ],
"external": [ "external": [
+4 -4
View File
@@ -1100,7 +1100,7 @@ async function main() {
document.getElementById('installations-search-btn').click(); document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const installationFalseMessage = document.getElementById('toast').textContent; const installationFalseMessage = document.getElementById('toast').textContent;
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled; const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => { dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) { if (layerId === 'military-installations' && shouldEnable) {
@@ -1111,7 +1111,7 @@ async function main() {
document.getElementById('installations-search-btn').click(); document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const installationEnableRejectionMessage = document.getElementById('toast').textContent; const installationEnableRejectionMessage = document.getElementById('toast').textContent;
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled; const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => { dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId); if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
@@ -1123,13 +1123,13 @@ async function main() {
document.getElementById('installations-search-btn').click(); document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const installationRejectionMessage = document.getElementById('toast').textContent; const installationRejectionMessage = document.getElementById('toast').textContent;
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled; const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
if (installations) installations.searchNearby = async () => false; if (installations) installations.searchNearby = async () => false;
document.getElementById('installations-search-btn').click(); document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const installationSearchFalseMessage = document.getElementById('toast').textContent; const installationSearchFalseMessage = document.getElementById('toast').textContent;
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled; const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
return { return {
semanticFalseMessage, semanticFalseMessage,
+13 -72
View File
@@ -1,12 +1,12 @@
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js'; import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -22,75 +22,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/); assert.match(button[0], /tabindex="0"/);
} }
const syncSource = _syncContextModeButtons.toString(); // The view keeps both tabs' tabindex static and never disables them; the
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/); // transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
assert.match(syncSource, /button\.tabIndex = 0/); for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/); const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
}); assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
test('Context transition state preserves focus and Tab availability until settle', () => {
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
} }
}); });
@@ -134,8 +71,12 @@ test('Context async action buttons remain focused while busy', () => {
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8'); const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/); assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/); assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/); assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/); assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
assert.match(radioSync, /_cockpitRadioEnableBtn\.disabled = false/); assert.match(radioSync, /_cockpitRadioEnableBtn\.disabled = false/);
+3 -4
View File
@@ -53,6 +53,7 @@ import {
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js'; import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
/** /**
* Central UI orchestrator for the God's Eye View application. * Central UI orchestrator for the God's Eye View application.
@@ -973,17 +974,15 @@ export class StyleManager extends ShellFacade {
_initGlobalContextPanel() { _initGlobalContextPanel() {
const { radioLayer, militaryInstallationsLayer } = this.services; const { radioLayer, militaryInstallationsLayer } = this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({ this._contextControls = new ContextControls({
elements: { elements: {
_globalContextPanel: document.getElementById('global-context-panel'),
_globalContextFlightsBtn: this._globalContextFlightsBtn, _globalContextFlightsBtn: this._globalContextFlightsBtn,
_globalContextMissionsBtn: this._globalContextMissionsBtn, _globalContextMissionsBtn: this._globalContextMissionsBtn,
_contextModeStandby: this._contextModeStandby,
_contextFlightsView: this._contextFlightsView,
_contextMissionsView: this._contextMissionsView,
_installationsSearchBtn: this._installationsSearchBtn, _installationsSearchBtn: this._installationsSearchBtn,
}, },
installations: militaryInstallationsLayer, installations: militaryInstallationsLayer,
publish: (view) => contextStore.publish(view),
actions: { actions: {
getCockpit: () => this.cockpitView, getCockpit: () => this.cockpitView,
refreshRadio: () => this._renderRadioState(radioLayer.getUIState()), refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
+5 -6
View File
@@ -86,10 +86,9 @@ export function _initGlobalContextPanel() {
!this._dataManager?.layers?.has('military-installations') !this._dataManager?.layers?.has('military-installations')
) )
return; return;
const button = this._installationsSearchBtn; if (this._installationsSearchBusy) return;
if (button.getAttribute('aria-busy') === 'true') return; this._installationsSearchBusy = true;
button.setAttribute('aria-disabled', 'true'); this._presentContext();
button.setAttribute('aria-busy', 'true');
void this._runUserFacingContextAction(async (notificationToken) => { void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled( const enabled = await this._dataManager.setEnabled(
'military-installations', 'military-installations',
@@ -118,8 +117,8 @@ export function _initGlobalContextPanel() {
}, t('Nearby installations could not be refreshed; try again')).finally( }, t('Nearby installations could not be refreshed; try again')).finally(
() => { () => {
if (this.destroyed) return; if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false'); this._installationsSearchBusy = false;
button.setAttribute('aria-busy', 'false'); this._presentContext();
}, },
); );
}); });
+15 -3
View File
@@ -1,5 +1,8 @@
import { _initGlobalContextPanel } from './contextBindings.js'; import { _initGlobalContextPanel } from './contextBindings.js';
import { _syncContextModeButtons } from './contextPresentation.js'; import {
_syncContextModeButtons,
_presentContext,
} from './contextPresentation.js';
import { import {
_trackContextLayerReaction, _trackContextLayerReaction,
_waitForContextLayerSettlement, _waitForContextLayerSettlement,
@@ -22,12 +25,18 @@ import {
} from './contextActions.js'; } from './contextActions.js';
import { connectContextManager } from './contextSubscriptions.js'; import { connectContextManager } from './contextSubscriptions.js';
/** Own Context transactions and controls through explicit layer and presentation ports. */ /**
* Own Context transactions and controls through explicit layer and
* presentation ports. The mode switch is presented through `publish`
* (the app hands it the `gev-context` store) and rendered by the view.
*/
export class ContextControls { export class ContextControls {
constructor({ elements, installations, actions }) { constructor({ elements, installations, actions, publish = () => {} }) {
Object.assign(this, elements); Object.assign(this, elements);
this.installations = installations; this.installations = installations;
this.actions = actions; this.actions = actions;
this.publish = publish;
this._installationsSearchBusy = false;
this.listeners = new AbortController(); this.listeners = new AbortController();
this.destroyed = false; this.destroyed = false;
this._dataManager = null; this._dataManager = null;
@@ -93,6 +102,9 @@ export class ContextControls {
_syncContextModeButtons(...args) { _syncContextModeButtons(...args) {
return _syncContextModeButtons.call(this, ...args); return _syncContextModeButtons.call(this, ...args);
} }
_presentContext(...args) {
return _presentContext.call(this, ...args);
}
_trackContextLayerReaction(...args) { _trackContextLayerReaction(...args) {
return _trackContextLayerReaction.call(this, ...args); return _trackContextLayerReaction.call(this, ...args);
} }
+8 -7
View File
@@ -17,7 +17,9 @@ class Button extends EventTarget {
const turn = () => new Promise((resolve) => setImmediate(resolve)); const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t, elements = {}) { function fixture(t, elements = {}) {
const calls = []; const calls = [];
const views = [];
const controls = new ContextControls({ const controls = new ContextControls({
publish: (view) => views.push(view),
elements, elements,
installations: {}, installations: {},
actions: { actions: {
@@ -35,7 +37,7 @@ function fixture(t, elements = {}) {
controls.stop(); controls.stop();
controls.disconnect(); controls.disconnect();
}); });
return { controls, calls }; return { controls, calls, views };
} }
test('Context starts with an explicit idle snapshot', (t) => { test('Context starts with an explicit idle snapshot', (t) => {
@@ -70,7 +72,9 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => {
test('stopping during installations enable prevents the delayed search', async (t) => { test('stopping during installations enable prevents the delayed search', async (t) => {
const button = new Button(); const button = new Button();
const { controls, calls } = fixture(t, { _installationsSearchBtn: button }); const { controls, calls, views } = fixture(t, {
_installationsSearchBtn: button,
});
let release; let release;
controls._dataManager = { controls._dataManager = {
layers: new Map([['military-installations', {}]]), layers: new Map([['military-installations', {}]]),
@@ -87,11 +91,8 @@ test('stopping during installations enable prevents the delayed search', async (
release(true); release(true);
await turn(); await turn();
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
assert.equal( assert.equal(views.length, 1, 'disposed controls publish nothing more');
button.getAttribute('aria-busy'), assert.equal(views[0].installationsBusy, true);
'true',
'disposed controls are not mutated by completion',
);
}); });
test('a late installations search cannot publish a notice after disposal', async (t) => { test('a late installations search cannot publish a notice after disposal', async (t) => {
+163
View File
@@ -0,0 +1,163 @@
// src/ui/contextPanel.test.mjs — the Context mode switch renders what
// ContextControls presents (`_presentContext()`), over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { useContextStore } from './stores/context.js';
import ContextView from './views/ContextView.vue';
import { ContextControls } from './contextControls.js';
const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(ContextView).use(pinia);
app.mount(host);
const $ = (id) => document.getElementById(id);
const store = useContextStore(pinia);
const controls = new ContextControls({
elements: {
_globalContextFlightsBtn: $('global-context-flights-btn'),
_globalContextMissionsBtn: $('global-context-missions-btn'),
_installationsSearchBtn: $('installations-search-btn'),
},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority() {},
showToast() {},
setClearBusy() {},
syncDetection() {},
scheduleLayout() {},
refreshRadio() {},
setPanelCollapsed() {},
},
publish: (view) => store.publish(view),
});
t.after(() => {
controls.stop();
controls.disconnect();
app.unmount();
});
const sync = async () => {
controls._syncContextModeButtons();
await nextTick();
};
return { $, controls, sync };
}
test('before ContextControls renders, the switch shows its initial markup', (t) => {
const { $ } = fixture(t);
const panel = $('global-context-panel');
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(panel.hasAttribute('data-context-mode'), false);
for (const id of [
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const tab = $(id);
assert.equal(tab.className, 'context-mode-button');
assert.equal(tab.getAttribute('aria-selected'), 'false');
assert.equal(tab.getAttribute('tabindex'), '0');
assert.equal(tab.hasAttribute('aria-busy'), false);
}
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
assert.equal($('context-missions-view').hidden, true);
assert.equal($('installations-search-btn').hasAttribute('aria-busy'), false);
});
test('a transition keeps focus and Tab on both tabs until it settles', async (t) => {
const { $, controls, sync } = fixture(t);
const contacts = $('global-context-flights-btn');
const missions = $('global-context-missions-btn');
missions.focus();
controls._contextModeChanging = true;
await sync();
assert.equal(
document.activeElement === missions,
true,
'busy render keeps focus',
);
for (const tab of [contacts, missions]) {
assert.equal(tab.disabled, false);
assert.equal(tab.getAttribute('tabindex'), '0');
assert.equal(tab.getAttribute('aria-disabled'), 'true');
assert.equal(tab.getAttribute('aria-busy'), 'true');
}
controls._contextModeChanging = false;
controls._contextMode = 'space-missions';
await sync();
assert.equal(
document.activeElement === missions,
true,
'settled render keeps focus',
);
for (const tab of [contacts, missions]) {
assert.equal(tab.disabled, false);
assert.equal(tab.getAttribute('aria-disabled'), 'false');
assert.equal(tab.getAttribute('aria-busy'), 'false');
}
assert.equal(missions.classList.contains('active'), true);
assert.equal(missions.getAttribute('aria-selected'), 'true');
assert.equal(contacts.classList.contains('active'), false);
assert.equal(contacts.getAttribute('aria-selected'), 'false');
assert.equal($('context-mode-standby').hidden, true);
assert.equal($('context-flights-view').hidden, true);
assert.equal($('context-missions-view').hidden, false);
});
test('the panel carries the mode beside the classes its chrome writes', async (t) => {
const { $, controls, sync } = fixture(t);
const panel = $('global-context-panel');
panel.classList.remove('collapsed');
controls._contextMode = 'flights';
await sync();
assert.equal(panel.className, 'panel-collapsible context-enabled');
assert.equal(panel.getAttribute('data-context-mode'), 'flights');
assert.equal($('context-flights-view').hidden, false);
panel.classList.add('collapsed');
controls._contextMode = null;
await sync();
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(panel.getAttribute('data-context-mode'), 'none');
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
});
test('the nearby sites search is busy, without native disable, until it settles', async (t) => {
const { $, controls } = fixture(t);
const button = $('installations-search-btn');
let release;
let enables = 0;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
setEnabled: () => {
enables += 1;
return new Promise((resolve) => (release = resolve));
},
isEnabled: () => true,
};
controls.installations.searchNearby = async () => true;
button.focus();
button.click();
await nextTick();
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.disabled, false);
assert.equal(document.activeElement === button, true);
button.click();
assert.equal(enables, 1, 'a busy search ignores a second click');
release(true);
await turn();
await nextTick();
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.getAttribute('aria-disabled'), 'false');
});
+21 -36
View File
@@ -1,44 +1,29 @@
export function _syncContextModeButtons() { export function _syncContextModeButtons() {
if (this.destroyed) return; if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights'; this._presentContext();
const missionsActive = this._contextMode === 'space-missions';
const panel = this._globalContextPanel;
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
this._globalContextFlightsBtn?.setAttribute(
'aria-selected',
String(flightsActive),
);
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute(
'aria-selected',
String(missionsActive),
);
const transitionBusy = Boolean(this._contextModeChanging);
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still
// provide tablist navigation, but must not be the only way to reach Space
// Missions from the keyboard. Semantic busy state keeps them perceivable
// while synchronous click guards prevent a second transition.
for (const button of [
this._globalContextFlightsBtn,
this._globalContextMissionsBtn,
]) {
if (!button) continue;
button.disabled = false;
button.tabIndex = 0;
button.setAttribute('aria-disabled', String(transitionBusy));
button.setAttribute('aria-busy', String(transitionBusy));
}
if (this._contextModeStandby)
this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextFlightsView)
this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView)
this._contextMissionsView.hidden = !missionsActive;
this.cockpitView?.syncEntry(); this.cockpitView?.syncEntry();
// Every _contextMode mutation funnels through here; the sync no-ops until // Every _contextMode mutation funnels through here; the sync no-ops until
// the transaction settles, so this is the activation/deactivation edge. // the transaction settles, so this is the activation/deactivation edge.
this.actions.syncDetection(); this.actions.syncDetection();
this.actions.scheduleLayout(); this.actions.scheduleLayout();
} }
/** Publish the mode switch and the nearby sites search to the view. */
export function _presentContext() {
if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights';
const missionsActive = this._contextMode === 'space-missions';
// Both Context choices stay in the ordinary Tab sequence and are never
// native-disabled. Arrow keys still provide tablist navigation, but must
// not be the only way to reach Space Missions from the keyboard. Semantic
// busy state keeps them perceivable while synchronous click guards prevent
// a second transition.
this.publish({
mode: this._contextMode || 'none',
enabled: flightsActive || missionsActive,
flightsActive,
missionsActive,
busy: Boolean(this._contextModeChanging),
installationsBusy: this._installationsSearchBusy,
});
}
-3
View File
@@ -85,9 +85,6 @@ export function readShellElements(document = globalThis.document) {
_globalContextMissionsBtn: document.getElementById( _globalContextMissionsBtn: document.getElementById(
'global-context-missions-btn', 'global-context-missions-btn',
), ),
_contextModeStandby: document.getElementById('context-mode-standby'),
_contextFlightsView: document.getElementById('context-flights-view'),
_contextMissionsView: document.getElementById('context-missions-view'),
_installationsSearchBtn: document.getElementById( _installationsSearchBtn: document.getElementById(
'installations-search-btn', 'installations-search-btn',
), ),
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The Context panel's mode switch as its view renders it. ContextControls
* presents the selected mode, the transition busy state and the nearby
* sites search (`_presentContext()`) and publishes them here; it
* keeps its listeners and transactions.
*/
export const useContextStore = defineStore('gev-context', () => {
/** The presented mode switch; 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 = markRaw(next);
}
return { view, publish };
});
+14 -7
View File
@@ -1,5 +1,12 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia';
import { vClassToggle } from '../rail/classToggle.js';
import { useContextStore } from '../stores/context.js';
import RadioPanel from './panels/RadioPanel.vue'; import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore());
</script> </script>
<template> <template>
@@ -29,7 +36,7 @@ import RadioPanel from './panels/RadioPanel.vue';
<div id="recent-imagery-panel-body" data-rail-scroller></div> <div id="recent-imagery-panel-body" data-rail-scroller></div>
</div> </div>
</div> </div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel"> <div id="global-context-panel" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="global-context-panel-inner" data-rail-scroller> <div class="global-context-panel-inner" data-rail-scroller>
<div class="panel-header"> <div class="panel-header">
@@ -39,26 +46,26 @@ import RadioPanel from './panels/RadioPanel.vue';
</div> </div>
<div class="cyber-panel-body" data-rail-scroller> <div class="cyber-panel-body" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label> <div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title> <button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">radar</span> <span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span data-i18n>CONTACTS</span> <span data-i18n>CONTACTS</span>
</button> </button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view"> <button id="global-context-missions-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.missionsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.missionsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span> <span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span data-i18n>SPACE MISSIONS</span> <span data-i18n>SPACE MISSIONS</span>
</button> </button>
</div> </div>
<div id="context-mode-standby" class="context-mode-standby"> <div id="context-mode-standby" class="context-mode-standby" :hidden="ctx?.enabled">
<strong data-i18n>SELECT CONTEXT</strong> <strong data-i18n>SELECT CONTEXT</strong>
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span> <span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div> </div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden> <section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label> <div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
<button id="cockpit-entry" type="button" hidden> <button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span> <span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span data-i18n>COCKPIT</span> <span data-i18n>COCKPIT</span>
</button> </button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n>SEARCH NEARBY SITES</button> <button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button> <button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
</div> </div>
<aside id="military-awareness-panel"> <aside id="military-awareness-panel">
@@ -68,7 +75,7 @@ import RadioPanel from './panels/RadioPanel.vue';
</div> </div>
</aside> </aside>
</section> </section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden> <section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
<div id="space-mission-panel-host"> <div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label> <div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div class="space-mission-roster-header"> <div class="space-mission-roster-header">