feat(panels): render each panel's state classes from the store

A panel's collapsed, dock-pinned, dock-pinned-top, layout-auto-collapsed
and accordion-collapsed classes are drawn by its root's v-panel-state from
the gev-panel-chrome store. PanelChrome, its restore, the layout
controller, both rails and the side accordion present each panel's state
there instead of writing its classes; the rails call onCollapse(panel,
{collapsed, auto}). The directive applies a change in a synchronous
watcher, since the rails measure a panel in the task that opens or closes
it.

Destroying the chrome inside Cockpit now puts back the panels Cockpit's
entry closed while it can still present them. Without a shell, the voice
tools' set_panel_open reports that there is no panel to open. QA scripts
present the states they set instead of writing the classes.
This commit is contained in:
Sameh Khamis committed 2026-10-31 23:51:47 -07:00
1 parent dd3a2c87f9
commit 95523e09cd
37 files changed
+404 -135

No files matched your search

+1 -1
View File
@@ -1088,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]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?classList\.remove\('collapsed'\)/,
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
);
assert.match(
radioBindings,
+9 -4
View File
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { msg, t } from './i18n/index.js';
import { followPanelState, readPanelState } from './ui/rail/panelState.js';
import { usePanelChromeStore } from './ui/stores/panelChrome.js';
// Exercise the installed event routes and central close method, without WebGL.
@@ -108,18 +109,22 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now };
document.defaultView = window;
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg',
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg,
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg', 'readPanelState',
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_presentPanel').toString()}});`)(
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg, readPanelState,
);
const saves = [];
const claims = [];
let shareSyncs = 0;
const chromeStore = usePanelChromeStore(createPinia());
const chromeToken = chromeStore.connect({});
// The trays' roots draw the store's state, as v-panel-state does.
followPanelState(chromeStore, 'control-panel', panel);
followPanelState(chromeStore, 'location-bar', location);
const manager = {
_lifetime: { frame() {} },
_chromeStore: chromeStore,
_chromeToken: chromeStore.connect({}),
_chromeToken: chromeToken,
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods,
_savePanelCollapsedState(...args) { saves.push(args); },
+3 -3
View File
@@ -141,8 +141,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(rails, /onCollapse\(panel, \{ collapsed: true, auto: 'layout' \}\)/);
assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /_presentPanel\(panel, \{ collapsed: false, auto: null \}\)/);
assert.match(ui, /reconsiderAutoCollapse/);
assert.match(
ui,
@@ -169,7 +169,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/);
// Collapse buttons render aria-expanded from the state the chrome presents
// (panelChromeButtons.test.mjs covers the views).
assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{\s*collapsed: panelEl\.classList\.contains\('collapsed'\),/);
assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/);
assert.match(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-panel-allocated-height/);
assert.match(
+1 -1
View File
@@ -359,7 +359,7 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
// The collapse buttons render aria-expanded from the state the chrome presents.
assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{/);
assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
+21
View File
@@ -0,0 +1,21 @@
// src/testSupport/panelState.mjs — present a panel's state the way the shell
// does (PanelChrome._presentPanel), drawn at once as its root's v-panel-state
// would, for fixtures whose panels are plain objects with a classList.
import { applyPanelState, readPanelState } from '../ui/rail/panelState.js';
/**
* @param {object|string} panelOrId A panel, or its id in `documentRef`.
* @param {object} [patch] The state that changes.
* @param {Document} [documentRef]
*/
export function presentPanelLocally(
panelOrId,
patch = {},
documentRef = globalThis.document,
) {
const panel =
typeof panelOrId === 'string'
? documentRef?.getElementById(panelOrId)
: panelOrId;
if (panel) applyPanelState(panel, { ...readPanelState(panel), ...patch });
}
+3 -2
View File
@@ -77,13 +77,14 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
// against the code that decides them.
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
const { presentPanelLocally } = await import('../testSupport/panelState.mjs');
const html = await readFile(
path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8',
);
assert.match(
html,
/<div id="cctv-panel" class="panel-collapsible collapsed"/,
/<div id="cctv-panel"[^>]*class="panel-collapsible collapsed"/,
'the CCTV panel starts collapsed in the markup',
);
@@ -110,7 +111,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
};
try {
const controls = new PanelPositionControls({
syncPanelCollapseButton: () => {},
presentPanel: presentPanelLocally,
layoutRightPanels: () => {},
syncCctvPanelViewport: () => {},
showToast: () => {},
+1 -2
View File
@@ -234,8 +234,7 @@ export class StyleManager extends ShellFacade {
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args),
_syncPanelCollapseButton: (...args) =>
this._syncPanelCollapseButton(...args),
_presentPanel: (...args) => this._presentPanel(...args),
_syncShareState: (...args) => this._syncShareState(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
},
+4 -5
View File
@@ -17,7 +17,8 @@ import { settleSideAccordion } from './panelAccordion.js';
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onCollapse Present a panel's collapse:
* `(panel, {collapsed, auto})`.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache.
@@ -45,8 +46,7 @@ export function layoutLeftPanelRail({
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: false, auto: null });
}
}
@@ -262,8 +262,7 @@ export function layoutLeftPanelRail({
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: true, auto: 'layout' });
}
onRetry();
return;
+4 -5
View File
@@ -9,7 +9,8 @@
* @param {boolean} options.enabled Whether the one-panel rule applies.
* @param {string} [options.preferredPanelId] Most recently opened panel.
* @param {Document} [options.documentRef] Document supplying keyboard focus.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onCollapse Present a panel's collapse:
* `(panel, {collapsed, auto})`.
*/
export function settleSideAccordion({
panels,
@@ -21,8 +22,7 @@ export function settleSideAccordion({
if (!enabled) {
for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: false, auto: null });
}
return;
}
@@ -35,7 +35,6 @@ export function settleSideAccordion({
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'accordion-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: true, auto: 'accordion' });
}
}
+32 -23
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { readPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
@@ -81,7 +82,7 @@ export class PanelChrome {
this._cockpitPanelRestore = null;
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
presentPanel: (panel, patch) => this._presentPanel(panel, patch),
showToast: (message) => this._showToast(message),
});
this._panelLayout = new PanelLayoutController({
@@ -90,7 +91,7 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
presentPanel: (panel, patch) => this._presentPanel(panel, patch),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -221,18 +222,18 @@ export class PanelChrome {
typeof pin === 'boolean'
? pin
: !panelEl.classList.contains('dock-pinned');
panelEl.classList.toggle('dock-pinned', shouldPin);
this._chromeStore.publish(this._chromeToken, panelId, {
collapsed: panelEl.classList.contains('collapsed'),
pinned: shouldPin,
});
document
.querySelectorAll('#command-dock .dock-pinned-top')
.forEach((pinnedPanel) => {
pinnedPanel.classList.remove('dock-pinned-top');
// The tray pinned last draws on top; unpinning hands the top to the
// other tray if it is pinned.
this._presentPanel(panelEl, { pinned: shouldPin, pinnedTop: shouldPin });
for (const otherId of PINNABLE_PANEL_IDS) {
if (otherId === panelId) continue;
const other = document.getElementById(otherId);
if (!other) continue;
this._presentPanel(other, {
pinnedTop: !shouldPin && other.classList.contains('dock-pinned'),
});
}
if (shouldPin) {
panelEl.classList.add('dock-pinned-top');
this.setPanelCollapsed(panelId, false, {
explicit: !restore,
restore,
@@ -240,10 +241,6 @@ export class PanelChrome {
syncShare: false,
});
} else {
const remainingPinnedPanel = document.querySelector(
'#command-dock .dock-pinned',
);
remainingPinnedPanel?.classList.add('dock-pinned-top');
if (!restore && !panelEl.matches(':hover')) {
this.setPanelCollapsed(panelId, true, {
explicit: true,
@@ -323,11 +320,23 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
/** Present a panel's open state to its collapse and dock buttons. */
_syncPanelCollapseButton(panelEl) {
/**
* Present a panel's state, merged over what it shows now, to the panel
* chrome store; its root and buttons draw it from there.
* @param {Element|string} panelOrId
* @param {Partial<import('./stores/panelChrome.js').PanelState>} [patch]
*/
_presentPanel(panelOrId, patch = {}) {
const panelEl =
typeof panelOrId === 'string'
? document.getElementById(panelOrId)
: panelOrId;
if (!panelEl?.id) return;
const shown =
this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
this._chromeStore.publish(this._chromeToken, panelEl.id, {
collapsed: panelEl.classList.contains('collapsed'),
...shown,
...patch,
});
}
@@ -478,7 +487,6 @@ export class PanelChrome {
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
) {
this._syncPanelCollapseButton(panelEl);
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
}
@@ -487,7 +495,6 @@ export class PanelChrome {
}
return;
}
panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed');
if (
!nextCollapsed &&
this.cockpitView?.active &&
@@ -518,7 +525,7 @@ export class PanelChrome {
});
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
this._presentPanel(panelEl, { collapsed: nextCollapsed, auto: null });
if (
nextCollapsed &&
this.cockpitView?.active &&
@@ -528,7 +535,6 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false);
}
this._syncPanelCollapseButton(panelEl);
if (persist !== false)
this._savePanelCollapsedState(panelId, nextCollapsed);
if (panelId === 'pp-toggles') {
@@ -586,6 +592,9 @@ export class PanelChrome {
}
destroy() {
if (this._disposed) return;
// Cockpit's own teardown follows the chrome's: put back the panels its
// entry closed while this owner can still present them.
this.exitCockpit();
this._disposed = true;
this._lifetime.destroy();
this._chromeStore.detach(this._chromeToken);
+94 -3
View File
@@ -4,9 +4,11 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, h, nextTick } from 'vue';
import { createApp, h, nextTick, withDirectives } from 'vue';
import { createPinia } from 'pinia';
import { settleSideAccordion } from './panelAccordion.js';
import { PanelChrome } from './panelChrome.js';
import { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import { usePanelChromeStore } from './stores/panelChrome.js';
@@ -30,6 +32,8 @@ test('the store republishes a panel only when its state changes', () => {
assert.deepEqual(store.panels['data-panel'], {
collapsed: true,
pinned: false,
pinnedTop: false,
auto: null,
});
store.publish(token, 'data-panel', { collapsed: true });
assert.ok(store.panels === first, 'an unchanged state is not republished');
@@ -175,6 +179,8 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
<div id="control-panel" class="collapsed"></div>`;
const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store);
for (const id of ['data-panel', 'cctv-panel', 'control-panel'])
followPanelState(store, id, document.getElementById(id));
const calls = [];
const setPanelCollapsed = chrome.setPanelCollapsed.bind(chrome);
chrome.setPanelCollapsed = (id, collapsed, options) => {
@@ -196,6 +202,8 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
assert.deepEqual(store.panels['control-panel'], {
collapsed: false,
pinned: true,
pinnedTop: true,
auto: null,
});
assert.equal(
document.getElementById('control-panel').classList.contains('dock-pinned'),
@@ -206,8 +214,12 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
const before = calls.length;
store.act('toggle', 'data-panel');
assert.equal(calls.length, before, 'a destroyed owner gets no clicks');
chrome._syncPanelCollapseButton(document.getElementById('cctv-panel'));
chrome._presentPanel('cctv-panel', { collapsed: true });
assert.equal(store.panels['cctv-panel'], undefined, 'nor publishes');
assert.equal(
document.getElementById('cctv-panel').classList.contains('collapsed'),
false,
);
});
test('a remounted chrome starts from its own states, not a pin left behind', () => {
@@ -221,10 +233,12 @@ test('a remounted chrome starts from its own states, not a pin left behind', ()
document.body.innerHTML = '<div id="location-bar" class="collapsed"></div>';
const second = panelChrome(store);
assert.equal(store.panels['location-bar'], undefined);
second._syncPanelCollapseButton(document.getElementById('location-bar'));
second._presentPanel('location-bar');
assert.deepEqual(store.panels['location-bar'], {
collapsed: true,
pinned: false,
pinnedTop: false,
auto: null,
});
second.destroy();
});
@@ -238,3 +252,80 @@ test('bindPanelDisclosure refuses the retired button wiring', () => {
);
}
});
test('a panel root draws its state at once, keeping classes others own', async (t) => {
const store = mount(t, {
render: () =>
withDirectives(
h('div', { id: 'scene-panel', class: 'panel-collapsible collapsed' }),
[[vPanelState, 'scene-panel']],
),
});
const panel = document.getElementById('scene-panel');
panel.classList.add('running');
assert.equal(panel.classList.contains('collapsed'), true, 'markup stands');
const token = store.connect({});
store.publish(token, 'scene-panel', { collapsed: true, auto: 'accordion' });
// No tick: a layout measures in the same task.
assert.equal(panel.classList.contains('accordion-collapsed'), true);
store.publish(token, 'scene-panel', { collapsed: false, auto: null });
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(panel.classList.contains('accordion-collapsed'), false);
assert.equal(panel.classList.contains('running'), true);
assert.equal(panel.classList.contains('panel-collapsible'), true);
store.publish(token, 'scene-panel', { pinned: true, pinnedTop: true });
assert.ok(panel.classList.contains('dock-pinned'));
assert.ok(panel.classList.contains('dock-pinned-top'));
});
test('destroying the chrome inside Cockpit puts back the panels its entry closed', () => {
document.body.innerHTML = `
<div id="data-panel"></div>
<div id="cctv-panel" class="collapsed"></div>`;
const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store);
const data = document.getElementById('data-panel');
followPanelState(store, 'data-panel', data);
chrome.enterCockpit();
assert.equal(data.classList.contains('collapsed'), true);
chrome.destroy();
assert.equal(data.classList.contains('collapsed'), false);
chrome.exitCockpit();
assert.equal(data.classList.contains('collapsed'), false);
});
test('the accordion closes peers through the real store, and opens them again', () => {
document.body.innerHTML = `
<div id="data-panel" data-panel-id="data-panel"></div>
<div id="scene-panel" data-panel-id="scene-panel"></div>`;
const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store);
const panels = ['data-panel', 'scene-panel'].map((id) => {
const panel = document.getElementById(id);
followPanelState(store, id, panel);
return panel;
});
const onCollapse = (panel, patch) => chrome._presentPanel(panel, patch);
settleSideAccordion({
panels,
enabled: true,
preferredPanelId: 'scene-panel',
documentRef: document,
onCollapse,
});
assert.deepEqual(store.panels['data-panel'], {
collapsed: true,
pinned: false,
pinnedTop: false,
auto: 'accordion',
});
assert.equal(panels[0].className, 'collapsed accordion-collapsed');
assert.equal(
store.panels['scene-panel'],
undefined,
'the owner is untouched',
);
settleSideAccordion({ panels, enabled: false, onCollapse });
assert.equal(panels[0].className, '');
chrome.destroy();
});
+6 -9
View File
@@ -64,12 +64,12 @@ export class PanelLayoutController {
constructor({
readHud,
scheduleCockpitLayout,
syncPanelCollapseButton,
presentPanel,
readDisplayScrollTop,
}) {
this.readHud = readHud;
this.scheduleCockpitLayout = scheduleCockpitLayout;
this._syncPanelCollapseButton = syncPanelCollapseButton;
this._presentPanel = presentPanel;
this.readDisplayScrollTop = readDisplayScrollTop;
this.destroyed = false;
this._adaptivePanelSettleTimer = null;
@@ -198,7 +198,6 @@ export class PanelLayoutController {
]) {
if (!panel) continue;
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top');
@@ -286,8 +285,7 @@ export class PanelLayoutController {
for (const panel of this._rightPanelStack.querySelectorAll(
'.layout-auto-collapsed',
)) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
this._presentPanel(panel, { collapsed: false, auto: null });
}
}
this._syncRightPanelAdaptiveLayout();
@@ -303,7 +301,7 @@ export class PanelLayoutController {
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._rightStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleRightPanelLayout(),
leftStack: this._leftPanelStack,
displayPanel: this._ppToggles,
@@ -422,8 +420,7 @@ export class PanelLayoutController {
for (const panel of this._leftPanelStack.querySelectorAll(
'.layout-auto-collapsed',
)) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
this._presentPanel(panel, { collapsed: false, auto: null });
}
}
this._syncLeftPanelAdaptiveLayout();
@@ -438,7 +435,7 @@ export class PanelLayoutController {
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._leftStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleLeftPanelLayout(),
collapsedHeights: this._leftStackCollapsedHeights,
onAligned: () => this._scheduleRightPanelLayout(),
+3 -4
View File
@@ -14,8 +14,8 @@ const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
export class PanelPositionControls {
constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
constructor({ presentPanel, showToast }) {
this._presentPanel = presentPanel;
this._showToast = showToast;
}
@@ -75,7 +75,7 @@ export class PanelPositionControls {
// first-run mission card for the one first impression there is. A stored
// choice still wins in both directions, so anyone who opens it keeps it.
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
panelEl.classList.toggle('collapsed', collapsed);
this._presentPanel(panelEl, { collapsed });
// Bodies that expand themselves on first appearance must not override a
// choice the user (or a share link) already made.
if (panelEl.dataset)
@@ -84,7 +84,6 @@ export class PanelPositionControls {
: stored === null
? 'default'
: 'stored';
this._syncPanelCollapseButton(panelEl);
}
_savePanelCollapsedState(panelId, collapsed) {
+6 -1
View File
@@ -7,6 +7,7 @@ import {
layoutRightPanelRail,
measurePanelNaturalHeight,
} from './panelRails.js';
import { applyPanelState, readPanelState } from './rail/panelState.js';
test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 };
@@ -169,7 +170,11 @@ function fixture(
matchMedia: () => ({ matches: mobile }),
getComputedStyle: (node) => node.computed,
},
onCollapse: (panel) => collapsed.push(panel.id),
// The owner presents the patch; the panel's root draws it at once.
onCollapse: (panel, patch) => {
collapsed.push(panel.id);
applyPanelState(panel, { ...readPanelState(panel), ...patch });
},
onRetry: () => {
retries += 1;
},
+70
View File
@@ -0,0 +1,70 @@
import { watch } from 'vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
/** The classes a panel's state draws on its root. */
const CLASSES = Object.freeze({
collapsed: (state) => state.collapsed,
'dock-pinned': (state) => state.pinned,
'dock-pinned-top': (state) => state.pinnedTop,
'layout-auto-collapsed': (state) => state.auto === 'layout',
'accordion-collapsed': (state) => state.auto === 'accordion',
});
/**
* Draw `state` on a panel's root with `classList`, leaving other classes.
* @param {Element} el
* @param {import('../stores/panelChrome.js').PanelState} state
*/
export function applyPanelState(el, state) {
for (const [name, on] of Object.entries(CLASSES))
if (el.classList.contains(name) !== on(state))
el.classList.toggle(name, on(state));
}
/**
* Keep `el` drawing the state `store` holds for `panelId`, synchronously:
* the panel layouts measure a panel in the same task that opens or closes
* it. Until the panel is presented its markup's classes stand.
* @returns {() => void} Stops following the store.
*/
export function followPanelState(store, panelId, el) {
return watch(
() => store.panels[panelId],
(state) => state && applyPanelState(el, state),
{ flush: 'sync', immediate: true },
);
}
/**
* `v-panel-state="'data-panel'"`: the panel root draws its open, pinned and
* layout-collapsed state from the panel chrome store.
*/
export const vPanelState = {
mounted(el, binding) {
const store = usePanelChromeStore(binding.instance.$pinia);
el._stopPanelState = followPanelState(store, binding.value, el);
},
beforeUnmount(el) {
el._stopPanelState?.();
},
};
/**
* A panel's state as its classes draw it: the markup's state, before its
* owner first presents the panel.
* @param {Element} el
* @returns {import('../stores/panelChrome.js').PanelState}
*/
export function readPanelState(el) {
const has = (name) => el.classList.contains(name);
return {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed')
? 'layout'
: has('accordion-collapsed')
? 'accordion'
: null,
};
}
+4 -5
View File
@@ -41,7 +41,8 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onCollapse Present a panel's collapse:
* `(panel, {collapsed, auto})`.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
@@ -83,8 +84,7 @@ export function layoutRightPanelRail({
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: false, auto: null });
}
}
// One panel is open per desktop side; this marker is distinct from
@@ -275,8 +275,7 @@ export function layoutRightPanelRail({
if (!isCollapseRetry && autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: true, auto: 'layout' });
}
pendingCollapseRetries.add(stack);
onRetry();
+6 -5
View File
@@ -1024,13 +1024,14 @@ export class ShellFacade {
}
/**
* Updates collapse button glyphs based on panel state. Right-rail panels
* use directional arrows; left-stack panels use +/- symbols.
* @param {HTMLElement} panelEl - The panel DOM element.
* Presents a panel's state, merged over what it shows now; its root and
* buttons draw it from the panel chrome store.
* @param {HTMLElement|string} panelOrId - The panel or its id.
* @param {object} [patch] - The state that changes.
* @returns {void}
*/
_syncPanelCollapseButton(panelEl) {
return this._panelChrome._syncPanelCollapseButton(...arguments);
_presentPanel(panelOrId, patch) {
return this._panelChrome._presentPanel(...arguments);
}
_buildSharePanelState() {
+14 -10
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js';
import { followPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { ShellFeedback } from './shellFeedback.js';
@@ -150,7 +151,7 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
syncPanelCollapseButton() {},
presentPanel() {},
readDisplayScrollTop: () => 0,
});
owner._syncLeftPanelAdaptiveLayout = () => layouts++;
@@ -257,7 +258,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -298,16 +299,16 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
const presented = [];
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0,
syncPanelCollapseButton(panel) {
synced.push(panel.id);
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, panel);
presentPanel(panel, patch) {
presented.push(panel.id);
PanelChrome.prototype._presentPanel.call(chrome, panel, patch);
},
});
owner._initRightPanelAdaptiveLayout();
@@ -315,12 +316,15 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(store.panels['weather-panel'].collapsed, true);
weather.classList.remove('collapsed');
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
// Moving a panel into the rail leaves its state as it was.
assert.deepEqual(presented, []);
followPanelState(store, 'weather-panel', weather);
PanelChrome.prototype._presentPanel.call(chrome, weather, {
collapsed: false,
});
assert.equal(store.panels['weather-panel'].collapsed, false);
assert.equal(weather.classList.contains('collapsed'), false);
owner.destroy();
assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0);
+2 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
import { presentPanelLocally } from '../testSupport/panelState.mjs';
function fixture(theme = 'cyber') {
const nodes = new Map();
@@ -35,7 +36,7 @@ function fixture(theme = 'cyber') {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
_presentPanel: presentPanelLocally, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
+29 -13
View File
@@ -8,15 +8,35 @@ import { shallowRef } from 'vue';
*/
/**
* Each panel's disclosure state as its collapse, dock and pin buttons render
* it. PanelChrome owns the panels: it publishes here whenever a panel opens,
* closes or is pinned, and connects the buttons' clicks; the views only render
* and forward clicks.
* @typedef {object} PanelState
* @property {boolean} collapsed Whether the panel is closed, by the person or
* by its layout.
* @property {boolean} pinned A dock tray kept open (`dock-pinned`).
* @property {boolean} pinnedTop The pinned tray drawn on top.
* @property {?('layout'|'accordion')} auto Why a layout closed the panel
* (`layout-auto-collapsed`, `accordion-collapsed`), or null when the
* person's own choice stands.
*/
/** A panel's state before its owner has said otherwise. */
const BLANK = Object.freeze({
collapsed: false,
pinned: false,
pinnedTop: false,
auto: null,
});
/**
* Each panel's disclosure state, which its root (`vPanelState`) and its
* collapse, dock and pin buttons render. PanelChrome owns the panels: it and
* the layouts it runs publish here whenever a panel opens, closes or is
* pinned, and it connects the buttons' clicks; the views only render and
* forward clicks.
*/
export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
/**
* `{[panelId]: {collapsed, pinned}}`, frozen; a panel is absent until its
* owner first presents it, and its buttons keep their markup until then.
* `{[panelId]: PanelState}`, frozen; a panel is absent until its owner
* first presents it, and it keeps its markup's classes until then.
*/
const panels = shallowRef(Object.freeze({}));
/** @type {{handlers: PanelChromeHandlers}|null} */
@@ -44,17 +64,13 @@ export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
* unchanged state is not republished.
* @param {object} token
* @param {string} panelId
* @param {{collapsed?: boolean, pinned?: boolean}} patch
* @param {Partial<PanelState>} patch
*/
function publish(token, panelId, patch) {
if (!owns(token) || !panelId) return;
const prior = panels.value[panelId];
const next = { collapsed: false, pinned: false, ...prior, ...patch };
if (
prior &&
prior.collapsed === next.collapsed &&
prior.pinned === next.pinned
)
const next = { ...BLANK, ...prior, ...patch };
if (prior && Object.keys(BLANK).every((key) => prior[key] === next[key]))
return;
panels.value = Object.freeze({
...panels.value,
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { msg, t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import UiButton from '../kit/UiButton.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js';
@@ -67,7 +68,7 @@ function selectMapSource(chip) {
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<template v-for="slot in dockOrder" :key="slot">
<!-- Control panel -->
<div v-if="slot === 'control'" id="control-panel" class="panel-collapsible collapsed">
<div v-if="slot === 'control'" id="control-panel" v-panel-state="'control-panel'" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
@@ -128,7 +129,7 @@ function selectMapSource(chip) {
<VoiceControl v-else-if="slot === 'voice'" :key="voice.id" :view="voice" />
<!-- Location bar -->
<div v-else-if="slot === 'location'" id="location-bar" class="panel-collapsible collapsed">
<div v-else-if="slot === 'location'" id="location-bar" v-panel-state="'location-bar'" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
+4 -3
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
@@ -28,7 +29,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<template>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail" class="ui-scroll" v-class-toggle="{ 'bhote-event-active': !!bhotePanel }">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div id="weather-panel" v-panel-state="'weather-panel'" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header">
@@ -40,7 +41,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div id="recent-imagery-panel" v-panel-state="'recent-imagery-panel'" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header">
@@ -52,7 +53,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="global-context-panel" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div id="global-context-panel" v-panel-state="'global-context-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js';
@@ -37,7 +38,7 @@ async function importChosen(event) {
<template>
<div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div id="data-panel" v-panel-state="'data-panel'" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
@@ -56,7 +57,7 @@ async function importChosen(event) {
<StreetLevelPanel />
<!-- Scene Director Panel -->
<div id="scene-panel" v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div id="scene-panel" v-panel-state="'scene-panel'" v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner ui-scroll">
<div class="panel-header">
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue';
@@ -27,7 +28,7 @@ const frameData = computed(() =>
</script>
<template>
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div id="cctv-panel" v-panel-state="'cctv-panel'" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
+3 -2
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js';
@@ -43,7 +44,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<template>
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed ui-scroll" data-panel-id="pp-toggles">
<div id="pp-toggles" v-panel-state="'pp-toggles'" class="panel-collapsible collapsed ui-scroll" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
@@ -123,7 +124,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel">
<div id="param-slider-panel" v-panel-state="'param-slider-panel'">
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
@@ -29,7 +30,7 @@ const radioWords = {
</script>
<template>
<section id="radio-panel" class="panel-collapsible collapsed" v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
<section id="radio-panel" v-panel-state="'radio-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
<div class="radio-panel-inner ui-scroll">
<div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span>
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -101,7 +102,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</script>
<template>
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel" :data-sl-enabled="view ? String(view.enabled) : undefined">
<div id="street-level-panel" v-panel-state="'street-level-panel'" class="panel-collapsible collapsed" data-panel-id="street-level-panel" :data-sl-enabled="view ? String(view.enabled) : undefined">
<div class="panel-glow"></div>
<div class="street-level-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
+2 -4
View File
@@ -445,8 +445,7 @@ export class VisualSettings {
return;
}
if (!this._cockpitDisclosureOpen?.('display')) return;
this._sliderPanel.classList.remove('collapsed');
this._syncPanelCollapseButton(this._sliderPanel);
this._presentPanel(this._sliderPanel, { collapsed: false });
this._lifetime.frame(() =>
this._lifetime.frame(() => {
this._sliderPanel?.scrollIntoView?.({ block: 'nearest' });
@@ -1434,8 +1433,7 @@ export class VisualSettings {
_revealStyleParameters() {
if (!this._sliderPanel?.classList.contains('active')) return;
if (this._cockpitDisplayPortalActive) return;
this._sliderPanel.classList.remove('collapsed');
this._syncPanelCollapseButton(this._sliderPanel);
this._presentPanel(this._sliderPanel, { collapsed: false });
this.setPanelCollapsed('pp-toggles', false, { explicit: true });
this._lifetime.frame(() =>
this._lifetime.frame(() => {
+18 -7
View File
@@ -817,7 +817,17 @@ export function createGevActionRunner({
...(!applied ? { error: say } : {}),
};
}
setPanelOpen(styleManager, panelId, open);
if (!setPanelOpen(styleManager, panelId, open)) {
const say = 'There is no panel to open here.';
return {
ok: false,
action: 'set_panel_open',
panelId,
open,
say,
error: say,
};
}
return { ok: true, action: 'set_panel_open', panelId, open };
}
@@ -3143,13 +3153,14 @@ function normalizeAircraftClassFilter(value) {
return raw.replace(/[\s-]+/g, '') === TR3B_CLASS ? TR3B_CLASS : raw;
}
/**
* @returns {boolean} Whether a shell took the change: its panel chrome owns
* panel state, so without one there is no panel to open.
*/
function setPanelOpen(styleManager, panelId, open) {
if (styleManager && typeof styleManager.setPanelCollapsed === 'function') {
styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
} else {
const panel = document.getElementById(panelId);
if (panel) panel.classList.toggle('collapsed', !open);
}
if (typeof styleManager?.setPanelCollapsed !== 'function') return false;
styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
return true;
}
function normalizeContextMode(value) {