mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
58504e370c
commit
b6d565a83f
20 files changed
+495
-205
No files matched your search
@@ -144,7 +144,11 @@ They contain no application state.
|
||||
every HUD update (about 10 Hz); in a dev build their Vue patch costs
|
||||
about 0.5 ms, close to the DOM writes it replaced. The controls keep the
|
||||
controller's listeners, and the cards' visibility, collapse and layout
|
||||
stay with the controller. The radio controls follow).
|
||||
stay with the controller. `CockpitUtilityControls` renders the Display
|
||||
and Radio launchers, which popover is open and the compact Radio
|
||||
controls from the `gev-radio` store that RadioControls publishes to; the
|
||||
Display popover's slots stay static for the Cockpit display portal. The
|
||||
Radio tuner follows).
|
||||
Layers that write into the right rail directly gain a slot instead.
|
||||
|
||||
4. **Cleanup.** The build-time template expansion and the attribute
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"src/ui/views/panels/CockpitRoute.vue",
|
||||
"src/ui/views/panels/CockpitRollingValue.vue",
|
||||
"src/ui/views/panels/CockpitSignalList.vue",
|
||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
|
||||
@@ -1720,6 +1720,9 @@ try {
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
signal.style.maxHeight = '150px';
|
||||
manager._setCockpitDisclosure(kind, true);
|
||||
// Let Vue render the disclosure, but not the scheduled layout frame: the
|
||||
// first syncSignalLayout below must be the first pass.
|
||||
await Promise.resolve();
|
||||
// One pass must be enough. Record the result of the FIRST layout call, then
|
||||
// confirm a second changes nothing, so a convergence regression cannot hide
|
||||
// behind a repeated call.
|
||||
@@ -1851,7 +1854,7 @@ try {
|
||||
JSON.stringify(portalScroll),
|
||||
);
|
||||
|
||||
const boundary = await page.evaluate(() => {
|
||||
const boundary = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
@@ -1866,6 +1869,7 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
await new Promise(requestAnimationFrame);
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
const priorSignalHeight = signal.style.height;
|
||||
// The strip is pinned to the viewport ceiling once the briefing card grows
|
||||
@@ -2261,12 +2265,14 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const collapsed = {
|
||||
display: displayControl.getBoundingClientRect().toJSON(),
|
||||
radio: radioControl.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
display.click();
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
return {
|
||||
collapsed,
|
||||
|
||||
@@ -2154,16 +2154,20 @@ async function main() {
|
||||
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
||||
?.closest('#cockpit-display-panel'));
|
||||
display.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
||||
&& !document.getElementById('cockpit-radio-panel').hidden;
|
||||
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
const radioNavigationAvailable = [
|
||||
document.getElementById('cockpit-radio-prev-btn'),
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in new issue
Block a user