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

+3 -1
View File
@@ -2943,5 +2943,7 @@
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen"
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
"Left panels": "Linke Panels",
"Right panels": "Rechte Panels"
}
+3 -1
View File
@@ -3013,5 +3013,7 @@
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos"
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
"Left panels": "Paneles izquierdos",
"Right panels": "Paneles derechos"
}
+3 -1
View File
@@ -3013,5 +3013,7 @@
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources"
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources",
"Left panels": "Panneaux de gauche",
"Right panels": "Panneaux de droite"
}
+3 -1
View File
@@ -3013,5 +3013,7 @@
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse"
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse",
"Left panels": "Pannelli a sinistra",
"Right panels": "Pannelli a destra"
}
+3 -1
View File
@@ -2873,5 +2873,7 @@
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード"
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
"Left panels": "左パネル",
"Right panels": "右パネル"
}
+3 -1
View File
@@ -3013,5 +3013,7 @@
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos"
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos",
"Left panels": "Painéis à esquerda",
"Right panels": "Painéis à direita"
}
+3 -1
View File
@@ -3083,5 +3083,7 @@
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов"
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
"Left panels": "Левые панели",
"Right panels": "Правые панели"
}
+3 -1
View File
@@ -3083,5 +3083,7 @@
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів"
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
"Left panels": "Ліві панелі",
"Right panels": "Праві панелі"
}
+3 -1
View File
@@ -2873,5 +2873,7 @@
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包"
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
"Left panels": "左侧面板",
"Right panels": "右侧面板"
}
+3 -1
View File
@@ -2873,5 +2873,7 @@
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包"
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板"
}
+47
View File
@@ -0,0 +1,47 @@
<script setup>
import { computed } from 'vue';
import { t } from '../i18n/index.js';
import UiRail from './kit/UiRail.vue';
import {
PANEL_RAIL_FALLBACK_ICON,
PANEL_RAIL_ITEMS,
} from './panelRailCatalog.js';
import { usePanelStore } from './stores/panels.js';
const props = defineProps({
/** `left` or `right`. */
side: { type: String, required: true },
/** Open or close a panel; receives the panel id. */
onSelect: { type: Function, required: true },
});
const panels = usePanelStore();
const items = computed(() =>
panels.sides[props.side]
.filter((panel) => panel.available)
.map((panel) => {
const item = PANEL_RAIL_ITEMS[panel.id];
return {
id: panel.id,
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
// Static markup is translated at boot, so a host's title already is.
label: item ? t(item.label) : panel.title,
};
}),
);
const active = computed(() => panels.openPanelId(props.side));
const label = computed(() =>
props.side === 'left' ? t('Left panels') : t('Right panels'),
);
</script>
<template>
<UiRail
:items="items"
:active="active"
:side="side"
:label="label"
:panel-open="active !== null"
@select="onSelect"
/>
</template>
+13
View File
@@ -51,6 +51,8 @@ import {
t,
} from '../i18n/index.js';
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js';
import { createUiRuntime } from './vueRuntime.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -133,6 +135,8 @@ export class StyleManager extends ShellFacade {
readSharedCollapsed: (panelId) =>
this._mobileShell?.sharedCollapsed(panelId),
});
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime();
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
});
@@ -594,6 +598,13 @@ export class StyleManager extends ShellFacade {
// Headers first: panel chrome builds its toggle labels from them.
this._applyRuntimeStaticHeaderText();
this._initPanelChrome();
this._panelNavigation = new PanelNavigation({
ui: this._ui,
stacks: { left: this._leftPanelStack, right: this._rightPanelStack },
setPanelCollapsed: (...args) =>
this._panelChrome.setPanelCollapsed(...args),
});
this._panelNavigation.init();
this._initLocaleSelector();
this._sizeDisplayLabelColumn();
this._initLeftPanelAdaptiveLayout();
@@ -1776,6 +1787,8 @@ export class StyleManager extends ShellFacade {
this._localePicker = null;
this._recording.destroy();
this._mobileShell?.destroy();
this._panelNavigation?.destroy();
this._ui.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
+5 -2
View File
@@ -1,6 +1,7 @@
/** Own rail measurement, layout scheduling and dock tray observation. */
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
import { isPanelRailMutation } from './panelRailCatalog.js';
const COCKPIT_LAYOUT_SETTLE_MS = 240;
/**
* Fixed UI regions that can occupy the left accordion's vertical lane.
@@ -233,7 +234,8 @@ export class PanelLayoutController {
}
if (typeof MutationObserver !== 'undefined') {
this._rightStackMutationObserver = new MutationObserver(() => {
this._rightStackMutationObserver = new MutationObserver((records) => {
if (records.every(isPanelRailMutation)) return;
this._scheduleRightPanelLayout();
});
this._rightStackMutationObserver.observe(stack, {
@@ -334,7 +336,8 @@ export class PanelLayoutController {
}
if (typeof MutationObserver !== 'undefined') {
this._leftStackMutationObserver = new MutationObserver(() => {
this._leftStackMutationObserver = new MutationObserver((records) => {
if (records.every(isPanelRailMutation)) return;
this._scheduleLeftPanelLayout();
});
this._leftStackMutationObserver.observe(stack, {
+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 = [];
}
}
+140
View File
@@ -0,0 +1,140 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../testSupport/vueDom.mjs';
let PanelNavigation;
let createUiRuntime;
let nextTick;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ nextTick } = await import('vue'));
({ PanelNavigation } = await import('./panelNavigation.js'));
({ createUiRuntime } = await import('./vueRuntime.js'));
});
after(() => uninstall());
function panel(id, { collapsed = true, hidden = false, title = '' } = {}) {
const element = document.createElement('div');
element.id = id;
element.dataset.panelId = id;
element.className = collapsed
? 'panel-collapsible collapsed'
: 'panel-collapsible';
// Data Layers shows only while `active` (the `f` shortcut toggles it).
if (id === 'data-panel') element.classList.add('active');
element.hidden = hidden;
if (title) element.innerHTML = `<span class="panel-title">${title}</span>`;
return element;
}
function setup() {
document.body.innerHTML = '';
const left = document.createElement('div');
left.id = 'left-panel-stack';
left.append(panel('data-panel', { collapsed: false }), panel('scene-panel'));
const right = document.createElement('div');
right.id = 'right-context-rail';
right.append(
panel('pp-toggles'),
panel('weather-panel', { hidden: true }),
panel('global-context-panel'),
panel('host-panel', { title: 'HOST' }),
);
document.body.append(left, right);
const calls = [];
const ui = createUiRuntime();
const navigation = new PanelNavigation({
ui,
stacks: { left, right },
setPanelCollapsed: (...args) => calls.push(args),
});
navigation.init();
return { left, right, calls, ui, navigation };
}
const railItems = (stack) =>
[...stack.querySelectorAll('.gev-panel-rail [data-rail-item]')].map(
(button) => [button.dataset.railItem, button.getAttribute('aria-pressed')],
);
test('each stack gets a rail of its offered panels, marking the open one', async () => {
const { left, right, ui, navigation } = setup();
await nextTick();
assert.equal(
left.firstElementChild.className,
'gev-panel-rail gev-panel-rail--left',
);
assert.deepEqual(railItems(left), [
['data-panel', 'true'],
['scene-panel', 'false'],
]);
// Hidden panels are not offered; a host's panel gets a generic item.
assert.deepEqual(railItems(right), [
['pp-toggles', 'false'],
['global-context-panel', 'false'],
['host-panel', 'false'],
]);
assert.equal(
right
.querySelector('[data-rail-item="host-panel"]')
.getAttribute('aria-label'),
'HOST',
);
assert.equal(
left.querySelector('nav').getAttribute('aria-label'),
'Left panels',
);
navigation.destroy();
ui.destroy();
});
test('selecting an item toggles its panel explicitly', async () => {
const { left, calls, ui, navigation } = setup();
await nextTick();
left.querySelector('[data-rail-item="scene-panel"]').click();
left.querySelector('[data-rail-item="data-panel"]').click();
assert.deepEqual(calls, [
['scene-panel', false, { explicit: true }],
['data-panel', true, { explicit: true }],
]);
navigation.destroy();
ui.destroy();
});
test('the rails follow disclosure and availability changes', async () => {
const { left, right, ui, navigation } = setup();
document.getElementById('data-panel').classList.add('collapsed');
document.getElementById('scene-panel').classList.remove('collapsed');
document.getElementById('weather-panel').hidden = false;
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
assert.deepEqual(railItems(left), [
['data-panel', 'false'],
['scene-panel', 'true'],
]);
assert.deepEqual(railItems(right), [
['pp-toggles', 'false'],
['weather-panel', 'false'],
['global-context-panel', 'false'],
['host-panel', 'false'],
]);
// `f` hides Data Layers outright, so the rail stops offering it.
document.getElementById('data-panel').classList.remove('active');
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
assert.deepEqual(railItems(left), [['scene-panel', 'true']]);
navigation.destroy();
ui.destroy();
});
test('destroy removes the rails and stops following the stacks', async () => {
const { left, right, calls, ui, navigation } = setup();
navigation.destroy();
navigation.destroy();
assert.equal(left.querySelector('.gev-panel-rail'), null);
assert.equal(right.querySelector('.gev-panel-rail'), null);
navigation.select('data-panel');
assert.deepEqual(calls, []);
ui.destroy();
});
+41
View File
@@ -0,0 +1,41 @@
import { msg } from '../i18n/index.js';
/** Class of the element each side stack hosts its rail in. */
export const PANEL_RAIL_HOST_CLASS = 'gev-panel-rail';
/** Glyph for a panel without a catalog entry, such as one a host adds. */
export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz';
/**
* How each side panel appears on its rail. Labels are the panels' own titles.
* `shownBy` names a class the panel needs to be shown at all (`f` toggles
* Data Layers' `active`).
*/
export const PANEL_RAIL_ITEMS = Object.freeze({
'data-panel': {
icon: 'layers',
label: msg('DATA LAYERS'),
shownBy: 'active',
},
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'recent-imagery-panel': {
icon: 'satellite_alt',
label: msg('RECENT IMAGERY'),
},
'street-level-panel': { icon: 'streetview', label: msg('STREET LEVEL') },
'global-context-panel': { icon: 'radar', label: msg('CONTEXT') },
});
/**
* Whether a mutation happened inside a panel rail, which no stack layout or
* panel state depends on.
* @param {MutationRecord} record Observed mutation.
*/
export function isPanelRailMutation(record) {
const target =
record.target.nodeType === 1 ? record.target : record.target.parentElement;
return Boolean(target?.closest?.(`.${PANEL_RAIL_HOST_CLASS}`));
}
+57
View File
@@ -0,0 +1,57 @@
import { defineStore } from 'pinia';
import { reactive } from 'vue';
/**
* @typedef {object} RailPanel
* @property {string} id Panel element id.
* @property {string} title The panel's translated title.
* @property {boolean} open Whether the panel is expanded.
* @property {boolean} available Whether the panel is offered (not `hidden`).
*/
const samePanels = (a, b) =>
a.length === b.length &&
a.every(
(panel, index) =>
panel.id === b[index].id &&
panel.title === b[index].title &&
panel.open === b[index].open &&
panel.available === b[index].available,
);
/**
* The desktop side panels as the rails present them. Panel disclosure stays
* with PanelChrome; this store publishes each side's panels in order.
*/
export const usePanelStore = defineStore('gev-panels', () => {
/** @type {{left: RailPanel[], right: RailPanel[]}} */
const sides = reactive({ left: [], right: [] });
/**
* Replace one side's panels, keeping the array when nothing changed.
* @param {'left'|'right'} side Sidebar.
* @param {RailPanel[]} panels Panels in display order.
*/
function setSide(side, panels) {
if (!samePanels(sides[side], panels)) sides[side] = panels;
}
/**
* @param {'left'|'right'} side Sidebar.
* @returns {string|null} The open panel's id.
*/
function openPanelId(side) {
return (
sides[side].find((panel) => panel.open && panel.available)?.id ?? null
);
}
/** @param {string} id Panel id. */
function isOpen(id) {
return [...sides.left, ...sides.right].some(
(panel) => panel.id === id && panel.open,
);
}
return { sides, setSide, openPanelId, isOpen };
});
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { usePanelStore } from './panels.js';
test('the panel store reports each side’s open panel and keeps unchanged sides', () => {
const store = usePanelStore(createPinia());
const left = [
{ id: 'data-panel', open: false, available: true },
{ id: 'scene-panel', open: true, available: true },
];
store.setSide('left', left);
store.setSide('right', [
{ id: 'weather-panel', open: true, available: false },
]);
assert.equal(store.openPanelId('left'), 'scene-panel');
// An open panel that is not offered is not the side's open panel.
assert.equal(store.openPanelId('right'), null);
assert.equal(store.isOpen('scene-panel'), true);
assert.equal(store.isOpen('data-panel'), false);
const published = store.sides.left;
store.setSide(
'left',
left.map((panel) => ({ ...panel })),
);
assert.equal(store.sides.left, published);
});
+27
View File
@@ -263,6 +263,33 @@
#left-panel-stack.layout-exclusive > [data-panel-id].collapsed {
display: none;
}
/* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack,
so it shows and hides with the stack, and floats in the screen-edge gutter. */
.gev-panel-rail {
position: absolute;
top: 0;
z-index: 1;
pointer-events: auto;
}
.gev-panel-rail--left {
right: calc(100% + 6px);
}
.gev-panel-rail--right {
left: calc(100% + 6px);
}
body.mobile-shell #left-panel-stack > .gev-panel-rail,
body.mobile-shell #right-context-rail > .gev-panel-rail,
body.cockpit-mode .gev-panel-rail {
display: none;
}
/* Clean view and recording hide the panels one by one, not their stacks. */
body.ui-clean-view .gev-panel-rail,
body.recording-mode .gev-panel-rail {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
#right-context-rail.layout-focus > [data-panel-id].collapsed {
display: block;
flex: 0 0 auto;
+36
View File
@@ -0,0 +1,36 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
/**
* Create the application's Vue runtime: one Pinia instance shared by every
* view the shell mounts, and the mounts themselves for teardown.
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
*/
export function createUiRuntime() {
const pinia = createPinia();
const apps = new Set();
return {
pinia,
/**
* Mount a root component into a host element.
* @param {object} component Root component.
* @param {Element} host Element the view renders into.
* @param {object} [props] Root props.
* @returns {Function} Unmount this view.
*/
mount(component, host, props = {}) {
const app = createApp(component, props);
app.use(pinia);
app.mount(host);
apps.add(app);
return () => {
if (!apps.delete(app)) return;
app.unmount();
};
},
destroy() {
for (const app of apps) app.unmount();
apps.clear();
},
};
}