mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): keep one side panel open per desktop sidebar in every HUD layout
The Cyber accordion now applies to both sidebars and every HUD layout: opening a side panel collapses its peers, the layout normalizes restored state to one owner, and collapsed panels are hidden on desktop. Phones keep showing several panels per sheet.
This commit is contained in:
1 parent
7307c11c74
commit
2035c767ce
12 files changed
+178
-214
No files matched your search
@@ -188,7 +188,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
||||
'focus mode must hide every collapsed sibling, including presentation-only auto-collapses',
|
||||
);
|
||||
assert.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/);
|
||||
assert.match(leftRail, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/);
|
||||
assert.match(leftRail, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||
});
|
||||
|
||||
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
||||
@@ -198,7 +198,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\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
/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'\);/,
|
||||
'responsive auto-collapse must serialize the explicit expanded preference',
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js';
|
||||
|
||||
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
||||
'contacts',
|
||||
'space-missions',
|
||||
|
||||
@@ -2,50 +2,20 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
shouldExpandGlobalContextPanel,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './rightRailPolicy.js';
|
||||
import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js';
|
||||
|
||||
test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'tactical',
|
||||
hasExpandedPanel: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('collapsed launchers remain when Tactical has no expanded panel', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'tactical',
|
||||
hasExpandedPanel: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('other HUD layouts keep collapsed right-rail launchers visible', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'minimal',
|
||||
hasExpandedPanel: true,
|
||||
}), false);
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'full',
|
||||
hasExpandedPanel: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
||||
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
||||
test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => {
|
||||
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));
|
||||
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
|
||||
assert.match(
|
||||
ui,
|
||||
/!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||
);
|
||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||
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(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||
assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||
});
|
||||
|
||||
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
||||
|
||||
+38
-11
@@ -21,6 +21,7 @@ import { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
|
||||
* @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache.
|
||||
* @param {Function} options.onAligned Notify the caller after the left rail moves.
|
||||
* @param {Document} [options.documentRef] Document supplying current keyboard focus.
|
||||
*/
|
||||
export function layoutLeftPanelRail({
|
||||
stack,
|
||||
@@ -32,6 +33,7 @@ export function layoutLeftPanelRail({
|
||||
onRetry,
|
||||
collapsedHeights,
|
||||
onAligned,
|
||||
documentRef = stack?.ownerDocument,
|
||||
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
||||
}) {
|
||||
if (!stack) return;
|
||||
@@ -49,20 +51,49 @@ export function layoutLeftPanelRail({
|
||||
|
||||
// The existing narrow-screen composition has its own full-width stack.
|
||||
// Keep this desktop lane engine from fighting those dedicated rules.
|
||||
if (windowRef.matchMedia('(max-width: 720px)').matches) {
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
if (isMobile) {
|
||||
stack.classList.remove('layout-focus');
|
||||
stack.classList.remove('layout-tail');
|
||||
stack.style.removeProperty('--left-stack-safe-top');
|
||||
stack.style.removeProperty('--left-stack-safe-bottom');
|
||||
stack.style.removeProperty('--left-stack-centered-height');
|
||||
stack.classList.remove('layout-exclusive');
|
||||
stack.dataset.layoutMode = 'mobile';
|
||||
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;
|
||||
stack.classList.toggle('layout-exclusive', exclusive);
|
||||
const laidOutPanels = panels.filter(
|
||||
(panel) => !exclusive || !panel.classList.contains('collapsed'),
|
||||
);
|
||||
|
||||
const viewportHeight = Math.max(1, windowRef.innerHeight);
|
||||
const stackRect = stack.getBoundingClientRect();
|
||||
const baseTop = viewportHeight * 0.26;
|
||||
@@ -162,7 +193,7 @@ export function layoutLeftPanelRail({
|
||||
(sum, height) => sum + height,
|
||||
0,
|
||||
);
|
||||
const siblingHeight = panels.reduce((total, panel) => {
|
||||
const siblingHeight = laidOutPanels.reduce((total, panel) => {
|
||||
if (!panel.classList.contains('collapsed')) return total;
|
||||
const measured = collapsedHeights.get(panel.id);
|
||||
return total + (measured || panel.getBoundingClientRect().height || 0);
|
||||
@@ -170,12 +201,8 @@ export function layoutLeftPanelRail({
|
||||
let requiredHeight = siblingHeight;
|
||||
|
||||
const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
||||
if (expandedPanels.length) {
|
||||
requiredHeight += naturalExpandedHeight;
|
||||
requiredHeight += rowGap * Math.max(0, panels.length - 1);
|
||||
} else {
|
||||
requiredHeight += rowGap * Math.max(0, panels.length - 1);
|
||||
}
|
||||
if (expandedPanels.length) requiredHeight += naturalExpandedHeight;
|
||||
requiredHeight += rowGap * Math.max(0, laidOutPanels.length - 1);
|
||||
|
||||
const wasFocused = stack.classList.contains('layout-focus');
|
||||
const wasTail = stack.classList.contains('layout-tail');
|
||||
@@ -189,7 +216,7 @@ export function layoutLeftPanelRail({
|
||||
const tailRequiredHeight =
|
||||
naturalExpandedHeight +
|
||||
siblingHeight +
|
||||
rowGap * Math.max(0, panels.length - 1);
|
||||
rowGap * Math.max(0, laidOutPanels.length - 1);
|
||||
// A compact expansion should not make the whole control stack jump down
|
||||
// merely to center a few short rows. Preserve the normal top anchor when
|
||||
// the centered stack would begin below it; tall stacks can still grow
|
||||
@@ -296,8 +323,8 @@ export function layoutLeftPanelRail({
|
||||
// CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead.
|
||||
|
||||
for (const panel of panels) {
|
||||
const hiddenSibling = shouldFocus && panel.classList.contains('collapsed');
|
||||
if (hiddenSibling) panel.setAttribute('aria-hidden', 'true');
|
||||
if (exclusive && panel.classList.contains('collapsed'))
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
else panel.removeAttribute('aria-hidden');
|
||||
}
|
||||
// The right controls share this top baseline; update them after the left
|
||||
|
||||
@@ -208,7 +208,7 @@ export function initMobileShell({
|
||||
return (
|
||||
classes.contains('collapsed') &&
|
||||
!classes.contains('layout-auto-collapsed') &&
|
||||
!classes.contains('cyber-accordion-collapsed')
|
||||
!classes.contains('accordion-collapsed')
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+11
-26
@@ -362,7 +362,7 @@ export class PanelChrome {
|
||||
// restore the user's explicit expanded preference at its own viewport.
|
||||
const collapsed =
|
||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
||||
panelEl.classList.contains('accordion-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
||||
@@ -420,19 +420,22 @@ export class PanelChrome {
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const wasAutoCollapsed =
|
||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||
panelEl.classList.contains('cyber-accordion-collapsed');
|
||||
// A user opening a Cyber rail panel owns the whole accordion, including
|
||||
// peers that were only presentation-collapsed during a saved-state restore.
|
||||
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.
|
||||
const sideStack = [this._leftPanelStack, this._rightPanelStack].find(
|
||||
(stack) => stack && panelEl.parentElement === stack,
|
||||
);
|
||||
if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
!nextCollapsed &&
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
panelEl.parentElement === this._rightPanelStack &&
|
||||
sideStack &&
|
||||
// Phone sheets show several rail panels at once.
|
||||
!document.body?.classList?.contains('mobile-shell')
|
||||
) {
|
||||
for (const peer of this._rightPanelStack.children) {
|
||||
for (const peer of sideStack.children) {
|
||||
if (
|
||||
peer !== panelEl &&
|
||||
peer.matches('[data-panel-id]') &&
|
||||
@@ -480,7 +483,6 @@ 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.
|
||||
if (
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document
|
||||
@@ -507,10 +509,7 @@ export class PanelChrome {
|
||||
}
|
||||
return;
|
||||
}
|
||||
panelEl.classList.remove(
|
||||
'layout-auto-collapsed',
|
||||
'cyber-accordion-collapsed',
|
||||
);
|
||||
panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed');
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
@@ -522,20 +521,6 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document.documentElement?.dataset.uiTheme !== 'cyber' &&
|
||||
document
|
||||
.getElementById('global-context-panel')
|
||||
?.classList.contains('collapsed')
|
||||
) {
|
||||
this.setPanelCollapsed('global-context-panel', false, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||
const otherPanel = document.getElementById('control-panel');
|
||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||
|
||||
@@ -60,19 +60,3 @@ export function resolveHudRailLayout({
|
||||
constrained: panelHeight > availableHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tactical HUD gives an expanded right-rail panel the whole control lane.
|
||||
* Other HUD layouts keep collapsed launchers visible for quick switching.
|
||||
*
|
||||
* @param {object} input Current rail state.
|
||||
* @param {string} input.hudVariant Active HUD layout variant.
|
||||
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
|
||||
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
|
||||
*/
|
||||
export function shouldHideCollapsedRightPanels({
|
||||
hudVariant,
|
||||
hasExpandedPanel,
|
||||
}) {
|
||||
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
export { layoutLeftPanelRail } from './leftPanelRail.js';
|
||||
export { layoutRightPanelRail } from './rightPanelRail.js';
|
||||
export { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||
export {
|
||||
resolveHudRailLayout,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './panelRailGeometry.js';
|
||||
export { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||
export {
|
||||
allocatePanelStackHeights,
|
||||
panelStackAutoCollapseIndices,
|
||||
|
||||
+69
-70
@@ -20,61 +20,57 @@ test('Display uses an inner scroll body only in Cyber', () => {
|
||||
assert.equal(displayPanelScroller(null), null);
|
||||
});
|
||||
|
||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
||||
for (const mobile of [false]) {
|
||||
const f = fixture('right', {
|
||||
mobile,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
for (const side of ['left', 'right']) {
|
||||
test(`${side} desktop rail keeps one expanded owner in every HUD layout`, () => {
|
||||
for (const variant of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture(side, { hud: { visible: true, variant } });
|
||||
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.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(f.retries(), 0, 'the accordion needs no auto-collapse pass');
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.first.classList.contains('collapsed'),
|
||||
true,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
// Phone sheets restore the presentation-only collapse.
|
||||
f.options.windowRef.matchMedia = () => ({ matches: true });
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('accordion-collapsed'), false);
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${side} rail keeps no accordion on phones, where it is presented as tab sheets`, () => {
|
||||
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
|
||||
// rail's panels at once; the accordion would collapse all but one.
|
||||
const f = fixture(side, { mobile: true });
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.first.classList.contains('collapsed'),
|
||||
true,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
f.options.hud.variant = 'minimal';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(
|
||||
f.first.classList.contains('cyber-accordion-collapsed'),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.first.classList.contains('accordion-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => {
|
||||
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
|
||||
// rail's panels at once; the accordion would collapse all but one.
|
||||
const f = fixture('right', {
|
||||
mobile: true,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.documentRef.activeElement = f.second;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
test(`${side} restored accordion prefers keyboard focus when no explicit owner exists`, () => {
|
||||
const f = fixture(side);
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.documentRef.activeElement = f.second;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
}
|
||||
|
||||
function element(
|
||||
id,
|
||||
@@ -250,16 +246,16 @@ for (const side of ['left', 'right']) {
|
||||
);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
});
|
||||
test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => {
|
||||
test(`${side} constrained layout keeps the preferred panel without another pass`, () => {
|
||||
const f = fixture(side);
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.options.preferredPanelId = 'second';
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
assert.equal(f.retries(), 1);
|
||||
assert.equal(f.retries(), 0);
|
||||
});
|
||||
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
|
||||
const f = fixture(side, { hud: { visible: false, variant: 'tactical' } });
|
||||
@@ -277,7 +273,7 @@ test('right layout uses keyboard focus when there is no preferred panel', () =>
|
||||
f.expand(f.second, 900);
|
||||
f.options.documentRef.activeElement = f.second;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||
assert.equal(f.first.classList.contains('accordion-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
@@ -532,26 +528,27 @@ test('right rail enters focus only above available height and leaves below the f
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||
});
|
||||
|
||||
test('right rail auto-collapse requests only one retry and settles within two passes', () => {
|
||||
test('right rail settles a second tall panel through the accordion in one pass', () => {
|
||||
const f = thrashingRightRail({ weatherOpen: true });
|
||||
const modes = [];
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
f.run();
|
||||
modes.push(f.stack.classList.contains('layout-focus'));
|
||||
modes.push(f.stack.dataset.layoutMode);
|
||||
}
|
||||
assert.deepEqual(f.collapsed, ['weather-panel']);
|
||||
assert.equal(f.retries(), 1);
|
||||
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
||||
assert.equal(f.weather.classList.contains('accordion-collapsed'), true);
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.deepEqual(modes.slice(1), Array(9).fill(modes[0]));
|
||||
});
|
||||
|
||||
test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => {
|
||||
test('right rail reopening a peer outside the rail keeps one owner without retrying', () => {
|
||||
const f = thrashingRightRail({ weatherOpen: true });
|
||||
f.run();
|
||||
assert.equal(f.retries(), 1);
|
||||
f.weather.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||
f.weather.classList.remove('collapsed', 'accordion-collapsed');
|
||||
f.run();
|
||||
assert.equal(f.retries(), 1);
|
||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.equal(f.weather.classList.contains('collapsed'), true);
|
||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('right rail restores presentation even when intrinsic measurement throws', () => {
|
||||
@@ -595,14 +592,14 @@ test('right rail measuring pass keeps an opted-in scroller position', () => {
|
||||
});
|
||||
|
||||
for (const variant of ['minimal', 'full']) {
|
||||
test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => {
|
||||
test(`right rail settles two tall panels without retrying in the ${variant} HUD`, () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant } });
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
for (let pass = 0; pass < 10; pass++) f.run();
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('accordion-collapsed'), true);
|
||||
});
|
||||
}
|
||||
// ── Narrow-screen rail overflow pin ──────────────────────────────────────────
|
||||
@@ -699,10 +696,11 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity
|
||||
|
||||
f.run();
|
||||
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||||
// The rail lists the collapsed panels; the CSS-hidden one claims nothing.
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||
assert.equal(f.stack.dataset.expandedCount, '0');
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(weather.classList.contains('collapsed'), false);
|
||||
assert.equal(weather.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(
|
||||
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'',
|
||||
@@ -730,9 +728,10 @@ test('right layout still restores panels that exclusive presentation hides', ()
|
||||
|
||||
f.options.hud.visible = false;
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
// Restored from the space-based collapse, it yields to the accordion owner.
|
||||
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.deepEqual(f.collapsed, ['second']);
|
||||
assert.equal(f.second.classList.contains('accordion-collapsed'), true);
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||||
@@ -766,10 +765,10 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
|
||||
const retriesBefore = f.retries();
|
||||
f.run();
|
||||
assert.ok(
|
||||
f.collapsed.includes('recent-imagery-panel'),
|
||||
'focus mode collapses the later panel and asks for another pass',
|
||||
imagery.classList.contains('accordion-collapsed'),
|
||||
'the accordion keeps the first panel and collapses the later one',
|
||||
);
|
||||
assert.equal(f.retries(), retriesBefore + 1);
|
||||
assert.equal(f.retries(), retriesBefore);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.ok(
|
||||
|
||||
+11
-22
@@ -2,10 +2,7 @@ import {
|
||||
allocatePanelStackHeights,
|
||||
panelStackAutoCollapseIndices,
|
||||
} from '../panelStackLayout.js';
|
||||
import {
|
||||
resolveHudRailLayout,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './panelRailGeometry.js';
|
||||
import { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
|
||||
@@ -89,11 +86,12 @@ export function layoutRightPanelRail({
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
// Normalize restored state and theme entry 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. 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.
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
if (hud.variant === 'cyber' && !isMobile) {
|
||||
if (!isMobile) {
|
||||
const expanded = panels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
@@ -103,29 +101,20 @@ export function layoutRightPanelRail({
|
||||
expanded[0];
|
||||
for (const panel of expanded) {
|
||||
if (panel === owner) continue;
|
||||
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
|
||||
panel.classList.add('collapsed', 'accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
} else {
|
||||
for (const panel of panels) {
|
||||
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
|
||||
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
|
||||
if (!panel.classList.contains('accordion-collapsed')) continue;
|
||||
panel.classList.remove('collapsed', 'accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
const hasExpandedPanel = panels.some(
|
||||
(panel) =>
|
||||
!panel.classList.contains('collapsed') &&
|
||||
(!isMobile || panel.id !== 'pp-toggles'),
|
||||
);
|
||||
// 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.
|
||||
const exclusive =
|
||||
!isMobile &&
|
||||
shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
const exclusive = !isMobile;
|
||||
stack.classList.toggle('layout-exclusive', exclusive);
|
||||
for (const panel of panels) {
|
||||
if (exclusive && panel.classList.contains('collapsed'))
|
||||
|
||||
@@ -19,6 +19,10 @@ function fixture(theme = 'cyber') {
|
||||
nodes.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const left = node('left-stack');
|
||||
const layers = node('data-panel', false), scenes = node('scene-panel');
|
||||
left.children = [layers, scenes];
|
||||
for (const panel of left.children) panel.parentElement = left;
|
||||
const rail = node('rail');
|
||||
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
|
||||
rail.children = [display, context, cctv];
|
||||
@@ -27,6 +31,7 @@ function fixture(theme = 'cyber') {
|
||||
radio.parentElement = context;
|
||||
const saved = [], claimed = [];
|
||||
const owner = {
|
||||
_leftPanelStack: left,
|
||||
_rightPanelStack: rail,
|
||||
_panelLayout: {},
|
||||
_lifetime: { frame() {} },
|
||||
@@ -36,29 +41,36 @@ function fixture(theme = 'cyber') {
|
||||
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
|
||||
};
|
||||
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
|
||||
return { display, context, cctv, radio, owner, saved, claimed,
|
||||
return { layers, scenes, display, context, cctv, radio, owner, saved, claimed,
|
||||
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
|
||||
};
|
||||
}
|
||||
|
||||
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
||||
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
|
||||
f.cctv.classList.add('cyber-accordion-collapsed');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
|
||||
} finally { globalThis.document = prior; }
|
||||
test('explicit opening collapses side peers and claims their restoration lanes in every theme', () => {
|
||||
for (const theme of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
||||
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
|
||||
f.cctv.classList.add('accordion-collapsed');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
assert.equal(f.cctv.classList.contains('accordion-collapsed'), false);
|
||||
// The left side has its own accordion; the right side is untouched.
|
||||
f.owner.setPanelCollapsed('scene-panel', false, { explicit: true });
|
||||
assert.equal(f.scenes.classList.contains('collapsed'), false);
|
||||
assert.equal(f.layers.classList.contains('collapsed'), true);
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
} finally { globalThis.document = prior; }
|
||||
}
|
||||
});
|
||||
|
||||
test('opening already-expanded Radio reveals Context and closes Display', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
const f = fixture('tactical'), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
@@ -71,17 +83,17 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
|
||||
test('restored preferences keep every saved panel open for the layout to normalize', () => {
|
||||
for (const theme of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
|
||||
const options = { restore: true, persist: false };
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, options);
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, options);
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||||
if (theme === 'cyber') assert.deepEqual(f.saved, []);
|
||||
assert.deepEqual(f.saved, []);
|
||||
} finally { globalThis.document = prior; }
|
||||
}
|
||||
});
|
||||
@@ -260,6 +260,9 @@
|
||||
#right-context-rail.layout-exclusive > [data-panel-id].collapsed {
|
||||
display: none;
|
||||
}
|
||||
#left-panel-stack.layout-exclusive > [data-panel-id].collapsed {
|
||||
display: none;
|
||||
}
|
||||
#right-context-rail.layout-focus > [data-panel-id].collapsed {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
Reference in new issue
Block a user