refactor(panels): read panel state from the panel chrome store

PanelChrome, its collapse-preference restore and the phone shell read a
panel's disclosure state back from the gev-panel-chrome store
(_shownPanel), falling back to the markup's classes before the panel is
first presented, instead of reading the classes v-panel-state draws. The
share link and the phone shell share one rule for a panel the person keeps
closed (prefersCollapsed). The rails, the accordion and the hover
disclosure still read the classes, which the directive draws in the same
task as their layout.
This commit is contained in:
Sameh Khamis committed 2026-11-01 14:48:31 -08:00
1 parent 4c92f517db
commit 9ed9098593
12 files changed
+117 -49

No files matched your search

+7 -1
View File
@@ -124,7 +124,13 @@ a feature supplying only its accent.
`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.
the same task that opens or closes it. PanelChrome and its preference
restore, and the phone shell, read a panel's state back from the store
(`_shownPanel`; the markup's classes before its first presentation), so
once a panel is presented a class written outside the store no longer
counts. The rails, the accordion and the hover disclosure
(`panelDisclosure.js`), which act within the same task as a layout, read
the classes the directive has just drawn. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids,
classes and CSS; text a template renders with `t()` drops its `data-i18n`
marker. The page places each view with a
+2 -2
View File
@@ -696,7 +696,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
);
assert.match(
callback[1],
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
"entry must remember each panel's exact collapsed state before hiding it",
);
assert.match(
@@ -757,7 +757,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
);
assert.match(
collapse,
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
);
assert.match(
collapse,
+5 -4
View File
@@ -110,7 +110,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.performance = { now: () => now };
document.defaultView = window;
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()}});`)(
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_presentPanel').toString()},\n${shellMethod('_shownPanel').toString()}});`)(
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg, readPanelState,
);
const saves = [];
@@ -248,7 +248,7 @@ test('opening the unpinned Location sibling cancels the Map Source request', ()
assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0);
});
test('a pinned Map Source tray retains its own request when the sibling opens', () => {
const h = harness(); h.panel.classes.add('dock-pinned'); h.key();
const h = harness(); h.manager._presentPanel(h.panel, { pinned: true }); h.key();
h.manager.setPanelCollapsed('location-bar', false); h.tick();
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.document.activeElement, h.active);
@@ -357,12 +357,13 @@ test('Location keyboard opening cancels the unpinned sibling and cannot revive i
});
test('Location keyboard opening preserves a pinned sibling and a pinned Location survives sibling opening', () => {
const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); h.tick();
// Pins go through the store, as the dock's pin button does.
const h = harness(); h.manager._presentPanel(h.panel, { pinned: true }); h.key(); h.tick();
h.focus(h.locationDisclosure); h.locationKey(); h.drain();
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.document.activeElement, h.locationDisclosure);
h.location.classes.add('dock-pinned');
h.manager._presentPanel(h.location, { pinned: true });
h.manager.setPanelCollapsed('control-panel', true);
h.focus(h.disclosure); h.key(); h.tick();
assert.equal(h.location.classList.contains('collapsed'), false);
+1 -1
View File
@@ -200,7 +200,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
assert.match(
ui,
/const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
/const shown = this\._shownPanel\(panelEl\);\s*const collapsed = prefersCollapsed\(shown\);/,
'responsive auto-collapse must serialize the explicit expanded preference',
);
assert.match(
+2
View File
@@ -78,6 +78,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
const { presentPanelLocally } = await import('../testSupport/panelState.mjs');
const { readPanelState } = await import('../ui/rail/panelState.js');
const html = await readFile(
path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8',
@@ -112,6 +113,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
try {
const controls = new PanelPositionControls({
presentPanel: presentPanelLocally,
shownPanel: readPanelState,
layoutRightPanels: () => {},
syncCctvPanelViewport: () => {},
showToast: () => {},
+1
View File
@@ -858,6 +858,7 @@ export class StyleManager extends ShellFacade {
this._mobileShell = initMobileShell({
setPanelCollapsed: (panelId, collapsed, options) =>
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
readPanel: (panel) => this._panelChrome._shownPanel(panel),
onSheetsClosed: () => this._syncShareState(),
onTabOpened: (sheet) => {
void this._onMobileTabOpened(sheet);
+14 -11
View File
@@ -13,6 +13,10 @@
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import { msg } from '../i18n/index.js';
import {
prefersCollapsed as prefersStateCollapsed,
readPanelState,
} from './rail/panelState.js';
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
export const MOBILE_SHELL_CLASS = 'mobile-shell';
@@ -97,6 +101,8 @@ function currentSheetOf(body) {
* @param {(query: string) => MediaQueryList} [input.matchMedia]
* @param {typeof MutationObserver} [input.MutationObserverImpl]
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
* @param {(panel: Element) => object} [input.readPanel] A panel's state as its
* owner presented it; its classes by default.
* @param {(callback: () => void) => number} [input.requestFrame]
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
* and its panels are put back (e.g. to refresh the share link).
@@ -112,6 +118,7 @@ function currentSheetOf(body) {
*/
export function initMobileShell({
setPanelCollapsed,
readPanel = readPanelState,
documentRef = globalThis.document,
matchMedia = globalThis.matchMedia?.bind(globalThis),
MutationObserverImpl = globalThis.MutationObserver,
@@ -182,18 +189,14 @@ export function initMobileShell({
: null;
removers.push(() => resize?.disconnect());
const isCollapsed = (panelId) =>
element(panelId)?.classList.contains('collapsed') ?? true;
// The user's preference, as share links record it: a collapse that layout
// applies only for space (Tactical, Cyber) still counts as expanded.
const isCollapsed = (panelId) => {
const panel = element(panelId);
return panel ? readPanel(panel).collapsed : true;
};
// The user's preference, as share links record it.
const prefersCollapsed = (panelId) => {
const classes = element(panelId)?.classList;
if (!classes) return true;
return (
classes.contains('collapsed') &&
!classes.contains('layout-auto-collapsed') &&
!classes.contains('accordion-collapsed')
);
const panel = element(panelId);
return panel ? prefersStateCollapsed(readPanel(panel)) : true;
};
const panelStates = () =>
+27 -26
View File
@@ -1,5 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { readPanelState } from './rail/panelState.js';
import { prefersCollapsed, readPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
@@ -83,6 +83,7 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
presentPanel: (panel, patch) => this._presentPanel(panel, patch),
shownPanel: (panel) => this._shownPanel(panel),
showToast: (message) => this._showToast(message),
});
this._panelLayout = new PanelLayoutController({
@@ -202,7 +203,7 @@ export class PanelChrome {
if (this._disposed || !this._disclosurePanelIds?.has(panelId)) return;
const panel = document.getElementById(panelId);
if (!panel) return;
this.setPanelCollapsed(panelId, !panel.classList.contains('collapsed'), {
this.setPanelCollapsed(panelId, !this._shownPanel(panel).collapsed, {
explicit: true,
});
}
@@ -216,9 +217,7 @@ export class PanelChrome {
const panelEl = document.getElementById(panelId);
if (!panelEl) return undefined;
const shouldPin =
typeof pin === 'boolean'
? pin
: !panelEl.classList.contains('dock-pinned');
typeof pin === 'boolean' ? pin : !this._shownPanel(panelEl).pinned;
// 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 });
@@ -227,7 +226,7 @@ export class PanelChrome {
const other = document.getElementById(otherId);
if (!other) continue;
this._presentPanel(other, {
pinnedTop: !shouldPin && other.classList.contains('dock-pinned'),
pinnedTop: !shouldPin && this._shownPanel(other).pinned,
});
}
if (shouldPin) {
@@ -329,14 +328,22 @@ export class PanelChrome {
? document.getElementById(panelOrId)
: panelOrId;
if (!panelEl?.id) return;
const shown =
this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
this._chromeStore.publish(this._chromeToken, panelEl.id, {
...shown,
...this._shownPanel(panelEl),
...patch,
});
}
/**
* A panel's state as PanelChrome presented it, or its markup's before the
* first presentation.
* @param {Element} panelEl
* @returns {import('./stores/panelChrome.js').PanelState}
*/
_shownPanel(panelEl) {
return this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
}
_buildSharePanelState() {
const specs = [];
for (const spec of SHARE_PANEL_STATE_SPECS) {
@@ -344,18 +351,14 @@ export class PanelChrome {
if (!panelEl) continue;
// Responsive auto-collapse is presentation only; the recipient should
// restore the user's explicit expanded preference at its own viewport.
const collapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('accordion-collapsed')
? false
: panelEl.classList.contains('collapsed');
const shown = this._shownPanel(panelEl);
const collapsed = prefersCollapsed(shown);
// A panel the phone shell only borrowed for a sheet shares its own state.
const entry = {
id: spec.id,
collapsed: this.readSharedCollapsed(spec.id) ?? collapsed,
};
if (spec.pinnable)
entry.pinned = panelEl.classList.contains('dock-pinned');
if (spec.pinnable) entry.pinned = shown.pinned;
specs.push(entry);
}
return specs.length ? { specs } : null;
@@ -402,9 +405,7 @@ export class PanelChrome {
if (explicit && !restore)
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
const nextCollapsed = Boolean(collapsed);
const wasAutoCollapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('accordion-collapsed');
const wasAutoCollapsed = Boolean(this._shownPanel(panelEl).auto);
// One panel is open per desktop side: opening a side panel, whether the
// user asked or a feature revealed it (Street Level's viewer, a selected
// radio station), owns the whole accordion, including peers that were
@@ -466,12 +467,12 @@ export class PanelChrome {
}
// Reveal Radio's owner before the no-op check: the nested section may
// already be expanded while its Context parent is closed.
const contextPanel = document.getElementById('global-context-panel');
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
contextPanel &&
this._shownPanel(contextPanel).collapsed
) {
this.setPanelCollapsed('global-context-panel', false, {
explicit,
@@ -481,7 +482,7 @@ export class PanelChrome {
});
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
this._shownPanel(panelEl).collapsed === nextCollapsed &&
!wasAutoCollapsed
) {
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
@@ -505,7 +506,7 @@ export class PanelChrome {
}
if (!nextCollapsed && !restore && panelId === 'location-bar') {
const otherPanel = document.getElementById('control-panel');
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
if (otherPanel && !this._shownPanel(otherPanel).pinned) {
this.setPanelCollapsed('control-panel', true, {
restore,
persist,
@@ -514,7 +515,7 @@ export class PanelChrome {
}
} else if (!nextCollapsed && !restore && panelId === 'control-panel') {
const otherPanel = document.getElementById('location-bar');
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
if (otherPanel && !this._shownPanel(otherPanel).pinned) {
this.setPanelCollapsed('location-bar', true, {
restore,
persist,
@@ -564,7 +565,7 @@ export class PanelChrome {
if (panel) {
this._cockpitPanelRestore.set(
panelId,
panel.classList.contains('collapsed'),
this._shownPanel(panel).collapsed,
);
}
this.setPanelCollapsed(panelId, true, {
+4 -2
View File
@@ -1,5 +1,6 @@
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
import { t } from '../i18n/index.js';
import { readPanelState } from './rail/panelState.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/**
@@ -14,8 +15,9 @@ const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
export class PanelPositionControls {
constructor({ presentPanel, showToast }) {
constructor({ presentPanel, shownPanel = readPanelState, showToast }) {
this._presentPanel = presentPanel;
this._shownPanel = shownPanel;
this._showToast = showToast;
}
@@ -55,7 +57,7 @@ export class PanelPositionControls {
_restorePanelCollapsedState(panelId, { allowStored = true } = {}) {
const panelEl = document.getElementById(panelId);
if (!panelEl) return;
let collapsed = panelEl.classList.contains('collapsed');
let collapsed = this._shownPanel(panelEl).collapsed;
let stored = null;
if (allowStored) {
try {
+9
View File
@@ -56,6 +56,15 @@ export const vPanelState = {
},
};
/**
* Whether the person keeps a panel closed, as share links record it: a
* collapse a layout applied only for space still counts as open.
* @param {import('../stores/panelChrome.js').PanelState} state
*/
export function prefersCollapsed(state) {
return state.collapsed && !state.auto;
}
/**
* A panel's state as its classes draw it: the markup's state, before its
* owner first presents the panel.
+5 -1
View File
@@ -301,7 +301,11 @@ test('weather rail orders between CCTV and Context, observes and presents chrome
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
const chrome = {
_chromeStore: store,
_chromeToken: store.connect({}),
_shownPanel: PanelChrome.prototype._shownPanel,
};
const presented = [];
const owner = new PanelLayoutController({
readHud: () => ({}),
+40 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
import { presentPanelLocally } from '../testSupport/panelState.mjs';
import { readPanelState } from './rail/panelState.js';
function fixture(theme = 'cyber') {
const nodes = new Map();
@@ -36,7 +37,7 @@ function fixture(theme = 'cyber') {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_presentPanel: presentPanelLocally, _scheduleRightPanelLayout() {},
_presentPanel: presentPanelLocally, _shownPanel: readPanelState, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
@@ -115,3 +116,41 @@ test('a feature revealing a side panel makes it the side owner', () => {
assert.deepEqual(f.claimed, []);
} finally { globalThis.document = prior; }
});
test('PanelChrome reads a panel’s state from its store, not classes written outside it', async () => {
const { createPinia } = await import('pinia');
const { usePanelChromeStore } = await import('./stores/panelChrome.js');
const store = usePanelChromeStore(createPinia());
const owner = {
_chromeStore: store,
_chromeToken: store.connect({}),
readSharedCollapsed: () => undefined,
};
const classes = new Set(['collapsed']);
const panel = {
id: 'data-panel',
classList: { contains: (name) => classes.has(name) },
};
// Before the first presentation the markup's classes stand.
assert.deepEqual(PanelChrome.prototype._shownPanel.call(owner, panel), {
collapsed: true, pinned: false, pinnedTop: false, auto: null,
});
// The accordion closed it; a class written outside the store doesn't count.
store.publish(owner._chromeToken, 'data-panel', {
collapsed: true, pinned: false, pinnedTop: false, auto: 'accordion',
});
classes.add('dock-pinned');
const shown = PanelChrome.prototype._shownPanel.call(owner, panel);
assert.equal(shown.auto, 'accordion');
assert.equal(shown.pinned, false);
// A share link records the accordion's collapse as the person's open panel.
const prior = globalThis.document;
globalThis.document = { getElementById: (id) => (id === 'data-panel' ? panel : null) };
try {
owner._shownPanel = PanelChrome.prototype._shownPanel;
const state = PanelChrome.prototype._buildSharePanelState.call(owner);
assert.deepEqual(state.specs, [{ id: 'data-panel', collapsed: false }]);
} finally {
globalThis.document = prior;
}
});