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

+10
View File
@@ -108,6 +108,16 @@
or `onChange`. `UiButton` joins the component kit with ui-vue's props or `onChange`. `UiButton` joins the component kit with ui-vue's props
(samehkhamis). (samehkhamis).
- The panels' own state classes (`collapsed`, `dock-pinned`,
`dock-pinned-top`, `layout-auto-collapsed`, `accordion-collapsed`) render
from the `gev-panel-chrome` store: the shell, the rails and the accordion
present each panel's state there instead of writing its classes.
`layoutLeftPanelRail` and `layoutRightPanelRail` (`./ui/layout`) now call
`onCollapse(panel, {collapsed, auto})` and leave drawing it to the host. Without a
shell to own the panels, the voice tools' `set_panel_open` now reports
that there is no panel to open instead of writing its classes
(samehkhamis).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption - Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+7 -1
View File
@@ -67,7 +67,13 @@ a feature supplying only its accent.
mirrors each side's panels and the rail renders from it. Each panel's mirrors each side's panels and the rail renders from it. Each panel's
collapse button (`PanelCollapseButton`), the dock tray toggles and the collapse button (`PanelCollapseButton`), the dock tray toggles and the
dock pins render the open and pinned state PanelChrome presents to the dock pins render the open and pinned state PanelChrome presents to the
`gev-panel-chrome` store, and their clicks call it there. Done. `gev-panel-chrome` store, and their clicks call it there. Each panel's
root draws its own state from there too (`v-panel-state`: `collapsed`,
`dock-pinned`, `dock-pinned-top`, `layout-auto-collapsed`,
`accordion-collapsed`): PanelChrome, the rails and the accordion present
it rather than writing classes. The directive applies it in a synchronous
watcher, not on Vue's next render, because the rails measure a panel in
the same task that opens or closes it. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids, 3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. The page places each view with a classes, `data-i18n` text and CSS. The page places each view with a
`.gev-view` host (`display: contents`), and `mountApplicationViews()` `.gev-view` host (`display: contents`), and `mountApplicationViews()`
+5 -1
View File
@@ -101,7 +101,11 @@ Escape, bound and disposed with `bindPanelDisclosure` /
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome `collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
initializer just to add one panel. `layoutLeftPanelRail` and initializer just to add one panel. `layoutLeftPanelRail` and
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their `layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
host owns layout scheduling, observations and collapse callbacks. Dynamic host owns layout scheduling, observations and collapse callbacks. A rail
never writes a panel's classes: it calls `onCollapse(panel, {collapsed,
auto})`, and the host draws that state (the application presents it to the
panel chrome store, which each root's `v-panel-state` draws at once, so the
rail can measure the panel in the same task). Dynamic
content that changes size may require an owner-scheduled layout pass or an content that changes size may require an owner-scheduled layout pass or an
owned `ResizeObserver`; adding CSS attributes does not register new observers. owned `ResizeObserver`; adding CSS attributes does not register new observers.
+8 -4
View File
@@ -122,7 +122,8 @@
"src/firstRunCopy.js", "src/firstRunCopy.js",
"src/ui/kit/UiButton.vue", "src/ui/kit/UiButton.vue",
"src/ui/stores/panelChrome.js", "src/ui/stores/panelChrome.js",
"src/ui/rail/PanelCollapseButton.vue" "src/ui/rail/PanelCollapseButton.vue",
"src/ui/rail/panelState.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
@@ -1048,7 +1049,8 @@
"src/ui/stores/worldOverlayActions.js", "src/ui/stores/worldOverlayActions.js",
"src/ui/stores/awarenessDirection.js", "src/ui/stores/awarenessDirection.js",
"src/ui/stores/hud.js", "src/ui/stores/hud.js",
"src/ui/stores/panelChrome.js" "src/ui/stores/panelChrome.js",
"src/ui/rail/panelState.js"
], ],
"external": ["cesium", "vue", "pinia"] "external": ["cesium", "vue", "pinia"]
}, },
@@ -3054,7 +3056,8 @@
"src/ui/stores/hud.js", "src/ui/stores/hud.js",
"src/ui/stores/firstRun.js", "src/ui/stores/firstRun.js",
"src/firstRunCopy.js", "src/firstRunCopy.js",
"src/ui/stores/panelChrome.js" "src/ui/stores/panelChrome.js",
"src/ui/rail/panelState.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3863,7 +3866,8 @@
"src/ui/panelRailCatalog.js", "src/ui/panelRailCatalog.js",
"src/ui/panelAccordion.js", "src/ui/panelAccordion.js",
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/ui/stores/panelChrome.js" "src/ui/stores/panelChrome.js",
"src/ui/rail/panelState.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
+2 -2
View File
@@ -1799,7 +1799,7 @@ try {
manager._setCockpitDisplayPortalActive(false); manager._setCockpitDisplayPortalActive(false);
document.body.classList.remove('cockpit-mode'); document.body.classList.remove('cockpit-mode');
await waitFrames(); await waitFrames();
standard.classList.remove('collapsed'); manager._presentPanel(standard, { collapsed: false });
standard.style.height = '120px'; standard.style.height = '120px';
standard.style.maxHeight = '120px'; standard.style.maxHeight = '120px';
standard.style.overflowY = 'auto'; standard.style.overflowY = 'auto';
@@ -1834,7 +1834,7 @@ try {
cockpit.style.height = priorCockpitHeight; cockpit.style.height = priorCockpitHeight;
cockpit.style.maxHeight = priorCockpitMaxHeight; cockpit.style.maxHeight = priorCockpitMaxHeight;
cockpit.style.overflowY = priorCockpitOverflow; cockpit.style.overflowY = priorCockpitOverflow;
standard.classList.toggle('collapsed', standardWasCollapsed); manager._presentPanel(standard, { collapsed: standardWasCollapsed });
return { return {
standardBefore, standardBefore,
standardAfter, standardAfter,
+18 -5
View File
@@ -1716,7 +1716,14 @@ async function main() {
else panel.setAttribute('style', state.style); else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden'); if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden); else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel); // The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
} }
for (const [id, state] of Object.entries(prior.stacks)) { for (const [id, state] of Object.entries(prior.stacks)) {
const stack = document.getElementById(id); const stack = document.getElementById(id);
@@ -1974,7 +1981,7 @@ async function main() {
manager._setCockpitDisplayPortalActive(true); manager._setCockpitDisplayPortalActive(true);
hud.hidden = false; hud.hidden = false;
signal.hidden = false; signal.hidden = false;
dataPanel.classList.remove('collapsed'); manager._presentPanel(dataPanel, { collapsed: false });
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout(); window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout(); window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
if (display.getAttribute('aria-expanded') === 'true') display.click(); if (display.getAttribute('aria-expanded') === 'true') display.click();
@@ -2059,8 +2066,7 @@ async function main() {
contextCard.hidden = false; contextCard.hidden = false;
// Expand the panel the same way this block already does at entry, so the // Expand the panel the same way this block already does at entry, so the
// portaled Display groups measured further down keep their DOM home. // portaled Display groups measured further down keep their DOM home.
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed'); manager._presentPanel(dataPanel, { collapsed: false, auto: null });
manager._syncPanelCollapseButton(dataPanel);
manager._syncLeftPanelAdaptiveLayout(); manager._syncLeftPanelAdaptiveLayout();
await waitForLayout(); await waitForLayout();
const panelRect = dataPanel.getBoundingClientRect(); const panelRect = dataPanel.getBoundingClientRect();
@@ -2287,7 +2293,14 @@ async function main() {
else panel.setAttribute('style', state.style); else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden'); if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden); else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel); // The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
} }
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear(); localStorage.clear();
+1 -1
View File
@@ -57,7 +57,7 @@ try {
persist: false, persist: false,
syncShare: false, syncShare: false,
}); });
manager._sliderPanel.classList.remove('collapsed'); manager._presentPanel(manager._sliderPanel, { collapsed: false });
const slider = manager._sliderContainer.querySelector('input'); const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider; window.__qaOldSlider = slider;
const name = 'pixelation'; const name = 'pixelation';
+1 -1
View File
@@ -261,7 +261,7 @@ try {
for (const [id, entry] of gev.dataManager.layers) for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled) if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' }); await gev.dataManager.setEnabled(id, false, { origin: 'user' });
document.querySelector('#data-panel')?.classList.remove('collapsed'); gev.styleManager._presentPanel('data-panel', { collapsed: false });
}); });
// 1. Earth view, clean. // 1. Earth view, clean.
+1 -1
View File
@@ -1088,7 +1088,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
); );
assert.match( assert.match(
ui, 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( assert.match(
radioBindings, radioBindings,
+9 -4
View File
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { msg, t } from './i18n/index.js'; import { msg, t } from './i18n/index.js';
import { followPanelState, readPanelState } from './ui/rail/panelState.js';
import { usePanelChromeStore } from './ui/stores/panelChrome.js'; import { usePanelChromeStore } from './ui/stores/panelChrome.js';
// Exercise the installed event routes and central close method, without WebGL. // 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.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now }; window.performance = { now: () => now };
document.defaultView = window; document.defaultView = window;
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', '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('_syncPanelCollapseButton').toString()}});`)( `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, createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg, readPanelState,
); );
const saves = []; const saves = [];
const claims = []; const claims = [];
let shareSyncs = 0; let shareSyncs = 0;
const chromeStore = usePanelChromeStore(createPinia()); 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 = { const manager = {
_lifetime: { frame() {} }, _lifetime: { frame() {} },
_chromeStore: chromeStore, _chromeStore: chromeStore,
_chromeToken: chromeStore.connect({}), _chromeToken: chromeToken,
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null }, _panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods, ...methods,
_savePanelCollapsedState(...args) { saves.push(args); }, _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 ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/); assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
assert.match(rails, /classList\.add\('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'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); 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, /reconsiderAutoCollapse/);
assert.match( assert.match(
ui, 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*\)/); 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 // Collapse buttons render aria-expanded from the state the chrome presents
// (panelChromeButtons.test.mjs covers the views). // (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(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-panel-allocated-height/); assert.match(rightRail, /--right-panel-allocated-height/);
assert.match( 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.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/); assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
// The collapse buttons render aria-expanded from the state the chrome presents. // 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\)/); 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. // against the code that decides them.
const { PanelPositionControls } = const { PanelPositionControls } =
await import('../ui/panelPositionControls.js'); await import('../ui/panelPositionControls.js');
const { presentPanelLocally } = await import('../testSupport/panelState.mjs');
const html = await readFile( const html = await readFile(
path.join(root, 'src/ui/views/panels/CctvPanel.vue'), path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8', 'utf8',
); );
assert.match( assert.match(
html, 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', '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 { try {
const controls = new PanelPositionControls({ const controls = new PanelPositionControls({
syncPanelCollapseButton: () => {}, presentPanel: presentPanelLocally,
layoutRightPanels: () => {}, layoutRightPanels: () => {},
syncCctvPanelViewport: () => {}, syncCctvPanelViewport: () => {},
showToast: () => {}, showToast: () => {},
+1 -2
View File
@@ -234,8 +234,7 @@ export class StyleManager extends ShellFacade {
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args), _setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind), _cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args), _setMapStack: (...args) => this._setMapStack(...args),
_syncPanelCollapseButton: (...args) => _presentPanel: (...args) => this._presentPanel(...args),
this._syncPanelCollapseButton(...args),
_syncShareState: (...args) => this._syncShareState(...args), _syncShareState: (...args) => this._syncShareState(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...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 {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation. * @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel. * @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.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override. * @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache. * @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache.
@@ -45,8 +46,7 @@ export function layoutLeftPanelRail({
for (const panel of panels.filter((item) => for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'), item.classList.contains('layout-auto-collapsed'),
)) { )) {
panel.classList.remove('collapsed', 'layout-auto-collapsed'); onCollapse(panel, { collapsed: false, auto: null });
onCollapse(panel);
} }
} }
@@ -262,8 +262,7 @@ export function layoutLeftPanelRail({
if (autoCollapseIndices.length) { if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) { for (const index of autoCollapseIndices) {
const panel = expandedPanels[index]; const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed'); onCollapse(panel, { collapsed: true, auto: 'layout' });
onCollapse(panel);
} }
onRetry(); onRetry();
return; return;
+4 -5
View File
@@ -9,7 +9,8 @@
* @param {boolean} options.enabled Whether the one-panel rule applies. * @param {boolean} options.enabled Whether the one-panel rule applies.
* @param {string} [options.preferredPanelId] Most recently opened panel. * @param {string} [options.preferredPanelId] Most recently opened panel.
* @param {Document} [options.documentRef] Document supplying keyboard focus. * @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({ export function settleSideAccordion({
panels, panels,
@@ -21,8 +22,7 @@ export function settleSideAccordion({
if (!enabled) { if (!enabled) {
for (const panel of panels) { for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue; if (!panel.classList.contains('accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'accordion-collapsed'); onCollapse(panel, { collapsed: false, auto: null });
onCollapse(panel);
} }
return; return;
} }
@@ -35,7 +35,6 @@ export function settleSideAccordion({
expanded[0]; expanded[0];
for (const panel of expanded) { for (const panel of expanded) {
if (panel === owner) continue; if (panel === owner) continue;
panel.classList.add('collapsed', 'accordion-collapsed'); onCollapse(panel, { collapsed: true, auto: 'accordion' });
onCollapse(panel);
} }
} }
+32 -23
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js'; import { UiLifetime } from './uiLifetime.js';
import { readPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js'; import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js'; import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js'; import { displayPanelScroller } from './displayPanelScroll.js';
@@ -81,7 +82,7 @@ export class PanelChrome {
this._cockpitPanelRestore = null; this._cockpitPanelRestore = null;
this._cockpitContextCollapsedForDataPanel = false; this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({ this._panelPosition = new PanelPositionControls({
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel), presentPanel: (panel, patch) => this._presentPanel(panel, patch),
showToast: (message) => this._showToast(message), showToast: (message) => this._showToast(message),
}); });
this._panelLayout = new PanelLayoutController({ this._panelLayout = new PanelLayoutController({
@@ -90,7 +91,7 @@ export class PanelChrome {
variant: this.hud.getVariant(), variant: this.hud.getVariant(),
}), }),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(), scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel), presentPanel: (panel, patch) => this._presentPanel(panel, patch),
readDisplayScrollTop: () => readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard' this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop ? this._standardDisplayScrollTop
@@ -221,18 +222,18 @@ export class PanelChrome {
typeof pin === 'boolean' typeof pin === 'boolean'
? pin ? pin
: !panelEl.classList.contains('dock-pinned'); : !panelEl.classList.contains('dock-pinned');
panelEl.classList.toggle('dock-pinned', shouldPin); // The tray pinned last draws on top; unpinning hands the top to the
this._chromeStore.publish(this._chromeToken, panelId, { // other tray if it is pinned.
collapsed: panelEl.classList.contains('collapsed'), this._presentPanel(panelEl, { pinned: shouldPin, pinnedTop: shouldPin });
pinned: shouldPin, for (const otherId of PINNABLE_PANEL_IDS) {
}); if (otherId === panelId) continue;
document const other = document.getElementById(otherId);
.querySelectorAll('#command-dock .dock-pinned-top') if (!other) continue;
.forEach((pinnedPanel) => { this._presentPanel(other, {
pinnedPanel.classList.remove('dock-pinned-top'); pinnedTop: !shouldPin && other.classList.contains('dock-pinned'),
}); });
}
if (shouldPin) { if (shouldPin) {
panelEl.classList.add('dock-pinned-top');
this.setPanelCollapsed(panelId, false, { this.setPanelCollapsed(panelId, false, {
explicit: !restore, explicit: !restore,
restore, restore,
@@ -240,10 +241,6 @@ export class PanelChrome {
syncShare: false, syncShare: false,
}); });
} else { } else {
const remainingPinnedPanel = document.querySelector(
'#command-dock .dock-pinned',
);
remainingPinnedPanel?.classList.add('dock-pinned-top');
if (!restore && !panelEl.matches(':hover')) { if (!restore && !panelEl.matches(':hover')) {
this.setPanelCollapsed(panelId, true, { this.setPanelCollapsed(panelId, true, {
explicit: true, explicit: true,
@@ -323,11 +320,23 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0); 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; if (!panelEl?.id) return;
const shown =
this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
this._chromeStore.publish(this._chromeToken, panelEl.id, { 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 && panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed !wasAutoCollapsed
) { ) {
this._syncPanelCollapseButton(panelEl);
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) { if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
} }
@@ -487,7 +495,6 @@ export class PanelChrome {
} }
return; return;
} }
panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed');
if ( if (
!nextCollapsed && !nextCollapsed &&
this.cockpitView?.active && this.cockpitView?.active &&
@@ -518,7 +525,7 @@ export class PanelChrome {
}); });
} }
} }
panelEl.classList.toggle('collapsed', nextCollapsed); this._presentPanel(panelEl, { collapsed: nextCollapsed, auto: null });
if ( if (
nextCollapsed && nextCollapsed &&
this.cockpitView?.active && this.cockpitView?.active &&
@@ -528,7 +535,6 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false; this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false); this.cockpitView.setContextCollapsed(false);
} }
this._syncPanelCollapseButton(panelEl);
if (persist !== false) if (persist !== false)
this._savePanelCollapsedState(panelId, nextCollapsed); this._savePanelCollapsedState(panelId, nextCollapsed);
if (panelId === 'pp-toggles') { if (panelId === 'pp-toggles') {
@@ -586,6 +592,9 @@ export class PanelChrome {
} }
destroy() { destroy() {
if (this._disposed) return; 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._disposed = true;
this._lifetime.destroy(); this._lifetime.destroy();
this._chromeStore.detach(this._chromeToken); this._chromeStore.detach(this._chromeToken);
+94 -3
View File
@@ -4,9 +4,11 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createApp, h, nextTick } from 'vue'; import { createApp, h, nextTick, withDirectives } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { settleSideAccordion } from './panelAccordion.js';
import { PanelChrome } from './panelChrome.js'; import { PanelChrome } from './panelChrome.js';
import { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js'; import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue'; import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import { usePanelChromeStore } from './stores/panelChrome.js'; 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'], { assert.deepEqual(store.panels['data-panel'], {
collapsed: true, collapsed: true,
pinned: false, pinned: false,
pinnedTop: false,
auto: null,
}); });
store.publish(token, 'data-panel', { collapsed: true }); store.publish(token, 'data-panel', { collapsed: true });
assert.ok(store.panels === first, 'an unchanged state is not republished'); 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>`; <div id="control-panel" class="collapsed"></div>`;
const store = usePanelChromeStore(createPinia()); const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store); const chrome = panelChrome(store);
for (const id of ['data-panel', 'cctv-panel', 'control-panel'])
followPanelState(store, id, document.getElementById(id));
const calls = []; const calls = [];
const setPanelCollapsed = chrome.setPanelCollapsed.bind(chrome); const setPanelCollapsed = chrome.setPanelCollapsed.bind(chrome);
chrome.setPanelCollapsed = (id, collapsed, options) => { 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'], { assert.deepEqual(store.panels['control-panel'], {
collapsed: false, collapsed: false,
pinned: true, pinned: true,
pinnedTop: true,
auto: null,
}); });
assert.equal( assert.equal(
document.getElementById('control-panel').classList.contains('dock-pinned'), 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; const before = calls.length;
store.act('toggle', 'data-panel'); store.act('toggle', 'data-panel');
assert.equal(calls.length, before, 'a destroyed owner gets no clicks'); 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(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', () => { 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>'; document.body.innerHTML = '<div id="location-bar" class="collapsed"></div>';
const second = panelChrome(store); const second = panelChrome(store);
assert.equal(store.panels['location-bar'], undefined); assert.equal(store.panels['location-bar'], undefined);
second._syncPanelCollapseButton(document.getElementById('location-bar')); second._presentPanel('location-bar');
assert.deepEqual(store.panels['location-bar'], { assert.deepEqual(store.panels['location-bar'], {
collapsed: true, collapsed: true,
pinned: false, pinned: false,
pinnedTop: false,
auto: null,
}); });
second.destroy(); 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({ constructor({
readHud, readHud,
scheduleCockpitLayout, scheduleCockpitLayout,
syncPanelCollapseButton, presentPanel,
readDisplayScrollTop, readDisplayScrollTop,
}) { }) {
this.readHud = readHud; this.readHud = readHud;
this.scheduleCockpitLayout = scheduleCockpitLayout; this.scheduleCockpitLayout = scheduleCockpitLayout;
this._syncPanelCollapseButton = syncPanelCollapseButton; this._presentPanel = presentPanel;
this.readDisplayScrollTop = readDisplayScrollTop; this.readDisplayScrollTop = readDisplayScrollTop;
this.destroyed = false; this.destroyed = false;
this._adaptivePanelSettleTimer = null; this._adaptivePanelSettleTimer = null;
@@ -198,7 +198,6 @@ export class PanelLayoutController {
]) { ]) {
if (!panel) continue; if (!panel) continue;
stack.insertBefore(panel, globalContextPanel); stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
} }
if (this._sliderPanel) { if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top'); this._sliderPanel.style.removeProperty('top');
@@ -286,8 +285,7 @@ export class PanelLayoutController {
for (const panel of this._rightPanelStack.querySelectorAll( for (const panel of this._rightPanelStack.querySelectorAll(
'.layout-auto-collapsed', '.layout-auto-collapsed',
)) { )) {
panel.classList.remove('collapsed', 'layout-auto-collapsed'); this._presentPanel(panel, { collapsed: false, auto: null });
this._syncPanelCollapseButton(panel);
} }
} }
this._syncRightPanelAdaptiveLayout(); this._syncRightPanelAdaptiveLayout();
@@ -303,7 +301,7 @@ export class PanelLayoutController {
windowRef: window, windowRef: window,
hud: this.readHud(), hud: this.readHud(),
preferredPanelId: this._rightStackPreferredPanelId, preferredPanelId: this._rightStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel), onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleRightPanelLayout(), onRetry: () => this._scheduleRightPanelLayout(),
leftStack: this._leftPanelStack, leftStack: this._leftPanelStack,
displayPanel: this._ppToggles, displayPanel: this._ppToggles,
@@ -422,8 +420,7 @@ export class PanelLayoutController {
for (const panel of this._leftPanelStack.querySelectorAll( for (const panel of this._leftPanelStack.querySelectorAll(
'.layout-auto-collapsed', '.layout-auto-collapsed',
)) { )) {
panel.classList.remove('collapsed', 'layout-auto-collapsed'); this._presentPanel(panel, { collapsed: false, auto: null });
this._syncPanelCollapseButton(panel);
} }
} }
this._syncLeftPanelAdaptiveLayout(); this._syncLeftPanelAdaptiveLayout();
@@ -438,7 +435,7 @@ export class PanelLayoutController {
windowRef: window, windowRef: window,
hud: this.readHud(), hud: this.readHud(),
preferredPanelId: this._leftStackPreferredPanelId, preferredPanelId: this._leftStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel), onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleLeftPanelLayout(), onRetry: () => this._scheduleLeftPanelLayout(),
collapsedHeights: this._leftStackCollapsedHeights, collapsedHeights: this._leftStackCollapsedHeights,
onAligned: () => this._scheduleRightPanelLayout(), onAligned: () => this._scheduleRightPanelLayout(),
+3 -4
View File
@@ -14,8 +14,8 @@ const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`, `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
]; ];
export class PanelPositionControls { export class PanelPositionControls {
constructor({ syncPanelCollapseButton, showToast }) { constructor({ presentPanel, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton; this._presentPanel = presentPanel;
this._showToast = showToast; this._showToast = showToast;
} }
@@ -75,7 +75,7 @@ export class PanelPositionControls {
// first-run mission card for the one first impression there is. A stored // 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. // choice still wins in both directions, so anyone who opens it keeps it.
if (panelId === 'pp-toggles' && stored === null) collapsed = true; 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 // Bodies that expand themselves on first appearance must not override a
// choice the user (or a share link) already made. // choice the user (or a share link) already made.
if (panelEl.dataset) if (panelEl.dataset)
@@ -84,7 +84,6 @@ export class PanelPositionControls {
: stored === null : stored === null
? 'default' ? 'default'
: 'stored'; : 'stored';
this._syncPanelCollapseButton(panelEl);
} }
_savePanelCollapsedState(panelId, collapsed) { _savePanelCollapsedState(panelId, collapsed) {
+6 -1
View File
@@ -7,6 +7,7 @@ import {
layoutRightPanelRail, layoutRightPanelRail,
measurePanelNaturalHeight, measurePanelNaturalHeight,
} from './panelRails.js'; } from './panelRails.js';
import { applyPanelState, readPanelState } from './rail/panelState.js';
test('Display uses an inner scroll body only in Cyber', () => { test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 }; const body = { scrollTop: 95 };
@@ -169,7 +170,11 @@ function fixture(
matchMedia: () => ({ matches: mobile }), matchMedia: () => ({ matches: mobile }),
getComputedStyle: (node) => node.computed, 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: () => { onRetry: () => {
retries += 1; 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 {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation. * @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel. * @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.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override. * @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline. * @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
@@ -83,8 +84,7 @@ export function layoutRightPanelRail({
for (const panel of panels.filter((item) => for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'), item.classList.contains('layout-auto-collapsed'),
)) { )) {
panel.classList.remove('collapsed', 'layout-auto-collapsed'); onCollapse(panel, { collapsed: false, auto: null });
onCollapse(panel);
} }
} }
// One panel is open per desktop side; this marker is distinct from // One panel is open per desktop side; this marker is distinct from
@@ -275,8 +275,7 @@ export function layoutRightPanelRail({
if (!isCollapseRetry && autoCollapseIndices.length) { if (!isCollapseRetry && autoCollapseIndices.length) {
for (const index of autoCollapseIndices) { for (const index of autoCollapseIndices) {
const panel = expandedPanels[index]; const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed'); onCollapse(panel, { collapsed: true, auto: 'layout' });
onCollapse(panel);
} }
pendingCollapseRetries.add(stack); pendingCollapseRetries.add(stack);
onRetry(); onRetry();
+6 -5
View File
@@ -1024,13 +1024,14 @@ export class ShellFacade {
} }
/** /**
* Updates collapse button glyphs based on panel state. Right-rail panels * Presents a panel's state, merged over what it shows now; its root and
* use directional arrows; left-stack panels use +/- symbols. * buttons draw it from the panel chrome store.
* @param {HTMLElement} panelEl - The panel DOM element. * @param {HTMLElement|string} panelOrId - The panel or its id.
* @param {object} [patch] - The state that changes.
* @returns {void} * @returns {void}
*/ */
_syncPanelCollapseButton(panelEl) { _presentPanel(panelOrId, patch) {
return this._panelChrome._syncPanelCollapseButton(...arguments); return this._panelChrome._presentPanel(...arguments);
} }
_buildSharePanelState() { _buildSharePanelState() {
+14 -10
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js'; import { UiLifetime } from './uiLifetime.js';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js'; import { PanelChrome } from './panelChrome.js';
import { followPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js'; import { usePanelChromeStore } from './stores/panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js'; import { PanelLayoutController } from './panelLayoutController.js';
import { ShellFeedback } from './shellFeedback.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({ const owner = new PanelLayoutController({
readHud: () => ({}), readHud: () => ({}),
scheduleCockpitLayout() {}, scheduleCockpitLayout() {},
syncPanelCollapseButton() {}, presentPanel() {},
readDisplayScrollTop: () => 0, readDisplayScrollTop: () => 0,
}); });
owner._syncLeftPanelAdaptiveLayout = () => layouts++; 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 f = fixture();
const observed = new Set(); const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver; 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'); const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')]; stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const store = usePanelChromeStore(createPinia()); const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) }; const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
const presented = [];
const owner = new PanelLayoutController({ const owner = new PanelLayoutController({
readHud: () => ({}), readHud: () => ({}),
scheduleCockpitLayout() {}, scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0, readDisplayScrollTop: () => 0,
syncPanelCollapseButton(panel) { presentPanel(panel, patch) {
synced.push(panel.id); presented.push(panel.id);
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, panel); PanelChrome.prototype._presentPanel.call(chrome, panel, patch);
}, },
}); });
owner._initRightPanelAdaptiveLayout(); owner._initRightPanelAdaptiveLayout();
@@ -315,12 +316,15 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
stack.children.map((panel) => panel.id), stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'], ['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
); );
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather)); assert.ok(observed.has(weather));
assert.equal(store.panels['weather-panel'].collapsed, true); // Moving a panel into the rail leaves its state as it was.
weather.classList.remove('collapsed'); assert.deepEqual(presented, []);
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather); followPanelState(store, 'weather-panel', weather);
PanelChrome.prototype._presentPanel.call(chrome, weather, {
collapsed: false,
});
assert.equal(store.panels['weather-panel'].collapsed, false); assert.equal(store.panels['weather-panel'].collapsed, false);
assert.equal(weather.classList.contains('collapsed'), false);
owner.destroy(); owner.destroy();
assert.equal(observed.size, 0); assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0); assert.equal(f.frames.size, 0);
+2 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js'; import { PanelChrome } from './panelChrome.js';
import { presentPanelLocally } from '../testSupport/panelState.mjs';
function fixture(theme = 'cyber') { function fixture(theme = 'cyber') {
const nodes = new Map(); const nodes = new Map();
@@ -35,7 +36,7 @@ function fixture(theme = 'cyber') {
_rightPanelStack: rail, _rightPanelStack: rail,
_panelLayout: {}, _panelLayout: {},
_lifetime: { frame() {} }, _lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {}, _presentPanel: presentPanelLocally, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {}, _scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args), _savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} }, 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 * @typedef {object} PanelState
* it. PanelChrome owns the panels: it publishes here whenever a panel opens, * @property {boolean} collapsed Whether the panel is closed, by the person or
* closes or is pinned, and connects the buttons' clicks; the views only render * by its layout.
* and forward clicks. * @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', () => { export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
/** /**
* `{[panelId]: {collapsed, pinned}}`, frozen; a panel is absent until its * `{[panelId]: PanelState}`, frozen; a panel is absent until its owner
* owner first presents it, and its buttons keep their markup until then. * first presents it, and it keeps its markup's classes until then.
*/ */
const panels = shallowRef(Object.freeze({})); const panels = shallowRef(Object.freeze({}));
/** @type {{handlers: PanelChromeHandlers}|null} */ /** @type {{handlers: PanelChromeHandlers}|null} */
@@ -44,17 +64,13 @@ export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
* unchanged state is not republished. * unchanged state is not republished.
* @param {object} token * @param {object} token
* @param {string} panelId * @param {string} panelId
* @param {{collapsed?: boolean, pinned?: boolean}} patch * @param {Partial<PanelState>} patch
*/ */
function publish(token, panelId, patch) { function publish(token, panelId, patch) {
if (!owns(token) || !panelId) return; if (!owns(token) || !panelId) return;
const prior = panels.value[panelId]; const prior = panels.value[panelId];
const next = { collapsed: false, pinned: false, ...prior, ...patch }; const next = { ...BLANK, ...prior, ...patch };
if ( if (prior && Object.keys(BLANK).every((key) => prior[key] === next[key]))
prior &&
prior.collapsed === next.collapsed &&
prior.pinned === next.pinned
)
return; return;
panels.value = Object.freeze({ panels.value = Object.freeze({
...panels.value, ...panels.value,
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { msg, t } from '../../i18n/index.js'; import { msg, t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import UiButton from '../kit/UiButton.vue'; import UiButton from '../kit/UiButton.vue';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.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> <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"> <template v-for="slot in dockOrder" :key="slot">
<!-- Control panel --> <!-- 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-glow"></div>
<div class="panel-inner"> <div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button" <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" /> <VoiceControl v-else-if="slot === 'voice'" :key="voice.id" :view="voice" />
<!-- Location bar --> <!-- 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="panel-glow"></div>
<div class="location-inner"> <div class="location-inner">
<div class="location-toolbar"> <div class="location-toolbar">
+4 -3
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js'; import { useCockpitStore } from '../stores/cockpit.js';
@@ -28,7 +29,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<template> <template>
<!-- Global Context is deliberately independent from the left layer accordion. --> <!-- 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 }"> <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="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller> <div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header"> <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 id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div> </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="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller> <div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header"> <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 id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div> </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="panel-glow"></div>
<div class="global-context-panel-inner ui-scroll" data-rail-scroller> <div class="global-context-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header"> <div class="panel-header">
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js'; import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js'; import { useSceneStore } from '../stores/scenes.js';
@@ -37,7 +38,7 @@ async function importChosen(event) {
<template> <template>
<div id="left-panel-stack" class="ui-scroll"> <div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel --> <!-- 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="panel-glow"></div>
<div class="data-panel-inner"> <div class="data-panel-inner">
<div class="panel-header"> <div class="panel-header">
@@ -56,7 +57,7 @@ async function importChosen(event) {
<StreetLevelPanel /> <StreetLevelPanel />
<!-- Scene Director Panel --> <!-- 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="panel-glow"></div>
<div class="scene-panel-inner ui-scroll"> <div class="scene-panel-inner ui-scroll">
<div class="panel-header"> <div class="panel-header">
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue'; import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue'; import CctvPicker from '../../cctv/CctvPicker.vue';
@@ -27,7 +28,7 @@ const frameData = computed(() =>
</script> </script>
<template> <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="panel-glow"></div>
<div class="cctv-panel-inner ui-scroll" data-rail-scroller> <div class="cctv-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header"> <div class="panel-header">
+3 -2
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js'; import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
@@ -43,7 +44,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<template> <template>
<!-- Post-processing toggles (top-right) --> <!-- 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"> <div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span> <span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span> <span class="panel-divider" aria-hidden="true"></span>
@@ -123,7 +124,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
</div> </div>
</div> </div>
<!-- The one shared preset-parameter surface is inserted here at runtime. --> <!-- 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"> <div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span> <span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span> <span class="param-panel-divider"></span>
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../../rail/classToggle.js'; import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js'; import { vFieldValue } from '../../rail/fieldValue.js';
@@ -29,7 +30,7 @@ const radioWords = {
</script> </script>
<template> <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="radio-panel-inner ui-scroll">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span> <span class="panel-title" data-i18n>RADIO</span>
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue'; import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailChips from '../../rail/RailChips.vue'; import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js'; import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -101,7 +102,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</script> </script>
<template> <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="panel-glow"></div>
<div class="street-level-panel-inner ui-scroll" data-rail-scroller> <div class="street-level-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header"> <div class="panel-header">
+2 -4
View File
@@ -445,8 +445,7 @@ export class VisualSettings {
return; return;
} }
if (!this._cockpitDisclosureOpen?.('display')) return; if (!this._cockpitDisclosureOpen?.('display')) return;
this._sliderPanel.classList.remove('collapsed'); this._presentPanel(this._sliderPanel, { collapsed: false });
this._syncPanelCollapseButton(this._sliderPanel);
this._lifetime.frame(() => this._lifetime.frame(() =>
this._lifetime.frame(() => { this._lifetime.frame(() => {
this._sliderPanel?.scrollIntoView?.({ block: 'nearest' }); this._sliderPanel?.scrollIntoView?.({ block: 'nearest' });
@@ -1434,8 +1433,7 @@ export class VisualSettings {
_revealStyleParameters() { _revealStyleParameters() {
if (!this._sliderPanel?.classList.contains('active')) return; if (!this._sliderPanel?.classList.contains('active')) return;
if (this._cockpitDisplayPortalActive) return; if (this._cockpitDisplayPortalActive) return;
this._sliderPanel.classList.remove('collapsed'); this._presentPanel(this._sliderPanel, { collapsed: false });
this._syncPanelCollapseButton(this._sliderPanel);
this.setPanelCollapsed('pp-toggles', false, { explicit: true }); this.setPanelCollapsed('pp-toggles', false, { explicit: true });
this._lifetime.frame(() => this._lifetime.frame(() =>
this._lifetime.frame(() => { this._lifetime.frame(() => {
+18 -7
View File
@@ -817,7 +817,17 @@ export function createGevActionRunner({
...(!applied ? { error: say } : {}), ...(!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 }; 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; 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) { function setPanelOpen(styleManager, panelId, open) {
if (styleManager && typeof styleManager.setPanelCollapsed === 'function') { if (typeof styleManager?.setPanelCollapsed !== 'function') return false;
styleManager.setPanelCollapsed(panelId, !open, { explicit: true }); styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
} else { return true;
const panel = document.getElementById(panelId);
if (panel) panel.classList.toggle('collapsed', !open);
}
} }
function normalizeContextMode(value) { function normalizeContextMode(value) {