From 749bfd5cd4733fae1c999c46272f54f261c9923f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 00:51:29 -0700 Subject: [PATCH] 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. --- docs/VUE-MIGRATION.md | 5 +- scripts/package-boundaries.json | 3 + scripts/qa-radio.mjs | 8 +- src/contextTabKeyboard.test.mjs | 85 +++-------------- src/ui/applicationShell.js | 7 +- src/ui/contextBindings.js | 11 +-- src/ui/contextControls.js | 18 +++- src/ui/contextControls.test.mjs | 15 +-- src/ui/contextPanel.test.mjs | 163 ++++++++++++++++++++++++++++++++ src/ui/contextPresentation.js | 57 ++++------- src/ui/shellElements.js | 3 - src/ui/stores/context.js | 20 ++++ src/ui/views/ContextView.vue | 21 ++-- 13 files changed, 273 insertions(+), 143 deletions(-) create mode 100644 src/ui/contextPanel.test.mjs create mode 100644 src/ui/stores/context.js diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 5e173c829..72b33c1b7 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 361cebe60..dc216dd7b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [ diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 366557185..74ccc338b 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -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, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 07386647e..e7de01feb 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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(`