Files
gods-eye-view/src/ui/rightPanelRail.js
T
Sameh Khamis 95523e09cd feat(panels): render each panel's state classes from the store
A panel's collapsed, dock-pinned, dock-pinned-top, layout-auto-collapsed
and accordion-collapsed classes are drawn by its root's v-panel-state from
the gev-panel-chrome store. PanelChrome, its restore, the layout
controller, both rails and the side accordion present each panel's state
there instead of writing its classes; the rails call onCollapse(panel,
{collapsed, auto}). The directive applies a change in a synchronous
watcher, since the rails measure a panel in the task that opens or closes
it.

Destroying the chrome inside Cockpit now puts back the panels Cockpit's
entry closed while it can still present them. Without a shell, the voice
tools' set_panel_open reports that there is no panel to open. QA scripts
present the states they set instead of writing the classes.
2026-10-31 23:51:47 -07:00

325 lines
12 KiB
JavaScript

import {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
} from '../panelStackLayout.js';
import { resolveHudRailLayout } from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { settleSideAccordion } from './panelAccordion.js';
const pendingCollapseRetries = new WeakSet();
/**
* Where the left rail's top will settle, not where it is mid-transition:
* nothing re-runs this pass when the `top` animation ends (ResizeObserver
* ignores moves). Returns NaN when there is no left rail.
*/
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
const top = leftStack?.getBoundingClientRect().top;
if (!Number.isFinite(top)) return NaN;
const transition = leftStack
.getAnimations?.()
.find((animation) => animation.transitionProperty === 'top');
if (!transition) return top;
const keyframes = transition.effect?.getKeyframes?.() || [];
const targetTop = parseFloat(keyframes.at(-1)?.top);
const currentTop = parseFloat(getComputedStyle(leftStack).top);
// The rect and the computed `top` move together; keep any offset between them.
if (Number.isFinite(targetTop) && Number.isFinite(currentTop))
return top + targetTop - currentTop;
const targetPct = parseFloat(leftStack.dataset?.safeTopPct);
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top;
}
/**
* 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 Present a panel's collapse:
* `(panel, {collapsed, auto})`.
* @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;
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// Other CSS may hide a panel, such as an event view: it is not laid out.
// Exclusive presentation hides collapsed panels itself; those stay in the
// rail so they can be restored.
const hiddenByRail = (panel) =>
stack.classList.contains('layout-exclusive') &&
panel.classList.contains('collapsed');
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]') &&
!panel.hidden &&
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
onCollapse(panel, { collapsed: false, auto: null });
}
}
// One panel is open per desktop side; this marker is distinct from
// Tactical's space-based collapse.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
settleSideAccordion({
panels,
enabled: !isMobile,
preferredPanelId,
documentRef,
onCollapse,
});
// The desktop panel rail lists every panel, so collapsed ones are hidden.
// Phones present the rail as tab sheets (mobileShell.js), which choose what
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
const exclusive = !isMobile;
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 = settledLeftRailTop(
leftStack,
viewportHeight,
getComputedStyle,
);
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,
});
}
// Auto-collapsed headers contribute the same need even when exclusive
// presentation hides them. Manually collapsed launchers keep their policy.
const measuredPanels = panels.filter(
(panel) =>
!exclusive ||
!panel.classList.contains('collapsed') ||
panel.classList.contains('layout-auto-collapsed'),
);
const visiblePanels = panels.filter(
(panel) => !exclusive || !panel.classList.contains('collapsed'),
);
const displayScrollTop = readDisplayScrollTop();
// Measuring lifts each opted-in scroller's max-height, which clamps its
// scroll offset; remember the offsets to put back afterwards.
const scrollers = [
...(stack.querySelectorAll?.('[data-rail-scroller]') || []),
]
.map((node) => [node, node.scrollTop || 0])
.filter(([, top]) => top > 0);
const naturalHeights = new Map();
// Remove live height allocations through a synchronous CSS override. Keeping
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
// Do not toggle layout classes here: the class observer schedules new passes.
stack.setAttribute('data-rail-measuring', '');
try {
for (const panel of measuredPanels) {
naturalHeights.set(
panel,
Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
panel.classList.contains('collapsed') ? 42 : 0,
),
);
}
} finally {
stack.removeAttribute('data-rail-measuring');
for (const [node, top] of scrollers)
if (node.scrollTop !== top) node.scrollTop = top;
}
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight =
measuredPanels.reduce(
(total, panel) => total + naturalHeights.get(panel),
0,
) +
gap * Math.max(0, measuredPanels.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 = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused
? naturalHeight >= availableHeight - stabilityBand
: naturalHeight > availableHeight;
const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce(
(total, panel) =>
panel.classList.contains('collapsed')
? total + naturalHeights.get(panel)
: 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) => naturalHeights.get(panel)),
availableHeight: expandedAvailableHeight,
});
// Other HUD variants restore automatic collapse at the start of each pass.
// Do not immediately collapse those panels again and schedule a loop.
const autoCollapseIndices =
hud.visible && hud.variant === 'tactical'
? panelStackAutoCollapseIndices({
naturalHeights: expandedPanels.map((panel) =>
naturalHeights.get(panel),
),
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
})
: [];
if (!isCollapseRetry && autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
onCollapse(panel, { collapsed: true, auto: 'layout' });
}
pendingCollapseRetries.add(stack);
onRetry();
return;
}
// Write-if-changed. This pass runs on the 500 ms stats cadence. Rewriting
// an unchanged allocation wakes the world-overlay occluder observer and
// defeats parked-idle render savings. Measurement leaves these values intact.
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 scroller = displayPanelScroller(displayPanel);
const maxScrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight,
);
scroller.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}