feat(cockpit): teleport the shared Display groups into Cockpit's popover

The Cockpit display portal publishes the Display popover's slots to the
gev-display store while Cockpit is on, and the Display panel teleports its
HUD, Detection, Parameters and 3D groups into them and back, each only
while its own slot is published. The portal no longer moves the groups:
it keeps each surface's scroll position, puts back focus the move
dropped, and lays the surfaces out once Vue has moved them. The popover's
uses-shared-display-controls class renders from the store, and the right
rail no longer re-homes the parameters panel the markup already places.
This commit is contained in:
Sameh Khamis committed 2026-11-01 15:01:41 -08:00
1 parent 9ed9098593
commit fd4b69ff52
12 files changed
+250 -49

No files matched your search

+2
View File
@@ -403,6 +403,8 @@ export class StyleManager extends ShellFacade {
utilityLayout: (layout) =>
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
},
publishCockpitPortal: (slots) =>
useDisplayStore(applicationPinia()).publishCockpitPortal(slots),
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
+5
View File
@@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
@@ -380,6 +381,10 @@ export class CockpitCoordinator {
this._layoutRightPanels();
this.cockpitView?.scheduleContextLayout();
},
publish: (slots) => this.publishCockpitPortal?.(slots),
afterRender: (callback) => {
void nextTick(callback);
},
});
}
+37 -25
View File
@@ -1,11 +1,25 @@
import { displayPanelScroller } from './displayPanelScroll.js';
/** Move the existing Display groups between their two presentation surfaces. */
/**
* Show the existing Display groups in Cockpit's Display popover while Cockpit
* is on. The portal publishes the popover's slots; the Display panel
* teleports its groups into them and back. The portal keeps each surface's
* scroll position and puts back focus the move dropped.
*/
export class CockpitDisplayPortal {
constructor({ standardPanel, cockpitPanel, groups, layout }) {
constructor({
standardPanel,
cockpitPanel,
groups,
layout,
publish = () => {},
afterRender = (callback) => callback(),
}) {
this.standardPanel = standardPanel;
this.cockpitPanel = cockpitPanel;
this.layout = layout;
this.publish = publish;
this.afterRender = afterRender;
this.active = false;
this.destroyed = false;
this.stopped = false;
@@ -17,11 +31,12 @@ export class CockpitDisplayPortal {
const slot = cockpitPanel?.querySelector(
`[data-cockpit-display-slot="${name}"]`,
);
if (!group || !slot || !group.parentNode) return [];
const anchor = document.createComment(`cockpit-display-home:${name}`);
group.before(anchor);
return [{ name, group, slot, anchor }];
if (!group || !slot) return [];
return [{ name, group, slot }];
});
this.slots = Object.freeze(
Object.fromEntries(this.records.map(({ name, slot }) => [name, slot])),
);
this.standardScrollTop =
displayPanelScroller(standardPanel)?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
@@ -78,14 +93,7 @@ export class CockpitDisplayPortal {
const focusedElement = focusedRecord ? document.activeElement : null;
this.restoreOwner = nextActive ? 'cockpit' : 'standard';
this.active = nextActive;
for (const record of this.records) {
if (nextActive) record.slot.append(record.group);
else if (record.anchor.parentNode) record.anchor.after(record.group);
}
this.cockpitPanel?.classList.toggle(
'uses-shared-display-controls',
nextActive,
);
this.publish(nextActive ? this.slots : null);
const restoreScroll = () => {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
@@ -94,8 +102,11 @@ export class CockpitDisplayPortal {
this.standardScrollTop;
};
if (!settle) {
restoreScroll();
this.restoreOwner = null;
this.afterRender(() => {
if (generation !== this.generation) return;
restoreScroll();
this.restoreOwner = null;
});
return;
}
this._frame(generation, () => {
@@ -114,7 +125,10 @@ export class CockpitDisplayPortal {
this.restoreOwner = null;
});
});
this.layout();
// The surfaces are measured once the groups have moved.
this.afterRender(() => {
if (!this.stopped && generation === this.generation) this.layout();
});
}
stop() {
@@ -129,14 +143,12 @@ export class CockpitDisplayPortal {
this.stop();
this.destroyed = true;
this.active = false;
for (const record of this.records) {
if (record.anchor.parentNode) record.anchor.after(record.group);
record.anchor.remove();
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
this.publish(null);
const { standardPanel, standardScrollTop } = this;
this.afterRender(() => {
if (standardPanel)
displayPanelScroller(standardPanel).scrollTop = standardScrollTop;
});
this.records = [];
this.restoreOwner = null;
}
+41
View File
@@ -79,6 +79,14 @@ function fixture() {
return id;
};
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const published = [];
// The Display panel's Teleport: the group moves to the published slot and
// back to its home ahead of its sibling.
const publish = (slots) => {
published.push(slots);
if (slots) slots.hud.append(group);
else if (group.parentNode !== standard) standard.children[0].before(group);
};
const create = () =>
new CockpitDisplayPortal({
standardPanel: standard,
@@ -87,6 +95,7 @@ function fixture() {
layout: () => {
layouts += 1;
},
publish,
});
return {
standard,
@@ -96,6 +105,7 @@ function fixture() {
slot,
frames,
create,
published,
get focusCalls() {
return focusCalls;
},
@@ -120,6 +130,7 @@ test('Display groups return to their exact home with independent scroll position
try {
const owner = f.create();
owner.setActive(true);
assert.deepEqual(f.published, [{ hud: f.slot }]);
assert.equal(f.group.parentNode, f.slot);
f.cockpit.scrollTop = 0;
f.flush();
@@ -130,6 +141,7 @@ test('Display groups return to their exact home with independent scroll position
f.flush();
assert.equal(f.standard.scrollTop, 71);
owner.destroy();
assert.deepEqual(f.published, [{ hud: f.slot }, null, null]);
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
} finally {
@@ -137,6 +149,35 @@ test('Display groups return to their exact home with independent scroll position
}
});
test('the surfaces are laid out and scrolled once the groups have rendered', () => {
const f = fixture();
try {
const rendered = [];
const owner = new CockpitDisplayPortal({
standardPanel: f.standard,
cockpitPanel: f.cockpit,
groups: [['hud', f.group]],
layout: () => rendered.push('layout'),
publish: (slots) => rendered.push(slots ? 'cockpit' : 'standard'),
afterRender: (callback) => rendered.push(callback),
});
owner.setActive(true, { settle: false });
// Published first; the scroll waits for the render.
assert.deepEqual(rendered.slice(0, 1), ['cockpit']);
f.cockpit.scrollTop = 0;
rendered.splice(1).forEach((callback) => callback());
assert.equal(f.cockpit.scrollTop, 19);
owner.setActive(false);
const pending = rendered.filter((item) => typeof item === 'function');
assert.equal(rendered.includes('layout'), false);
pending.forEach((callback) => callback());
assert.equal(rendered.at(-1), 'layout');
owner.destroy();
} finally {
f.restore();
}
});
test('focus moved elsewhere before the frame is not taken back', () => {
const f = fixture();
try {
+114 -2
View File
@@ -10,6 +10,7 @@ import DisplayPanel from './views/panels/DisplayPanel.vue';
import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js';
import { initGeoFileImport } from './geoFileImport.js';
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
// Core's classes on an element, without the kit's `ui-` classes.
const coreClasses = (el) =>
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
@@ -20,9 +21,14 @@ function fixture(t) {
const pinia = createPinia();
const app = createApp(DisplayPanel).use(pinia);
app.mount(host);
t.after(() => app.unmount());
let mounted = true;
const unmount = () => {
if (mounted) app.unmount();
mounted = false;
};
t.after(unmount);
const $ = (id) => document.getElementById(id);
return { store: useDisplayStore(pinia), $ };
return { store: useDisplayStore(pinia), $, unmount };
}
/** A VisualSettings over stand-in effects, publishing to `store`. */
@@ -575,3 +581,109 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider
owner.stop();
assert.equal(store.parameters, newer);
});
test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => {
const { store, $ } = fixture(t);
const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id);
const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle'];
const body = $('pp-toggles').querySelector('.pp-panel-body');
const order = () =>
[...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel'))
.map((el) => el.id || el.querySelector('[id]')?.id);
const home = order();
const popover = document.body.appendChild(document.createElement('div'));
const slots = {};
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
slots[name] = popover.appendChild(document.createElement('div'));
}
store.publishCockpitPortal(slots);
await nextTick();
assert.equal(slots.hud.contains($('hud-toggle')), true);
assert.equal(slots.detection.contains($('detection-toggle')), true);
assert.equal(slots.parameters.firstElementChild, $('param-slider-panel'));
assert.equal(slots.models3d.contains($('models3d-toggle')), true);
// The teleported controls still render from the store.
store.publishParameters({ style: 'noir', rows: [] });
await nextTick();
assert.equal($('param-slider-panel').classList.contains('active'), true);
store.publishCockpitPortal(null);
await nextTick();
for (const id of ids) assert.equal(body.contains(groupOf(id)), true, id);
assert.deepEqual(order(), home);
// A second trip uses the same slots.
store.publishCockpitPortal(slots);
await nextTick();
assert.equal(slots.hud.contains($('hud-toggle')), true);
store.publishCockpitPortal(null);
await nextTick();
assert.deepEqual(order(), home);
});
test('a group without a published slot stays home; a replaced slot and an unmount leave nothing behind', async (t) => {
const { store, $, unmount } = fixture(t);
const body = $('pp-toggles').querySelector('.pp-panel-body');
const popover = document.body.appendChild(document.createElement('div'));
const slot = () => popover.appendChild(document.createElement('div'));
const first = { hud: slot(), detection: slot(), parameters: slot() };
store.publishCockpitPortal(first);
await nextTick();
// No models3d slot: 3D stays in the Display panel, not in body.
assert.equal(body.contains($('models3d-toggle')), true);
assert.equal(first.hud.contains($('hud-toggle')), true);
// A new popover replaces the slots while the groups are out.
const second = { ...first, hud: slot() };
store.publishCockpitPortal(second);
await nextTick();
assert.equal(second.hud.contains($('hud-toggle')), true);
assert.equal(first.hud.childElementCount, 0);
// Unmounting while teleported removes the groups from the slots too.
unmount();
for (const target of Object.values(second))
assert.equal(target.childElementCount, 0);
});
test('the real portal lays out once the groups have moved and puts back the focus the move dropped', async (t) => {
const { store, $ } = fixture(t);
const popover = document.body.appendChild(document.createElement('div'));
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
const slot = popover.appendChild(document.createElement('div'));
slot.dataset.cockpitDisplaySlot = name;
}
const frames = [];
const priorFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
t.after(() => {
globalThis.requestAnimationFrame = priorFrame;
});
const layouts = [];
const portal = new CockpitDisplayPortal({
standardPanel: $('pp-toggles'),
cockpitPanel: popover,
groups: [
['hud', $('hud-toggle').closest('.pp-toggle-group')],
['detection', $('detection-toggle').closest('.pp-toggle-group')],
['parameters', $('param-slider-panel')],
['models3d', $('models3d-toggle').closest('.pp-toggle-group')],
],
layout: () => layouts.push(popover.contains($('hud-toggle'))),
publish: (slots) => store.publishCockpitPortal(slots),
afterRender: (callback) => {
void nextTick(callback);
},
});
t.after(() => portal.destroy());
$('hud-toggle').focus();
portal.setActive(true);
// Superseded before the flush: one layout, for the last state.
portal.setActive(false);
portal.setActive(true);
await nextTick();
assert.deepEqual(layouts, [true]);
while (frames.length) frames.shift()();
assert.equal(document.activeElement, $('hud-toggle'));
// A stopped portal lays out nothing.
portal.stop();
portal.setActive(false);
await nextTick();
assert.deepEqual(layouts, [true]);
});
-12
View File
@@ -97,8 +97,6 @@ export class PanelLayoutController {
this._weatherPanel = document.getElementById('weather-panel');
this._recentImageryPanel = document.getElementById('recent-imagery-panel');
this._streetLevelPanel = document.getElementById('street-level-panel');
this._sliderPanel = document.getElementById('param-slider-panel');
this._detectionBtn = document.getElementById('detection-toggle');
}
_scheduleAdaptivePanelLayout({ settle = false } = {}) {
if (this.destroyed) return;
@@ -199,16 +197,6 @@ export class PanelLayoutController {
if (!panel) continue;
stack.insertBefore(panel, globalContextPanel);
}
if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top');
this._sliderPanel.style.removeProperty('right');
this._sliderPanel.style.removeProperty('bottom');
this._sliderPanel.style.removeProperty('left');
this._sliderPanel.style.removeProperty('max-height');
const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group');
if (detectionGroup) detectionGroup.after(this._sliderPanel);
else this._ppToggles.append(this._sliderPanel);
}
if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout();
+14
View File
@@ -83,6 +83,18 @@ export const useDisplayStore = defineStore('gev-display', () => {
parameters.value = next && markRaw(next);
}
/**
* The Cockpit's Display popover slots while Cockpit shows the Display
* controls, `{hud, detection, parameters, models3d}` (elements); null while
* they stay in the Display panel. The panel teleports its groups there.
*/
const cockpitPortal = shallowRef(null);
/** @param {?object} next The slots, or null to bring the groups home. */
function publishCockpitPortal(next) {
cockpitPortal.value = next && markRaw(next);
}
/** The phone shell's tab bar; null until the shell first presents it. */
const phoneShell = shallowRef(null);
@@ -104,6 +116,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
publishStyle,
parameters,
publishParameters,
cockpitPortal,
publishCockpitPortal,
phoneShell,
publishPhoneShell,
mapSource,
@@ -6,14 +6,15 @@ import { vClassToggle } from '../../rail/classToggle.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
// presents which popover is open and the compact Radio controls; until it
// does, both are closed and the controls show their initial state. The
// controls call RadioControls through the store. The Display popover's slots
// are static: the Cockpit display portal moves the real Display controls into
// them.
// are static: the Display panel teleports its real controls into them while
// the Cockpit display portal publishes them.
const store = useRadioStore();
const { act } = store;
const { view: radio, utility } = storeToRefs(store);
@@ -22,6 +23,9 @@ const cockpit = computed(() => radio.value?.cockpit ?? null);
// The Cockpit's layout pass measures which controls fit; until it runs, the
// controls carry no aria-hidden.
const { utilityLayout: layout } = storeToRefs(useCockpitStore());
// The Display panel's groups sit in the popover's slots while the Cockpit
// display portal publishes them.
const { cockpitPortal } = storeToRefs(useDisplayStore());
const ariaHidden = (expanded) =>
layout.value
? String(
@@ -50,7 +54,7 @@ const radioLabel = computed(() =>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel">{{ open.display ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" v-class-toggle="{ 'uses-shared-display-controls': !!cockpitPortal }" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
+10 -1
View File
@@ -19,7 +19,7 @@ import { StyleParameterRows } from '../../styleParameters.js';
// the draw tool its controls, and each publishes them; until they do, the
// panel shows its initial markup. GeoImport renders the file import.
const display = useDisplayStore();
const { view, models3d, draw, parameters } = storeToRefs(display);
const { view, models3d, draw, parameters, cockpitPortal } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args);
const pct = (value) => `${value}%`;
@@ -64,6 +64,8 @@ const allocationOptions = [
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways start-collapsed />
</div>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<!-- Cockpit shows these four groups in its Display popover: each teleports to the slot the Cockpit display portal publishes. While disabled a Teleport stays in place; its 'body' target only keeps server rendering from dropping it. -->
<Teleport :to="cockpitPortal?.hud ?? 'body'" :disabled="!cockpitPortal?.hud">
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" @click="act('toggleHud')" title="Intelligence HUD (H)" data-i18n-title>
<span class="pp-icon">&#x25CE;</span>
@@ -108,6 +110,8 @@ const allocationOptions = [
<span class="pp-slider-value" id="cyber-sonar-sector-value">{{ `${sonar?.sector ?? 24}°` }}</span>
</div>
</div>
</Teleport>
<Teleport :to="cockpitPortal?.detection ?? 'body'" :disabled="!cockpitPortal?.detection">
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" :pressed="detectButton?.pressed ?? false" id="detection-toggle" @click="act('cycleDetection')" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" data-i18n-title>
<span class="pp-icon">&#x25A3;</span>
@@ -133,7 +137,9 @@ const allocationOptions = [
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div>
</div>
</Teleport>
<!-- The one shared preset-parameter surface: the active style's parameters, as VisualSettings publishes them. -->
<Teleport :to="cockpitPortal?.parameters ?? 'body'" :disabled="!cockpitPortal?.parameters">
<div id="param-slider-panel" v-panel-state="'param-slider-panel'" v-class-toggle="{ active: !!parameters }">
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
@@ -142,6 +148,8 @@ const allocationOptions = [
</div>
<div id="param-sliders"><StyleParameterRows v-if="parameters" :key="parameters.style" :rows="parameters.rows" :on-input="(name, value) => act('setStyleParameter', name, value)" /></div>
</div>
</Teleport>
<Teleport :to="cockpitPortal?.models3d ?? 'body'" :disabled="!cockpitPortal?.models3d">
<div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
button ships lit and the mode row ships open — the same markup-carries-the-default
@@ -156,6 +164,7 @@ const allocationOptions = [
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('3D model coverage')" :options="modelsOptions" :model-value="modelsMode" @update:model-value="act('setModelsMode', $event)" />
</div>
</div>
</Teleport>
<div class="pp-toggle-group">
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>