fix: more work on menus

This commit is contained in:
2026-02-17 11:25:50 +01:00
parent 7b5829e965
commit 70bc0b1b09
7 changed files with 442 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { ipcMain, dialog, shell } from 'electron'; import { app, ipcMain, dialog, shell } from 'electron';
import * as path from 'path'; import * as path from 'path';
import * as fsPromises from 'fs/promises'; import * as fsPromises from 'fs/promises';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -715,6 +715,22 @@ export function registerIpcHandlers(): void {
safeHandle('app:triggerMenuAction', async (event, action: string) => { safeHandle('app:triggerMenuAction', async (event, action: string) => {
const typedAction = action as AppMenuAction; const typedAction = action as AppMenuAction;
if (typedAction === 'quit') {
app.quit();
return;
}
if (typedAction === 'viewOnGitHub') {
await shell.openExternal('https://github.com/rfc1437/bDS');
return;
}
if (typedAction === 'reportIssue') {
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
return;
}
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction); const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
if (handledByWebContents) { if (handledByWebContents) {
return; return;

View File

@@ -179,6 +179,21 @@ function createApplicationMenu(): Menu {
}, {}); }, {});
const triggerMenuAction = (action: AppMenuAction): void => { const triggerMenuAction = (action: AppMenuAction): void => {
if (action === 'quit') {
app.quit();
return;
}
if (action === 'viewOnGitHub') {
void shell.openExternal('https://github.com/rfc1437/bDS');
return;
}
if (action === 'reportIssue') {
void shell.openExternal('https://github.com/rfc1437/bDS/issues');
return;
}
const channel = APP_MENU_ACTION_EVENT_MAP[action]; const channel = APP_MENU_ACTION_EVENT_MAP[action];
if (channel) { if (channel) {
mainWindow?.webContents.send(channel); mainWindow?.webContents.send(channel);
@@ -251,13 +266,7 @@ function createApplicationMenu(): Menu {
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
{ buildSharedMenuItem('quit'),
label: 'Exit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4',
click: () => {
app.quit();
},
},
], ],
}, },
{ {
@@ -313,22 +322,7 @@ function createApplicationMenu(): Menu {
}, },
{ {
label: 'Help', label: 'Help',
submenu: [ submenu: buildSharedGroupMenuItems('Help'),
buildSharedMenuItem('about'),
{ type: 'separator' },
{
label: 'View on GitHub',
click: async () => {
await shell.openExternal('https://github.com/rfc1437/bDS');
},
},
{
label: 'Report Issue',
click: async () => {
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
},
},
],
}, },
]; ];

View File

@@ -2,6 +2,7 @@ export type AppMenuAction =
| 'newPost' | 'newPost'
| 'importMedia' | 'importMedia'
| 'save' | 'save'
| 'quit'
| 'undo' | 'undo'
| 'redo' | 'redo'
| 'cut' | 'cut'
@@ -20,7 +21,9 @@ export type AppMenuAction =
| 'rebuildDatabase' | 'rebuildDatabase'
| 'reindexText' | 'reindexText'
| 'metadataDiff' | 'metadataDiff'
| 'about'; | 'about'
| 'viewOnGitHub'
| 'reportIssue';
export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll'; export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll';
@@ -44,6 +47,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' }, { label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' }, { label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' }, { label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
{ label: '', action: 'file-separator-1', separator: true },
{ label: 'Quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
], ],
}, },
{ {
@@ -86,6 +91,9 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
label: 'Help', label: 'Help',
items: [ items: [
{ label: 'About Blogging Desktop Server', action: 'about' }, { label: 'About Blogging Desktop Server', action: 'about' },
{ label: '', action: 'help-separator-1', separator: true },
{ label: 'View on GitHub', action: 'viewOnGitHub' },
{ label: 'Report Issue', action: 'reportIssue' },
], ],
}, },
]; ];

View File

