feat(ui): render the Cockpit utility launchers and compact Radio controls from a store

The Display and Radio launchers, their popovers and the compact Radio
transport, Enable button and volume now render from the Radio store.
RadioControls owns the disclosure state; Cockpit Escape and the Display
reveal read that state instead of the DOM, and the Enable toggle guards
re-presses synchronously. The compact transport reuses the panel's
transport state and adds only its glyph and title.
This commit is contained in:
Sameh Khamis committed 2026-10-31 16:53:28 -07:00
1 parent 58504e370c
commit b6d565a83f
20 files changed
+495 -205

No files matched your search

+2 -10
View File
@@ -235,7 +235,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
@@ -476,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css,
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
);
assert.match(
radioBindings,
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
);
assert.match(
radioBindings,
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
);
assert.match(
css,
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
@@ -1096,7 +1088,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
);
assert.match(
ui,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?classList\.remove\('collapsed'\)/,
);
assert.match(
radioBindings,
+5 -3
View File
@@ -5,7 +5,6 @@ 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';
@@ -67,7 +66,6 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('this._radioView = {'));
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString();
@@ -79,7 +77,11 @@ test('Context async action buttons remain focused while busy', () => {
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/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<button id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
// The Radio view never disables its Enable button; it marks it busy.
const enable = radioPanel.match(/<button id="radio-enable-btn"[^>]*>/)?.[0];
assert.ok(enable, 'Radio Enable button is missing');
+8 -8
View File
@@ -213,7 +213,6 @@ export class StyleManager extends ShellFacade {
setScopeMaskFeather: services.setScopeMaskFeather,
},
elements: {
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_ppToggles: this._ppToggles,
_sliderContainer: this._sliderContainer,
_sliderPanel: this._sliderPanel,
@@ -226,6 +225,7 @@ export class StyleManager extends ShellFacade {
_scheduleRightPanelLayout: (...args) =>
this._scheduleRightPanelLayout(...args),
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args),
_syncPanelCollapseButton: (...args) =>
this._syncPanelCollapseButton(...args),
@@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade {
readDataManager: () => this._dataManager,
readContext: () => this.getContextModeState(),
readActiveStyle: () => this.activeStyle,
readUtilityDisclosureOpen: () =>
this._cockpitDisclosureOpen('display') ||
this._cockpitDisclosureOpen('radio'),
enterPanels: () => this._panelChrome.enterCockpit(),
exitPanels: () => this._panelChrome.exitCockpit(),
});
@@ -1043,17 +1046,13 @@ export class StyleManager extends ShellFacade {
this._radioControls?.destroy();
this._radioControls = new RadioControls({
elements: {
_cockpitDisplayPanel: this._cockpitDisplayPanel,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
_cockpitRadioNextBtn: this._cockpitRadioNextBtn,
_cockpitRadioPanel: this._cockpitRadioPanel,
_cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
_cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
_cockpitRadioStation: this._cockpitRadioStation,
_cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
_cockpitUtilityControls: this._cockpitUtilityControls,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
@@ -1713,9 +1712,10 @@ export class StyleManager extends ShellFacade {
this._updateHudButtonState();
this._lifetime.listen(this._cockpitDisplayToggleBtn, 'click', () => {
const open =
this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true';
this._setCockpitDisclosure?.('display', !open);
this._setCockpitDisclosure?.(
'display',
!this._cockpitDisclosureOpen('display'),
);
});
this._initCockpitDisplayPortal();
}
+5
View File
@@ -64,6 +64,7 @@ export class CockpitViewController {
getInitialVisionMode = null,
onCameraTakeover = null,
isEntryAllowed = null,
isUtilityDisclosureOpen = null,
onEntered = null,
onExited = null,
restoreTrackingFrame = null,
@@ -112,6 +113,10 @@ export class CockpitViewController {
: () => false;
this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.isUtilityDisclosureOpen =
typeof isUtilityDisclosureOpen === 'function'
? isUtilityDisclosureOpen
: () => false;
this.context = document.getElementById('cockpit-context');
this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next');
+3
View File
@@ -14,6 +14,7 @@ export class CockpitCoordinator {
readDataManager,
readContext,
readActiveStyle,
readUtilityDisclosureOpen,
enterPanels,
exitPanels,
}) {
@@ -25,6 +26,7 @@ export class CockpitCoordinator {
readDataManager,
readContext,
readActiveStyle,
readUtilityDisclosureOpen,
enterPanels,
exitPanels,
},
@@ -83,6 +85,7 @@ export class CockpitCoordinator {
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
restoreTrackingFrame: (entity) => {
+2 -4
View File
@@ -8,10 +8,8 @@ export function onKeyDown(event) {
// focus. Its target handler closes the overlay and restores attribution
// focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
)
return;
// An open Display or Radio popover takes Escape itself.
if (this.isUtilityDisclosureOpen?.()) return;
if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
+297
View File
@@ -0,0 +1,297 @@
// src/ui/cockpitUtilityControls.test.mjs — the Cockpit's Display and Radio
// launchers and the compact Radio controls render what RadioControls
// presents, over happy-dom with the real controls bound to them.
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 { setLocale } from '../i18n/index.js';
import { onKeyDown as cockpitKeyDown } from './cockpitInput.js';
import { RadioControls } from './radioControls.js';
import { renderRadioState } from './radioPresentation.js';
import { useRadioStore } from './stores/radio.js';
import CockpitUtilityControls from './views/panels/CockpitUtilityControls.vue';
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const outside = document.body.appendChild(document.createElement('button'));
const pinia = createPinia();
const app = createApp(CockpitUtilityControls).use(pinia);
app.mount(host);
const $ = (id) => document.getElementById(id);
const calls = [];
let enabled = false;
let toggles = 0;
let finishToggle = null;
const actions = {
isRegistered: () => true,
isEnabled: () => enabled,
runUserAction: (operation) => operation(),
setEnabled: (next) => {
toggles += 1;
return new Promise((resolve) => {
finishToggle = () => {
enabled = next;
resolve(true);
};
});
},
setParams: (params) => calls.push(['params', params]),
setPanelCollapsed() {},
setSignalCollapsed: (collapsed) => calls.push(['signal', collapsed]),
revealStyleParameters: () => calls.push(['reveal']),
layoutCockpit() {},
isCockpitActive: () => true,
signalUserCollapsed: () => false,
scheduleLayout() {},
getLifecycle: () => null,
preservePanelStateDuringClear: () => true,
};
const controls = new RadioControls({
elements: {
_radioPanel: document.createElement('section'),
// A bare tuner: the panel's tuner has its own tests.
_radioTuner: document.createElement('div'),
_radioTunerSlider: Object.assign(document.createElement('input'), {
type: 'range',
}),
_cockpitUtilityControls: $('cockpit-utility-controls'),
_cockpitDisplayToggleBtn: $('cockpit-display-toggle-btn'),
_cockpitRadioToggleBtn: $('cockpit-radio-toggle-btn'),
_cockpitRadioEnableBtn: $('cockpit-radio-enable-btn'),
_cockpitRadioPrevBtn: $('cockpit-radio-prev-btn'),
_cockpitRadioPlayBtn: $('cockpit-radio-play-btn'),
_cockpitRadioNextBtn: $('cockpit-radio-next-btn'),
_cockpitRadioVolume: $('cockpit-radio-volume'),
},
radio: {
subscribe: () => () => {},
endTuning() {},
getTunerStations: () => [],
},
actions,
canvas: document.createElement('canvas'),
store: useRadioStore(pinia),
});
t.after(() => {
controls.destroy();
app.unmount();
});
const render = async (state) => {
renderRadioState.call(controls, {
enabled: true,
categories: [{ id: 'all', label: 'All', count: 3, color: '' }],
filter: 'all',
filteredCount: 3,
stationCount: 3,
volume: 0.8,
audioState: 'stopped',
...state,
});
await nextTick();
};
return {
$,
controls,
calls,
render,
outside,
finishToggle: () => finishToggle(),
toggles: () => toggles,
};
}
const control = (button) => button.closest('.cockpit-utility-control');
test('before RadioControls presents, both popovers are closed and the controls start idle', (t) => {
const { $ } = fixture(t);
for (const kind of ['display', 'radio']) {
const toggle = $(`cockpit-${kind}-toggle-btn`);
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(toggle.textContent, '◀');
assert.equal($(`cockpit-${kind}-panel`).hidden, true);
assert.equal(control(toggle).classList.contains('is-expanded'), false);
}
assert.equal(
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
'Expand Cockpit display options',
);
assert.equal(
$('cockpit-radio-toggle-btn').title,
'Expand Cockpit Radio controls',
);
assert.equal($('cockpit-radio-station').textContent, 'READY');
const enable = $('cockpit-radio-enable-btn');
assert.equal(enable.textContent, 'ENABLE');
assert.equal(enable.getAttribute('aria-pressed'), 'false');
assert.equal(enable.hasAttribute('aria-busy'), false);
assert.equal($('cockpit-radio-play-btn').disabled, true);
assert.equal($('cockpit-radio-play-btn').textContent, '▶');
assert.equal($('cockpit-radio-volume').value, '80');
assert.equal($('cockpit-radio-volume').disabled, true);
assert.equal($('cockpit-radio-volume-value').textContent, '80%');
assert.equal(
document.querySelectorAll('[data-cockpit-display-slot]').length,
4,
);
});
test('a launcher opens its popover, closes the other, and Escape returns focus', async (t) => {
const { $, controls, calls } = fixture(t);
// The application shell takes the Display launcher's clicks.
controls._setCockpitDisclosure('display', true);
await nextTick();
assert.equal(controls._cockpitDisclosureOpen('display'), true);
assert.equal($('cockpit-display-panel').hidden, false);
assert.equal($('cockpit-display-toggle-btn').textContent, '▶');
assert.equal(
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
'Collapse Cockpit display options',
);
assert.equal(
control($('cockpit-display-toggle-btn')).classList.contains('is-expanded'),
true,
);
assert.deepEqual(calls.slice(0, 2), [['signal', true], ['reveal']]);
$('cockpit-radio-toggle-btn').click();
await nextTick();
assert.equal($('cockpit-display-panel').hidden, true);
assert.equal($('cockpit-radio-panel').hidden, false);
assert.equal(
$('cockpit-radio-toggle-btn').getAttribute('aria-expanded'),
'true',
);
$('cockpit-radio-play-btn').dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, true);
assert.equal(document.activeElement === $('cockpit-radio-toggle-btn'), true);
assert.deepEqual(calls.at(-1), ['signal', false]);
});
test('a press outside the Cockpit controls closes the open popover', async (t) => {
const { $, controls, outside } = fixture(t);
controls._setCockpitDisclosure('radio', true);
await nextTick();
$('cockpit-radio-volume').dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
);
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, false);
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, true);
assert.equal(controls._cockpitDisclosureOpen('radio'), false);
});
test('the compact controls render the presented Radio state', async (t) => {
const { $, render } = fixture(t);
await render({
selected: { id: 's1', name: 'KUTX' },
audioState: 'playing',
volume: 0.35,
});
assert.equal($('cockpit-radio-station').textContent, 'KUTX');
const enable = $('cockpit-radio-enable-btn');
assert.equal(enable.textContent, 'DISABLE');
assert.equal(enable.classList.contains('active'), true);
assert.equal(enable.classList.contains('panel-layer-toggle'), true);
assert.equal(enable.getAttribute('aria-pressed'), 'true');
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
assert.equal(enable.getAttribute('aria-busy'), 'false');
assert.equal(enable.disabled, false);
const play = $('cockpit-radio-play-btn');
assert.equal(play.disabled, false);
assert.equal(play.textContent, 'Ⅱ');
assert.equal(play.classList.contains('active'), true);
assert.equal(play.getAttribute('aria-label'), 'Pause selected radio station');
assert.equal(play.title, 'Pause');
assert.equal($('cockpit-radio-prev-btn').disabled, false);
assert.equal($('cockpit-radio-volume').value, '35');
assert.equal($('cockpit-radio-volume').disabled, false);
assert.equal($('cockpit-radio-volume-value').textContent, '35%');
await render({ enabled: false, selected: null, loading: true });
assert.equal($('cockpit-radio-station').textContent, 'SYNCING');
assert.equal(enable.textContent, 'ENABLE');
assert.equal(enable.classList.contains('active'), false);
assert.equal(play.disabled, true);
assert.equal(play.textContent, '▶');
assert.equal($('cockpit-radio-volume').disabled, true);
});
test('dragging the volume shows the value at once and a render leaves the held slider alone', async (t) => {
const { $, calls, render } = fixture(t);
await render({ volume: 0.8 });
const volume = $('cockpit-radio-volume');
volume.focus();
volume.value = '42';
volume.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
assert.equal($('cockpit-radio-volume-value').textContent, '42%');
assert.deepEqual(calls.at(-1), ['params', { volume: 0.42 }]);
// A render reporting an older volume while the slider is held.
await render({ volume: 0.8 });
assert.equal(volume.value, '42');
});
test('the Cockpit Enable button is marked busy while its own toggle runs', async (t) => {
const { $, render, finishToggle, toggles } = fixture(t);
await render({ enabled: false });
const enable = $('cockpit-radio-enable-btn');
// A double press lands before the busy state renders; only one toggle runs.
enable.click();
enable.click();
assert.equal(toggles(), 1);
await nextTick();
assert.equal(enable.getAttribute('aria-busy'), 'true');
assert.equal(enable.getAttribute('aria-disabled'), 'true');
assert.equal(enable.disabled, false);
// A second press while busy starts nothing.
enable.click();
assert.equal(toggles(), 1);
finishToggle();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
assert.equal(enable.getAttribute('aria-busy'), 'false');
});
test('the launchers read in the UI language', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const { $, controls } = fixture(t);
const before = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
controls._setCockpitDisclosure('radio', true);
await nextTick();
const after = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
assert.notEqual(before, 'Expand Cockpit Radio controls');
assert.notEqual(after, before);
assert.notEqual(after, 'Collapse Cockpit Radio controls');
});
test('Cockpit Escape defers to a popover the moment its disclosure opens', async (t) => {
const { controls } = fixture(t);
let exits = 0;
const cockpit = {
active: true,
isUtilityDisclosureOpen: () =>
controls._cockpitDisclosureOpen('display') ||
controls._cockpitDisclosureOpen('radio'),
exit: () => (exits += 1),
};
const escape = () =>
cockpitKeyDown.call(
cockpit,
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
// Dispatched in the same task, before Vue renders the open popover.
controls._setCockpitDisclosure('radio', true);
escape();
assert.equal(exits, 0);
controls._setCockpitDisclosure('radio', false);
escape();
assert.equal(exits, 1);
await nextTick();
});
+24 -65
View File
@@ -20,56 +20,9 @@ export function bindRadioControls() {
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
if (this._cockpitDisplayPanel)
this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute(
'aria-expanded',
String(displayOpen),
);
this._cockpitRadioToggleBtn?.setAttribute(
'aria-expanded',
String(radioOpen),
);
this._cockpitDisclosure = { display: displayOpen, radio: radioOpen };
this.store?.publishUtility({ ...this._cockpitDisclosure });
if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) {
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute(
'aria-label',
t(
displayOpen
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
),
);
this._cockpitDisplayToggleBtn.title = t(
displayOpen
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
);
}
if (this._cockpitRadioToggleBtn) {
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute(
'aria-label',
t(
radioOpen
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
),
);
this._cockpitRadioToggleBtn.title = t(
radioOpen
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
);
}
if (!expanded && returnFocus) {
(kind === 'display'
? this._cockpitDisplayToggleBtn
@@ -87,6 +40,9 @@ export function bindRadioControls() {
this.actions.layoutCockpit();
};
this._setCockpitDisclosure = setCockpitDisclosure;
/** @param {'display'|'radio'} kind @returns {boolean} Whether it is open. */
this._cockpitDisclosureOpen = (kind) =>
Boolean(this._cockpitDisclosure?.[kind]);
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
@@ -339,22 +295,29 @@ export function bindRadioControls() {
return;
}
};
// The panel's Enable button renders from the Radio store; the Cockpit one
// is still written directly.
// Both Enable buttons render from the Radio store; before its first render
// there is nothing shown to mark, so the button is marked directly.
const setTriggerBusy = (trigger, busy) => {
if (
trigger === this._radioEnableBtn &&
showRadioEnableBusy.call(this, busy)
)
return;
const where =
trigger === this._radioEnableBtn
? 'panel'
: trigger === this._cockpitRadioEnableBtn
? 'cockpit'
: null;
if (where && showRadioEnableBusy.call(this, busy, where)) return;
trigger.setAttribute('aria-disabled', String(busy));
trigger.setAttribute('aria-busy', String(busy));
};
// The store patches aria-busy on the next render, so a second press before
// it lands is turned away by this set.
const busyTriggers = new Set();
const toggleRadio = async (trigger) => {
if (!this.actions.isRegistered()) return;
if (busyTriggers.has(trigger)) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this.actions.isEnabled();
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
busyTriggers.add(trigger);
setTriggerBusy(trigger, true);
try {
const toggled = await this.actions.runUserAction(
@@ -387,6 +350,7 @@ export function bindRadioControls() {
if (revealAfterEnable)
await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
busyTriggers.delete(trigger);
if (!this.destroyed) {
setTriggerBusy(trigger, false);
if (revealAfterEnable && trigger.isConnected)
@@ -405,9 +369,7 @@ export function bindRadioControls() {
() => void toggleRadio(this._cockpitRadioEnableBtn),
);
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open =
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
setCockpitDisclosure('radio', !this._cockpitDisclosureOpen('radio'));
});
this.listen(
document,
@@ -431,10 +393,8 @@ export function bindRadioControls() {
'keydown',
(event) => {
if (event.key !== 'Escape') return;
const displayOpen =
this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen =
this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
const displayOpen = this._cockpitDisclosureOpen('display');
const radioOpen = this._cockpitDisclosureOpen('radio');
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
@@ -534,8 +494,7 @@ export function bindRadioControls() {
);
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue)
this._cockpitRadioVolumeValue.textContent = `${value}%`;
showRadioVolume.call(this, value);
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
+1
View File
@@ -29,6 +29,7 @@ export class RadioControls {
this._radioCategorySignature = '';
this._radioFilterOptions = null;
this._radioVolumeShown = null;
this._cockpitDisclosure = { display: false, radio: false };
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
+43 -61
View File
@@ -1,8 +1,8 @@
/**
* Present Radio state without making playback or Context decisions: the
* panel view renders the directory row, station card, transport, volume and
* playback line from what this publishes (`gev-radio` store); the tuner and
* the Cockpit radio controls are still written here.
* playback line, and the Cockpit's compact radio controls, from what this
* publishes (`gev-radio` store); the tuner is still written here.
*/
import { t } from '../i18n/index.js';
@@ -50,23 +50,6 @@ export function renderRadioState(state) {
: enabled
? t('Disable Radio')
: t('Enable Radio');
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleLabel
: enableLabel;
this._cockpitRadioEnableBtn.setAttribute('aria-label', enableAria);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute(
'aria-disabled',
String(transitioning),
);
this._cockpitRadioEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
}
const filter = state.filter || 'all';
const categories = state.categories || [];
@@ -136,10 +119,6 @@ export function renderRadioState(state) {
: t('Choose a globe marker or use next.');
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
if (this._cockpitRadioPrevBtn)
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn)
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
const playbackAction = activePlayback
? t('Pause')
: state.audioState === 'paused'
@@ -156,34 +135,11 @@ export function renderRadioState(state) {
action: playbackAction,
target: playTargetWord,
});
if (this._cockpitRadioPlayBtn) {
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', playAria);
this._cockpitRadioPlayBtn.title = playbackAction;
}
// A slider the person is dragging keeps the value they are setting.
this._radioVolumeShown =
this._radioVolume && document.activeElement === this._radioVolume
? Number(this._radioVolume.value)
: Math.round(state.volume * 100);
if (
this._cockpitRadioVolume &&
document.activeElement !== this._cockpitRadioVolume
) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume)
this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? t('UNCERTAIN')
: selected?.name || (state.loading ? t('SYNCING') : t('READY'));
}
let playbackText = '';
let playbackError = false;
{
@@ -243,16 +199,17 @@ export function renderRadioState(state) {
state.audioState === 'error',
);
}
const enable = {
active: enabled,
text: transitioning ? lifecycleLabel : enableLabel,
label: enableAria,
busy: transitioning,
};
this._radioView = {
enabled,
uncertain,
layerState: { active: enabled, text: layerStateText },
enable: {
active: enabled,
text: transitioning ? lifecycleLabel : enableLabel,
label: enableAria,
busy: transitioning,
},
enable,
filter: {
options: this._radioFilterOptions ?? null,
value: filter,
@@ -277,6 +234,20 @@ export function renderRadioState(state) {
},
volume: { disabled: !interactive, value: this._radioVolumeShown },
playback: { text: playbackText, error: playbackError },
// The Cockpit's compact controls: a glyph transport and the layer's own
// volume, which only a focused slider holds back.
cockpit: {
station: uncertain
? t('UNCERTAIN')
: selected?.name || (state.loading ? t('SYNCING') : t('READY')),
enable: { ...enable },
// The rest of the transport is the panel's; the glyphs are the Cockpit's.
play: { glyph: activePlayback ? 'Ⅱ' : '▶', title: playbackAction },
volume: {
disabled: !interactive,
value: Math.round(state.volume * 100),
},
},
};
this.store?.publish(this._radioView);
if (
@@ -291,31 +262,42 @@ export function renderRadioState(state) {
}
/**
* Show the volume the person is setting before the layer reports it.
* Show the volume the person is setting, on either slider, before the layer
* reports it.
* @param {number} value Percent, as the slider holds it.
*/
export function showRadioVolume(value) {
this._radioVolumeShown = value;
if (!this._radioView) return;
const { volume, cockpit } = this._radioView;
this._radioView = {
...this._radioView,
volume: { ...this._radioView.volume, value },
volume: { ...volume, value },
cockpit: { ...cockpit, volume: { ...cockpit.volume, value } },
};
this.store?.publish(this._radioView);
}
/**
* Mark the panel's Enable button busy while its own toggle runs, as a render
* would until the next one reports the lifecycle.
* Mark an Enable button busy while its own toggle runs, as a render would
* until the next one reports the lifecycle.
* @param {boolean} busy
* @param {'panel'|'cockpit'} [where] The panel's button or the Cockpit's.
* @returns {boolean} False before the first render, when nothing is shown yet.
*/
export function showRadioEnableBusy(busy) {
export function showRadioEnableBusy(busy, where = 'panel') {
if (!this._radioView) return false;
this._radioView = {
...this._radioView,
enable: { ...this._radioView.enable, busy },
};
const view = this._radioView;
this._radioView =
where === 'cockpit'
? {
...view,
cockpit: {
...view.cockpit,
enable: { ...view.cockpit.enable, busy },
},
}
: { ...view, enable: { ...view.enable, busy } };
this.store?.publish(this._radioView);
return true;
}
-5
View File
@@ -46,16 +46,11 @@ export function readShellElements(document = globalThis.document) {
),
_cockpitDisplayPanel: document.getElementById('cockpit-display-panel'),
_cockpitRadioToggleBtn: document.getElementById('cockpit-radio-toggle-btn'),
_cockpitRadioPanel: document.getElementById('cockpit-radio-panel'),
_cockpitRadioEnableBtn: document.getElementById('cockpit-radio-enable-btn'),
_cockpitRadioStation: document.getElementById('cockpit-radio-station'),
_cockpitRadioPrevBtn: document.getElementById('cockpit-radio-prev-btn'),
_cockpitRadioPlayBtn: document.getElementById('cockpit-radio-play-btn'),
_cockpitRadioNextBtn: document.getElementById('cockpit-radio-next-btn'),
_cockpitRadioVolume: document.getElementById('cockpit-radio-volume'),
_cockpitRadioVolumeValue: document.getElementById(
'cockpit-radio-volume-value',
),
_radioLayerState: document.getElementById('radio-layer-state'),
_radioEnableBtn: document.getElementById('radio-enable-btn'),
_radioFilter: document.getElementById('radio-filter'),
+4
View File
@@ -948,6 +948,10 @@ export class ShellFacade {
return this._radioControls?._setCockpitDisclosure?.(...args);
}
_cockpitDisclosureOpen(kind) {
return Boolean(this._radioControls?._cockpitDisclosureOpen?.(kind));
}
_renderRadioState(...args) {
return this._radioControls?._renderRadioState?.(...args);
}
+14 -5
View File
@@ -3,10 +3,11 @@ import { markRaw, shallowRef } from 'vue';
/**
* The Radio panel as its view renders it. RadioControls presents the
* directory row, station card, transport, volume and playback line
* (`renderRadioState()`) and publishes them here; it keeps its listeners,
* the tuner and the Cockpit radio controls. LocalSdrControls publishes the
* local RTL-SDR card beside it.
* directory row, station card, transport, volume and playback line and the
* Cockpit's compact radio controls (`renderRadioState()`), and which Cockpit
* utility popover is open, and publishes them here; it keeps its listeners
* and the tuner. LocalSdrControls publishes the local RTL-SDR card beside
* it.
*/
export const useRadioStore = defineStore('gev-radio', () => {
/** The presented panel; null until the controls first render. */
@@ -25,5 +26,13 @@ export const useRadioStore = defineStore('gev-radio', () => {
sdr.value = markRaw(next);
}
return { view, publish, sdr, publishSdr };
/** The open Cockpit utility popover; null until one first opens or closes. */
const utility = shallowRef(null);
/** @param {{display: boolean, radio: boolean}} next */
function publishUtility(next) {
utility.value = markRaw(next);
}
return { view, publish, sdr, publishSdr, utility, publishUtility };
});
+2 -36
View File
@@ -12,6 +12,7 @@ import { useCockpitStore } from '../stores/cockpit.js';
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
import CockpitRollingValue from './panels/CockpitRollingValue.vue';
import CockpitUtilityControls from './panels/CockpitUtilityControls.vue';
// The Cockpit HUD. The Cockpit controller presents the instruments, the
// destination arrow and the vision selector on every HUD update; until it
@@ -180,42 +181,7 @@ const tickStyle = (tick) =>
</div>
<CockpitContext />
<CockpitBriefing />
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label data-i18n-title>◀</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label data-i18n-title>◀</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label>
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n>READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
</div>
</aside>
<CockpitUtilityControls />
<div class="cockpit-help"><bdi data-i18n>ESC EXIT</bdi> <span>·</span> <bdi data-i18n>C TOGGLE</bdi></div>
</section>
@@ -0,0 +1,66 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { useRadioStore } from '../../stores/radio.js';
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
// presents which popover is open and the compact Radio controls; until it
// does, both are closed and the controls show their initial state. The
// controls keep its listeners. The Display popover's slots are static: the
// Cockpit display portal moves the real Display controls into them.
const { view: radio, utility } = storeToRefs(useRadioStore());
const open = computed(() => utility.value ?? { display: false, radio: false });
const cockpit = computed(() => radio.value?.cockpit ?? null);
const displayLabel = computed(() =>
open.value.display
? t('Collapse Cockpit display options')
: t('Expand Cockpit display options'),
);
const radioLabel = computed(() =>
open.value.radio
? t('Collapse Cockpit Radio controls')
: t('Expand Cockpit Radio controls'),
);
</script>
<template>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control" v-class-toggle="{ 'is-expanded': open.display }">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel">{{ open.display ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control" v-class-toggle="{ 'is-expanded': open.radio }">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.radio)" aria-controls="cockpit-radio-panel" :aria-label="radioLabel" :title="radioLabel">{{ open.radio ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" :hidden="!open.radio" data-i18n-aria-label>
<strong id="cockpit-radio-station" class="cockpit-radio-station">{{ cockpit ? cockpit.station : t('READY') }}</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" v-class-toggle="{ active: cockpit?.enable.active }" type="button" :aria-pressed="String(cockpit?.enable.active ?? false)" :aria-label="cockpit?.enable.label" :aria-disabled="cockpit ? String(cockpit.enable.busy) : undefined" :aria-busy="cockpit ? String(cockpit.enable.busy) : undefined">{{ cockpit ? cockpit.enable.text : t('ENABLE') }}</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" v-class-toggle="{ active: radio?.transport.play.active }" :aria-label="radio ? radio.transport.play.label : t('Play selected radio station')" :title="cockpit?.play.title" :disabled="radio ? radio.transport.play.disabled : true">{{ cockpit ? cockpit.play.glyph : '▶' }}</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" :disabled="cockpit ? cockpit.volume.disabled : true" v-field-value="cockpit ? cockpit.volume.value : 80" data-i18n-aria-label />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">{{ cockpit ? `${cockpit.volume.value}%` : '80%' }}</output>
</label>
</div>
</div>
</aside>
</template>
+2 -6
View File
@@ -440,15 +440,11 @@ export class VisualSettings {
!this._sliderPanel?.classList.contains('active')
)
return;
if (
openDisplay &&
this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true'
) {
if (openDisplay && !this._cockpitDisclosureOpen?.('display')) {
this._setCockpitDisclosure?.('display', true);
return;
}
if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true')
return;
if (!this._cockpitDisclosureOpen?.('display')) return;
this._sliderPanel.classList.remove('collapsed');
this._syncPanelCollapseButton(this._sliderPanel);
this._lifetime.frame(() =>