diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9a82fa11e..29476fb6f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] }, diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index dde4c64be..89e5b4d73 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -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 \{/); }); diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index a0de82746..d9930676a 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -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'), diff --git a/src/ui/panelAccordion.js b/src/ui/panelAccordion.js new file mode 100644 index 000000000..5690bf71a --- /dev/null +++ b/src/ui/panelAccordion.js @@ -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); + } +} diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 459694967..8f8bd219f 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -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 && diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index e7f0d07cf..291eaac6c 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -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); +}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5afdce2b7..1dcd4460f 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -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. diff --git a/src/ui/sidePanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs index 41b0977f7..7badbd464 100644 --- a/src/ui/sidePanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -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; } +}); diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4dd7de5b3..b625b9f81 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -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) {