diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 4afd3761d..fa341ec50 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 4fe327381..f47403e73 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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, diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index ee2f82eef..22d00f2cf 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -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); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index c7324fd2d..8e83827c6 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -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( diff --git a/src/tooling/panelStorageDocs.test.mjs b/src/tooling/panelStorageDocs.test.mjs index 5a83f9fb7..7ccfefdcc 100644 --- a/src/tooling/panelStorageDocs.test.mjs +++ b/src/tooling/panelStorageDocs.test.mjs @@ -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: () => {}, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 6329785d0..46dd3d04c 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 8a6457380..65673cd55 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -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 = () => diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 97dcaa737..71d64fb00 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -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, { diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 4e0e4ca1b..d19ba0a5a 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -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 { diff --git a/src/ui/rail/panelState.js b/src/ui/rail/panelState.js index ffcc904a0..294ac0443 100644 --- a/src/ui/rail/panelState.js +++ b/src/ui/rail/panelState.js @@ -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. diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index f5746a4e6..2c8bc8ee7 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -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: () => ({}), diff --git a/src/ui/sidePanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs index 1e7b89ac6..0814ed8ba 100644 --- a/src/ui/sidePanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -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; + } +});