mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
feat(overlay): add in-app changelog modal (#187)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogEntry, ChangelogSnapshot } from '../../types/changelog';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
tokenizeInlineMarkdown,
|
||||
} from './changelog-render';
|
||||
|
||||
type FakeNode = {
|
||||
tagName: string;
|
||||
className: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
children: FakeNode[];
|
||||
textContent: string;
|
||||
};
|
||||
|
||||
function createFakeNode(tagName: string): FakeNode {
|
||||
const node: FakeNode = {
|
||||
tagName: tagName.toLowerCase(),
|
||||
className: '',
|
||||
title: '',
|
||||
open: false,
|
||||
tabIndex: 0,
|
||||
dataset: {},
|
||||
children: [],
|
||||
textContent: '',
|
||||
};
|
||||
return Object.assign(node, {
|
||||
appendChild: (child: FakeNode) => {
|
||||
node.children.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function withFakeDocument<T>(run: () => T): T {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeNode(tagName),
|
||||
createTextNode: (value: string) => ({
|
||||
tagName: '#text',
|
||||
textContent: value,
|
||||
children: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(globalThis, 'document', previous);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(node: FakeNode): FakeNode[] {
|
||||
return [node, ...(node.children ?? []).flatMap((child) => flatten(child as FakeNode))];
|
||||
}
|
||||
|
||||
const ENTRY: ChangelogEntry = {
|
||||
version: '0.19.2',
|
||||
date: '2026-08-04',
|
||||
groupKey: '0.19',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Fixed',
|
||||
items: [
|
||||
{
|
||||
text: '**Overlay:**',
|
||||
children: [
|
||||
{ text: 'Fixed `something`.', children: [] },
|
||||
{ text: 'Fixed another thing.', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Standalone fix.', children: [] },
|
||||
],
|
||||
internal: false,
|
||||
},
|
||||
{
|
||||
heading: 'Internal',
|
||||
items: [{ text: 'Patched deps.', children: [] }],
|
||||
internal: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('inline markdown tokenizer splits code, bold, and links', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('Patched `undici` and **brace**.'), [
|
||||
{ kind: 'text', value: 'Patched ' },
|
||||
{ kind: 'code', value: 'undici' },
|
||||
{ kind: 'text', value: ' and ' },
|
||||
{ kind: 'strong', value: 'brace' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
assert.deepEqual(tokenizeInlineMarkdown('See [docs](https://example.com).'), [
|
||||
{ kind: 'text', value: 'See ' },
|
||||
{ kind: 'link', value: 'docs', href: 'https://example.com' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline markdown tokenizer leaves plain text untouched', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('No markup here'), [
|
||||
{ kind: 'text', value: 'No markup here' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('entry badges mark the installed version and newer releases', () => {
|
||||
assert.equal(resolveEntryBadge('0.19.2', '0.19.2'), 'installed');
|
||||
assert.equal(resolveEntryBadge('0.20.0', '0.19.2'), 'newer');
|
||||
assert.equal(resolveEntryBadge('0.19.1', '0.19.2'), null);
|
||||
});
|
||||
|
||||
test('only entries in the newest major.minor line start expanded', () => {
|
||||
const snapshot = { expandedGroupKey: '0.19' } as Pick<ChangelogSnapshot, 'expandedGroupKey'>;
|
||||
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, snapshot), true);
|
||||
assert.equal(shouldEntryStartExpanded({ ...ENTRY, groupKey: '0.18' }, snapshot), false);
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, { expandedGroupKey: null }), false);
|
||||
});
|
||||
|
||||
test('entry node renders a collapsible section with badge and internal block', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: 'installed', index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.tagName, 'details');
|
||||
assert.equal(node.open, true);
|
||||
assert.equal(node.dataset.changelogVersion, '0.19.2');
|
||||
|
||||
const nodes = flatten(node);
|
||||
const summary = nodes.find((child) => child.className === 'changelog-entry-summary');
|
||||
assert.ok(summary);
|
||||
assert.equal(summary?.dataset.changelogIndex, '0');
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className === 'changelog-entry-version')?.textContent,
|
||||
'v0.19.2',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className?.includes('changelog-entry-badge-installed'))
|
||||
?.textContent,
|
||||
'Installed',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.filter((child) => child.className === 'changelog-internal').length,
|
||||
1,
|
||||
'internal sections stay behind their own fold',
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders sub-bullets as a nested list under their lead bullet', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: null, index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
const lists = flatten(node).filter((child) => child.className?.startsWith('changelog-items'));
|
||||
const topLevel = lists.find((list) => list.className === 'changelog-items');
|
||||
const nested = lists.filter((list) => list.className?.includes('changelog-items-nested'));
|
||||
|
||||
assert.ok(topLevel);
|
||||
assert.equal(topLevel?.children.length, 2, 'lead bullet and standalone fix stay siblings');
|
||||
assert.equal(nested.length, 1, 'children render in exactly one nested list');
|
||||
assert.equal(nested[0]?.children.length, 2);
|
||||
|
||||
// The nested list hangs off its parent <li>, not off the section.
|
||||
const leadItem = topLevel?.children[0];
|
||||
assert.equal(
|
||||
leadItem?.children.some((child) => child.className?.includes('changelog-items-nested')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders collapsed when it is outside the current line', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: false, badge: null, index: 3 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.open, false);
|
||||
assert.equal(
|
||||
flatten(node).some((child) => child.className?.startsWith('changelog-entry-badge')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('source description names the release the changelog came from', () => {
|
||||
const base: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: null,
|
||||
expandedGroupKey: null,
|
||||
source: 'remote',
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
describeChangelogSource({ ...base, releaseTag: 'v0.20.0' }),
|
||||
'Latest release v0.20.0',
|
||||
);
|
||||
assert.equal(describeChangelogSource(base), 'Latest published changelog');
|
||||
assert.equal(describeChangelogSource({ ...base, source: 'bundled' }), 'Bundled changelog');
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
ChangelogEntry,
|
||||
ChangelogItem,
|
||||
ChangelogSection,
|
||||
ChangelogSnapshot,
|
||||
} from '../../types/changelog';
|
||||
import { compareSemverLike } from '../../core/utils/semver-compare';
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
Added: '✦',
|
||||
Changed: '⟲',
|
||||
Fixed: '✔',
|
||||
Docs: '▤',
|
||||
Internal: '⚙',
|
||||
'Breaking Changes': '⚠',
|
||||
Changes: '•',
|
||||
};
|
||||
|
||||
export type ChangelogEntryBadge = 'installed' | 'newer' | null;
|
||||
|
||||
export function resolveEntryBadge(
|
||||
entryVersion: string,
|
||||
installedVersion: string,
|
||||
): ChangelogEntryBadge {
|
||||
const comparison = compareSemverLike(entryVersion, installedVersion);
|
||||
if (comparison === 0) return 'installed';
|
||||
if (comparison > 0) return 'newer';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version entries in the newest major.minor line start expanded, mirroring the
|
||||
* docs-site changelog where older lines sit behind "Previous Versions".
|
||||
*/
|
||||
export function shouldEntryStartExpanded(
|
||||
entry: ChangelogEntry,
|
||||
snapshot: Pick<ChangelogSnapshot, 'expandedGroupKey'>,
|
||||
): boolean {
|
||||
if (!snapshot.expandedGroupKey) return false;
|
||||
return entry.groupKey === snapshot.expandedGroupKey;
|
||||
}
|
||||
|
||||
type InlineToken =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'code'; value: string }
|
||||
| { kind: 'strong'; value: string }
|
||||
| { kind: 'link'; value: string; href: string };
|
||||
|
||||
/**
|
||||
* Minimal inline-markdown tokenizer for changelog bullets: backtick code,
|
||||
* bold, and links. Anything else stays literal text.
|
||||
*/
|
||||
export function tokenizeInlineMarkdown(text: string): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
tokens.push({ kind: 'code', value: match[1] });
|
||||
} else if (match[2] !== undefined) {
|
||||
tokens.push({ kind: 'strong', value: match[2] });
|
||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||
tokens.push({ kind: 'link', value: match[3], href: match[4] });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function appendInlineMarkdown(target: HTMLElement, text: string): void {
|
||||
for (const token of tokenizeInlineMarkdown(text)) {
|
||||
if (token.kind === 'text') {
|
||||
target.appendChild(document.createTextNode(token.value));
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'code') {
|
||||
const code = document.createElement('code');
|
||||
code.className = 'changelog-code';
|
||||
code.textContent = token.value;
|
||||
target.appendChild(code);
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'strong') {
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = token.value;
|
||||
target.appendChild(strong);
|
||||
continue;
|
||||
}
|
||||
// Links stay inert: the overlay has nowhere to navigate to.
|
||||
const link = document.createElement('span');
|
||||
link.className = 'changelog-link';
|
||||
link.textContent = token.value;
|
||||
link.title = token.href;
|
||||
target.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
function createItemList(items: ChangelogItem[], depth: number): HTMLUListElement {
|
||||
const list = document.createElement('ul');
|
||||
list.className = depth === 0 ? 'changelog-items' : 'changelog-items changelog-items-nested';
|
||||
|
||||
for (const item of items) {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = 'changelog-item';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'changelog-item-text';
|
||||
appendInlineMarkdown(text, item.text);
|
||||
listItem.appendChild(text);
|
||||
|
||||
if (item.children.length > 0) {
|
||||
listItem.appendChild(createItemList(item.children, depth + 1));
|
||||
}
|
||||
list.appendChild(listItem);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function createSectionNode(section: ChangelogSection): HTMLElement {
|
||||
const node = document.createElement('section');
|
||||
node.className = 'changelog-section';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.className = 'changelog-section-title';
|
||||
title.textContent = `${SECTION_ICON[section.heading] ?? '•'} ${section.heading}`;
|
||||
node.appendChild(title);
|
||||
|
||||
node.appendChild(createItemList(section.items, 0));
|
||||
return node;
|
||||
}
|
||||
|
||||
function createInternalNode(sections: ChangelogSection[]): HTMLElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-internal';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-internal-summary';
|
||||
summary.textContent = 'Internal changes';
|
||||
details.appendChild(summary);
|
||||
|
||||
for (const section of sections) {
|
||||
details.appendChild(createSectionNode(section));
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function createChangelogEntryNode(
|
||||
entry: ChangelogEntry,
|
||||
options: { expanded: boolean; badge: ChangelogEntryBadge; index: number },
|
||||
): HTMLDetailsElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-entry';
|
||||
details.open = options.expanded;
|
||||
details.dataset.changelogVersion = entry.version;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-entry-summary';
|
||||
summary.dataset.changelogIndex = String(options.index);
|
||||
summary.tabIndex = -1;
|
||||
|
||||
const version = document.createElement('span');
|
||||
version.className = 'changelog-entry-version';
|
||||
version.textContent = `v${entry.version}`;
|
||||
summary.appendChild(version);
|
||||
|
||||
if (entry.date) {
|
||||
const date = document.createElement('span');
|
||||
date.className = 'changelog-entry-date';
|
||||
date.textContent = entry.date;
|
||||
summary.appendChild(date);
|
||||
}
|
||||
|
||||
if (options.badge) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `changelog-entry-badge changelog-entry-badge-${options.badge}`;
|
||||
badge.textContent = options.badge === 'installed' ? 'Installed' : 'New';
|
||||
summary.appendChild(badge);
|
||||
}
|
||||
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'changelog-entry-body';
|
||||
const publicSections = entry.sections.filter((section) => !section.internal);
|
||||
const internalSections = entry.sections.filter((section) => section.internal);
|
||||
|
||||
for (const section of publicSections) {
|
||||
body.appendChild(createSectionNode(section));
|
||||
}
|
||||
if (internalSections.length > 0) {
|
||||
body.appendChild(createInternalNode(internalSections));
|
||||
}
|
||||
if (publicSections.length === 0 && internalSections.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'changelog-empty-entry';
|
||||
empty.textContent = 'No release notes recorded for this version.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
|
||||
details.appendChild(body);
|
||||
return details;
|
||||
}
|
||||
|
||||
export function describeChangelogSource(snapshot: ChangelogSnapshot): string {
|
||||
if (snapshot.source === 'remote') {
|
||||
return snapshot.releaseTag
|
||||
? `Latest release ${snapshot.releaseTag}`
|
||||
: 'Latest published changelog';
|
||||
}
|
||||
return 'Bundled changelog';
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createChangelogModal } from './changelog.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === true) tokens.add(entry);
|
||||
else if (force === false) tokens.delete(entry);
|
||||
else if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SummaryStub = {
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
getClientRects: () => Array<{ width: number; height: number }>;
|
||||
focusCount: number;
|
||||
scrollCount: number;
|
||||
focus: () => void;
|
||||
scrollIntoView: () => void;
|
||||
};
|
||||
|
||||
function createSummaryStub(index: number): SummaryStub {
|
||||
const summary: SummaryStub = {
|
||||
classList: createClassList(),
|
||||
tabIndex: -1,
|
||||
dataset: { changelogIndex: String(index) },
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focusCount: 0,
|
||||
scrollCount: 0,
|
||||
focus: () => {
|
||||
summary.focusCount += 1;
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
summary.scrollCount += 1;
|
||||
},
|
||||
};
|
||||
return summary;
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const listeners = new Map<string, Array<(event?: unknown) => void>>();
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
classList: createClassList(['hidden']),
|
||||
contains: () => false,
|
||||
setAttribute: () => {},
|
||||
addEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
summaries: [] as SummaryStub[],
|
||||
querySelectorAll(this: { summaries: SummaryStub[] }) {
|
||||
return this.summaries;
|
||||
},
|
||||
focus: () => {},
|
||||
dispatchEventType: (type: string, event?: unknown) => {
|
||||
for (const listener of listeners.get(type) ?? []) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: '0.19.2',
|
||||
expandedGroupKey: '0.19',
|
||||
source: 'remote',
|
||||
releaseTag: 'v0.19.2',
|
||||
};
|
||||
|
||||
type Harness = {
|
||||
modal: ReturnType<typeof createChangelogModal>;
|
||||
dom: Record<string, ReturnType<typeof createElementStub>>;
|
||||
snapshotRequests: Array<{ refresh?: boolean } | undefined>;
|
||||
modalClosedNotifications: string[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
getChangelogSnapshot?: (request?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
|
||||
const previousElement = Object.getOwnPropertyDescriptor(globalThis, 'Element');
|
||||
const previousDetails = Object.getOwnPropertyDescriptor(globalThis, 'HTMLDetailsElement');
|
||||
|
||||
const snapshotRequests: Array<{ refresh?: boolean } | undefined> = [];
|
||||
const modalClosedNotifications: string[] = [];
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
// getSelectedEntry() narrows with `instanceof HTMLDetailsElement`.
|
||||
class TestDetailsElement {
|
||||
open = false;
|
||||
}
|
||||
Object.defineProperty(globalThis, 'HTMLDetailsElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestDetailsElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalClosedNotifications.push(modal);
|
||||
},
|
||||
getChangelogSnapshot: async (request?: { refresh?: boolean }) => {
|
||||
snapshotRequests.push(request);
|
||||
return options.getChangelogSnapshot
|
||||
? await options.getChangelogSnapshot(request)
|
||||
: SNAPSHOT;
|
||||
},
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const dom = {
|
||||
overlay: createElementStub(),
|
||||
changelogModal: createElementStub(),
|
||||
changelogClose: createElementStub(),
|
||||
changelogRefresh: createElementStub(),
|
||||
changelogInstalled: createElementStub(),
|
||||
changelogSource: createElementStub(),
|
||||
changelogWarning: createElementStub(),
|
||||
changelogStatus: createElementStub(),
|
||||
changelogList: createElementStub(),
|
||||
};
|
||||
|
||||
const modal = createChangelogModal(
|
||||
{
|
||||
state: createRendererState(),
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: false,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
dom,
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
dom,
|
||||
snapshotRequests,
|
||||
modalClosedNotifications,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of [
|
||||
['window', previousWindow],
|
||||
['document', previousDocument],
|
||||
['HTMLElement', previousHTMLElement],
|
||||
['Element', previousElement],
|
||||
['HTMLDetailsElement', previousDetails],
|
||||
] as const) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('changelog modal loads a snapshot on open and shows the installed version', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined]);
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Latest release v0.19.2');
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal surfaces the bundled-fallback warning', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => ({
|
||||
...SNAPSHOT,
|
||||
source: 'bundled',
|
||||
releaseTag: undefined,
|
||||
warning: 'Showing the bundled changelog: offline',
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
harness.dom.changelogWarning?.textContent,
|
||||
'Showing the bundled changelog: offline',
|
||||
);
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Bundled changelog');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal reports a load failure instead of hanging on the spinner text', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
throw new Error('ipc down');
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: ipc down/,
|
||||
);
|
||||
assert.equal(harness.dom.changelogStatus?.textContent, 'Press Esc to close.');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a later open fails to load', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('offline');
|
||||
return SNAPSHOT;
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
harness.modal.closeChangelogModal();
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The previous session's values must not linger behind the error message.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.match(harness.dom.changelogList?.textContent ?? '', /Changelog failed to load: offline/);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a refresh fails', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('refresh offline');
|
||||
return { ...SNAPSHOT, warning: 'Showing the bundled changelog: earlier failure' };
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The rendered snapshot is gone, so nothing may still describe it.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.equal(harness.dom.changelogWarning?.textContent, '');
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: refresh offline/,
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal moves selection styling and focus together on J/K', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1), createSummaryStub(2)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
harness.modal.handleChangelogKeydown({ key: 'j', preventDefault: () => {} } as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true, false],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0, -1],
|
||||
);
|
||||
assert.equal(summaries[1]?.focusCount, 1, 'the keyboard path focuses the new selection');
|
||||
assert.equal(summaries[1]?.scrollCount, 1);
|
||||
|
||||
// Wraps backwards past the start.
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, false, true],
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal click selection restyles without stealing focus back', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
// The handler guards on `instanceof Element`, so the target has to inherit
|
||||
// from the Element stand-in the harness installs.
|
||||
const elementCtor = (globalThis as unknown as { Element: { prototype: object } }).Element;
|
||||
const clickTarget = Object.assign(Object.create(elementCtor.prototype), {
|
||||
closest: (selector: string) =>
|
||||
selector === '.changelog-entry-summary' ? summaries[1] : null,
|
||||
});
|
||||
harness.dom.changelogList!.dispatchEventType('click', { target: clickTarget });
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0],
|
||||
);
|
||||
// The browser already focused the clicked summary; re-focusing would fight it.
|
||||
assert.equal(summaries[1]?.focusCount, 0);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal folds on Enter only from the selected summary', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const detailsCtor = (
|
||||
globalThis as unknown as { HTMLDetailsElement: new () => { open: boolean } }
|
||||
).HTMLDetailsElement;
|
||||
const entry = new detailsCtor();
|
||||
entry.open = true;
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
Object.assign(summaries[0]!, { parentElement: entry });
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
let prevented = 0;
|
||||
const press = (target: unknown) =>
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'Enter',
|
||||
target,
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
// Close button focused: the button must keep its own Enter activation.
|
||||
assert.equal(press(harness.dom.changelogClose), true);
|
||||
assert.equal(prevented, 0, 'Enter on a button is not swallowed');
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// A non-selected summary (the nested "Internal changes" fold) is left alone.
|
||||
assert.equal(press(summaries[1]), true);
|
||||
assert.equal(prevented, 0);
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// The selected summary does fold, exactly once.
|
||||
assert.equal(press(summaries[0]), true);
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(entry.open, false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal closes on Escape and notifies the main process', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const handled = harness.modal.handleChangelogKeydown({
|
||||
key: 'Escape',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.modalClosedNotifications, ['changelog']);
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), true);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal refetches on R and ignores keys while closed', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
harness.modal.handleChangelogKeydown({ key: 'r', preventDefault: () => {} } as KeyboardEvent),
|
||||
false,
|
||||
);
|
||||
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined, { refresh: true }]);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal drops a late in-flight load after close', async () => {
|
||||
const pending: Array<(snapshot: ChangelogSnapshot) => void> = [];
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: () =>
|
||||
new Promise<ChangelogSnapshot>((resolve) => {
|
||||
pending.push(resolve);
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
harness.modal.closeChangelogModal();
|
||||
pending[0]?.(SNAPSHOT);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
} from './changelog-render';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export function createChangelogModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
function getSummaries(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
function applySelectionStyles(index: number): HTMLElement[] {
|
||||
const summaries = getSummaries();
|
||||
ctx.state.changelogSelectedIndex = index;
|
||||
summaries.forEach((summary, idx) => {
|
||||
summary.classList.toggle('active', idx === index);
|
||||
summary.tabIndex = idx === index ? 0 : -1;
|
||||
});
|
||||
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' });
|
||||
}
|
||||
|
||||
function getSelectedEntry(): HTMLDetailsElement | null {
|
||||
const summary = getSummaries()[ctx.state.changelogSelectedIndex];
|
||||
const entry = summary?.parentElement;
|
||||
return entry instanceof HTMLDetailsElement ? entry : 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 = '';
|
||||
ctx.dom.changelogList.classList.remove('changelog-list-empty');
|
||||
|
||||
ctx.dom.changelogInstalled.textContent = `Installed v${snapshot.installedVersion}`;
|
||||
ctx.dom.changelogSource.textContent = describeChangelogSource(snapshot);
|
||||
ctx.dom.changelogWarning.textContent = snapshot.warning ?? '';
|
||||
|
||||
if (snapshot.entries.length === 0) {
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
ctx.dom.changelogList.textContent =
|
||||
snapshot.error ?? 'No changelog entries are available right now.';
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.entries.forEach((entry, index) => {
|
||||
ctx.dom.changelogList.appendChild(
|
||||
createChangelogEntryNode(entry, {
|
||||
expanded: shouldEntryStartExpanded(entry, snapshot),
|
||||
badge: resolveEntryBadge(entry.version, snapshot.installedVersion),
|
||||
index,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
async function load(options?: { refresh?: boolean }): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
ctx.dom.changelogStatus.textContent = options?.refresh
|
||||
? 'Refreshing changelog...'
|
||||
: 'Loading changelog...';
|
||||
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getChangelogSnapshot(options);
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
renderSnapshot(snapshot);
|
||||
ctx.dom.changelogStatus.textContent =
|
||||
'J/K or arrows to move, Enter to fold, R to refresh, Esc closes.';
|
||||
} catch (error) {
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
const message = error instanceof Error ? error.message : 'Unknown error.';
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
// A failed refresh replaces an already-rendered snapshot, so the metadata
|
||||
// line has to be cleared here too or it keeps describing stale entries.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
ctx.dom.changelogList.textContent = `Changelog failed to load: ${message}`;
|
||||
ctx.dom.changelogStatus.textContent = 'Press Esc to close.';
|
||||
}
|
||||
}
|
||||
|
||||
function openChangelogModal(): void {
|
||||
if (ctx.state.changelogModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.changelogModal.classList.remove('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.changelogModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
// Reset the metadata line too, so a failed load can't leave the previous
|
||||
// session's installed/source values on screen.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
function closeChangelogModal(): void {
|
||||
if (!ctx.state.changelogModalOpen) return;
|
||||
|
||||
ctx.state.changelogModalOpen = false;
|
||||
loadToken += 1;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.changelogModal.classList.add('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('changelog');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
} else if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function handleChangelogKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.changelogModalOpen) return false;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeChangelogModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
void load({ refresh: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
const summaries = getSummaries();
|
||||
if (summaries.length === 0) return true;
|
||||
|
||||
if (key === 'arrowdown' || key === 'j') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowup' || key === 'k') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowleft' || key === 'h') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowright' || key === 'l') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.changelogClose.addEventListener('click', () => {
|
||||
closeChangelogModal();
|
||||
});
|
||||
|
||||
ctx.dom.changelogRefresh.addEventListener('click', () => {
|
||||
void load({ refresh: true });
|
||||
});
|
||||
|
||||
ctx.dom.changelogList.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const summary = target.closest('.changelog-entry-summary') as HTMLElement | null;
|
||||
if (!summary) return;
|
||||
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
|
||||
if (!Number.isFinite(index)) return;
|
||||
applySelectionStyles(index);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeChangelogModal,
|
||||
handleChangelogKeydown,
|
||||
openChangelogModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
type Listener = (event?: unknown) => void;
|
||||
|
||||
function createRoot(contains: boolean) {
|
||||
const listeners: Array<{ type: string; listener: Listener }> = [];
|
||||
return {
|
||||
contains: () => contains,
|
||||
addEventListener: (type: string, listener: Listener) => {
|
||||
listeners.push({ type, listener });
|
||||
},
|
||||
removeEventListener: (type: string, listener: Listener) => {
|
||||
const index = listeners.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
guard: ReturnType<typeof createModalFocusGuard>;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
focusMainWindowCalls: () => number;
|
||||
focused: () => string[];
|
||||
documentListeners: () => string[];
|
||||
windowListeners: () => string[];
|
||||
handlerFor: (scope: 'document' | 'window', type: string) => Listener | undefined;
|
||||
setActiveElement: (value: unknown) => void;
|
||||
advanceClock: (ms: number) => void;
|
||||
runTimers: () => void;
|
||||
clearedTimers: () => number[];
|
||||
pendingTimerCount: () => number;
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
isOpen?: () => boolean;
|
||||
isModalLayer?: boolean;
|
||||
contains?: boolean;
|
||||
preferredVisible?: boolean;
|
||||
preferredAcceptsFocus?: boolean;
|
||||
extraPreferred?: boolean;
|
||||
fallback?: 'element' | null;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previous = (['window', 'document', 'HTMLElement', 'Element'] as const).map(
|
||||
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||
);
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
|
||||
let focusMainWindowCalls = 0;
|
||||
let now = 1_000;
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => now;
|
||||
const focused: string[] = [];
|
||||
const documentListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
const windowListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
|
||||
const register = (registry: Array<{ type: string; listener: Listener }>) => ({
|
||||
add: (type: string, listener: Listener) => {
|
||||
registry.push({ type, listener });
|
||||
},
|
||||
remove: (type: string, listener: Listener) => {
|
||||
const index = registry.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) registry.splice(index, 1);
|
||||
},
|
||||
});
|
||||
const windowRegistry = register(windowListeners);
|
||||
const documentRegistry = register(documentListeners);
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextTimerId = 0;
|
||||
const clearedTimers: number[] = [];
|
||||
let activeElement: unknown = null;
|
||||
|
||||
const root = createRoot(options.contains ?? false);
|
||||
|
||||
const preferred = Object.assign(new TestElement(), {
|
||||
// A position:fixed element has a null offsetParent but still has rects.
|
||||
offsetParent: null,
|
||||
getClientRects: () => (options.preferredVisible === false ? [] : [{ width: 10, height: 10 }]),
|
||||
focus: () => {
|
||||
focused.push('preferred');
|
||||
// A rendered-but-unfocusable target (e.g. disabled) never becomes active.
|
||||
if (options.preferredAcceptsFocus !== false) activeElement = preferred;
|
||||
},
|
||||
});
|
||||
const secondPreferred = Object.assign(new TestElement(), {
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focus: () => {
|
||||
focused.push('second');
|
||||
activeElement = secondPreferred;
|
||||
},
|
||||
});
|
||||
const fallback =
|
||||
options.fallback === null
|
||||
? null
|
||||
: Object.assign(new TestElement(), {
|
||||
focus: () => {
|
||||
focused.push('fallback');
|
||||
activeElement = fallback;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
focused.push('window');
|
||||
},
|
||||
addEventListener: windowRegistry.add,
|
||||
removeEventListener: windowRegistry.remove,
|
||||
setTimeout: (callback: () => void) => {
|
||||
nextTimerId += 1;
|
||||
timers.set(nextTimerId, callback);
|
||||
return nextTimerId;
|
||||
},
|
||||
clearTimeout: (id: number) => {
|
||||
clearedTimers.push(id);
|
||||
timers.delete(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
addEventListener: documentRegistry.add,
|
||||
removeEventListener: documentRegistry.remove,
|
||||
},
|
||||
});
|
||||
|
||||
const guard = createModalFocusGuard({
|
||||
isOpen: options.isOpen ?? (() => true),
|
||||
getModalRoot: () => root as unknown as Element,
|
||||
getPreferredFocusTargets: () =>
|
||||
(options.extraPreferred
|
||||
? [preferred, secondPreferred]
|
||||
: [preferred]) as unknown as HTMLElement[],
|
||||
getFallbackFocusTarget: () => fallback as unknown as Element | null,
|
||||
isModalLayer: options.isModalLayer ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
guard,
|
||||
root,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
focused: () => focused,
|
||||
documentListeners: () => documentListeners.map((entry) => entry.type),
|
||||
windowListeners: () => windowListeners.map((entry) => entry.type),
|
||||
handlerFor: (scope: 'document' | 'window', type: string) =>
|
||||
(scope === 'document' ? documentListeners : windowListeners).find(
|
||||
(entry) => entry.type === type,
|
||||
)?.listener,
|
||||
setActiveElement: (value: unknown) => {
|
||||
activeElement = value;
|
||||
},
|
||||
advanceClock: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
runTimers: () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const callback of pending) callback();
|
||||
},
|
||||
clearedTimers: () => clearedTimers,
|
||||
pendingTimerCount: () => timers.size,
|
||||
restore: () => {
|
||||
Date.now = realDateNow;
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('modal focus guard attaches once and detaches every listener', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.guard.attach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), ['focusin']);
|
||||
assert.deepEqual(harness.windowListeners(), ['blur', 'focus']);
|
||||
assert.deepEqual(
|
||||
harness.root.listeners.map((entry) => entry.type),
|
||||
['pointerdown', 'click'],
|
||||
);
|
||||
|
||||
harness.guard.detach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), []);
|
||||
assert.deepEqual(harness.windowListeners(), []);
|
||||
assert.deepEqual(harness.root.listeners, []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard pulls focus back when focusin lands outside the modal', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
|
||||
const focusin = harness.handlerFor('document', 'focusin');
|
||||
assert.ok(focusin, 'attach registers a focusin handler');
|
||||
|
||||
// focusin is not cancelable, so recovery is the only observable effect.
|
||||
focusin?.({ target: {} });
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard ignores focusin while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.handlerFor('document', 'focusin')?.({ target: {} });
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard restores focus to the first rendered target', () => {
|
||||
// The target is position:fixed (null offsetParent) yet visible, so it must
|
||||
// still win over the fallback.
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard tries the next target when one refuses focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false, extraPreferred: true });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'second']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard reaches the fallback when no preferred target takes focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard falls back when no preferred target is rendered', () => {
|
||||
const harness = createHarness({ preferredVisible: false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard focuses the window when nothing else can take focus', () => {
|
||||
const harness = createHarness({ preferredVisible: false, fallback: null });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), false);
|
||||
assert.deepEqual(harness.focused(), ['window']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard leaves focus alone while it is already inside the modal', () => {
|
||||
const harness = createHarness({ contains: true });
|
||||
try {
|
||||
harness.setActiveElement(
|
||||
Object.create((globalThis as { Element: { prototype: object } }).Element.prototype),
|
||||
);
|
||||
harness.guard.enforceModalFocus();
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard does nothing while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard clears recovery state on detach so a reopen is not blocked', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
assert.equal(harness.pendingTimerCount(), 1, 'recovery armed the debounce timer');
|
||||
|
||||
// Close while recovery is still in flight.
|
||||
harness.guard.detach();
|
||||
assert.equal(harness.clearedTimers().length, 1, 'the pending debounce timer is cancelled');
|
||||
assert.equal(harness.pendingTimerCount(), 0);
|
||||
|
||||
// Immediate reopen: recovery must work straight away, not 120 ms later.
|
||||
harness.guard.attach();
|
||||
harness.setActiveElement(null);
|
||||
harness.guard.enforceModalFocus();
|
||||
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard debounces recovery so a focus fight cannot spin', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
harness.setActiveElement(null);
|
||||
|
||||
// Re-entry guard: the recovery timer has not fired yet.
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
|
||||
// Timer cleared the re-entry flag, but the debounce window still holds.
|
||||
harness.runTimers();
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred'], 'debounce still blocks the retry');
|
||||
|
||||
// Past the window, recovery resumes.
|
||||
harness.advanceClock(200);
|
||||
harness.setActiveElement(null);
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard asks the main window for focus off the modal layer only', () => {
|
||||
const onModalLayer = createHarness({ isModalLayer: true });
|
||||
try {
|
||||
onModalLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onModalLayer.focusMainWindowCalls(), 0);
|
||||
} finally {
|
||||
onModalLayer.restore();
|
||||
}
|
||||
|
||||
const onOverlayLayer = createHarness({ isModalLayer: false });
|
||||
try {
|
||||
onOverlayLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onOverlayLayer.focusMainWindowCalls(), 1);
|
||||
} finally {
|
||||
onOverlayLayer.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Keeps focus inside an overlay-hosted modal.
|
||||
*
|
||||
* The overlay can lose focus to mpv or to the compositor while a modal is up,
|
||||
* which leaves the modal visible but inert. Recovery is debounced (and guarded
|
||||
* against re-entry) so a focus fight with the window manager cannot spin.
|
||||
*/
|
||||
export type ModalFocusGuardDeps = {
|
||||
isOpen: () => boolean;
|
||||
/** Modal root; focus inside it counts as "still in the modal". */
|
||||
getModalRoot: () => Element;
|
||||
/** Preferred focus targets in order; the first rendered one wins. */
|
||||
getPreferredFocusTargets: () => HTMLElement[];
|
||||
/** Used when no preferred target is rendered, e.g. the close button. */
|
||||
getFallbackFocusTarget: () => Element | null;
|
||||
/** Modal-layer windows own their focus; other layers ask the main window. */
|
||||
isModalLayer: boolean;
|
||||
};
|
||||
|
||||
const FOCUS_RECOVERY_DEBOUNCE_MS = 120;
|
||||
|
||||
export function createModalFocusGuard(deps: ModalFocusGuardDeps) {
|
||||
let focusinGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let pointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let pointerFocusRoot: Element | null = null;
|
||||
let isRecovering = false;
|
||||
let lastRecoveryAt = 0;
|
||||
// Browser setTimeout id; typed loosely because @types/node widens the global.
|
||||
let recoveryTimer: number | null = null;
|
||||
|
||||
function isModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && deps.getModalRoot().contains(target);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!deps.isModalLayer) {
|
||||
// Best-effort: a rejected focus request must not surface as an unhandled
|
||||
// rejection, since this runs from blur/focus handlers.
|
||||
void Promise.resolve(window.electronAPI.focusMainWindow()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
requestOverlayFocus();
|
||||
|
||||
// getClientRects() rather than offsetParent: the latter is null for
|
||||
// position:fixed elements, which would skip a perfectly visible target.
|
||||
// Rendered is not the same as focusable, so keep trying until one sticks
|
||||
// instead of giving up on the first candidate that refuses focus.
|
||||
for (const target of deps.getPreferredFocusTargets()) {
|
||||
if (target.getClientRects().length === 0) continue;
|
||||
target.focus({ preventScroll: true });
|
||||
if (document.activeElement === target) return true;
|
||||
}
|
||||
|
||||
const fallback = deps.getFallbackFocusTarget();
|
||||
if (fallback instanceof HTMLElement) {
|
||||
fallback.focus({ preventScroll: true });
|
||||
return document.activeElement === fallback;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!deps.isOpen()) return;
|
||||
if (isModalFocusTarget(document.activeElement)) return;
|
||||
if (isRecovering) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRecoveryAt < FOCUS_RECOVERY_DEBOUNCE_MS) return;
|
||||
|
||||
isRecovering = true;
|
||||
lastRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
recoveryTimer = window.setTimeout(() => {
|
||||
recoveryTimer = null;
|
||||
isRecovering = false;
|
||||
}, FOCUS_RECOVERY_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Idempotent; safe to call on every open. */
|
||||
function attach(): void {
|
||||
if (focusinGuard === null) {
|
||||
// focusin is not cancelable, so there is nothing to preventDefault here;
|
||||
// focus is taken back afterwards instead.
|
||||
focusinGuard = (event: FocusEvent) => {
|
||||
if (!deps.isOpen()) return;
|
||||
if (!isModalFocusTarget(event.target)) {
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusinGuard);
|
||||
}
|
||||
|
||||
if (pointerFocusGuard === null) {
|
||||
pointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
// Remember the root we bound to: resolving it again on detach could
|
||||
// return a different element and leak the listeners on the old one.
|
||||
pointerFocusRoot = deps.getModalRoot();
|
||||
pointerFocusRoot.addEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot.addEventListener('click', pointerFocusGuard);
|
||||
}
|
||||
|
||||
if (windowFocusGuard === null) {
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
}
|
||||
|
||||
function detach(): void {
|
||||
if (focusinGuard) {
|
||||
document.removeEventListener('focusin', focusinGuard);
|
||||
focusinGuard = null;
|
||||
}
|
||||
|
||||
if (pointerFocusGuard) {
|
||||
pointerFocusRoot?.removeEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot?.removeEventListener('click', pointerFocusGuard);
|
||||
pointerFocusGuard = null;
|
||||
pointerFocusRoot = null;
|
||||
}
|
||||
|
||||
if (windowFocusGuard) {
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
|
||||
// Closing mid-recovery must not leave the debounce armed: a modal reopened
|
||||
// straight away would otherwise get no focus recovery for the next 120 ms.
|
||||
if (recoveryTimer !== null) {
|
||||
window.clearTimeout(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
}
|
||||
isRecovering = false;
|
||||
lastRecoveryAt = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
detach,
|
||||
enforceModalFocus,
|
||||
focusFallbackTarget,
|
||||
isModalFocusTarget,
|
||||
requestOverlayFocus,
|
||||
};
|
||||
}
|
||||
@@ -279,6 +279,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './session-help-sections';
|
||||
import { createSessionHelpSectionNode } from './session-help-render';
|
||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export {
|
||||
buildSessionHelpSections,
|
||||
@@ -69,11 +70,6 @@ export function createSessionHelpModal(
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let activeTabId: SessionHelpTabId = 'essentials';
|
||||
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;
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
@@ -102,47 +98,13 @@ export function createSessionHelpModal(
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
firstItem.focus({ preventScroll: true });
|
||||
return document.activeElement === firstItem;
|
||||
}
|
||||
|
||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
||||
if (isRecoveringModalFocus) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFocusRecoveryAt < 120) return;
|
||||
|
||||
isRecoveringModalFocus = true;
|
||||
lastFocusRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
|
||||
window.setTimeout(() => {
|
||||
isRecoveringModalFocus = false;
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.sessionHelpModalOpen,
|
||||
getModalRoot: () => ctx.dom.sessionHelpModal,
|
||||
getPreferredFocusTargets: () => getItems(),
|
||||
getFallbackFocusTarget: () => ctx.dom.sessionHelpClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function isFilterInputFocused(): boolean {
|
||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||
@@ -192,48 +154,6 @@ export function createSessionHelpModal(
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
if (modalPointerFocusGuard) return;
|
||||
|
||||
modalPointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
|
||||
}
|
||||
|
||||
function removePointerFocusListener(): void {
|
||||
if (!modalPointerFocusGuard) return;
|
||||
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.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;
|
||||
}
|
||||
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
@@ -310,22 +230,10 @@ export function createSessionHelpModal(
|
||||
}
|
||||
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusGuard);
|
||||
}
|
||||
|
||||
addPointerFocusListener();
|
||||
startFocusRecoveryGuards();
|
||||
requestOverlayFocus();
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void render().then((dataLoaded) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
@@ -353,12 +261,7 @@ export function createSessionHelpModal(
|
||||
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 });
|
||||
@@ -395,7 +298,7 @@ export function createSessionHelpModal(
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
applyFilterAndRender();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -442,7 +345,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user