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

+5 -1
View File
@@ -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
+1
View File
@@ -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",
+7 -1
View File
@@ -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,
+4
View File
@@ -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'),
+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(() =>