fix(ui): let revealed panels own their side and keep Cockpit's left strip

A panel a feature opens (Street Level's viewer, a selected radio station)
now becomes its side's open panel instead of being collapsed again by the
next layout pass. Cockpit keeps showing its collapsed Data Layers strip,
which Cyber's Cockpit layout aligns to, and Cyber's focus layout no longer
re-shows collapsed left panels. Both sidebars share one accordion helper.
This commit is contained in:
Sameh Khamis committed 2026-10-30 16:41:29 -07:00
1 parent 2035c767ce
commit eed7657516
9 files changed
+124 -58

No files matched your search

+8 -4
View File
@@ -426,7 +426,8 @@
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/panelStackLayout.js" "src/panelStackLayout.js",
"src/ui/panelAccordion.js"
], ],
"external": [] "external": []
}, },
@@ -874,7 +875,8 @@
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/visualStylePolicy.js" "src/visualStylePolicy.js",
"src/ui/panelAccordion.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -2810,7 +2812,8 @@
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/sceneCommandRelease.js", "src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/visualStylePolicy.js" "src/visualStylePolicy.js",
"src/ui/panelAccordion.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3603,7 +3606,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json",
"src/ui/panelAccordion.js"
], ],
"external": [] "external": []
}, },
+7 -2
View File
@@ -4,16 +4,21 @@ import { readFileSync } from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js';
test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => { test('desktop side rails hide collapsed panels in every HUD layout, never on phones or beside Cockpit', () => {
const right = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const right = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
const left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); const left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
for (const ui of [right, left]) { for (const ui of [right, left]) {
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match(ui, /const exclusive = !isMobile;/);
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
assert.doesNotMatch(ui, /hud\.variant === 'cyber'/); assert.doesNotMatch(ui, /hud\.variant === 'cyber'/);
} }
assert.match(right, /const exclusive = !isMobile;/);
// Cockpit keeps its collapsed Data Layers strip, which its layout aligns to.
assert.match(
left,
/const exclusive =\s*!isMobile && !documentRef\?\.body\?\.classList\?\.contains\('cockpit-mode'\);/,
);
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
}); });
+14 -22
View File
@@ -5,6 +5,7 @@ import {
resolvePanelStackCorridor, resolvePanelStackCorridor,
} from '../panelStackLayout.js'; } from '../panelStackLayout.js';
import { measurePanelNaturalHeight } from './panelMeasurement.js'; import { measurePanelNaturalHeight } from './panelMeasurement.js';
import { settleSideAccordion } from './panelAccordion.js';
/** /**
* Measure and place the left panel rail for one synchronous layout pass. * Measure and place the left panel rail for one synchronous layout pass.
@@ -60,35 +61,26 @@ export function layoutLeftPanelRail({
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
stack.classList.remove('layout-exclusive'); stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile'; stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse });
for (const panel of panels) { for (const panel of panels) {
if (panel.classList.contains('accordion-collapsed')) {
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
panel.removeAttribute('aria-hidden'); panel.removeAttribute('aria-hidden');
panel.style.removeProperty('--left-panel-allocated-height'); panel.style.removeProperty('--left-panel-allocated-height');
} }
return; return;
} }
// One panel is open per desktop side and the panel rail lists the others, // One panel is open per desktop side, and the panel rail lists the others,
// so collapsed panels are hidden. Normalize restored state without // so collapsed panels are hidden. Cockpit keeps its collapsed Data Layers
// overwriting saved panel preferences. // strip, which its layout aligns to, and shows no rail.
const expandedBeforeAccordion = panels.filter( settleSideAccordion({
(panel) => !panel.classList.contains('collapsed'), panels,
); enabled: true,
const accordionOwner = preferredPanelId,
expandedBeforeAccordion.find((panel) => panel.id === preferredPanelId) || documentRef,
expandedBeforeAccordion.find((panel) => onCollapse,
panel.contains(documentRef?.activeElement), });
) || const exclusive =
expandedBeforeAccordion[0]; !isMobile && !documentRef?.body?.classList?.contains('cockpit-mode');
for (const panel of expandedBeforeAccordion) {
if (panel === accordionOwner) continue;
panel.classList.add('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
const exclusive = !isMobile;
stack.classList.toggle('layout-exclusive', exclusive); stack.classList.toggle('layout-exclusive', exclusive);
const laidOutPanels = panels.filter( const laidOutPanels = panels.filter(
(panel) => !exclusive || !panel.classList.contains('collapsed'), (panel) => !exclusive || !panel.classList.contains('collapsed'),
+41
View File
@@ -0,0 +1,41 @@
/**
* Keep one side panel open on desktop, and on phones put back the collapse
* this applied, since a sheet shows several panels at once. The owner is the
* most recently opened panel, then the one holding keyboard focus, then the
* first. `accordion-collapsed` marks the collapse as presentation only, so
* saved and shared preferences still report the panel as the user left it.
* @param {object} options
* @param {HTMLElement[]} options.panels The side's laid-out panels.
* @param {boolean} options.enabled Whether the one-panel rule applies.
* @param {string} [options.preferredPanelId] Most recently opened panel.
* @param {Document} [options.documentRef] Document supplying keyboard focus.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
*/
export function settleSideAccordion({
panels,
enabled,
preferredPanelId,
documentRef,
onCollapse,
}) {
if (!enabled) {
for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
return;
}
const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
const owner =
expanded.find((panel) => panel.id === preferredPanelId) ||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
}
+6 -6
View File
@@ -421,14 +421,14 @@ export class PanelChrome {
const wasAutoCollapsed = const wasAutoCollapsed =
panelEl.classList.contains('layout-auto-collapsed') || panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('accordion-collapsed'); panelEl.classList.contains('accordion-collapsed');
// One panel is open per desktop side: a user opening a side panel owns // One panel is open per desktop side: opening a side panel, whether the
// the whole accordion, including peers that were only // user asked or a feature revealed it (Street Level's viewer, a selected
// presentation-collapsed during a saved-state restore. // radio station), owns the whole accordion, including peers that were
// only presentation-collapsed during a saved-state restore.
const sideStack = [this._leftPanelStack, this._rightPanelStack].find( const sideStack = [this._leftPanelStack, this._rightPanelStack].find(
(stack) => stack && panelEl.parentElement === stack, (stack) => stack && panelEl.parentElement === stack,
); );
if ( if (
explicit &&
!restore && !restore &&
!nextCollapsed && !nextCollapsed &&
sideStack && sideStack &&
@@ -460,7 +460,7 @@ export class PanelChrome {
: null; : null;
const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId; const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
const priorRightOwner = this._panelLayout._rightStackPreferredPanelId; const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { if (!restore && !nextCollapsed && leftOwnerPanel) {
this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id; this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
} else if ( } else if (
explicit && explicit &&
@@ -470,7 +470,7 @@ export class PanelChrome {
) { ) {
this._panelLayout._leftStackPreferredPanelId = null; this._panelLayout._leftStackPreferredPanelId = null;
} }
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { if (!restore && !nextCollapsed && rightOwnerPanel) {
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id; this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
} else if ( } else if (
explicit && explicit &&
+14
View File
@@ -777,3 +777,17 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
) <= alone, ) <= alone,
); );
}); });
test('left rail keeps collapsed panels shown beside Cockpit, still one open', () => {
const f = fixture('left');
f.options.documentRef.body = {
classList: { contains: (name) => name === 'cockpit-mode' },
};
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.preferredPanelId = f.second.id;
f.run();
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
});
+10 -24
View File
@@ -5,6 +5,7 @@ import {
import { resolveHudRailLayout } from './panelRailGeometry.js'; import { resolveHudRailLayout } from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js'; import { displayPanelScroller } from './displayPanelScroll.js';
import { settleSideAccordion } from './panelAccordion.js';
const pendingCollapseRetries = new WeakSet(); const pendingCollapseRetries = new WeakSet();
@@ -86,31 +87,16 @@ export function layoutRightPanelRail({
onCollapse(panel); onCollapse(panel);
} }
} }
// One panel is open per desktop side. Normalize restored state without // One panel is open per desktop side; this marker is distinct from
// overwriting saved panel preferences; this marker is distinct from // Tactical's space-based collapse.
// Tactical's space-based collapse. Phones present the rail as tab sheets
// that show several panels at once.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
if (!isMobile) { settleSideAccordion({
const expanded = panels.filter( panels,
(panel) => !panel.classList.contains('collapsed'), enabled: !isMobile,
); preferredPanelId,
const owner = documentRef,
expanded.find((panel) => panel.id === preferredPanelId) || onCollapse,
expanded.find((panel) => panel.contains(documentRef?.activeElement)) || });
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
} else {
for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
}
// The desktop panel rail lists every panel, so collapsed ones are hidden. // The desktop panel rail lists every panel, so collapsed ones are hidden.
// Phones present the rail as tab sheets (mobileShell.js), which choose what // Phones present the rail as tab sheets (mobileShell.js), which choose what
// shows; hiding collapsed siblings would empty a sheet and its a11y tree. // shows; hiding collapsed siblings would empty a sheet and its a11y tree.
+17
View File
@@ -97,3 +97,20 @@ test('restored preferences keep every saved panel open for the layout to normali
} finally { globalThis.document = prior; } } finally { globalThis.document = prior; }
} }
}); });
test('a feature revealing a side panel makes it the side owner', () => {
const f = fixture('tactical'), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
f.claimed.length = 0;
// Street Level's viewer or a selected radio station opens its panel
// without an explicit user disclosure.
f.owner.setPanelCollapsed('radio-panel', false);
assert.equal(f.context.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), true);
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel');
// Only a user's choice claims restoration lanes.
assert.deepEqual(f.claimed, []);
} finally { globalThis.document = prior; }
});
+7
View File
@@ -1674,6 +1674,13 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
/* The panel rail lists the collapsed panels. */
:root[data-ui-theme='cyber']
#left-panel-stack.layout-focus.layout-exclusive
> [data-panel-id].collapsed {
display: none;
}
:root[data-ui-theme='cyber'] :root[data-ui-theme='cyber']
#left-panel-stack #left-panel-stack
> :is(#data-panel, #scene-panel) { > :is(#data-panel, #scene-panel) {