feat(ui): add a desktop panel rail to each sidebar

Each side stack hosts a Vue rail listing its panels; selecting one opens it
through PanelChrome. The shell owns one Vue runtime and Pinia instance, and a
panel store mirrors each side's panels from the DOM. The rail hides with its
stack, in Cockpit, clean view and recording, and on phones. Panels without a
catalog entry get a generic item, and Data Layers leaves the rail while the
f shortcut hides it.
This commit is contained in:
Sameh Khamis committed 2026-10-30 16:41:29 -07:00
1 parent eed7657516
commit 3d5d3ef014
29 files changed
+627 -32

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import PanelRailView from './PanelRailView.vue';
import { PANEL_RAIL_HOST_CLASS, PANEL_RAIL_ITEMS } from './panelRailCatalog.js';
import { usePanelStore } from './stores/panels.js';
/**
* Own the desktop panel rails: publish each side's panels to the panel store
* and open or close a panel when its rail item is selected. Disclosure itself
* stays with PanelChrome, which enforces one open panel per side.
*/
export class PanelNavigation {
/**
* @param {object} options
* @param {{pinia: object, mount: Function}} options.ui Shell Vue runtime.
* @param {{left: HTMLElement|null, right: HTMLElement|null}} options.stacks Side panel stacks.
* @param {Function} options.setPanelCollapsed PanelChrome's disclosure operation.
*/
constructor({ ui, stacks, setPanelCollapsed }) {
this._ui = ui;
this._stacks = stacks;
this._setPanelCollapsed = setPanelCollapsed;
this._store = usePanelStore(ui.pinia);
this._unmounts = [];
this._hosts = [];
this._observer = null;
this._destroyed = false;
}
init() {
if (this._destroyed) return;
for (const side of ['left', 'right']) {
const stack = this._stacks[side];
if (!stack) continue;
// Inside the stack, the rail shows and hides with its panels in every
// presentation mode (Cockpit, clean view, recording, phone sheets).
const host = stack.ownerDocument.createElement('div');
host.className = `${PANEL_RAIL_HOST_CLASS} ${PANEL_RAIL_HOST_CLASS}--${side}`;
stack.prepend(host);
this._hosts.push(host);
this._unmounts.push(
this._ui.mount(PanelRailView, host, {
side,
onSelect: (panelId) => this.select(panelId),
}),
);
}
if (typeof MutationObserver !== 'undefined') {
// Only the panels themselves matter: their order, disclosure and
// availability, not changes inside them or inside the rails.
this._observer = new MutationObserver((records) => {
if (records.some((record) => this._isPanelRecord(record))) this.sync();
});
for (const stack of Object.values(this._stacks)) {
if (!stack) continue;
this._observer.observe(stack, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'hidden'],
});
}
}
this.sync();
}
/** Publish both sides' panels in display order. */
sync() {
if (this._destroyed) return;
for (const side of ['left', 'right']) {
const stack = this._stacks[side];
const panels = stack
? [...stack.children]
.filter((panel) => panel.matches('[data-panel-id]'))
.map((panel) => {
const shownBy = PANEL_RAIL_ITEMS[panel.id]?.shownBy;
return {
id: panel.id,
title:
panel
.querySelector('.panel-title, .pp-header-label')
?.textContent.trim() || panel.id,
open: !panel.classList.contains('collapsed'),
available:
!panel.hidden &&
(!shownBy || panel.classList.contains(shownBy)),
};
})
: [];
this._store.setSide(side, panels);
}
}
/**
* Toggle a panel from its rail item.
* @param {string} panelId Panel element id.
*/
select(panelId) {
if (this._destroyed) return;
this._setPanelCollapsed(panelId, this._store.isOpen(panelId), {
explicit: true,
});
}
_isPanelRecord(record) {
const stacks = Object.values(this._stacks);
return record.type === 'childList'
? stacks.includes(record.target) &&
[...record.addedNodes, ...record.removedNodes].some(
(node) => !this._hosts.includes(node),
)
: stacks.includes(record.target.parentElement);
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._observer?.disconnect();
this._observer = null;
for (const unmount of this._unmounts) unmount();
this._unmounts = [];
for (const host of this._hosts) host.remove();
this._hosts = [];
}
}