mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
b6c7c130f6
commit
749bfd5cd4
13 files changed
+273
-143
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 & orbital assets</span>
|
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & 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">
|
||||||
|
|||||||
Reference in new issue
Block a user