@@ -46,6 +46,11 @@
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
} }
.window-titlebar-menu-mnemonic {
text-decoration: underline;
text-underline-offset: 2px;
}
.window-titlebar-menu-dropdown { .window-titlebar-menu-dropdown {
position: absolute; position: absolute;
top: 30px; top: 30px;
@@ -89,6 +94,10 @@
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45)); background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
} }
.window-titlebar-menu-item.is-keyboard-active {
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
}
.window-titlebar-menu-item-accelerator { .window-titlebar-menu-item-accelerator {
opacity: 0.8; opacity: 0.8;
} }

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands'; import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
import './WindowTitleBar.css'; import './WindowTitleBar.css';
@@ -14,7 +14,10 @@ export const WindowTitleBar: React.FC = () => {
const { sidebarVisible, toggleSidebar } = useAppStore(); const { sidebarVisible, toggleSidebar } = useAppStore();
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server'); const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null); const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
const [keyboardMenuItemIndex, setKeyboardMenuItemIndex] = useState<number | null>(null);
const menuRootRef = useRef<HTMLDivElement | null>(null); const menuRootRef = useRef<HTMLDivElement | null>(null);
const menuButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const isMac = navigator.platform.toLowerCase().includes('mac'); const isMac = navigator.platform.toLowerCase().includes('mac');
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV)); ?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV));
@@ -30,6 +33,57 @@ export const WindowTitleBar: React.FC = () => {
}; };
}); });
const mnemonicByKey = useMemo(() => {
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
const mnemonicKey = group.label.charAt(0).toLowerCase();
acc[mnemonicKey] = group.label;
return acc;
}, {});
}, [visibleMenuGroups]);
const getMenuLeft = (label: string): number | null => {
const button = menuButtonRefs.current[label];
if (!button) {
return null;
}
const buttonRect = button.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
return rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
};
const openMenuByLabel = (label: string) => {
const left = getMenuLeft(label);
if (left === null) {
return;
}
setKeyboardMenuItemIndex(null);
setOpenMenu({ label, left });
};
const getMenuActionableItems = (label: string) => {
const group = visibleMenuGroups.find(item => item.label === label);
if (!group) {
return [];
}
return group.items.filter(item => !item.separator);
};
const switchOpenMenuByOffset = (offset: number) => {
if (!openMenu) {
return;
}
const currentIndex = visibleMenuGroups.findIndex(group => group.label === openMenu.label);
if (currentIndex < 0) {
return;
}
const nextIndex = (currentIndex + offset + visibleMenuGroups.length) % visibleMenuGroups.length;
openMenuByLabel(visibleMenuGroups[nextIndex].label);
};
useEffect(() => { useEffect(() => {
const rootStyle = document.documentElement.style; const rootStyle = document.documentElement.style;
const setInsets = (left: number, right: number) => { const setInsets = (left: number, right: number) => {
@@ -110,12 +164,111 @@ export const WindowTitleBar: React.FC = () => {
const target = event.target as Node; const target = event.target as Node;
if (menuRootRef.current && !menuRootRef.current.contains(target)) { if (menuRootRef.current && !menuRootRef.current.contains(target)) {
setOpenMenu(null); setOpenMenu(null);
setShowMnemonics(false);
} }
}; };
const onEscape = (event: KeyboardEvent) => { const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpenMenu(null); setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
switchOpenMenuByOffset(1);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
switchOpenMenuByOffset(-1);
return;
}
if (!openMenu) {
return;
}
const actionableItems = getMenuActionableItems(openMenu.label);
if (actionableItems.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setKeyboardMenuItemIndex((previous) => {
if (previous === null) {
return 0;
}
return (previous + 1) % actionableItems.length;
});
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setKeyboardMenuItemIndex((previous) => {
if (previous === null) {
return actionableItems.length - 1;
}
return (previous - 1 + actionableItems.length) % actionableItems.length;
});
return;
}
if ((event.key === 'Enter' || event.key === ' ') && keyboardMenuItemIndex !== null) {
event.preventDefault();
const targetItem = actionableItems[keyboardMenuItemIndex];
if (targetItem) {
void window.electronAPI?.app?.triggerMenuAction?.(targetItem.action);
setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
}
return;
}
if (event.key === 'Home') {
event.preventDefault();
setKeyboardMenuItemIndex(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
setKeyboardMenuItemIndex(actionableItems.length - 1);
return;
}
if (event.key.length === 1 && !event.altKey && !event.shiftKey) {
const typed = event.key.toLowerCase();
const matchingIndices = actionableItems
.map((item, index) => ({ item, index }))
.filter(entry => entry.item.label.toLowerCase().startsWith(typed))
.map(entry => entry.index);
if (matchingIndices.length === 0) {
return;
}
event.preventDefault();
if (keyboardMenuItemIndex === null) {
setKeyboardMenuItemIndex(matchingIndices[0]);
return;
}
const currentMatchPosition = matchingIndices.findIndex(index => index === keyboardMenuItemIndex);
if (currentMatchPosition >= 0) {
const nextPosition = (currentMatchPosition + 1) % matchingIndices.length;
setKeyboardMenuItemIndex(matchingIndices[nextPosition]);
return;
}
const firstAfterCurrent = matchingIndices.find(index => index > keyboardMenuItemIndex);
setKeyboardMenuItemIndex(firstAfterCurrent ?? matchingIndices[0]);
} }
}; };
@@ -126,26 +279,99 @@ export const WindowTitleBar: React.FC = () => {
document.removeEventListener('mousedown', onDocumentMouseDown); document.removeEventListener('mousedown', onDocumentMouseDown);
document.removeEventListener('keydown', onEscape); document.removeEventListener('keydown', onEscape);
}; };
}, [openMenu]); }, [keyboardMenuItemIndex, openMenu, visibleMenuGroups]);
useEffect(() => {
setKeyboardMenuItemIndex(null);
}, [openMenu?.label]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey) {
return;
}
if (event.key === 'Alt' && !event.shiftKey) {
setShowMnemonics(true);
return;
}
if (event.altKey && event.key.length === 1) {
const targetMenuLabel = mnemonicByKey[event.key.toLowerCase()];
if (targetMenuLabel) {
event.preventDefault();
setShowMnemonics(true);
openMenuByLabel(targetMenuLabel);
}
return;
}
if (showMnemonics && event.key !== 'Shift') {
setShowMnemonics(false);
}
};
const onDocumentMouseDown = () => {
if (showMnemonics) {
setShowMnemonics(false);
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('mousedown', onDocumentMouseDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('mousedown', onDocumentMouseDown);
};
}, [mnemonicByKey, showMnemonics]);
const handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => { const handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
const buttonRect = event.currentTarget.getBoundingClientRect(); const left = getMenuLeft(label);
const rootRect = menuRootRef.current?.getBoundingClientRect(); if (left === null) {
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left; return;
}
if (openMenu?.label === label) { if (openMenu?.label === label) {
setOpenMenu(null); setOpenMenu(null);
setKeyboardMenuItemIndex(null);
return; return;
} }
setOpenMenu({ label, left }); setOpenMenu({ label, left });
}; };
const handleMenuButtonMouseEnter = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
if (!openMenu || openMenu.label === label) {
return;
}
const buttonRect = event.currentTarget.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
setOpenMenu({ label, left });
};
const handleMenuItemClick = (action: string) => { const handleMenuItemClick = (action: string) => {
setOpenMenu(null); setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
void window.electronAPI?.app?.triggerMenuAction?.(action); void window.electronAPI?.app?.triggerMenuAction?.(action);
}; };
const renderMenuLabel = (label: string) => {
if (!showMnemonics || label.length === 0) {
return label;
}
return (
<>
<span className="window-titlebar-menu-mnemonic">{label.charAt(0)}</span>
{label.slice(1)}
</>
);
};
const formatAccelerator = (accelerator: string): string => { const formatAccelerator = (accelerator: string): string => {
const firstPass = accelerator const firstPass = accelerator
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl') .replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
@@ -162,12 +388,16 @@ export const WindowTitleBar: React.FC = () => {
{visibleMenuGroups.map(group => ( {visibleMenuGroups.map(group => (
<button <button
key={group.label} key={group.label}
ref={(element) => {
menuButtonRefs.current[group.label] = element;
}}
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`} className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
type="button" type="button"
onClick={(event) => handleMenuButtonClick(event, group.label)} onClick={(event) => handleMenuButtonClick(event, group.label)}
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
aria-label={group.label} aria-label={group.label}
> >
{group.label} {renderMenuLabel(group.label)}
</button> </button>
))} ))}
</div> </div>
@@ -193,18 +423,21 @@ export const WindowTitleBar: React.FC = () => {
data-testid="window-titlebar-menu-dropdown" data-testid="window-titlebar-menu-dropdown"
style={{ left: `${openMenu.left}px` }} style={{ left: `${openMenu.left}px` }}
> >
{activeMenu.items.map(item => { {activeMenu.items.map((item) => {
if (item.separator) { if (item.separator) {
return <div key={item.action} className="window-titlebar-menu-separator" />; return <div key={item.action} className="window-titlebar-menu-separator" />;
} }
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null; const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
const isKeyboardActive = keyboardMenuItemIndex !== null && keyboardMenuItemIndex === currentActionableIndex;
return ( return (
<button <button
key={item.action} key={item.action}
type="button" type="button"
className="window-titlebar-menu-item" className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
onClick={() => handleMenuItemClick(item.action)} onClick={() => handleMenuItemClick(item.action)}
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label} aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
> >

View File

@@ -16,6 +16,9 @@ const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
// Mock ipcMain to capture handler registrations // Mock ipcMain to capture handler registrations
vi.mock('electron', () => ({ vi.mock('electron', () => ({
app: {
quit: vi.fn(),
},
ipcMain: { ipcMain: {
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => { handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
registeredHandlers.set(channel, handler); registeredHandlers.set(channel, handler);
@@ -27,6 +30,7 @@ vi.mock('electron', () => ({
}, },
shell: { shell: {
openPath: vi.fn(), openPath: vi.fn(),
openExternal: vi.fn(),
showItemInFolder: vi.fn(), showItemInFolder: vi.fn(),
}, },
})); }));
@@ -1371,6 +1375,28 @@ describe('IPC Handlers', () => {
expect(toggleDevTools).toHaveBeenCalled(); expect(toggleDevTools).toHaveBeenCalled();
expect(send).not.toHaveBeenCalled(); expect(send).not.toHaveBeenCalled();
}); });
it('should quit the application when action is quit', async () => {
const { app } = await import('electron');
const send = vi.fn();
const event = { sender: { send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'quit');
expect(app.quit).toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it('should open repository URL when action is viewOnGitHub', async () => {
const { shell } = await import('electron');
const send = vi.fn();
const event = { sender: { send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'viewOnGitHub');
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/rfc1437/bDS');
expect(send).not.toHaveBeenCalled();
});
}); });
}); });

View File

@@ -146,6 +146,129 @@ describe('WindowTitleBar', () => {
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
}); });
it('shows Quit in File menu and View on GitHub in Help menu', () => {
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
expect(screen.getByRole('button', { name: 'Quit Ctrl+Q' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Help' }));
expect(screen.getByRole('button', { name: 'View on GitHub' })).toBeInTheDocument();
});
it('switches to another menu on hover when a menu is already open', () => {
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Edit' }));
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New Post Ctrl+N' })).toBeNull();
});
it('shows menu mnemonics when Alt is pressed', () => {
const { container } = render(<WindowTitleBar />);
expect(container.querySelector('.window-titlebar-menu-mnemonic')).toBeNull();
fireEvent.keyDown(document, { key: 'Alt' });
expect(container.querySelector('.window-titlebar-menu-mnemonic')).not.toBeNull();
});
it('opens File menu when Alt+F is pressed', () => {
render(<WindowTitleBar />);
fireEvent.keyDown(document, { key: 'f', altKey: true });
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
});
it('navigates menu items with arrow keys and activates selection with Enter', () => {
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
fireEvent.keyDown(document, { key: 'ArrowDown' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
});
it('switches open menu with ArrowRight and ArrowLeft', () => {
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'ArrowRight' });
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'ArrowLeft' });
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
});
it('jumps to first and last menu item with Home and End', () => {
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
fireEvent.keyDown(document, { key: 'End' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(triggerMenuAction).toHaveBeenCalledWith('quit');
fireEvent.click(screen.getByRole('button', { name: 'File' }));
fireEvent.keyDown(document, { key: 'Home' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
});
it('jumps to matching item by first letter key and activates with Enter', () => {
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
fireEvent.keyDown(document, { key: 'i' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(triggerMenuAction).toHaveBeenCalledWith('importMedia');
});
it('cycles through same-letter matches on repeated key presses', () => {
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.keyDown(document, { key: 'r' });
fireEvent.keyDown(document, { key: 'r' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(triggerMenuAction).toHaveBeenCalledWith('replace');
});
it('shows Toggle Developer Tools in View menu in development mode', () => { it('shows Toggle Developer Tools in View menu in development mode', () => {
(window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true; (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true;
const triggerMenuAction = vi.fn().mockResolvedValue(undefined); const triggerMenuAction = vi.fn().mockResolvedValue(undefined);