mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge pull request #389 from bilawalsidhu/refactor/panel-placement
refactor(ui): extract adaptive panel rail layout
This commit is contained in:
21 files changed
+1120
-529
No files matched your search
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Extract adaptive panel rail placement and measurement into reusable UI modules,
|
||||||
|
preserving obstacle clearance, responsive allocation, disclosure and scroll behavior.
|
||||||
|
|
||||||
- Extract shared surface keyboard handling for the welcome launcher and Provider
|
- Extract shared surface keyboard handling for the welcome launcher and Provider
|
||||||
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
|
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
|
||||||
|
|
||||||
|
|||||||
@@ -213,3 +213,21 @@ current visible/enabled controls for each key; ordinary movement within those
|
|||||||
boundaries remains native. The component honors already-handled keys and has no
|
boundaries remains native. The component honors already-handled keys and has no
|
||||||
app, server, storage or network dependencies. Its package boundary is checked
|
app, server, storage or network dependencies. Its package boundary is checked
|
||||||
independently from the standalone screens that consume it.
|
independently from the standalone screens that consume it.
|
||||||
|
|
||||||
|
## Panel rail layout
|
||||||
|
|
||||||
|
`gods-eye-view/ui/layout` exports synchronous `layoutLeftPanelRail` and
|
||||||
|
`layoutRightPanelRail` passes, `measurePanelNaturalHeight`, and the existing pure
|
||||||
|
corridor/allocation helpers. Separate modules own left placement, right placement,
|
||||||
|
DOM height measurement and rail geometry. They import no application, renderer,
|
||||||
|
server, storage or network modules; package checks build this entry independently.
|
||||||
|
|
||||||
|
Callers supply rail/obstacle DOM nodes, the viewport, HUD state, the preferred
|
||||||
|
panel, disclosure/retry callbacks and the left measurement cache. Right layout
|
||||||
|
reads the caller's Display scroll value at measurement time and restores it
|
||||||
|
within the resulting scroll range. The left pass notifies its caller after
|
||||||
|
alignment so the right pass can follow. Neither pass installs listeners, timers
|
||||||
|
or observers; construction/import does no work. Scheduling, preference writes,
|
||||||
|
share restoration and movement of controls between containers remain caller-owned.
|
||||||
|
Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain
|
||||||
|
compatible through re-exports.
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
## Adaptive panel rail layout
|
||||||
|
|
||||||
|
`ui/layout` supplies the left/right rail layout passes, natural-height
|
||||||
|
measurement and pure corridor/allocation helpers. The UI facade passes live DOM
|
||||||
|
nodes, obstacle nodes, HUD presentation, preferred panels and callbacks. It
|
||||||
|
retains observers, frame scheduling, saved/share preferences and Cockpit portals.
|
||||||
|
|
||||||
|
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
|
||||||
|
the right rail follows its top baseline and preserves Display's scroll owner.
|
||||||
|
Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||||
|
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||||
|
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||||
|
writes. This extraction does not change panel positions or layout defaults.
|
||||||
|
|
||||||
|
|
||||||
## Surface keyboard lifecycle
|
## Surface keyboard lifecycle
|
||||||
|
|
||||||
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
||||||
|
|||||||
+2
-1
@@ -120,6 +120,7 @@
|
|||||||
},
|
},
|
||||||
"./search": "./src/search/index.js",
|
"./search": "./src/search/index.js",
|
||||||
"./ui/panels": "./src/ui/panelDisclosure.js",
|
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.js"
|
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
|
||||||
|
"./ui/layout": "./src/ui/panelRails.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,5 +122,12 @@
|
|||||||
"src/standalone/placeSearch.js",
|
"src/standalone/placeSearch.js",
|
||||||
"src/ui/panelDisclosure.js",
|
"src/ui/panelDisclosure.js",
|
||||||
"src/ui/surfaceKeyboard.js",
|
"src/ui/surfaceKeyboard.js",
|
||||||
"src/ui/surfaceKeyboard.test.mjs"
|
"src/ui/surfaceKeyboard.test.mjs",
|
||||||
|
"src/ui/panelRails.js",
|
||||||
|
"src/ui/leftPanelRail.js",
|
||||||
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/panelMeasurement.js",
|
||||||
|
"src/ui/panelRailGeometry.js",
|
||||||
|
"src/ui/panelRails.test.mjs",
|
||||||
|
"src/panelStackLayout.js"
|
||||||
]
|
]
|
||||||
@@ -273,5 +273,17 @@
|
|||||||
"exports": ["./ui/surfaces"],
|
"exports": ["./ui/surfaces"],
|
||||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"panel-rail-layout": {
|
||||||
|
"exports": ["./ui/layout"],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/panelRails.js",
|
||||||
|
"src/ui/leftPanelRail.js",
|
||||||
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/panelMeasurement.js",
|
||||||
|
"src/ui/panelRailGeometry.js",
|
||||||
|
"src/panelStackLayout.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1466,6 +1466,20 @@ try {
|
|||||||
for (const width of [1000, 620, 480]) {
|
for (const width of [1000, 620, 480]) {
|
||||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||||
|
// Resizing schedules rail placement on animation frames. Wait for that
|
||||||
|
// pass and its CSS transitions before comparing a focus rectangle with
|
||||||
|
// hit testing; a fixed Tab delay can observe two different positions.
|
||||||
|
await page.evaluate(() => new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||||
|
}));
|
||||||
|
await page.waitForFunction((expectedWidth) => {
|
||||||
|
const stack = document.getElementById('left-panel-stack');
|
||||||
|
if (innerWidth !== expectedWidth || !stack) return false;
|
||||||
|
if ((stack.dataset.layoutMode === 'mobile') !== (expectedWidth <= 720)) return false;
|
||||||
|
return !stack.getAnimations({ subtree: true }).some((animation) => (
|
||||||
|
animation instanceof CSSTransition && animation.playState === 'running'
|
||||||
|
));
|
||||||
|
}, { timeout: 5_000 }, width);
|
||||||
await page.focus('#data-panel .panel-collapse-btn');
|
await page.focus('#data-panel .panel-collapse-btn');
|
||||||
const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]];
|
const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]];
|
||||||
for (const [id, label, enabled] of targets) {
|
for (const [id, label, enabled] of targets) {
|
||||||
|
|||||||
@@ -525,12 +525,10 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
|||||||
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
|
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
|
||||||
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
|
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
|
||||||
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
|
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
|
||||||
const leftLayout = ui.match(
|
const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
|
||||||
/_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/,
|
|
||||||
);
|
|
||||||
assert.ok(leftLayout, 'left accordion layout pass is missing');
|
assert.ok(leftLayout, 'left accordion layout pass is missing');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
leftLayout[1],
|
leftLayout,
|
||||||
/setProperty\('--cockpit-utility-top'/,
|
/setProperty\('--cockpit-utility-top'/,
|
||||||
'the right margin must not borrow the left accordion corridor: it is solved '
|
'the right margin must not borrow the left accordion corridor: it is solved '
|
||||||
+ 'against left-lane obstacles and put the strip through the briefing card',
|
+ 'against left-lane obstacles and put the strip through the briefing card',
|
||||||
@@ -585,17 +583,15 @@ test('an expanded Cockpit left panel stays above Contact, HUD, and attribution',
|
|||||||
/COCKPIT_PASSABLE_LEFT_OBSTACLE_SELECTOR|cockpitOverlaysPassable/,
|
/COCKPIT_PASSABLE_LEFT_OBSTACLE_SELECTOR|cockpitOverlaysPassable/,
|
||||||
'Cockpit obstacles must never be bypassed by an expanded map panel',
|
'Cockpit obstacles must never be bypassed by an expanded map panel',
|
||||||
);
|
);
|
||||||
const leftLayout = ui.match(
|
const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
|
||||||
/_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/,
|
|
||||||
);
|
|
||||||
assert.ok(leftLayout, 'left accordion layout pass is missing');
|
assert.ok(leftLayout, 'left accordion layout pass is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
leftLayout[1],
|
leftLayout,
|
||||||
/rect\.top >= baseTop\) \{\s*\n\s*bottomObstacles\.push\(\{ top: rect\.top \}\);/,
|
/rect\.top >= baseTop\) \{\s*\n\s*bottomObstacles\.push\(\{ top: rect\.top \}\);/,
|
||||||
'every rendered lower-lane obstacle must constrain the panel corridor',
|
'every rendered lower-lane obstacle must constrain the panel corridor',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
leftLayout[1],
|
leftLayout,
|
||||||
/safeBottom = resolveLeftStackBottomBoundary\(\{[\s\S]*?safeGap,\s*\n\s*\}\);/,
|
/safeBottom = resolveLeftStackBottomBoundary\(\{[\s\S]*?safeGap,\s*\n\s*\}\);/,
|
||||||
);
|
);
|
||||||
const cockpitHud = css.match(/#cockpit-hud\s*\{[\s\S]*?z-index:\s*(\d+);/);
|
const cockpitHud = css.match(/#cockpit-hud\s*\{[\s\S]*?z-index:\s*(\d+);/);
|
||||||
|
|||||||
+2
-42
@@ -1,3 +1,5 @@
|
|||||||
|
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||||
|
|
||||||
/** Normalize a heading into the [0, 360) range. */
|
/** Normalize a heading into the [0, 360) range. */
|
||||||
export function normalizeHeading(value) {
|
export function normalizeHeading(value) {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
@@ -282,45 +284,3 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
|
|||||||
contactLost,
|
contactLost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a vertically centered panel slot inside a HUD rail while respecting
|
|
||||||
* live rectangles that intersect that rail above or below the viewport center.
|
|
||||||
*/
|
|
||||||
export function resolveHudRailLayout({
|
|
||||||
viewportHeight,
|
|
||||||
panelHeight,
|
|
||||||
laneLeft,
|
|
||||||
laneRight,
|
|
||||||
obstacles = [],
|
|
||||||
baseTop,
|
|
||||||
baseBottom,
|
|
||||||
gap = 12,
|
|
||||||
align = 'center',
|
|
||||||
}) {
|
|
||||||
if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom]
|
|
||||||
.every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null;
|
|
||||||
const midpoint = viewportHeight * 0.5;
|
|
||||||
let safeTop = Math.max(0, baseTop);
|
|
||||||
let safeBottom = Math.min(viewportHeight, baseBottom);
|
|
||||||
|
|
||||||
for (const rect of obstacles) {
|
|
||||||
if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue;
|
|
||||||
if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue;
|
|
||||||
if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap);
|
|
||||||
else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
safeBottom = Math.max(safeTop, safeBottom);
|
|
||||||
const availableHeight = Math.max(0, safeBottom - safeTop);
|
|
||||||
const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight);
|
|
||||||
return {
|
|
||||||
top: align === 'start'
|
|
||||||
? safeTop
|
|
||||||
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5),
|
|
||||||
maxHeight: availableHeight,
|
|
||||||
safeTop,
|
|
||||||
safeBottom,
|
|
||||||
constrained: panelHeight > availableHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -479,9 +479,10 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
|
|||||||
// over-constrained. It is safe only because the rail's layout pass switches
|
// over-constrained. It is safe only because the rail's layout pass switches
|
||||||
// to a mobile mode at the SAME breakpoint and removes both the class and the
|
// to a mobile mode at the SAME breakpoint and removes both the class and the
|
||||||
// custom property. Pin that, or the exemption above is unearned.
|
// custom property. Pin that, or the exemption above is unearned.
|
||||||
const gate = ui.indexOf("window.matchMedia('(max-width: 720px)')");
|
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
|
||||||
|
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')");
|
||||||
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
|
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
|
||||||
const mobileBranch = ui.slice(gate, ui.indexOf("layoutMode = 'mobile'", gate) + 40);
|
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
|
||||||
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
|
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
|
||||||
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
|
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-4
@@ -14,7 +14,9 @@ export function allocatePanelStackHeights({
|
|||||||
availableHeight,
|
availableHeight,
|
||||||
minimumHeight = 96,
|
minimumHeight = 96,
|
||||||
}) {
|
}) {
|
||||||
const natural = naturalHeights.map((height) => Math.max(0, Number(height) || 0));
|
const natural = naturalHeights.map((height) =>
|
||||||
|
Math.max(0, Number(height) || 0),
|
||||||
|
);
|
||||||
if (!natural.length) return [];
|
if (!natural.length) return [];
|
||||||
|
|
||||||
const available = Math.max(0, Number(availableHeight) || 0);
|
const available = Math.max(0, Number(availableHeight) || 0);
|
||||||
@@ -31,10 +33,14 @@ export function allocatePanelStackHeights({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remaining = available - baseTotal;
|
const remaining = available - baseTotal;
|
||||||
const unmet = natural.map((height, index) => Math.max(0, height - base[index]));
|
const unmet = natural.map((height, index) =>
|
||||||
|
Math.max(0, height - base[index]),
|
||||||
|
);
|
||||||
const unmetTotal = unmet.reduce((sum, height) => sum + height, 0);
|
const unmetTotal = unmet.reduce((sum, height) => sum + height, 0);
|
||||||
if (unmetTotal <= 0) return base;
|
if (unmetTotal <= 0) return base;
|
||||||
return base.map((height, index) => height + remaining * (unmet[index] / unmetTotal));
|
return base.map(
|
||||||
|
(height, index) => height + remaining * (unmet[index] / unmetTotal),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +131,10 @@ export function resolvePanelStackCorridor({
|
|||||||
boundaryTop,
|
boundaryTop,
|
||||||
Math.min(height, Number(obstacleSafeBottom) || 0),
|
Math.min(height, Number(obstacleSafeBottom) || 0),
|
||||||
);
|
);
|
||||||
let top = Math.max(boundaryTop, Math.min(boundaryBottom, Number(safeTop) || 0));
|
let top = Math.max(
|
||||||
|
boundaryTop,
|
||||||
|
Math.min(boundaryBottom, Number(safeTop) || 0),
|
||||||
|
);
|
||||||
let bottom = Math.max(top, Math.min(boundaryBottom, Number(safeBottom) || 0));
|
let bottom = Math.max(top, Math.min(boundaryBottom, Number(safeBottom) || 0));
|
||||||
const minimum = Math.max(0, Number(minimumHeight) || 0);
|
const minimum = Math.max(0, Number(minimumHeight) || 0);
|
||||||
const midpoint = height * 0.5;
|
const midpoint = height * 0.5;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
const leftRail = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
|
||||||
|
const rightRail = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
||||||
|
const rails = leftRail + rightRail;
|
||||||
import {
|
import {
|
||||||
allocatePanelStackHeights,
|
allocatePanelStackHeights,
|
||||||
panelStackAutoCollapseIndices,
|
panelStackAutoCollapseIndices,
|
||||||
@@ -135,7 +138,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
|||||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||||
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
|
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
|
||||||
assert.match(ui, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
|
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
|
||||||
assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
|
assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
|
||||||
assert.match(ui, /reconsiderAutoCollapse/);
|
assert.match(ui, /reconsiderAutoCollapse/);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -146,32 +149,32 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
|||||||
ui,
|
ui,
|
||||||
/_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/,
|
/_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/,
|
||||||
);
|
);
|
||||||
assert.match(ui, /collapseLaterPanels: shouldFocus && this\.hud\.getVariant\(\) === 'tactical'/);
|
assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/);
|
||||||
assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
|
assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
leftRail,
|
||||||
/preferredExpandedPanel[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/,
|
/preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
|
||||||
'the latest explicitly opened left panel must receive primary allocation',
|
'the latest explicitly opened left panel must receive primary allocation',
|
||||||
);
|
);
|
||||||
assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
|
assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
rightRail,
|
||||||
/panel\.id === this\._rightStackPreferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/,
|
/panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
|
||||||
'the latest explicitly opened right panel must receive primary allocation',
|
'the latest explicitly opened right panel must receive primary allocation',
|
||||||
);
|
);
|
||||||
assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/);
|
assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/);
|
||||||
assert.match(ui, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(document\.activeElement\)\)/);
|
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/);
|
||||||
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
|
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
|
||||||
assert.match(ui, /--left-panel-allocated-height/);
|
assert.match(leftRail, /--left-panel-allocated-height/);
|
||||||
assert.match(ui, /--right-panel-allocated-height/);
|
assert.match(rightRail, /--right-panel-allocated-height/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
leftRail,
|
||||||
/expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?_measureLeftPanelNaturalHeight/,
|
/expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?measurePanelNaturalHeight/,
|
||||||
'left intrinsic measurement must clear the prior allocation first',
|
'left intrinsic measurement must clear the prior allocation first',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
rightRail,
|
||||||
/panel !== this\._ppToggles[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/,
|
/panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/,
|
||||||
'right intrinsic measurement must retain Display allocation while clearing other panel allocations',
|
'right intrinsic measurement must retain Display allocation while clearing other panel allocations',
|
||||||
);
|
);
|
||||||
assert.match(css, /var\(--left-panel-allocated-height/);
|
assert.match(css, /var\(--left-panel-allocated-height/);
|
||||||
@@ -182,7 +185,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',
|
'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.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/);
|
||||||
assert.match(ui, /const hiddenSibling = shouldFocus && panel\.classList\.contains\('collapsed'\);/);
|
assert.match(leftRail, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
||||||
@@ -213,8 +216,8 @@ test('parameterized Display presets keep one stable scroll owner', () => {
|
|||||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||||
|
|
||||||
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
|
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
|
||||||
assert.match(ui, /const displayScrollTop = this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
|
assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
|
||||||
assert.match(ui, /this\._ppToggles\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
|
assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
|
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
|
||||||
|
|||||||
+1
-12
@@ -1,15 +1,4 @@
|
|||||||
/**
|
export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js';
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
||||||
'contacts',
|
'contacts',
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
||||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
||||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||||
assert.match(ui, /const isMobile = window\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
|
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/!panel\.classList\.contains\('collapsed'\) && \(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
|
/!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
ui,
|
ui,
|
||||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||||
);
|
);
|
||||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\) panel\.setAttribute\('aria-hidden', 'true'\)/);
|
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
|
||||||
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { retroShader } from './styles/retro.js';
|
import { retroShader } from './styles/retro.js';
|
||||||
@@ -109,14 +110,7 @@ import {
|
|||||||
} from './contextModePolicy.js';
|
} from './contextModePolicy.js';
|
||||||
import {
|
import {
|
||||||
shouldExpandGlobalContextPanel,
|
shouldExpandGlobalContextPanel,
|
||||||
shouldHideCollapsedRightPanels,
|
|
||||||
} from './rightRailPolicy.js';
|
} from './rightRailPolicy.js';
|
||||||
import {
|
|
||||||
allocatePanelStackHeights,
|
|
||||||
panelStackAutoCollapseIndices,
|
|
||||||
resolveLeftStackBottomBoundary,
|
|
||||||
resolvePanelStackCorridor,
|
|
||||||
} from './panelStackLayout.js';
|
|
||||||
import {
|
import {
|
||||||
resolveCockpitUtilityAnchor,
|
resolveCockpitUtilityAnchor,
|
||||||
resolveCockpitUtilityLayout,
|
resolveCockpitUtilityLayout,
|
||||||
@@ -6868,187 +6862,20 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_syncRightPanelAdaptiveLayout() {
|
_syncRightPanelAdaptiveLayout() {
|
||||||
const stack = this._rightPanelStack;
|
layoutRightPanelRail({
|
||||||
if (!stack) return;
|
stack: this._rightPanelStack,
|
||||||
|
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
|
||||||
const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]'));
|
windowRef: window,
|
||||||
if (!this.hud.visible || this.hud.getVariant() !== 'tactical') {
|
hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
|
||||||
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
|
preferredPanelId: this._rightStackPreferredPanelId,
|
||||||
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
|
||||||
this._syncPanelCollapseButton(panel);
|
onRetry: () => this._scheduleRightPanelLayout(),
|
||||||
}
|
leftStack: this._leftPanelStack,
|
||||||
}
|
displayPanel: this._ppToggles,
|
||||||
const isMobile = window.matchMedia('(max-width: 720px)').matches;
|
readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard'
|
||||||
const hasExpandedPanel = panels.some((panel) => (
|
? this._standardDisplayScrollTop
|
||||||
!panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles')
|
: (this._ppToggles?.scrollTop || 0),
|
||||||
));
|
|
||||||
const exclusive = shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: this.hud.getVariant(),
|
|
||||||
hasExpandedPanel,
|
|
||||||
});
|
});
|
||||||
stack.classList.toggle('layout-exclusive', exclusive);
|
|
||||||
for (const panel of panels) {
|
|
||||||
if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true');
|
|
||||||
else panel.removeAttribute('aria-hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
stack.classList.remove('layout-focus');
|
|
||||||
stack.style.removeProperty('--right-stack-safe-top');
|
|
||||||
stack.style.removeProperty('--right-stack-max-height');
|
|
||||||
for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height');
|
|
||||||
stack.dataset.layoutMode = 'mobile';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportHeight = Math.max(1, window.innerHeight);
|
|
||||||
const safeGap = Math.max(8, viewportHeight * 0.012);
|
|
||||||
const stackRect = stack.getBoundingClientRect();
|
|
||||||
const leftStackTop = this._leftPanelStack?.getBoundingClientRect().top;
|
|
||||||
const alignedTop = Number.isFinite(leftStackTop)
|
|
||||||
? leftStackTop
|
|
||||||
: viewportHeight * 0.26;
|
|
||||||
const obstacleRects = [];
|
|
||||||
|
|
||||||
for (const obstacle of document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR)) {
|
|
||||||
if (stack.contains(obstacle)) continue;
|
|
||||||
let hiddenByAncestor = false;
|
|
||||||
for (let element = obstacle; element; element = element.parentElement) {
|
|
||||||
const style = getComputedStyle(element);
|
|
||||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
|
||||||
hiddenByAncestor = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hiddenByAncestor) continue;
|
|
||||||
const rect = obstacle.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
||||||
obstacleRects.push({
|
|
||||||
left: rect.left,
|
|
||||||
right: rect.right,
|
|
||||||
top: rect.top,
|
|
||||||
bottom: rect.bottom,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const visiblePanels = panels.filter((panel) => (
|
|
||||||
!exclusive || !panel.classList.contains('collapsed')
|
|
||||||
));
|
|
||||||
const displayScrollTop = this._displayPortalScrollRestoreOwner === 'standard'
|
|
||||||
? this._standardDisplayScrollTop
|
|
||||||
: (this._ppToggles?.scrollTop || 0);
|
|
||||||
// Measure intrinsic content, not the allocation written by the previous
|
|
||||||
// layout pass. Display is the exception: its own scrollHeight already
|
|
||||||
// exposes every control, and removing its live allocation can reset the
|
|
||||||
// user's scroll position while HUD or preset content is settling.
|
|
||||||
for (const panel of visiblePanels) {
|
|
||||||
if (!panel.classList.contains('collapsed') && panel !== this._ppToggles) {
|
|
||||||
panel.style.removeProperty('--right-panel-allocated-height');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
|
||||||
const naturalHeight = visiblePanels.reduce((total, panel) => (
|
|
||||||
total + Math.max(
|
|
||||||
panel.getBoundingClientRect().height,
|
|
||||||
panel.scrollHeight || 0,
|
|
||||||
panel.classList.contains('collapsed') ? 42 : 0,
|
|
||||||
)
|
|
||||||
), 0) + gap * Math.max(0, visiblePanels.length - 1);
|
|
||||||
const layout = resolveHudRailLayout({
|
|
||||||
viewportHeight,
|
|
||||||
panelHeight: naturalHeight,
|
|
||||||
laneLeft: stackRect.left,
|
|
||||||
laneRight: stackRect.right,
|
|
||||||
obstacles: obstacleRects,
|
|
||||||
baseTop: alignedTop,
|
|
||||||
baseBottom: viewportHeight * 0.96,
|
|
||||||
gap: safeGap,
|
|
||||||
align: 'start',
|
|
||||||
});
|
|
||||||
if (!layout) return;
|
|
||||||
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
|
||||||
const stabilityBand = viewportHeight * 0.01;
|
|
||||||
const wasFocused = stack.classList.contains('layout-focus');
|
|
||||||
const shouldFocus = wasFocused
|
|
||||||
? naturalHeight > availableHeight - stabilityBand * 2
|
|
||||||
: naturalHeight > availableHeight - stabilityBand;
|
|
||||||
const layoutTop = shouldFocus ? safeTop : layout.top;
|
|
||||||
const collapsedHeight = visiblePanels.reduce((total, panel) => (
|
|
||||||
panel.classList.contains('collapsed')
|
|
||||||
? total + panel.getBoundingClientRect().height
|
|
||||||
: total
|
|
||||||
), 0);
|
|
||||||
const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed'));
|
|
||||||
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(document.activeElement));
|
|
||||||
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
|
|
||||||
(panel) => panel.id === this._rightStackPreferredPanelId,
|
|
||||||
) || focusedExpandedPanel;
|
|
||||||
// Match the left lane: allocation order follows the latest explicit
|
|
||||||
// disclosure, not DOM order. A focused panel is the fallback owner so
|
|
||||||
// temporary presentation collapse never strands keyboard focus.
|
|
||||||
const expandedPanels = preferredExpandedPanel
|
|
||||||
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
|
|
||||||
: expandedPanelsInDomOrder;
|
|
||||||
const expandedAvailableHeight = Math.max(
|
|
||||||
0,
|
|
||||||
safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1),
|
|
||||||
);
|
|
||||||
const expandedHeights = allocatePanelStackHeights({
|
|
||||||
naturalHeights: expandedPanels.map((panel) => Math.max(
|
|
||||||
panel.getBoundingClientRect().height,
|
|
||||||
panel.scrollHeight || 0,
|
|
||||||
)),
|
|
||||||
availableHeight: expandedAvailableHeight,
|
|
||||||
});
|
|
||||||
const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({
|
|
||||||
naturalHeights: expandedPanels.map((panel) => Math.max(
|
|
||||||
panel.getBoundingClientRect().height,
|
|
||||||
panel.scrollHeight || 0,
|
|
||||||
)),
|
|
||||||
allocatedHeights: expandedHeights,
|
|
||||||
collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical',
|
|
||||||
}) : [];
|
|
||||||
if (autoCollapseIndices.length) {
|
|
||||||
for (const index of autoCollapseIndices) {
|
|
||||||
const panel = expandedPanels[index];
|
|
||||||
panel.classList.add('collapsed', 'layout-auto-collapsed');
|
|
||||||
this._syncPanelCollapseButton(panel);
|
|
||||||
}
|
|
||||||
this._scheduleRightPanelLayout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Write-if-changed. This pass runs on the 500 ms stats cadence, and an
|
|
||||||
// unconditional REMOVE-then-SET of an unchanged allocation is two style
|
|
||||||
// mutations per tick on `#pp-toggles` (the one panel the measure-strip
|
|
||||||
// above deliberately skips) — churn that reads as a genuine panel move to
|
|
||||||
// the world-overlay host's occluder observer and defeats parked-idle
|
|
||||||
// render savings. Only a real allocation change may touch the attribute.
|
|
||||||
expandedPanels.forEach((panel, index) => {
|
|
||||||
const next = `${expandedHeights[index].toFixed(1)}px`;
|
|
||||||
if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) {
|
|
||||||
panel.style.setProperty('--right-panel-allocated-height', next);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (const panel of panels) {
|
|
||||||
if (expandedPanels.includes(panel)) continue;
|
|
||||||
panel.style.removeProperty('--right-panel-allocated-height');
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`);
|
|
||||||
stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`);
|
|
||||||
stack.classList.toggle('layout-focus', shouldFocus);
|
|
||||||
stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal';
|
|
||||||
stack.dataset.safeTop = layoutTop.toFixed(1);
|
|
||||||
stack.dataset.safeBottom = safeBottom.toFixed(1);
|
|
||||||
stack.dataset.availableHeight = availableHeight.toFixed(1);
|
|
||||||
stack.dataset.requiredHeight = naturalHeight.toFixed(1);
|
|
||||||
stack.dataset.expandedCount = String(expandedPanels.length);
|
|
||||||
|
|
||||||
if (this._ppToggles && expandedPanels.includes(this._ppToggles)) {
|
|
||||||
const maxScrollTop = Math.max(0, this._ppToggles.scrollHeight - this._ppToggles.clientHeight);
|
|
||||||
this._ppToggles.scrollTop = Math.min(displayScrollTop, maxScrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7152,40 +6979,6 @@ export class StyleManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates an expanded panel's unconstrained content height from its
|
|
||||||
* visible direct children and their scroll extents. This avoids treating a
|
|
||||||
* flex-grown panel as naturally tall while still accounting for nested lists.
|
|
||||||
* @param {HTMLElement} panel - Expanded accordion panel.
|
|
||||||
* @returns {number} Natural height in rendered CSS pixels.
|
|
||||||
*/
|
|
||||||
_measureLeftPanelNaturalHeight(panel) {
|
|
||||||
const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow'));
|
|
||||||
if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height);
|
|
||||||
|
|
||||||
const innerRect = inner.getBoundingClientRect();
|
|
||||||
const panelStyle = getComputedStyle(panel);
|
|
||||||
const innerStyle = getComputedStyle(inner);
|
|
||||||
const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0;
|
|
||||||
let contentBottom = parseFloat(innerStyle.paddingTop) || 0;
|
|
||||||
|
|
||||||
for (const child of inner.children) {
|
|
||||||
const childStyle = getComputedStyle(child);
|
|
||||||
if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue;
|
|
||||||
const childRect = child.getBoundingClientRect();
|
|
||||||
const marginBottom = parseFloat(childStyle.marginBottom) || 0;
|
|
||||||
const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0);
|
|
||||||
const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom;
|
|
||||||
contentBottom = Math.max(contentBottom, childBottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0)
|
|
||||||
+ (parseFloat(panelStyle.borderBottomWidth) || 0)
|
|
||||||
+ (parseFloat(panelStyle.paddingTop) || 0)
|
|
||||||
+ (parseFloat(panelStyle.paddingBottom) || 0);
|
|
||||||
return Math.ceil(contentBottom + paddingBottom + wrapperChrome);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measures a live obstacle-free corridor for the left accordion and toggles
|
* Measures a live obstacle-free corridor for the left accordion and toggles
|
||||||
* focus mode only when the expanded panel plus sibling labels cannot fit.
|
* focus mode only when the expanded panel plus sibling labels cannot fit.
|
||||||
@@ -7193,224 +6986,17 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_syncLeftPanelAdaptiveLayout() {
|
_syncLeftPanelAdaptiveLayout() {
|
||||||
const stack = this._leftPanelStack;
|
layoutLeftPanelRail({
|
||||||
if (!stack) return;
|
stack: this._leftPanelStack,
|
||||||
|
obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR),
|
||||||
const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')];
|
windowRef: window,
|
||||||
if (!panels.length) return;
|
hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
|
||||||
if (!this.hud.visible || this.hud.getVariant() !== 'tactical') {
|
preferredPanelId: this._leftStackPreferredPanelId,
|
||||||
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
|
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
|
||||||
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
onRetry: () => this._scheduleLeftPanelLayout(),
|
||||||
this._syncPanelCollapseButton(panel);
|
collapsedHeights: this._leftStackCollapsedHeights,
|
||||||
}
|
onAligned: () => this._scheduleRightPanelLayout(),
|
||||||
}
|
|
||||||
|
|
||||||
// The existing narrow-screen composition has its own full-width stack.
|
|
||||||
// Keep this desktop lane engine from fighting those dedicated rules.
|
|
||||||
if (window.matchMedia('(max-width: 720px)').matches) {
|
|
||||||
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.dataset.layoutMode = 'mobile';
|
|
||||||
for (const panel of panels) {
|
|
||||||
panel.removeAttribute('aria-hidden');
|
|
||||||
panel.style.removeProperty('--left-panel-allocated-height');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportHeight = Math.max(1, window.innerHeight);
|
|
||||||
const stackRect = stack.getBoundingClientRect();
|
|
||||||
const baseTop = viewportHeight * 0.26;
|
|
||||||
const baseBottomInset = viewportHeight * 0.04;
|
|
||||||
const safeGap = viewportHeight * 0.012;
|
|
||||||
let obstacleSafeTop = viewportHeight * 0.04;
|
|
||||||
let safeTop = baseTop;
|
|
||||||
let safeBottom = viewportHeight - baseBottomInset;
|
|
||||||
const bottomObstacles = [];
|
|
||||||
|
|
||||||
for (const obstacle of document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR)) {
|
|
||||||
if (stack.contains(obstacle)) continue;
|
|
||||||
let hiddenByAncestor = false;
|
|
||||||
for (let element = obstacle; element; element = element.parentElement) {
|
|
||||||
const style = getComputedStyle(element);
|
|
||||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
|
||||||
hiddenByAncestor = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hiddenByAncestor) continue;
|
|
||||||
const rect = obstacle.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
||||||
const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right;
|
|
||||||
if (!overlapsHorizontally) continue;
|
|
||||||
|
|
||||||
if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) {
|
|
||||||
const obstacleBottom = rect.bottom + safeGap;
|
|
||||||
obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom);
|
|
||||||
safeTop = Math.max(safeTop, obstacleBottom);
|
|
||||||
} else if (rect.top >= baseTop) {
|
|
||||||
bottomObstacles.push({ top: rect.top });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
safeBottom = resolveLeftStackBottomBoundary({
|
|
||||||
baseBottom: safeBottom,
|
|
||||||
obstacles: bottomObstacles,
|
|
||||||
safeGap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const obstacleSafeBottom = safeBottom;
|
|
||||||
|
|
||||||
// Keep the accordion visually centered when the balanced corridor remains
|
|
||||||
// useful. During live viewport-height changes, retain the aligned lane
|
|
||||||
// instead of extending a tiny midpoint corridor through a lower obstacle.
|
|
||||||
const minimumLaneHeight = viewportHeight * 0.16;
|
|
||||||
({ safeTop, safeBottom } = resolvePanelStackCorridor({
|
|
||||||
viewportHeight,
|
|
||||||
safeTop,
|
|
||||||
safeBottom,
|
|
||||||
obstacleSafeTop,
|
|
||||||
obstacleSafeBottom,
|
|
||||||
minimumHeight: minimumLaneHeight,
|
|
||||||
}));
|
|
||||||
const viewportMidpoint = viewportHeight * 0.5;
|
|
||||||
for (const panel of panels) {
|
|
||||||
const rect = panel.getBoundingClientRect();
|
|
||||||
if (panel.classList.contains('collapsed') && rect.height > 0) {
|
|
||||||
this._leftStackCollapsedHeights.set(panel.id, rect.height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed'));
|
|
||||||
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
|
|
||||||
(panel) => panel.id === this._leftStackPreferredPanelId,
|
|
||||||
);
|
|
||||||
// Auto-collapse is a presentation fallback, not permission to undo the
|
|
||||||
// user's newest disclosure. Measure and allocate that explicitly opened
|
|
||||||
// panel first so an older expanded sibling yields when the corridor cannot
|
|
||||||
// usefully present both (for example Map Stack followed by Scenes).
|
|
||||||
const expandedPanels = preferredExpandedPanel
|
|
||||||
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
|
|
||||||
: expandedPanelsInDomOrder;
|
|
||||||
// Clear the prior pass before reading intrinsic heights. The allocated
|
|
||||||
// outer height and the inner scroller otherwise feed their constrained
|
|
||||||
// size back into the next HUD-mode calculation.
|
|
||||||
for (const panel of expandedPanels) {
|
|
||||||
panel.style.removeProperty('--left-panel-allocated-height');
|
|
||||||
}
|
|
||||||
const availableHeight = Math.max(0, safeBottom - safeTop);
|
|
||||||
const naturalExpandedHeights = expandedPanels.map((panel) => this._measureLeftPanelNaturalHeight(panel));
|
|
||||||
const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0);
|
|
||||||
const siblingHeight = panels.reduce((total, panel) => {
|
|
||||||
if (!panel.classList.contains('collapsed')) return total;
|
|
||||||
const measured = this._leftStackCollapsedHeights.get(panel.id);
|
|
||||||
return total + (measured || panel.getBoundingClientRect().height || 0);
|
|
||||||
}, 0);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasFocused = stack.classList.contains('layout-focus');
|
|
||||||
const wasTail = stack.classList.contains('layout-tail');
|
|
||||||
const wasConstrained = wasFocused || wasTail;
|
|
||||||
const stabilityBand = viewportHeight * 0.01;
|
|
||||||
const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained
|
|
||||||
? requiredHeight > availableHeight - stabilityBand * 2
|
|
||||||
: requiredHeight > availableHeight - stabilityBand);
|
|
||||||
const tailRequiredHeight = naturalExpandedHeight
|
|
||||||
+ siblingHeight
|
|
||||||
+ rowGap * Math.max(0, panels.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
|
|
||||||
// upward around the viewport midpoint as their content requires.
|
|
||||||
const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5;
|
|
||||||
const tailLayoutTop = Math.min(centeredTailTop, safeTop);
|
|
||||||
const tailLayoutBottom = tailLayoutTop + tailRequiredHeight;
|
|
||||||
const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop);
|
|
||||||
const tailTolerance = wasTail ? stabilityBand : -stabilityBand;
|
|
||||||
const shouldTail = expandedPanels.length > 0
|
|
||||||
&& tailLayoutTop >= obstacleSafeTop - tailTolerance
|
|
||||||
&& tailLayoutBottom <= obstacleSafeBottom + tailTolerance;
|
|
||||||
const shouldFocus = exceedsCenteredCorridor && !shouldTail;
|
|
||||||
// Focus mode owns the lane, so let every expanded panel share the full
|
|
||||||
// obstacle-safe corridor. Tail/normal layouts keep the balanced
|
|
||||||
// viewport centering used for compact accordion stacks.
|
|
||||||
const layoutTop = shouldFocus
|
|
||||||
? obstacleSafeTop
|
|
||||||
: shouldTail ? tailLayoutTop : safeTop;
|
|
||||||
const layoutBottom = shouldFocus
|
|
||||||
? obstacleSafeBottom
|
|
||||||
: shouldTail ? tailLayoutBottom : safeBottom;
|
|
||||||
const topPct = (layoutTop / viewportHeight) * 100;
|
|
||||||
const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100;
|
|
||||||
const topValue = `${topPct.toFixed(3)}vh`;
|
|
||||||
const bottomValue = `${bottomPct.toFixed(3)}vh`;
|
|
||||||
const expandedAvailableHeight = shouldFocus
|
|
||||||
? Math.max(0, layoutBottom - layoutTop
|
|
||||||
- rowGap * Math.max(0, expandedPanels.length - 1))
|
|
||||||
: naturalExpandedHeight;
|
|
||||||
const allocatedExpandedHeights = allocatePanelStackHeights({
|
|
||||||
naturalHeights: naturalExpandedHeights,
|
|
||||||
availableHeight: expandedAvailableHeight,
|
|
||||||
});
|
|
||||||
const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({
|
|
||||||
naturalHeights: naturalExpandedHeights,
|
|
||||||
allocatedHeights: allocatedExpandedHeights,
|
|
||||||
collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical',
|
|
||||||
}) : [];
|
|
||||||
if (autoCollapseIndices.length) {
|
|
||||||
for (const index of autoCollapseIndices) {
|
|
||||||
const panel = expandedPanels[index];
|
|
||||||
panel.classList.add('collapsed', 'layout-auto-collapsed');
|
|
||||||
this._syncPanelCollapseButton(panel);
|
|
||||||
}
|
|
||||||
this._scheduleLeftPanelLayout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) {
|
|
||||||
stack.style.setProperty('--left-stack-safe-top', topValue);
|
|
||||||
}
|
|
||||||
if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) {
|
|
||||||
stack.style.setProperty('--left-stack-safe-bottom', bottomValue);
|
|
||||||
}
|
|
||||||
stack.style.removeProperty('--left-stack-centered-height');
|
|
||||||
for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height');
|
|
||||||
expandedPanels.forEach((panel, index) => {
|
|
||||||
panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`);
|
|
||||||
});
|
|
||||||
|
|
||||||
stack.classList.toggle('layout-focus', shouldFocus);
|
|
||||||
stack.classList.toggle('layout-tail', shouldTail);
|
|
||||||
stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal';
|
|
||||||
stack.dataset.safeTopPct = topPct.toFixed(2);
|
|
||||||
stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2);
|
|
||||||
stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2);
|
|
||||||
stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2);
|
|
||||||
stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2);
|
|
||||||
stack.dataset.expandedCount = String(expandedPanels.length);
|
|
||||||
|
|
||||||
// Cockpit Display/Radio live in the opposite margin and no longer borrow
|
|
||||||
// this corridor: the left accordion's top is solved against left-lane
|
|
||||||
// obstacles, which put the strip straight through the briefing card.
|
|
||||||
// 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');
|
|
||||||
else panel.removeAttribute('aria-hidden');
|
|
||||||
}
|
|
||||||
// The right controls share this top baseline; update them after the left
|
|
||||||
// accordion commits an HUD-variant or obstacle-driven position change.
|
|
||||||
this._scheduleRightPanelLayout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
allocatePanelStackHeights,
|
||||||
|
panelStackAutoCollapseIndices,
|
||||||
|
resolveLeftStackBottomBoundary,
|
||||||
|
resolvePanelStackCorridor,
|
||||||
|
} from '../panelStackLayout.js';
|
||||||
|
import { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure and place the left panel rail for one synchronous layout pass.
|
||||||
|
* The caller owns scheduling, obstacle selection, disclosure preferences and
|
||||||
|
* persistence. Auto-collapse is presentation only and reports through callbacks.
|
||||||
|
* @param {object} options Live DOM and caller policy.
|
||||||
|
* @param {HTMLElement} options.stack Rail element.
|
||||||
|
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
|
||||||
|
* @param {Window} options.windowRef Viewport and style reader.
|
||||||
|
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
|
||||||
|
* @param {string} options.preferredPanelId Most recently opened panel.
|
||||||
|
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
|
||||||
|
* @param {Function} options.onRetry Request another pass after automatic collapse.
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
export function layoutLeftPanelRail({
|
||||||
|
stack,
|
||||||
|
obstacles,
|
||||||
|
windowRef,
|
||||||
|
hud,
|
||||||
|
preferredPanelId,
|
||||||
|
onCollapse,
|
||||||
|
onRetry,
|
||||||
|
collapsedHeights,
|
||||||
|
onAligned,
|
||||||
|
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
||||||
|
}) {
|
||||||
|
if (!stack) return;
|
||||||
|
|
||||||
|
const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')];
|
||||||
|
if (!panels.length) return;
|
||||||
|
if (!hud.visible || hud.variant !== 'tactical') {
|
||||||
|
for (const panel of panels.filter((item) =>
|
||||||
|
item.classList.contains('layout-auto-collapsed'),
|
||||||
|
)) {
|
||||||
|
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||||
|
onCollapse(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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.dataset.layoutMode = 'mobile';
|
||||||
|
for (const panel of panels) {
|
||||||
|
panel.removeAttribute('aria-hidden');
|
||||||
|
panel.style.removeProperty('--left-panel-allocated-height');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = Math.max(1, windowRef.innerHeight);
|
||||||
|
const stackRect = stack.getBoundingClientRect();
|
||||||
|
const baseTop = viewportHeight * 0.26;
|
||||||
|
const baseBottomInset = viewportHeight * 0.04;
|
||||||
|
const safeGap = viewportHeight * 0.012;
|
||||||
|
let obstacleSafeTop = viewportHeight * 0.04;
|
||||||
|
let safeTop = baseTop;
|
||||||
|
let safeBottom = viewportHeight - baseBottomInset;
|
||||||
|
const bottomObstacles = [];
|
||||||
|
|
||||||
|
for (const obstacle of obstacles) {
|
||||||
|
if (stack.contains(obstacle)) continue;
|
||||||
|
let hiddenByAncestor = false;
|
||||||
|
for (let element = obstacle; element; element = element.parentElement) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
if (
|
||||||
|
style.display === 'none' ||
|
||||||
|
style.visibility === 'hidden' ||
|
||||||
|
Number(style.opacity) === 0
|
||||||
|
) {
|
||||||
|
hiddenByAncestor = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hiddenByAncestor) continue;
|
||||||
|
const rect = obstacle.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||||
|
const overlapsHorizontally =
|
||||||
|
rect.right > stackRect.left && rect.left < stackRect.right;
|
||||||
|
if (!overlapsHorizontally) continue;
|
||||||
|
|
||||||
|
if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) {
|
||||||
|
const obstacleBottom = rect.bottom + safeGap;
|
||||||
|
obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom);
|
||||||
|
safeTop = Math.max(safeTop, obstacleBottom);
|
||||||
|
} else if (rect.top >= baseTop) {
|
||||||
|
bottomObstacles.push({ top: rect.top });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safeBottom = resolveLeftStackBottomBoundary({
|
||||||
|
baseBottom: safeBottom,
|
||||||
|
obstacles: bottomObstacles,
|
||||||
|
safeGap,
|
||||||
|
});
|
||||||
|
|
||||||
|
const obstacleSafeBottom = safeBottom;
|
||||||
|
|
||||||
|
// Keep the accordion visually centered when the balanced corridor remains
|
||||||
|
// useful. During live viewport-height changes, retain the aligned lane
|
||||||
|
// instead of extending a tiny midpoint corridor through a lower obstacle.
|
||||||
|
const minimumLaneHeight = viewportHeight * 0.16;
|
||||||
|
({ safeTop, safeBottom } = resolvePanelStackCorridor({
|
||||||
|
viewportHeight,
|
||||||
|
safeTop,
|
||||||
|
safeBottom,
|
||||||
|
obstacleSafeTop,
|
||||||
|
obstacleSafeBottom,
|
||||||
|
minimumHeight: minimumLaneHeight,
|
||||||
|
}));
|
||||||
|
const viewportMidpoint = viewportHeight * 0.5;
|
||||||
|
for (const panel of panels) {
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
if (panel.classList.contains('collapsed') && rect.height > 0) {
|
||||||
|
collapsedHeights.set(panel.id, rect.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedPanelsInDomOrder = panels.filter(
|
||||||
|
(panel) => !panel.classList.contains('collapsed'),
|
||||||
|
);
|
||||||
|
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
|
||||||
|
(panel) => panel.id === preferredPanelId,
|
||||||
|
);
|
||||||
|
// Auto-collapse is a presentation fallback, not permission to undo the
|
||||||
|
// user's newest disclosure. Measure and allocate that explicitly opened
|
||||||
|
// panel first so an older expanded sibling yields when the corridor cannot
|
||||||
|
// usefully present both (for example Map Stack followed by Scenes).
|
||||||
|
const expandedPanels = preferredExpandedPanel
|
||||||
|
? [
|
||||||
|
preferredExpandedPanel,
|
||||||
|
...expandedPanelsInDomOrder.filter(
|
||||||
|
(panel) => panel !== preferredExpandedPanel,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: expandedPanelsInDomOrder;
|
||||||
|
// Clear the prior pass before reading intrinsic heights. The allocated
|
||||||
|
// outer height and the inner scroller otherwise feed their constrained
|
||||||
|
// size back into the next HUD-mode calculation.
|
||||||
|
for (const panel of expandedPanels) {
|
||||||
|
panel.style.removeProperty('--left-panel-allocated-height');
|
||||||
|
}
|
||||||
|
const availableHeight = Math.max(0, safeBottom - safeTop);
|
||||||
|
const naturalExpandedHeights = expandedPanels.map((panel) =>
|
||||||
|
measurePanelNaturalHeight(panel, getComputedStyle),
|
||||||
|
);
|
||||||
|
const naturalExpandedHeight = naturalExpandedHeights.reduce(
|
||||||
|
(sum, height) => sum + height,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const siblingHeight = panels.reduce((total, panel) => {
|
||||||
|
if (!panel.classList.contains('collapsed')) return total;
|
||||||
|
const measured = collapsedHeights.get(panel.id);
|
||||||
|
return total + (measured || panel.getBoundingClientRect().height || 0);
|
||||||
|
}, 0);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasFocused = stack.classList.contains('layout-focus');
|
||||||
|
const wasTail = stack.classList.contains('layout-tail');
|
||||||
|
const wasConstrained = wasFocused || wasTail;
|
||||||
|
const stabilityBand = viewportHeight * 0.01;
|
||||||
|
const exceedsCenteredCorridor =
|
||||||
|
expandedPanels.length > 0 &&
|
||||||
|
(wasConstrained
|
||||||
|
? requiredHeight > availableHeight - stabilityBand * 2
|
||||||
|
: requiredHeight > availableHeight - stabilityBand);
|
||||||
|
const tailRequiredHeight =
|
||||||
|
naturalExpandedHeight +
|
||||||
|
siblingHeight +
|
||||||
|
rowGap * Math.max(0, panels.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
|
||||||
|
// upward around the viewport midpoint as their content requires.
|
||||||
|
const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5;
|
||||||
|
const tailLayoutTop = Math.min(centeredTailTop, safeTop);
|
||||||
|
const tailLayoutBottom = tailLayoutTop + tailRequiredHeight;
|
||||||
|
const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop);
|
||||||
|
const tailTolerance = wasTail ? stabilityBand : -stabilityBand;
|
||||||
|
const shouldTail =
|
||||||
|
expandedPanels.length > 0 &&
|
||||||
|
tailLayoutTop >= obstacleSafeTop - tailTolerance &&
|
||||||
|
tailLayoutBottom <= obstacleSafeBottom + tailTolerance;
|
||||||
|
const shouldFocus = exceedsCenteredCorridor && !shouldTail;
|
||||||
|
// Focus mode owns the lane, so let every expanded panel share the full
|
||||||
|
// obstacle-safe corridor. Tail/normal layouts keep the balanced
|
||||||
|
// viewport centering used for compact accordion stacks.
|
||||||
|
const layoutTop = shouldFocus
|
||||||
|
? obstacleSafeTop
|
||||||
|
: shouldTail
|
||||||
|
? tailLayoutTop
|
||||||
|
: safeTop;
|
||||||
|
const layoutBottom = shouldFocus
|
||||||
|
? obstacleSafeBottom
|
||||||
|
: shouldTail
|
||||||
|
? tailLayoutBottom
|
||||||
|
: safeBottom;
|
||||||
|
const topPct = (layoutTop / viewportHeight) * 100;
|
||||||
|
const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100;
|
||||||
|
const topValue = `${topPct.toFixed(3)}vh`;
|
||||||
|
const bottomValue = `${bottomPct.toFixed(3)}vh`;
|
||||||
|
const expandedAvailableHeight = shouldFocus
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
layoutBottom -
|
||||||
|
layoutTop -
|
||||||
|
rowGap * Math.max(0, expandedPanels.length - 1),
|
||||||
|
)
|
||||||
|
: naturalExpandedHeight;
|
||||||
|
const allocatedExpandedHeights = allocatePanelStackHeights({
|
||||||
|
naturalHeights: naturalExpandedHeights,
|
||||||
|
availableHeight: expandedAvailableHeight,
|
||||||
|
});
|
||||||
|
const autoCollapseIndices = hud.visible
|
||||||
|
? panelStackAutoCollapseIndices({
|
||||||
|
naturalHeights: naturalExpandedHeights,
|
||||||
|
allocatedHeights: allocatedExpandedHeights,
|
||||||
|
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (autoCollapseIndices.length) {
|
||||||
|
for (const index of autoCollapseIndices) {
|
||||||
|
const panel = expandedPanels[index];
|
||||||
|
panel.classList.add('collapsed', 'layout-auto-collapsed');
|
||||||
|
onCollapse(panel);
|
||||||
|
}
|
||||||
|
onRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) {
|
||||||
|
stack.style.setProperty('--left-stack-safe-top', topValue);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue
|
||||||
|
) {
|
||||||
|
stack.style.setProperty('--left-stack-safe-bottom', bottomValue);
|
||||||
|
}
|
||||||
|
stack.style.removeProperty('--left-stack-centered-height');
|
||||||
|
for (const panel of panels)
|
||||||
|
panel.style.removeProperty('--left-panel-allocated-height');
|
||||||
|
expandedPanels.forEach((panel, index) => {
|
||||||
|
panel.style.setProperty(
|
||||||
|
'--left-panel-allocated-height',
|
||||||
|
`${allocatedExpandedHeights[index].toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stack.classList.toggle('layout-focus', shouldFocus);
|
||||||
|
stack.classList.toggle('layout-tail', shouldTail);
|
||||||
|
stack.dataset.layoutMode = shouldFocus
|
||||||
|
? 'focus'
|
||||||
|
: shouldTail
|
||||||
|
? 'tail'
|
||||||
|
: 'normal';
|
||||||
|
stack.dataset.safeTopPct = topPct.toFixed(2);
|
||||||
|
stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2);
|
||||||
|
stack.dataset.availableHeightPct = (
|
||||||
|
(availableHeight / viewportHeight) *
|
||||||
|
100
|
||||||
|
).toFixed(2);
|
||||||
|
stack.dataset.requiredHeightPct = (
|
||||||
|
(requiredHeight / viewportHeight) *
|
||||||
|
100
|
||||||
|
).toFixed(2);
|
||||||
|
stack.dataset.tailAvailableHeightPct = (
|
||||||
|
(tailAvailableHeight / viewportHeight) *
|
||||||
|
100
|
||||||
|
).toFixed(2);
|
||||||
|
stack.dataset.expandedCount = String(expandedPanels.length);
|
||||||
|
|
||||||
|
// Cockpit Display/Radio live in the opposite margin and no longer borrow
|
||||||
|
// this corridor: the left accordion's top is solved against left-lane
|
||||||
|
// obstacles, which put the strip straight through the briefing card.
|
||||||
|
// 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');
|
||||||
|
else panel.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
// The right controls share this top baseline; update them after the left
|
||||||
|
// accordion commits an HUD-variant or obstacle-driven position change.
|
||||||
|
onAligned();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Measure unconstrained visible content without feeding the allocated outer
|
||||||
|
* height back into the next layout pass.
|
||||||
|
* @param {HTMLElement} panel Panel whose natural height is needed.
|
||||||
|
* @param {Function} getComputedStyle Style reader for the panel's document.
|
||||||
|
* @returns {number} Natural height in CSS pixels.
|
||||||
|
*/
|
||||||
|
export function measurePanelNaturalHeight(panel, getComputedStyle) {
|
||||||
|
const inner = [...panel.children].find(
|
||||||
|
(child) => !child.classList.contains('panel-glow'),
|
||||||
|
);
|
||||||
|
if (!inner)
|
||||||
|
return Math.ceil(
|
||||||
|
panel.scrollHeight || panel.getBoundingClientRect().height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const innerRect = inner.getBoundingClientRect();
|
||||||
|
const panelStyle = getComputedStyle(panel);
|
||||||
|
const innerStyle = getComputedStyle(inner);
|
||||||
|
const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0;
|
||||||
|
let contentBottom = parseFloat(innerStyle.paddingTop) || 0;
|
||||||
|
|
||||||
|
for (const child of inner.children) {
|
||||||
|
const childStyle = getComputedStyle(child);
|
||||||
|
if (childStyle.display === 'none' || childStyle.visibility === 'hidden')
|
||||||
|
continue;
|
||||||
|
const childRect = child.getBoundingClientRect();
|
||||||
|
const marginBottom = parseFloat(childStyle.marginBottom) || 0;
|
||||||
|
const naturalChildHeight = Math.max(
|
||||||
|
childRect.height,
|
||||||
|
child.scrollHeight || 0,
|
||||||
|
);
|
||||||
|
const childBottom =
|
||||||
|
childRect.top - innerRect.top + naturalChildHeight + marginBottom;
|
||||||
|
contentBottom = Math.max(contentBottom, childBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperChrome =
|
||||||
|
(parseFloat(panelStyle.borderTopWidth) || 0) +
|
||||||
|
(parseFloat(panelStyle.borderBottomWidth) || 0) +
|
||||||
|
(parseFloat(panelStyle.paddingTop) || 0) +
|
||||||
|
(parseFloat(panelStyle.paddingBottom) || 0);
|
||||||
|
return Math.ceil(contentBottom + paddingBottom + wrapperChrome);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a vertically centered panel slot inside a HUD rail while respecting
|
||||||
|
* live rectangles that intersect that rail above or below the viewport center.
|
||||||
|
*/
|
||||||
|
export function resolveHudRailLayout({
|
||||||
|
viewportHeight,
|
||||||
|
panelHeight,
|
||||||
|
laneLeft,
|
||||||
|
laneRight,
|
||||||
|
obstacles = [],
|
||||||
|
baseTop,
|
||||||
|
baseBottom,
|
||||||
|
gap = 12,
|
||||||
|
align = 'center',
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
viewportHeight,
|
||||||
|
panelHeight,
|
||||||
|
laneLeft,
|
||||||
|
laneRight,
|
||||||
|
baseTop,
|
||||||
|
baseBottom,
|
||||||
|
].every(Number.isFinite) ||
|
||||||
|
viewportHeight <= 0 ||
|
||||||
|
laneRight <= laneLeft
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const midpoint = viewportHeight * 0.5;
|
||||||
|
let safeTop = Math.max(0, baseTop);
|
||||||
|
let safeBottom = Math.min(viewportHeight, baseBottom);
|
||||||
|
|
||||||
|
for (const rect of obstacles) {
|
||||||
|
if (
|
||||||
|
![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
rect.right <= laneLeft ||
|
||||||
|
rect.left >= laneRight ||
|
||||||
|
rect.bottom <= rect.top
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap);
|
||||||
|
else if (rect.top >= midpoint)
|
||||||
|
safeBottom = Math.min(safeBottom, rect.top - gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
safeBottom = Math.max(safeTop, safeBottom);
|
||||||
|
const availableHeight = Math.max(0, safeBottom - safeTop);
|
||||||
|
const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight);
|
||||||
|
return {
|
||||||
|
top:
|
||||||
|
align === 'start'
|
||||||
|
? safeTop
|
||||||
|
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5),
|
||||||
|
maxHeight: availableHeight,
|
||||||
|
safeTop,
|
||||||
|
safeBottom,
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { layoutLeftPanelRail } from './leftPanelRail.js';
|
||||||
|
export { layoutRightPanelRail } from './rightPanelRail.js';
|
||||||
|
export { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||||
|
export {
|
||||||
|
resolveHudRailLayout,
|
||||||
|
shouldHideCollapsedRightPanels,
|
||||||
|
} from './panelRailGeometry.js';
|
||||||
|
export {
|
||||||
|
allocatePanelStackHeights,
|
||||||
|
panelStackAutoCollapseIndices,
|
||||||
|
resolveLeftStackBottomBoundary,
|
||||||
|
resolvePanelStackCorridor,
|
||||||
|
} from '../panelStackLayout.js';
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
layoutLeftPanelRail,
|
||||||
|
layoutRightPanelRail,
|
||||||
|
measurePanelNaturalHeight,
|
||||||
|
} from './panelRails.js';
|
||||||
|
|
||||||
|
function element(
|
||||||
|
id,
|
||||||
|
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
||||||
|
) {
|
||||||
|
const classes = new Set(collapsed ? ['collapsed'] : []);
|
||||||
|
const properties = new Map();
|
||||||
|
const attributes = new Map();
|
||||||
|
const writes = [];
|
||||||
|
const node = {
|
||||||
|
id,
|
||||||
|
children: [],
|
||||||
|
dataset: {},
|
||||||
|
parentElement: null,
|
||||||
|
scrollHeight: height,
|
||||||
|
clientHeight: height,
|
||||||
|
scrollTop: 0,
|
||||||
|
computed: {
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
rowGap: '12px',
|
||||||
|
},
|
||||||
|
rect: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
},
|
||||||
|
classList: {
|
||||||
|
contains: (name) => classes.has(name),
|
||||||
|
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||||
|
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||||
|
toggle(name, value) {
|
||||||
|
if (value) classes.add(name);
|
||||||
|
else classes.delete(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
setProperty(name, value) {
|
||||||
|
writes.push(['set', name, value]);
|
||||||
|
properties.set(name, value);
|
||||||
|
},
|
||||||
|
getPropertyValue: (name) => properties.get(name) || '',
|
||||||
|
removeProperty(name) {
|
||||||
|
writes.push(['remove', name]);
|
||||||
|
properties.delete(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return this.rect;
|
||||||
|
},
|
||||||
|
matches: (selector) => selector === '[data-panel-id]',
|
||||||
|
contains(target) {
|
||||||
|
return (
|
||||||
|
target === this || this.children.some((child) => child.contains(target))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
querySelectorAll() {
|
||||||
|
return this.children;
|
||||||
|
},
|
||||||
|
setAttribute: (name, value) => attributes.set(name, value),
|
||||||
|
removeAttribute: (name) => attributes.delete(name),
|
||||||
|
getAttribute: (name) => attributes.get(name),
|
||||||
|
writes,
|
||||||
|
};
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function fixture(
|
||||||
|
side,
|
||||||
|
{ mobile = false, hud = { visible: true, variant: 'tactical' } } = {},
|
||||||
|
) {
|
||||||
|
const first = element('first', { height: 42, collapsed: true });
|
||||||
|
const second = element('second', { height: 42, collapsed: true });
|
||||||
|
const stack = element('rail', {
|
||||||
|
height: 96,
|
||||||
|
left: side === 'left' ? 20 : 1100,
|
||||||
|
});
|
||||||
|
stack.children = [first, second];
|
||||||
|
first.parentElement = second.parentElement = stack;
|
||||||
|
const documentRef = { activeElement: null };
|
||||||
|
stack.ownerDocument = documentRef;
|
||||||
|
const collapsed = [];
|
||||||
|
let retries = 0,
|
||||||
|
aligned = 0;
|
||||||
|
const options = {
|
||||||
|
stack,
|
||||||
|
hud,
|
||||||
|
documentRef,
|
||||||
|
obstacles: [],
|
||||||
|
collapsedHeights: new Map(),
|
||||||
|
windowRef: {
|
||||||
|
innerHeight: 900,
|
||||||
|
matchMedia: () => ({ matches: mobile }),
|
||||||
|
getComputedStyle: (node) => node.computed,
|
||||||
|
},
|
||||||
|
onCollapse: (panel) => collapsed.push(panel.id),
|
||||||
|
onRetry: () => {
|
||||||
|
retries += 1;
|
||||||
|
},
|
||||||
|
onAligned: () => {
|
||||||
|
aligned += 1;
|
||||||
|
},
|
||||||
|
displayPanel: null,
|
||||||
|
readDisplayScrollTop: () => 0,
|
||||||
|
leftStack: element('left'),
|
||||||
|
};
|
||||||
|
const run = () =>
|
||||||
|
(side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options);
|
||||||
|
const expand = (panel, height) => {
|
||||||
|
panel.classList.remove('collapsed');
|
||||||
|
panel.scrollHeight = height;
|
||||||
|
panel.rect.height = height;
|
||||||
|
panel.rect.bottom = panel.rect.top + height;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
stack,
|
||||||
|
run,
|
||||||
|
expand,
|
||||||
|
collapsed,
|
||||||
|
retries: () => retries,
|
||||||
|
aligned: () => aligned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('missing rails are inert without browser globals', () => {
|
||||||
|
layoutLeftPanelRail({});
|
||||||
|
layoutRightPanelRail({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => {
|
||||||
|
const f = fixture('left');
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.options.collapsedHeights.get('first'), 42);
|
||||||
|
assert.equal(f.first.classList.contains('collapsed'), true);
|
||||||
|
assert.equal(f.aligned(), 1);
|
||||||
|
assert.equal(f.retries(), 0);
|
||||||
|
assert.equal(f.stack.dataset.layoutMode, 'normal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => {
|
||||||
|
const f = fixture('left');
|
||||||
|
const blocker = element('blocker', { top: 600, height: 80 });
|
||||||
|
const hidden = element('hidden', { top: 300, height: 80 });
|
||||||
|
hidden.parentElement = element('hidden-parent');
|
||||||
|
hidden.parentElement.computed.opacity = '0';
|
||||||
|
f.options.obstacles = [blocker, hidden];
|
||||||
|
f.run();
|
||||||
|
assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const side of ['left', 'right']) {
|
||||||
|
test(`${side} mobile layout releases desktop height/position styles and labels`, () => {
|
||||||
|
const f = fixture(side, { mobile: true });
|
||||||
|
f.stack.classList.add('layout-focus');
|
||||||
|
f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px');
|
||||||
|
f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px');
|
||||||
|
f.first.setAttribute('aria-hidden', 'true');
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.stack.dataset.layoutMode, 'mobile');
|
||||||
|
assert.equal(
|
||||||
|
f.stack.style.getPropertyValue(`--${side}-stack-safe-top`),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||||
|
});
|
||||||
|
test(`${side} constrained layout preserves the preferred panel and requests 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.deepEqual(f.collapsed, ['first']);
|
||||||
|
assert.equal(f.retries(), 1);
|
||||||
|
});
|
||||||
|
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
|
||||||
|
const f = fixture(side, { hud: { visible: false, variant: 'tactical' } });
|
||||||
|
f.first.classList.add('layout-auto-collapsed');
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(f.second.classList.contains('collapsed'), true);
|
||||||
|
assert.deepEqual(f.collapsed, ['first']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('right layout uses keyboard focus when there is no preferred panel', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
f.expand(f.first, 900);
|
||||||
|
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.second.classList.contains('collapsed'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right layout retains Display allocation during measurement and caps restored scroll', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
f.first.id = 'pp-toggles';
|
||||||
|
f.expand(f.first, 900);
|
||||||
|
f.first.clientHeight = 400;
|
||||||
|
f.options.displayPanel = f.first;
|
||||||
|
f.options.readDisplayScrollTop = () => 800;
|
||||||
|
f.first.style.setProperty('--right-panel-allocated-height', '600px');
|
||||||
|
f.first.writes.length = 0;
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.first.scrollTop, 500);
|
||||||
|
assert.equal(
|
||||||
|
f.first.writes.some(
|
||||||
|
([op, name]) =>
|
||||||
|
op === 'remove' && name === '--right-panel-allocated-height',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
f.first.writes.length = 0;
|
||||||
|
f.run();
|
||||||
|
assert.equal(
|
||||||
|
f.first.writes.some(
|
||||||
|
([, name]) => name === '--right-panel-allocated-height',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'stable allocation must not churn the style attribute',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
f.options.leftStack.rect.top = 200;
|
||||||
|
const hidden = element('hidden', { left: 1100, top: 100, height: 200 });
|
||||||
|
hidden.computed.display = 'none';
|
||||||
|
f.options.obstacles = [hidden];
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.stack.dataset.safeTop, '200.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => {
|
||||||
|
const panel = element('panel');
|
||||||
|
const inner = element('inner', { top: 100 });
|
||||||
|
inner.computed.paddingTop = '10px';
|
||||||
|
inner.computed.paddingBottom = '5px';
|
||||||
|
const row = element('row', { top: 110, height: 40 });
|
||||||
|
row.scrollHeight = 80;
|
||||||
|
row.computed.marginBottom = '3px';
|
||||||
|
const hidden = element('hidden', { top: 1000, height: 900 });
|
||||||
|
hidden.computed.visibility = 'hidden';
|
||||||
|
panel.computed.borderTopWidth = '1px';
|
||||||
|
panel.computed.borderBottomWidth = '1px';
|
||||||
|
panel.children = [inner];
|
||||||
|
inner.children = [row, hidden];
|
||||||
|
assert.equal(
|
||||||
|
measurePanelNaturalHeight(panel, (node) => node.computed),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
allocatePanelStackHeights,
|
||||||
|
panelStackAutoCollapseIndices,
|
||||||
|
} from '../panelStackLayout.js';
|
||||||
|
import {
|
||||||
|
resolveHudRailLayout,
|
||||||
|
shouldHideCollapsedRightPanels,
|
||||||
|
} from './panelRailGeometry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure and place the right panel rail for one synchronous layout pass.
|
||||||
|
* The caller owns scheduling, obstacle selection, disclosure preferences and
|
||||||
|
* persistence. Auto-collapse is presentation only and reports through callbacks.
|
||||||
|
* @param {object} options Live DOM and caller policy.
|
||||||
|
* @param {HTMLElement} options.stack Rail element.
|
||||||
|
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
|
||||||
|
* @param {Window} options.windowRef Viewport and style reader.
|
||||||
|
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
|
||||||
|
* @param {string} options.preferredPanelId Most recently opened panel.
|
||||||
|
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
|
||||||
|
* @param {Function} options.onRetry Request another pass after automatic collapse.
|
||||||
|
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
|
||||||
|
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
|
||||||
|
* @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll.
|
||||||
|
* @param {Function} options.readDisplayScrollTop Read the caller's scroll restoration value.
|
||||||
|
* @param {Document} [options.documentRef] Document supplying current keyboard focus.
|
||||||
|
*/
|
||||||
|
export function layoutRightPanelRail({
|
||||||
|
stack,
|
||||||
|
obstacles,
|
||||||
|
windowRef,
|
||||||
|
hud,
|
||||||
|
preferredPanelId,
|
||||||
|
onCollapse,
|
||||||
|
onRetry,
|
||||||
|
leftStack,
|
||||||
|
displayPanel,
|
||||||
|
readDisplayScrollTop,
|
||||||
|
documentRef = stack?.ownerDocument,
|
||||||
|
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
||||||
|
}) {
|
||||||
|
if (!stack) return;
|
||||||
|
|
||||||
|
const panels = [...stack.children].filter((panel) =>
|
||||||
|
panel.matches('[data-panel-id]'),
|
||||||
|
);
|
||||||
|
if (!hud.visible || hud.variant !== 'tactical') {
|
||||||
|
for (const panel of panels.filter((item) =>
|
||||||
|
item.classList.contains('layout-auto-collapsed'),
|
||||||
|
)) {
|
||||||
|
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||||
|
onCollapse(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||||
|
const hasExpandedPanel = panels.some(
|
||||||
|
(panel) =>
|
||||||
|
!panel.classList.contains('collapsed') &&
|
||||||
|
(!isMobile || panel.id !== 'pp-toggles'),
|
||||||
|
);
|
||||||
|
const exclusive = shouldHideCollapsedRightPanels({
|
||||||
|
hudVariant: hud.variant,
|
||||||
|
hasExpandedPanel,
|
||||||
|
});
|
||||||
|
stack.classList.toggle('layout-exclusive', exclusive);
|
||||||
|
for (const panel of panels) {
|
||||||
|
if (exclusive && panel.classList.contains('collapsed'))
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
else panel.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
stack.classList.remove('layout-focus');
|
||||||
|
stack.style.removeProperty('--right-stack-safe-top');
|
||||||
|
stack.style.removeProperty('--right-stack-max-height');
|
||||||
|
for (const panel of panels)
|
||||||
|
panel.style.removeProperty('--right-panel-allocated-height');
|
||||||
|
stack.dataset.layoutMode = 'mobile';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = Math.max(1, windowRef.innerHeight);
|
||||||
|
const safeGap = Math.max(8, viewportHeight * 0.012);
|
||||||
|
const stackRect = stack.getBoundingClientRect();
|
||||||
|
const leftStackTop = leftStack?.getBoundingClientRect().top;
|
||||||
|
const alignedTop = Number.isFinite(leftStackTop)
|
||||||
|
? leftStackTop
|
||||||
|
: viewportHeight * 0.26;
|
||||||
|
const obstacleRects = [];
|
||||||
|
|
||||||
|
for (const obstacle of obstacles) {
|
||||||
|
if (stack.contains(obstacle)) continue;
|
||||||
|
let hiddenByAncestor = false;
|
||||||
|
for (let element = obstacle; element; element = element.parentElement) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
if (
|
||||||
|
style.display === 'none' ||
|
||||||
|
style.visibility === 'hidden' ||
|
||||||
|
Number(style.opacity) === 0
|
||||||
|
) {
|
||||||
|
hiddenByAncestor = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hiddenByAncestor) continue;
|
||||||
|
const rect = obstacle.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||||
|
obstacleRects.push({
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePanels = panels.filter(
|
||||||
|
(panel) => !exclusive || !panel.classList.contains('collapsed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayScrollTop = readDisplayScrollTop();
|
||||||
|
// Measure intrinsic content, not the allocation written by the previous
|
||||||
|
// layout pass. Display is the exception: its own scrollHeight already
|
||||||
|
// exposes every control, and removing its live allocation can reset the
|
||||||
|
// user's scroll position while HUD or preset content is settling.
|
||||||
|
for (const panel of visiblePanels) {
|
||||||
|
if (!panel.classList.contains('collapsed') && panel !== displayPanel) {
|
||||||
|
panel.style.removeProperty('--right-panel-allocated-height');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
||||||
|
const naturalHeight =
|
||||||
|
visiblePanels.reduce(
|
||||||
|
(total, panel) =>
|
||||||
|
total +
|
||||||
|
Math.max(
|
||||||
|
panel.getBoundingClientRect().height,
|
||||||
|
panel.scrollHeight || 0,
|
||||||
|
panel.classList.contains('collapsed') ? 42 : 0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
) +
|
||||||
|
gap * Math.max(0, visiblePanels.length - 1);
|
||||||
|
const layout = resolveHudRailLayout({
|
||||||
|
viewportHeight,
|
||||||
|
panelHeight: naturalHeight,
|
||||||
|
laneLeft: stackRect.left,
|
||||||
|
laneRight: stackRect.right,
|
||||||
|
obstacles: obstacleRects,
|
||||||
|
baseTop: alignedTop,
|
||||||
|
baseBottom: viewportHeight * 0.96,
|
||||||
|
gap: safeGap,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
if (!layout) return;
|
||||||
|
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
||||||
|
const stabilityBand = viewportHeight * 0.01;
|
||||||
|
const wasFocused = stack.classList.contains('layout-focus');
|
||||||
|
const shouldFocus = wasFocused
|
||||||
|
? naturalHeight > availableHeight - stabilityBand * 2
|
||||||
|
: naturalHeight > availableHeight - stabilityBand;
|
||||||
|
const layoutTop = shouldFocus ? safeTop : layout.top;
|
||||||
|
const collapsedHeight = visiblePanels.reduce(
|
||||||
|
(total, panel) =>
|
||||||
|
panel.classList.contains('collapsed')
|
||||||
|
? total + panel.getBoundingClientRect().height
|
||||||
|
: total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const expandedPanelsInDomOrder = visiblePanels.filter(
|
||||||
|
(panel) => !panel.classList.contains('collapsed'),
|
||||||
|
);
|
||||||
|
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) =>
|
||||||
|
panel.contains(documentRef.activeElement),
|
||||||
|
);
|
||||||
|
const preferredExpandedPanel =
|
||||||
|
expandedPanelsInDomOrder.find((panel) => panel.id === preferredPanelId) ||
|
||||||
|
focusedExpandedPanel;
|
||||||
|
// Match the left lane: allocation order follows the latest explicit
|
||||||
|
// disclosure, not DOM order. A focused panel is the fallback owner so
|
||||||
|
// temporary presentation collapse never strands keyboard focus.
|
||||||
|
const expandedPanels = preferredExpandedPanel
|
||||||
|
? [
|
||||||
|
preferredExpandedPanel,
|
||||||
|
...expandedPanelsInDomOrder.filter(
|
||||||
|
(panel) => panel !== preferredExpandedPanel,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: expandedPanelsInDomOrder;
|
||||||
|
const expandedAvailableHeight = Math.max(
|
||||||
|
0,
|
||||||
|
safeBottom -
|
||||||
|
layoutTop -
|
||||||
|
collapsedHeight -
|
||||||
|
gap * Math.max(0, visiblePanels.length - 1),
|
||||||
|
);
|
||||||
|
const expandedHeights = allocatePanelStackHeights({
|
||||||
|
naturalHeights: expandedPanels.map((panel) =>
|
||||||
|
Math.max(panel.getBoundingClientRect().height, panel.scrollHeight || 0),
|
||||||
|
),
|
||||||
|
availableHeight: expandedAvailableHeight,
|
||||||
|
});
|
||||||
|
const autoCollapseIndices = hud.visible
|
||||||
|
? panelStackAutoCollapseIndices({
|
||||||
|
naturalHeights: expandedPanels.map((panel) =>
|
||||||
|
Math.max(
|
||||||
|
panel.getBoundingClientRect().height,
|
||||||
|
panel.scrollHeight || 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
allocatedHeights: expandedHeights,
|
||||||
|
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (autoCollapseIndices.length) {
|
||||||
|
for (const index of autoCollapseIndices) {
|
||||||
|
const panel = expandedPanels[index];
|
||||||
|
panel.classList.add('collapsed', 'layout-auto-collapsed');
|
||||||
|
onCollapse(panel);
|
||||||
|
}
|
||||||
|
onRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Write-if-changed. This pass runs on the 500 ms stats cadence, and an
|
||||||
|
// unconditional REMOVE-then-SET of an unchanged allocation is two style
|
||||||
|
// mutations per tick on `#pp-toggles` (the one panel the measure-strip
|
||||||
|
// above deliberately skips) — churn that reads as a genuine panel move to
|
||||||
|
// the world-overlay host's occluder observer and defeats parked-idle
|
||||||
|
// render savings. Only a real allocation change may touch the attribute.
|
||||||
|
expandedPanels.forEach((panel, index) => {
|
||||||
|
const next = `${expandedHeights[index].toFixed(1)}px`;
|
||||||
|
if (
|
||||||
|
panel.style.getPropertyValue('--right-panel-allocated-height') !== next
|
||||||
|
) {
|
||||||
|
panel.style.setProperty('--right-panel-allocated-height', next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const panel of panels) {
|
||||||
|
if (expandedPanels.includes(panel)) continue;
|
||||||
|
panel.style.removeProperty('--right-panel-allocated-height');
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.style.setProperty(
|
||||||
|
'--right-stack-safe-top',
|
||||||
|
`${layoutTop.toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
stack.style.setProperty(
|
||||||
|
'--right-stack-max-height',
|
||||||
|
`${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
stack.classList.toggle('layout-focus', shouldFocus);
|
||||||
|
stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal';
|
||||||
|
stack.dataset.safeTop = layoutTop.toFixed(1);
|
||||||
|
stack.dataset.safeBottom = safeBottom.toFixed(1);
|
||||||
|
stack.dataset.availableHeight = availableHeight.toFixed(1);
|
||||||
|
stack.dataset.requiredHeight = naturalHeight.toFixed(1);
|
||||||
|
stack.dataset.expandedCount = String(expandedPanels.length);
|
||||||
|
|
||||||
|
if (displayPanel && expandedPanels.includes(displayPanel)) {
|
||||||
|
const maxScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
displayPanel.scrollHeight - displayPanel.clientHeight,
|
||||||
|
);
|
||||||
|
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user