mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
|
||||
their value through `v-field-value`, which never writes a focused field;
|
||||
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
|
||||
directly gain a slot instead.
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"src/ui/cctv/CctvPicker.vue",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/context.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/views/panels/DisplayPanel.vue",
|
||||
@@ -967,6 +968,7 @@
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/context.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia"]
|
||||
@@ -2936,6 +2938,7 @@
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/context.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
"external": [
|
||||
|
||||
@@ -1100,7 +1100,7 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) {
|
||||
@@ -1111,7 +1111,7 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
||||
@@ -1123,13 +1123,13 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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;
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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));
|
||||
return {
|
||||
semanticFalseMessage,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
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 contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
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"/);
|
||||
}
|
||||
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
|
||||
assert.match(syncSource, /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
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;
|
||||
// The view keeps both tabs' tabindex static and never disables them; the
|
||||
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
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.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
assert.match(radioSync, /_cockpitRadioEnableBtn\.disabled = false/);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
|
||||
import { PanelNavigation } from './panelNavigation.js';
|
||||
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
|
||||
/**
|
||||
* Central UI orchestrator for the God's Eye View application.
|
||||
@@ -973,17 +974,15 @@ export class StyleManager extends ShellFacade {
|
||||
|
||||
_initGlobalContextPanel() {
|
||||
const { radioLayer, militaryInstallationsLayer } = this.services;
|
||||
const contextStore = useContextStore(applicationPinia());
|
||||
this._contextControls = new ContextControls({
|
||||
elements: {
|
||||
_globalContextPanel: document.getElementById('global-context-panel'),
|
||||
_globalContextFlightsBtn: this._globalContextFlightsBtn,
|
||||
_globalContextMissionsBtn: this._globalContextMissionsBtn,
|
||||
_contextModeStandby: this._contextModeStandby,
|
||||
_contextFlightsView: this._contextFlightsView,
|
||||
_contextMissionsView: this._contextMissionsView,
|
||||
_installationsSearchBtn: this._installationsSearchBtn,
|
||||
},
|
||||
installations: militaryInstallationsLayer,
|
||||
publish: (view) => contextStore.publish(view),
|
||||
actions: {
|
||||
getCockpit: () => this.cockpitView,
|
||||
refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
|
||||
|
||||
@@ -86,10 +86,9 @@ export function _initGlobalContextPanel() {
|
||||
!this._dataManager?.layers?.has('military-installations')
|
||||
)
|
||||
return;
|
||||
const button = this._installationsSearchBtn;
|
||||
if (button.getAttribute('aria-busy') === 'true') return;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
if (this._installationsSearchBusy) return;
|
||||
this._installationsSearchBusy = true;
|
||||
this._presentContext();
|
||||
void this._runUserFacingContextAction(async (notificationToken) => {
|
||||
const enabled = await this._dataManager.setEnabled(
|
||||
'military-installations',
|
||||
@@ -118,8 +117,8 @@ export function _initGlobalContextPanel() {
|
||||
}, t('Nearby installations could not be refreshed; try again')).finally(
|
||||
() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
this._installationsSearchBusy = false;
|
||||
this._presentContext();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { _initGlobalContextPanel } from './contextBindings.js';
|
||||
import { _syncContextModeButtons } from './contextPresentation.js';
|
||||
import {
|
||||
_syncContextModeButtons,
|
||||
_presentContext,
|
||||
} from './contextPresentation.js';
|
||||
import {
|
||||
_trackContextLayerReaction,
|
||||
_waitForContextLayerSettlement,
|
||||
@@ -22,12 +25,18 @@ import {
|
||||
} from './contextActions.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 {
|
||||
constructor({ elements, installations, actions }) {
|
||||
constructor({ elements, installations, actions, publish = () => {} }) {
|
||||
Object.assign(this, elements);
|
||||
this.installations = installations;
|
||||
this.actions = actions;
|
||||
this.publish = publish;
|
||||
this._installationsSearchBusy = false;
|
||||
this.listeners = new AbortController();
|
||||
this.destroyed = false;
|
||||
this._dataManager = null;
|
||||
@@ -93,6 +102,9 @@ export class ContextControls {
|
||||
_syncContextModeButtons(...args) {
|
||||
return _syncContextModeButtons.call(this, ...args);
|
||||
}
|
||||
_presentContext(...args) {
|
||||
return _presentContext.call(this, ...args);
|
||||
}
|
||||
_trackContextLayerReaction(...args) {
|
||||
return _trackContextLayerReaction.call(this, ...args);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ class Button extends EventTarget {
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function fixture(t, elements = {}) {
|
||||
const calls = [];
|
||||
const views = [];
|
||||
const controls = new ContextControls({
|
||||
publish: (view) => views.push(view),
|
||||
elements,
|
||||
installations: {},
|
||||
actions: {
|
||||
@@ -35,7 +37,7 @@ function fixture(t, elements = {}) {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
});
|
||||
return { controls, calls };
|
||||
return { controls, calls, views };
|
||||
}
|
||||
|
||||
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) => {
|
||||
const button = new Button();
|
||||
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
|
||||
const { controls, calls, views } = fixture(t, {
|
||||
_installationsSearchBtn: button,
|
||||
});
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
@@ -87,11 +91,8 @@ test('stopping during installations enable prevents the delayed search', async (
|
||||
release(true);
|
||||
await turn();
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(
|
||||
button.getAttribute('aria-busy'),
|
||||
'true',
|
||||
'disposed controls are not mutated by completion',
|
||||
);
|
||||
assert.equal(views.length, 1, 'disposed controls publish nothing more');
|
||||
assert.equal(views[0].installationsBusy, true);
|
||||
});
|
||||
|
||||
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() {
|
||||
if (this.destroyed) return;
|
||||
const flightsActive = this._contextMode === 'flights';
|
||||
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._presentContext();
|
||||
this.cockpitView?.syncEntry();
|
||||
// Every _contextMode mutation funnels through here; the sync no-ops until
|
||||
// the transaction settles, so this is the activation/deactivation edge.
|
||||
this.actions.syncDetection();
|
||||
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(
|
||||
'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(
|
||||
'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>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { useContextStore } from '../stores/context.js';
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -29,7 +36,7 @@ import RadioPanel from './panels/RadioPanel.vue';
|
||||
<div id="recent-imagery-panel-body" data-rail-scroller></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="global-context-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
@@ -39,26 +46,26 @@ import RadioPanel from './panels/RadioPanel.vue';
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<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 data-i18n>CONTACTS</span>
|
||||
</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 data-i18n>SPACE MISSIONS</span>
|
||||
</button>
|
||||
</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>
|
||||
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</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>
|
||||
<button id="cockpit-entry" type="button" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span data-i18n>COCKPIT</span>
|
||||
</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>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
@@ -68,7 +75,7 @@ import RadioPanel from './panels/RadioPanel.vue';
|
||||
</div>
|
||||
</aside>
|
||||
</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-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
|
||||
<div class="space-mission-roster-header">
|
||||
|
||||
Reference in new issue
Block a user