fix: preserve keyboard focus and separate Space activation from voice

Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls.

Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171.
This commit is contained in:
manjunath22466 authored and Sameh Khamis committed 2026-09-10 23:48:12 -07:00
1 parent c82fdd7478
commit 5228ab9e40
22 files changed
+3500 -157

No files matched your search

+146 -24
View File
@@ -61,6 +61,7 @@ let _missionPanel = null;
let _missionRoster = null;
let _missionRosterHoverTimer = null;
let _hoveredRosterLaunchId = null;
let _missionRosterPreviewOwnership = null;
let _missionHoverReticleImage = null;
let _replayVehicleOverlay = null;
let _replayVehicleOverlayText = '';
@@ -786,6 +787,115 @@ export function missionHoverPreviewRange(cameraHeight) {
);
}
/**
* Coordinate pointer and keyboard ownership of the temporary roster preview.
* The most recent input owns the preview until it leaves, then any remaining
* input resumes ownership. This lets keyboard focus take over from a pointer
* resting on another row without losing that pointer preview on blur.
* @param {{preview: (index: number) => void, clear: () => void}} handlers
* @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void,
* reset: () => void}}
*/
export function createMissionRosterPreviewOwnership({ preview, clear }) {
let pointerIndex = null;
let focusIndex = null;
let latestOwner = null;
let activeIndex = null;
const sync = () => {
const nextIndex = latestOwner === 'pointer'
? pointerIndex ?? focusIndex
: focusIndex ?? pointerIndex;
if (nextIndex === activeIndex) return;
activeIndex = nextIndex;
if (Number.isInteger(nextIndex)) preview(nextIndex);
else clear();
};
return {
pointerEnter(index) {
pointerIndex = index;
latestOwner = 'pointer';
sync();
},
pointerLeave() {
pointerIndex = null;
if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus';
sync();
},
focus(index) {
focusIndex = index;
latestOwner = 'focus';
sync();
},
blur(nextIndex = null) {
focusIndex = Number.isInteger(nextIndex) ? nextIndex : null;
if (latestOwner === 'focus') latestOwner = focusIndex === null
? (pointerIndex === null ? null : 'pointer')
: 'focus';
sync();
},
reset() {
pointerIndex = null;
focusIndex = null;
latestOwner = null;
activeIndex = null;
clear();
},
};
}
/**
* Bind native keyboard focus events for one rendered roster row.
* @param {Element} button Mission roster button.
* @param {number} index Source-array mission index.
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
* @param {Element} roster Roster root used to reject focus targets outside the list.
*/
export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) {
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
button.addEventListener('mouseleave', () => ownership.pointerLeave());
button.addEventListener('focus', () => ownership.focus(index));
button.addEventListener('blur', (event) => {
const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]');
const nextIndex = nextButton && roster.contains(nextButton)
? Number(nextButton.dataset.missionRosterIndex)
: null;
ownership.blur(nextIndex);
});
}
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) {
const button = activeElement?.closest?.('[data-mission-roster-id]');
if (!button || !list?.contains(button)) return null;
const launchId = button.dataset.missionRosterId;
return launchId ? { launchId } : null;
}
/** Restore focus by mission identity, or continue after the list if it departed. */
export function restoreMissionRosterFocus(list, snapshot, continuation) {
if (!snapshot?.launchId) return 'none';
const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || [])
.find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId);
if (button) {
button.focus({ preventScroll: true });
return 'restored';
}
if (continuation?.focus) {
continuation.focus({ preventScroll: true });
return 'continued';
}
return 'none';
}
/** Resolve a pending preview against the current refresh, never a stale object. */
export function resolveMissionRosterPreviewLaunch(launches, launchId) {
return (launches || []).find((candidate) => candidate.id === launchId) || null;
}
/**
* Format a mission epoch for compact on-globe replay labels.
* @param {string|Date|null} launchTime ISO-8601 mission time or Date.
@@ -2327,9 +2437,16 @@ function renderMissionRoster() {
const count = _missionRoster.querySelector('[data-mission-roster-count]');
if (count) count.textContent = `${_launches.length} / 30D`;
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
_missionRosterPreviewOwnership?.reset();
const entries = missionRosterEntries(_launches);
if (!entries.length) {
list.innerHTML = '<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
restoreMissionRosterFocus(
list,
focusSnapshot,
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
);
return;
}
list.innerHTML = entries.map(({ launch, index }) => {
@@ -2337,8 +2454,22 @@ function renderMissionRoster() {
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
const label = shortMissionLabel(launch.name, 27).toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
}).join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
const index = Number(button.dataset.missionRosterIndex);
bindMissionRosterItemKeyboardPreview(
button,
index,
_missionRosterPreviewOwnership,
_missionRoster,
);
});
restoreMissionRosterFocus(
list,
focusSnapshot,
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
);
}
function escapeMissionText(value) {
@@ -2384,7 +2515,7 @@ function selectMissionAt(index) {
focusMission(launch);
}
function clearMissionRosterHover() {
function clearMissionRosterPreviewState() {
if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer);
_missionRosterHoverTimer = null;
const changed = _hoveredRosterLaunchId !== null;
@@ -2392,6 +2523,11 @@ function clearMissionRosterHover() {
if (changed) syncMissionOverlayEntries();
}
function clearMissionRosterHover() {
if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset();
else clearMissionRosterPreviewState();
}
function previewMissionFromRoster(launch) {
if (!_viewer || !launch || _selectedLaunchId) return;
const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height);
@@ -2414,7 +2550,9 @@ function scheduleMissionRosterPreview(index) {
syncMissionOverlayEntries();
_missionRosterHoverTimer = setTimeout(() => {
_missionRosterHoverTimer = null;
if (_hoveredRosterLaunchId === launch.id) previewMissionFromRoster(launch);
if (_hoveredRosterLaunchId !== launch.id) return;
const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id);
if (currentLaunch) previewMissionFromRoster(currentLaunch);
}, 140);
}
@@ -2499,6 +2637,10 @@ function createMissionPanel() {
if (!host) return;
_missionRoster = document.getElementById('space-mission-roster');
if (_missionRoster) {
_missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
_missionRoster.onclick = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
@@ -2506,23 +2648,6 @@ function createMissionPanel() {
if (!button) return;
selectMissionAt(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onmouseover = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (!button || button.contains(event.relatedTarget)) return;
scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onfocusin = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (button) scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onmouseleave = clearMissionRosterHover;
_missionRoster.onfocusout = (event) => {
if (!_missionRoster.contains(event.relatedTarget)) clearMissionRosterHover();
};
}
_missionPanel = document.createElement('aside');
_missionPanel.id = 'space-mission-panel';
@@ -3539,11 +3664,8 @@ const rocketLaunchesLayer = {
_missionPanel = null;
if (_missionRoster) {
_missionRoster.onclick = null;
_missionRoster.onmouseover = null;
_missionRoster.onfocusin = null;
_missionRoster.onmouseleave = null;
_missionRoster.onfocusout = null;
}
_missionRosterPreviewOwnership = null;
_missionRoster = null;
_viewer = null;
if (_dataSource) viewer.dataSources.remove(_dataSource, true);