fix(overlay): fix changelog modal keyboard nav and version parsing

- Extract shared modal-focus-guard module reused by changelog and session-help modals
- Only fold the selected changelog entry on Enter/Space; leave close/refresh buttons and nested folds to their own activation
- Stop re-stealing focus after a mouse click already selected an entry
- Parse prerelease and build metadata separately in version headings (e.g. 0.15.0-rc.1+build.2)
- Resolve tray menu test assertions by label instead of index
This commit is contained in:
2026-08-05 02:48:10 -07:00
parent 3a97239ade
commit 4e8abcc25e
8 changed files with 721 additions and 261 deletions
+35 -117
View File
@@ -6,6 +6,7 @@ import {
resolveEntryBadge,
shouldEntryStartExpanded,
} from './changelog-render';
import { createModalFocusGuard } from './modal-focus-guard';
export function createChangelogModal(
ctx: RendererContext,
@@ -15,11 +16,6 @@ export function createChangelogModal(
},
) {
let priorFocus: Element | null = null;
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
let isRecoveringModalFocus = false;
let lastFocusRecoveryAt = 0;
let loadToken = 0;
function getSummaries(): HTMLElement[] {
@@ -28,19 +24,25 @@ export function createChangelogModal(
) as HTMLElement[];
}
function setSelected(index: number): void {
function applySelectionStyles(index: number): HTMLElement[] {
const summaries = getSummaries();
if (summaries.length === 0) return;
const wrapped = index % summaries.length;
const next = wrapped < 0 ? wrapped + summaries.length : wrapped;
ctx.state.changelogSelectedIndex = next;
ctx.state.changelogSelectedIndex = index;
summaries.forEach((summary, idx) => {
summary.classList.toggle('active', idx === next);
summary.tabIndex = idx === next ? 0 : -1;
summary.classList.toggle('active', idx === index);
summary.tabIndex = idx === index ? 0 : -1;
});
const active = summaries[next];
return summaries;
}
function setSelected(index: number): void {
const count = getSummaries().length;
if (count === 0) return;
const wrapped = index % count;
const next = wrapped < 0 ? wrapped + count : wrapped;
// Only the keyboard path moves focus; clicking already focused the summary.
const active = applySelectionStyles(next)[next];
if (!active) return;
active.focus({ preventScroll: true });
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -52,82 +54,13 @@ export function createChangelogModal(
return entry instanceof HTMLDetailsElement ? entry : null;
}
function isChangelogModalFocusTarget(target: EventTarget | null): boolean {
return target instanceof Element && ctx.dom.changelogModal.contains(target);
}
function focusFallbackTarget(): boolean {
if (!ctx.platform.isModalLayer) {
void window.electronAPI.focusMainWindow();
}
const firstSummary = getSummaries().find((summary) => summary.offsetParent !== null);
if (firstSummary) {
firstSummary.focus({ preventScroll: true });
return document.activeElement === firstSummary;
}
if (ctx.dom.changelogClose instanceof HTMLElement) {
ctx.dom.changelogClose.focus({ preventScroll: true });
return document.activeElement === ctx.dom.changelogClose;
}
window.focus();
return false;
}
function enforceModalFocus(): void {
if (!ctx.state.changelogModalOpen) return;
if (isChangelogModalFocusTarget(document.activeElement)) return;
if (isRecoveringModalFocus) return;
const now = Date.now();
if (now - lastFocusRecoveryAt < 120) return;
isRecoveringModalFocus = true;
lastFocusRecoveryAt = now;
focusFallbackTarget();
window.setTimeout(() => {
isRecoveringModalFocus = false;
}, 120);
}
function requestOverlayFocus(): void {
if (!ctx.platform.isModalLayer) {
void window.electronAPI.focusMainWindow();
}
}
function addPointerFocusListener(): void {
if (modalPointerFocusGuard) return;
modalPointerFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.changelogModal.addEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.changelogModal.addEventListener('click', modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.changelogModal.removeEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.changelogModal.removeEventListener('click', modalPointerFocusGuard);
modalPointerFocusGuard = null;
}
function startFocusRecoveryGuards(): void {
if (windowFocusGuard) return;
windowFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
window.addEventListener('blur', windowFocusGuard);
window.addEventListener('focus', windowFocusGuard);
}
function stopFocusRecoveryGuards(): void {
if (!windowFocusGuard) return;
window.removeEventListener('blur', windowFocusGuard);
window.removeEventListener('focus', windowFocusGuard);
windowFocusGuard = null;
}
const focus = createModalFocusGuard({
isOpen: () => ctx.state.changelogModalOpen,
getModalRoot: () => ctx.dom.changelogModal,
getPreferredFocusTargets: () => getSummaries(),
getFallbackFocusTarget: () => ctx.dom.changelogClose,
isModalLayer: ctx.platform.isModalLayer,
});
function renderSnapshot(snapshot: ChangelogSnapshot): void {
ctx.dom.changelogList.innerHTML = '';
@@ -206,22 +139,10 @@ export function createChangelogModal(
window.electronAPI.setIgnoreMouseEvents(false);
}
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
if (!ctx.state.changelogModalOpen) return;
if (!isChangelogModalFocusTarget(event.target)) {
event.preventDefault();
enforceModalFocus();
}
};
document.addEventListener('focusin', focusGuard);
}
addPointerFocusListener();
startFocusRecoveryGuards();
requestOverlayFocus();
focus.attach();
focus.requestOverlayFocus();
window.focus();
enforceModalFocus();
focus.enforceModalFocus();
void load();
}
@@ -239,12 +160,7 @@ export function createChangelogModal(
ctx.dom.overlay.classList.remove('interactive');
}
if (focusGuard) {
document.removeEventListener('focusin', focusGuard);
focusGuard = null;
}
removePointerFocusListener();
stopFocusRecoveryGuards();
focus.detach();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
@@ -295,6 +211,12 @@ export function createChangelogModal(
}
if (key === 'enter' || key === ' ') {
// Only the selected release summary folds from here. The Close/Refresh
// buttons and the nested "Internal changes" fold activate themselves, and
// swallowing Enter/Space would make them unreachable by keyboard.
if (e.target !== summaries[ctx.state.changelogSelectedIndex]) {
return true;
}
e.preventDefault();
const entry = getSelectedEntry();
if (entry) entry.open = !entry.open;
@@ -334,11 +256,7 @@ export function createChangelogModal(
if (!summary) return;
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
if (!Number.isFinite(index)) return;
ctx.state.changelogSelectedIndex = index;
getSummaries().forEach((item, idx) => {
item.classList.toggle('active', idx === index);
item.tabIndex = idx === index ? 0 : -1;
});
applySelectionStyles(index);
});
}