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/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/panelStackLayout.js"
"src/panelStackLayout.js",
"src/ui/panelAccordion.js"
],
"external": []
},
@@ -874,7 +875,8 @@
"src/ui/statusText.js",
"src/ui/statusBadge.js",
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
"src/visualStylePolicy.js",
"src/ui/panelAccordion.js"
],
"external": ["cesium"]
},
@@ -2810,7 +2812,8 @@
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
"src/visualStylePolicy.js",
"src/ui/panelAccordion.js"
],
"external": [
"hls.js",
@@ -3603,7 +3606,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/ui/panelAccordion.js"
],
"external": []
},
+7 -2
View File
@@ -4,16 +4,21 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
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 left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
for (const ui of [right, left]) {
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.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, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
});
+14 -22
View File
@@ -5,6 +5,7 @@ import {
resolvePanelStackCorridor,
} from '../panelStackLayout.js';
import { measurePanelNaturalHeight } from './panelMeasurement.js';
import { settleSideAccordion } from './panelAccordion.js';
/**
* 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.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse });
for (const panel of panels) {
if (panel.classList.contains('accordion-collapsed')) {
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
panel.removeAttribute('aria-hidden');
panel.style.removeProperty('--left-panel-allocated-height');
}
return;
}
// One panel is open per desktop side and the panel rail lists the others,
// so collapsed panels are hidden. Normalize restored state without
// overwriting saved panel preferences.
const expandedBeforeAccordion = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
const accordionOwner =
expandedBeforeAccordion.find((panel) => panel.id === preferredPanelId) ||
expandedBeforeAccordion.find((panel) =>
panel.contains(documentRef?.activeElement),
) ||
expandedBeforeAccordion[0];
for (const panel of expandedBeforeAccordion) {
if (panel === accordionOwner) continue;
panel.classList.add('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
const exclusive = !isMobile;
// One panel is open per desktop side, and the panel rail lists the others,
// so collapsed panels are hidden. Cockpit keeps its collapsed Data Layers
// strip, which its layout aligns to, and shows no rail.
settleSideAccordion({
panels,
enabled: true,
preferredPanelId,
documentRef,
onCollapse,
});
const exclusive =
!isMobile && !documentRef?.body?.classList?.contains('cockpit-mode');
stack.classList.toggle('layout-exclusive', exclusive);
const laidOutPanels = panels.filter(
(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 =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('accordion-collapsed');
// One panel is open per desktop side: a user opening a side panel owns
// the whole accordion, including peers that were only
// presentation-collapsed during a saved-state restore.
// 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
// only presentation-collapsed during a saved-state restore.
const sideStack = [this._leftPanelStack, this._rightPanelStack].find(
(stack) => stack && panelEl.parentElement === stack,
);
if (
explicit &&
!restore &&
!nextCollapsed &&
sideStack &&
@@ -460,7 +460,7 @@ export class PanelChrome {
: null;
const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
if (!restore && !nextCollapsed && leftOwnerPanel) {
this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
} else if (
explicit &&
@@ -470,7 +470,7 @@ export class PanelChrome {
) {
this._panelLayout._leftStackPreferredPanelId = null;
}
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
if (!restore && !nextCollapsed && rightOwnerPanel) {
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
} else if (
explicit &&
+14
View File
@@ -777,3 +777,17 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
) <= 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 { displayPanelScroller } from './displayPanelScroll.js';
import { settleSideAccordion } from './panelAccordion.js';
const pendingCollapseRetries = new WeakSet();
@@ -86,31 +87,16 @@ export function layoutRightPanelRail({
onCollapse(panel);
}
}
// One panel is open per desktop side. Normalize restored state without
// overwriting saved panel preferences; this marker is distinct from
// Tactical's space-based collapse. Phones present the rail as tab sheets
// that show several panels at once.
// One panel is open per desktop side; this marker is distinct from
// Tactical's space-based collapse.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
if (!isMobile) {
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);
}
} else {
for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'accordion-collapsed');
onCollapse(panel);
}
}
settleSideAccordion({
panels,
enabled: !isMobile,
preferredPanelId,
documentRef,
onCollapse,
});
// The desktop panel rail lists every panel, so collapsed ones are hidden.
// Phones present the rail as tab sheets (mobileShell.js), which choose what
// 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; }
}
});
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;
}
/* 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']
#left-panel-stack
> :is(#data-panel, #scene-panel) {