mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
4c92f517db
commit
9ed9098593
12 files changed
+117
-49
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user