feat(ui): render the Space Missions roster and selected mission from the launches layer's published view

The launches layer no longer builds the roster or the selected mission
panel as HTML strings in #space-mission-panel-host. It presents both as a
plain view and publishes it through subscribePanel(); MissionRoster and
MissionDetail render it from the gev-context store and call the layer's
panel commands. Replay controls are presented the same way.

The roster keeps keyboard focus on the same mission across refreshes, or
continues beyond the list when the mission departs, and the row under the
pointer takes the preview back on its next movement. Lines a null view
rebuilds are translated through t(). Disposing the shell clears the
Contacts and Space Missions views.
This commit is contained in:
Sameh Khamis committed 2026-10-31 10:48:20 -07:00
1 parent 1e4df63efe
commit 66a60ff67e
23 files changed
+1131 -605

No files matched your search

+20 -3
View File
@@ -973,8 +973,12 @@ export class StyleManager extends ShellFacade {
}
_initGlobalContextPanel() {
const { radioLayer, militaryInstallationsLayer, militaryAwarenessLayer } =
this.services;
const {
radioLayer,
militaryInstallationsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer,
} = this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({
elements: {
@@ -1008,6 +1012,13 @@ export class StyleManager extends ShellFacade {
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
(view) => contextStore.publishAwareness(view),
);
// The Space Missions layer publishes its roster and selected mission.
contextStore.connectMissions(rocketLaunchesLayer);
this._missionsPanelUnsubscribe?.();
contextStore.publishMissions(null);
this._missionsPanelUnsubscribe = rocketLaunchesLayer?.subscribePanel?.(
(view) => contextStore.publishMissions(view),
);
}
/** Wire the independent Radio companion controls. */
@@ -1768,7 +1779,13 @@ export class StyleManager extends ShellFacade {
this._localSdrControls?.destroy();
this._awarenessPanelUnsubscribe?.();
this._awarenessPanelUnsubscribe = null;
useContextStore(applicationPinia()).connectAwareness(null);
this._missionsPanelUnsubscribe?.();
this._missionsPanelUnsubscribe = null;
const contextStore = useContextStore(applicationPinia());
contextStore.connectAwareness(null);
contextStore.publishAwareness(null);
contextStore.connectMissions(null);
contextStore.publishMissions(null);
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
+5 -1
View File
@@ -77,7 +77,11 @@ test('before the layer publishes, the panel shows its initial markup', (t) => {
standby.querySelector('strong').textContent,
'CONTACTS CONTEXT OFF',
);
assert.equal(standby.querySelector('strong').hasAttribute('data-i18n'), true);
// Rendered through t(), so a later null view is translated too.
assert.equal(
standby.querySelector('strong').hasAttribute('data-i18n'),
false,
);
});
test('a standby view replaces the initial copy', async (t) => {
+378
View File
@@ -0,0 +1,378 @@
// src/ui/missionPanels.test.mjs — the Space Missions roster and selected
// mission render what the launches layer publishes (`subscribePanel()`),
// over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, h, nextTick } from 'vue';
import { useContextStore } from './stores/context.js';
import MissionDetail from './views/panels/MissionDetail.vue';
import MissionRoster from './views/panels/MissionRoster.vue';
function fixture(t) {
document.body.replaceChildren();
const scroller = document.body.appendChild(document.createElement('div'));
scroller.className = 'global-context-panel-inner';
const host = scroller.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp({
render: () => [h(MissionRoster), h(MissionDetail)],
}).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useContextStore(pinia);
const calls = [];
const record =
(name) =>
(...args) =>
calls.push([name, ...args]);
store.connectMissions({
selectMissionAt: record('select'),
selectPreviousMission: record('previous'),
selectNextMission: record('next'),
deselectMission: record('deselect'),
focusSelectedLaunchSite: record('focus'),
startSelectedReplay: record('replay'),
toggleReplayPause: record('toggle'),
cancelReplay: record('cancel'),
setReplaySpeed: record('speed'),
rosterPointerEnter: record('pointer-enter'),
rosterPointerLeave: record('pointer-leave'),
rosterFocus: record('focus-row'),
rosterBlur: record('blur-row'),
});
const $ = (selector) => document.querySelector(selector);
const publish = async (view) => {
store.publishMissions(view);
await nextTick();
await nextTick();
};
return { $, publish, calls, scroller };
}
const entry = (id, index, label = id.toUpperCase()) => ({
index,
id,
color: 'rgb(1, 2, 3)',
label,
ariaLabel: `Select ${label}`,
provider: 'SpaceX',
date: '2026-11-01',
});
const roster = (entries) => ({
count: `${entries.length} / 30D`,
entries,
empty: 'NONE IN WINDOW',
});
const replay = (extra = {}) => ({
available: true,
active: false,
paused: false,
toggleText: 'Ⅱ',
toggleLabel: 'Pause replay',
transportLabel: null,
speed: '1',
speedText: '1×',
speedProgress: '20%',
...extra,
});
const mission = (extra = {}) => ({
scrollRequest: 1,
title: 'STARLINK 12-3',
provider: 'SpaceX',
status: 'Go for Launch',
site: null,
time: '2026-11-01T10:00:00Z',
orbit: 'LEO',
ascent: 'ESTIMATED',
distance: '410 KM',
speed: '7.66 KM/S',
speedTitle: '27,576 km/h',
payloads: [],
payloadsMore: null,
stages: [],
index: '2 / 5',
previousDisabled: false,
nextDisabled: true,
replay: replay(),
...extra,
});
test('before the layer presents, the roster loads and no mission shows', (t) => {
const { $ } = fixture(t);
assert.equal($('[data-mission-roster-count]').textContent, '—');
assert.equal(
$('.space-mission-roster-empty').textContent,
'LOADING 30-DAY MISSION INDEX',
);
assert.equal($('#space-mission-panel').hidden, true);
assert.equal($('[data-mission-replay-speed-output]').textContent, '1×');
assert.equal($('[data-mission-replay-speed]').getAttribute('value'), '1');
});
test('the roster lists missions and drives selection and preview', async (t) => {
const { $, publish, calls } = fixture(t);
await publish({
roster: roster([entry('a', 0), entry('b', 3)]),
mission: null,
});
assert.equal($('[data-mission-roster-count]').textContent, '2 / 30D');
const [a, b] = document.querySelectorAll('.space-mission-roster-item');
assert.equal(b.dataset.missionRosterIndex, '3');
assert.equal(b.dataset.missionRosterId, 'b');
assert.equal(b.getAttribute('aria-label'), 'Select B');
assert.equal(b.querySelector('small').textContent, 'SpaceX · 2026-11-01');
assert.equal(
b
.querySelector('.space-mission-roster-marker')
.style.getPropertyValue('--mission-roster-color'),
'rgb(1, 2, 3)',
);
b.click();
b.dispatchEvent(new MouseEvent('mouseenter'));
b.dispatchEvent(new MouseEvent('mouseleave'));
a.dispatchEvent(new FocusEvent('blur', { relatedTarget: b }));
a.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
assert.deepEqual(calls, [
['select', 3],
['pointer-enter', 3],
['pointer-leave'],
['blur-row', 3],
['blur-row', null],
]);
await publish({ roster: roster([]), mission: null });
assert.equal($('.space-mission-roster-empty').textContent, 'NONE IN WINDOW');
assert.equal(
document.querySelectorAll('.space-mission-roster-item').length,
0,
);
});
test('a roster refresh keeps keyboard focus and its preview on the same mission', async (t) => {
const { publish, calls } = fixture(t);
await publish({
roster: roster([entry('a', 0), entry('b', 1), entry('c', 2)]),
mission: null,
});
const list = document.querySelector('[data-mission-roster-list]');
// Browsers drop focus from a node that is moved; happy-dom keeps it, so
// any move drops it here.
const insertBefore = list.insertBefore;
list.insertBefore = function (node, ref) {
if (list.contains(document.activeElement)) document.activeElement.blur();
return insertBefore.call(this, node, ref);
};
document.querySelector('[data-mission-roster-id="b"]').focus();
calls.length = 0;
await publish({
roster: roster([entry('b', 0), entry('c', 1), entry('a', 2)]),
mission: null,
});
const focused = document.activeElement;
assert.equal(focused?.dataset?.missionRosterId, 'b');
assert.deepEqual(calls.at(-1), ['focus-row', 0]);
});
test('a departed focused mission continues focus beyond the list', async (t) => {
const { publish } = fixture(t);
await publish({
roster: roster([entry('a', 0), entry('b', 1)]),
mission: null,
});
document.querySelector('[data-mission-roster-id="b"]').focus();
await publish({ roster: roster([entry('a', 0)]), mission: null });
assert.equal(
document.activeElement ===
document.querySelector('[data-mission-roster-focus-continuation]'),
true,
);
});
test('the selected mission shows its details and hides absent rows', async (t) => {
const { $, publish } = fixture(t);
await publish({ roster: null, mission: mission() });
const panel = $('#space-mission-panel');
assert.equal(panel.hidden, false);
assert.equal($('[data-mission-title]').textContent, 'STARLINK 12-3');
assert.equal($('[data-mission-status]').textContent, 'Go for Launch');
assert.equal(
$('[data-mission-site]').closest('[data-mission-field]').hidden,
true,
);
assert.equal(
$('[data-mission-orbit]').closest('[data-mission-field]').hidden,
false,
);
assert.equal($('[data-mission-speed]').title, '27,576 km/h');
assert.equal($('[data-mission-index]').textContent, '2 / 5');
assert.equal($('[data-mission-prev]').disabled, false);
assert.equal($('[data-mission-next]').disabled, true);
assert.equal(
$('[data-mission-payloads]').textContent,
'PAYLOAD DATA UNAVAILABLE',
);
assert.equal($('[data-mission-stages-section]').hidden, true);
await publish({
roster: null,
mission: mission({
distance: null,
payloads: [
{
name: 'Sat ×2',
detail: 'Maker · 500 KG',
type: 'Comms',
destination: 'GTO',
},
],
payloadsMore: '+1 additional payload records',
stages: [
{
name: 'B1080',
detail: 'FLIGHT 9',
status: 'Landed',
destination: 'ASOG',
position: '28.40, -77.10 · DRONESHIP',
},
],
}),
});
assert.equal(
$('[data-mission-distance]').closest('[data-mission-field]').hidden,
true,
);
const rows = [...$('[data-mission-payloads]').querySelectorAll('tr')].map(
(row) => row.textContent,
);
assert.deepEqual(rows, [
'Sat ×2Maker · 500 KGCommsGTO',
'+1 additional payload records',
]);
assert.equal($('[data-mission-stages-section]').hidden, false);
assert.equal(
$('[data-mission-stages]').textContent,
'B1080FLIGHT 9LandedASOG28.40, -77.10 · DRONESHIP',
);
await publish({ roster: null, mission: null });
assert.equal(panel.hidden, true);
});
test('the replay controls follow the layer and call its commands', async (t) => {
const { $, publish, calls } = fixture(t);
await publish({
roster: null,
mission: mission({ replay: replay({ available: false }) }),
});
const start = $('[data-mission-replay]');
assert.equal(start.hidden, true);
assert.equal(start.disabled, true);
assert.equal($('.mission-replay-speed-control').hidden, true);
await publish({ roster: null, mission: mission() });
assert.equal(start.hidden, false);
assert.equal(start.getAttribute('aria-pressed'), 'false');
assert.equal($('[data-mission-replay-transport]').hidden, true);
start.click();
await publish({
roster: null,
mission: mission({
nextDisabled: false,
replay: replay({
active: true,
paused: true,
toggleText: '▶',
toggleLabel: 'Resume replay',
transportLabel: 'Liftoff, paused',
speed: '2.5',
speedText: '2.50×',
speedProgress: '60%',
}),
}),
});
const transport = $('[data-mission-replay-transport]');
assert.equal(start.hidden, true);
assert.equal(transport.hidden, false);
assert.equal(transport.classList.contains('is-paused'), true);
assert.equal(transport.getAttribute('aria-label'), 'Liftoff, paused');
const toggle = $('[data-mission-replay-toggle]');
assert.equal(toggle.textContent, '▶');
assert.equal(toggle.getAttribute('aria-label'), 'Resume replay');
const speed = $('[data-mission-replay-speed]');
assert.equal(speed.value, '2.5');
assert.equal(speed.style.getPropertyValue('--replay-speed-progress'), '60%');
assert.equal($('[data-mission-replay-speed-output]').textContent, '2.50×');
toggle.click();
$('[data-mission-replay-cancel]').click();
speed.value = '3';
speed.dispatchEvent(new Event('input'));
$('[data-mission-focus]').click();
$('[data-mission-prev]').click();
$('[data-mission-next]').click();
$('[data-mission-close]').click();
$('[data-mission-show-all]').click();
assert.deepEqual(calls, [
['replay'],
['toggle'],
['cancel'],
['speed', '3'],
['focus'],
['previous'],
['next'],
['deselect'],
['deselect'],
]);
});
test('each newly presented selection returns the Context panel to the top', async (t) => {
const { publish, scroller } = fixture(t);
scroller.scrollTop = 120;
await publish({ roster: null, mission: mission({ scrollRequest: 4 }) });
assert.equal(scroller.scrollTop, 0);
scroller.scrollTop = 80;
await publish({
roster: null,
mission: mission({ scrollRequest: 4, distance: '412 KM' }),
});
assert.equal(scroller.scrollTop, 80);
await publish({ roster: null, mission: mission({ scrollRequest: 5 }) });
assert.equal(scroller.scrollTop, 0);
});
test('a roster refresh lets the row under the pointer take the preview back on its next movement', async (t) => {
const { publish, calls } = fixture(t);
await publish({
roster: roster([entry('a', 0), entry('b', 1)]),
mission: null,
});
const row = document.querySelector('[data-mission-roster-id="a"]');
row.dispatchEvent(new MouseEvent('mouseenter'));
row.dispatchEvent(new MouseEvent('mousemove'));
await publish({
roster: roster([entry('a', 0, 'RENAMED'), entry('b', 1)]),
mission: null,
});
assert.equal(
document.querySelector('[data-mission-roster-id="a"]') === row,
true,
);
row.dispatchEvent(new MouseEvent('mousemove'));
row.dispatchEvent(new MouseEvent('mousemove'));
assert.deepEqual(calls, [
['pointer-enter', 0],
['pointer-enter', 0],
]);
});
test('a layer that stops presenting returns the roster to loading and hides the mission', async (t) => {
const { $, publish } = fixture(t);
await publish({ roster: roster([entry('a', 0)]), mission: mission() });
await publish({ roster: null, mission: null });
assert.equal($('[data-mission-roster-count]').textContent, '—');
assert.equal(
$('.space-mission-roster-empty').textContent,
'LOADING 30-DAY MISSION INDEX',
);
assert.equal($('#space-mission-panel').hidden, true);
});
+31 -2
View File
@@ -5,8 +5,9 @@ import { markRaw, shallowRef } from 'vue';
* The Context panel's mode switch as its view renders it. ContextControls
* presents the selected mode, the transition busy state and the nearby
* sites search (`_presentContext()`) and publishes them here; it
* keeps its listeners and transactions. The Contacts layer publishes its
* panel beside it, and the app connects that layer's navigation.
* keeps its listeners and transactions. The Contacts and Space Missions
* layers publish their panels beside it, and the app connects each layer's
* commands.
*/
export const useContextStore = defineStore('gev-context', () => {
/** The presented mode switch; null until the controls first render. */
@@ -36,6 +37,29 @@ export const useContextStore = defineStore('gev-context', () => {
awarenessLayer.value = layer && markRaw(layer);
}
/** The Space Missions roster; null until the layer first presents it. */
const missionRoster = shallowRef(null);
/** The selected space mission; null while none is selected. */
const mission = shallowRef(null);
/**
* @param {?{roster: ?object, mission: ?object}} next The layer's view. The
* layer keeps an unchanged roster's object, which re-renders nothing.
*/
function publishMissions(next) {
missionRoster.value = next?.roster ? markRaw(next.roster) : null;
mission.value = next?.mission ? markRaw(next.mission) : null;
}
/** The Space Missions layer whose panel commands the components call. */
const missionsLayer = shallowRef(null);
/** @param {?object} layer The Space Missions layer, or null to disconnect. */
function connectMissions(layer) {
missionsLayer.value = layer && markRaw(layer);
}
return {
view,
publish,
@@ -43,5 +67,10 @@ export const useContextStore = defineStore('gev-context', () => {
publishAwareness,
awarenessLayer,
connectAwareness,
missionRoster,
mission,
publishMissions,
missionsLayer,
connectMissions,
};
});
+4 -13
View File
@@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia';
import { vClassToggle } from '../rail/classToggle.js';
import { useContextStore } from '../stores/context.js';
import AwarenessPanel from './panels/AwarenessPanel.vue';
import MissionDetail from './panels/MissionDetail.vue';
import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and
@@ -73,19 +75,8 @@ const { view: ctx } = storeToRefs(useContextStore());
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div class="space-mission-roster-header">
<div>
<strong data-i18n>AVAILABLE MISSIONS</strong>
<span data-i18n>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>—</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty" data-i18n>LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
<MissionRoster />
<MissionDetail />
</div>
</section>
<RadioPanel />
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { onBeforeUpdate, onUpdated, ref } from 'vue';
import { t } from '../../../i18n/index.js';
import { useContextStore } from '../../stores/context.js';
import AwarenessNames from './AwarenessNames.vue';
@@ -42,8 +43,8 @@ onUpdated(() => {
<template>
<aside id="military-awareness-panel" ref="root">
<div v-if="!view" class="military-awareness-standby">
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
<strong>{{ t('CONTACTS CONTEXT OFF') }}</strong>
<span>{{ t('SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY') }}</span>
</div>
<div v-else-if="view.kind === 'standby'" class="military-awareness-standby">
<strong>{{ view.title }}</strong>
+83
View File
@@ -0,0 +1,83 @@
<script setup>
import { storeToRefs } from 'pinia';
import { nextTick, ref, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { useContextStore } from '../../stores/context.js';
// The selected space mission inside Context. The launches layer presents it
// and hides the panel while none is selected; the roster beside it hides
// while it shows. Buttons call the layer's panel commands.
const { mission: m, missionsLayer: layer } = storeToRefs(useContextStore());
const root = ref(null);
// Each newly presented selection returns the Context panel to the top.
const SCROLLER = ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner";
watch(
() => m.value?.scrollRequest,
async (request) => {
if (request === undefined) return;
await nextTick();
const scroller = root.value?.closest(SCROLLER);
if (scroller) scroller.scrollTop = 0;
},
);
</script>
<template>
<aside id="space-mission-panel" ref="root" class="context-space-mission-detail" :aria-label="t('Selected Space Mission')" :hidden="!m">
<div class="space-mission-view-header"><span>{{ t('SELECTED SPACE MISSION') }}</span><button type="button" data-mission-close :title="t('Show all missions')" :aria-label="t('Deselect mission')" @click="layer?.deselectMission()">×</button></div>
<div class="space-mission-detail">
<strong data-mission-title>{{ m ? m.title : t('MISSION') }}</strong>
<span data-mission-field data-mission-provider :hidden="m && !m.provider">{{ m?.provider }}</span>
<span data-mission-field :hidden="m && !m.status">{{ t('STATUS · ') }}<b data-mission-status>{{ m?.status }}</b></span>
<span data-mission-field :hidden="m && !m.site">{{ t('LAUNCH SITE · ') }}<b data-mission-site>{{ m?.site }}</b></span>
<span data-mission-field :hidden="m && !m.time">{{ t('LAUNCH TIME · ') }}<b data-mission-time>{{ m?.time }}</b></span>
<span data-mission-field :hidden="m && !m.orbit">{{ t('ORBIT · ') }}<b data-mission-orbit>{{ m?.orbit }}</b></span>
<span>{{ t('ASCENT PATH · ') }}<b data-mission-ascent-source>{{ m?.ascent }}</b></span>
<span data-mission-field :hidden="m && !m.distance">{{ t('CURRENT DISTANCE FROM EARTH · ') }}<b data-mission-distance>{{ m?.distance }}</b></span>
<span data-mission-field :hidden="m && !m.speed">{{ t('SATELLITE SPEED · ') }}<b data-mission-speed :title="m?.speedTitle">{{ m?.speed }}</b></span>
</div>
<section class="mission-data-section">
<h4>{{ t('PAYLOAD') }}</h4>
<div class="mission-table-scroll">
<table class="mission-data-table">
<thead><tr><th>{{ t('NAME') }}</th><th>{{ t('TYPE') }}</th><th>{{ t('DESTINATION') }}</th></tr></thead>
<tbody data-mission-payloads>
<template v-if="m"><tr v-if="!m.payloads.length"><td colspan="3" class="mission-table-empty">{{ t('PAYLOAD DATA UNAVAILABLE') }}</td></tr><tr v-for="(payload, index) in m.payloads" :key="index"><td>{{ payload.name }}<small v-if="payload.detail">{{ payload.detail }}</small></td><td>{{ payload.type }}</td><td>{{ payload.destination }}</td></tr><tr v-if="m.payloadsMore"><td colspan="3" class="mission-table-empty">{{ m.payloadsMore }}</td></tr></template>
</tbody>
</table>
</div>
</section>
<section class="mission-data-section" data-mission-stages-section :hidden="m && !m.stages.length">
<h4>{{ t('STAGE / RE-ENTRY / RECOVERY') }}</h4>
<div class="mission-table-scroll">
<table class="mission-data-table">
<thead><tr><th>{{ t('STAGE') }}</th><th>{{ t('STATUS') }}</th><th>{{ t('FINAL POSITION') }}</th></tr></thead>
<tbody data-mission-stages>
<template v-if="m"><tr v-if="!m.stages.length"><td colspan="3" class="mission-table-empty">{{ t('NO STAGE RE-ENTRY / RECOVERY DATA') }}</td></tr><tr v-for="(stage, index) in m.stages" :key="index"><td>{{ stage.name }}<small v-if="stage.detail">{{ stage.detail }}</small></td><td>{{ stage.status }}</td><td>{{ stage.destination }}<small>{{ stage.position }}</small></td></tr></template>
</tbody>
</table>
</div>
</section>
<div class="mission-replay-speed-control" :hidden="m && !m.replay.available">
<div class="mission-replay-speed-header"><label for="space-mission-replay-speed">{{ t('REPLAY SPEED') }}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>{{ m ? m.replay.speedText : '1×' }}</output></div>
<input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed :aria-label="t('Replay speed multiplier')" v-field-value="m?.replay.speed" :style="m && { '--replay-speed-progress': m.replay.speedProgress }" @input="layer?.setReplaySpeed($event.currentTarget.value)">
<div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div>
</div>
<div class="mission-action-row">
<button type="button" class="mission-focus-button" data-mission-focus @click="layer?.focusSelectedLaunchSite()">{{ t('FOCUS') }}</button>
<button type="button" class="mission-replay-button" data-mission-replay :aria-pressed="String(m?.replay.active ?? false)" :title="t('Replay the estimated ascent with a following camera')" :hidden="m && (m.replay.active || !m.replay.available)" :disabled="m && !m.replay.available" @click="layer?.startSelectedReplay()">{{ t('REPLAY ASCENT') }}</button>
<div class="mission-replay-transport" :class="{ 'is-paused': m?.replay.paused }" data-mission-replay-transport :hidden="!m?.replay.active" :aria-label="m?.replay.transportLabel">
<button type="button" data-mission-replay-toggle :title="m ? m.replay.toggleLabel : t('Pause replay')" :aria-label="m ? m.replay.toggleLabel : t('Pause replay')" :disabled="m && !m.replay.active" @click="layer?.toggleReplayPause()">{{ m ? m.replay.toggleText : 'Ⅱ' }}</button>
<button type="button" class="cancel" data-mission-replay-cancel :title="t('Cancel replay')" :aria-label="t('Cancel replay')" @click="layer?.cancelReplay()"><span aria-hidden="true">×</span></button>
</div>
</div>
<div class="space-mission-nav">
<button type="button" class="mission-nav-button" data-mission-prev :title="t('Previous mission')" :disabled="m?.previousDisabled" @click="layer?.selectPreviousMission()"><span aria-hidden="true">‹</span> {{ t('PREV') }}</button>
<span class="mission-nav-index" data-mission-index>{{ m ? m.index : '—' }}</span>
<button type="button" class="mission-nav-button" data-mission-next :title="t('Next mission')" :disabled="m?.nextDisabled" @click="layer?.selectNextMission()">{{ t('NEXT') }} <span aria-hidden="true">›</span></button>
</div>
<button type="button" class="panel-layer-toggle" data-mission-show-all @click="layer?.deselectMission()">{{ t('SHOW ALL / DESELECT') }}</button>
</aside>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup>
import { storeToRefs } from 'pinia';
import { onBeforeUpdate, onUpdated, ref } from 'vue';
import { t } from '../../../i18n/index.js';
import { useContextStore } from '../../stores/context.js';
// The Space Missions roster. The launches layer presents it; until it does,
// the list shows its loading line. Hover and keyboard focus drive the
// layer's preview, and a click selects the mission.
const { missionRoster: roster, missionsLayer: layer } = storeToRefs(useContextStore());
const root = ref(null);
const CONTINUATION = '[data-mission-roster-focus-continuation]';
const rosterIndex = (el) => {
const item = el?.closest?.('[data-mission-roster-index]');
return item && root.value?.contains(item) ? Number(item.dataset.missionRosterIndex) : null;
};
// A changed roster resets the layer's preview. The old list rebuilt its
// rows, so the next pointer movement entered the row under the pointer; a
// row Vue keeps re-enters on its next movement the same way.
let pointerStale = false;
function move(index) {
if (!pointerStale) return;
pointerStale = false;
layer.value?.rosterPointerEnter(index);
}
function enter(index) {
pointerStale = false;
layer.value?.rosterPointerEnter(index);
}
function blur(event) {
layer.value?.rosterBlur(rosterIndex(event.relatedTarget));
}
// A refresh keeps keyboard focus on the same mission by identity: browsers
// drop focus from a node Vue moves or removes, so it is found again by id,
// or continues beyond the list when the mission departed. A changed roster
// resets the layer's preview, so a mission still focused takes it back.
let focusedId = null;
onBeforeUpdate(() => {
const active = document.activeElement;
focusedId = root.value?.contains(active) ? active.closest?.('[data-mission-roster-id]')?.dataset.missionRosterId ?? null : null;
});
onUpdated(() => {
pointerStale = true;
const id = focusedId;
focusedId = null;
const list = root.value;
if (!id || !list) return;
const active = document.activeElement;
if (!list.contains(active)) {
const retained = [...list.querySelectorAll('[data-mission-roster-id]')].find((item) => item.dataset.missionRosterId === id);
(retained || list.querySelector(CONTINUATION))?.focus({ preventScroll: true });
}
const index = rosterIndex(document.activeElement);
if (index !== null) layer.value?.rosterFocus(index);
});
</script>
<template>
<div id="space-mission-roster" ref="root" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div class="space-mission-roster-header">
<div>
<strong data-i18n>AVAILABLE MISSIONS</strong>
<span data-i18n>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div v-if="!roster" class="space-mission-roster-empty">{{ t('LOADING 30-DAY MISSION INDEX') }}</div>
<div v-else-if="!roster.entries.length" class="space-mission-roster-empty">{{ roster.empty }}</div>
<button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</template>