Skip to content

Commit b9f7e71

Browse files
committed
feat: add keyboard shortcuts to main menu with visual indicators
Implement keyboard shortcuts for main menu actions with visual shortcut indicators displayed in the dropdown menu. Adds a MenuShortcutManager class to handle keyboard events with proper platform detection (Cmd on Mac, Ctrl on Windows). Features: - Connect: Ctrl+K / Cmd+K - Settings: Ctrl+, / Cmd+, - Status: Ctrl+I / Cmd+I - IFS: Ctrl+M / Cmd+M (when available) - Configure Shortcuts: Ctrl+P / Cmd+P Changes: - Add MenuShortcutManager class with keyboard event handling - Prevent shortcuts from firing when user is typing in input fields - Update menu item layout to display shortcut labels on the right - Add proper ARIA keyshortcuts attributes for accessibility - Dynamically update shortcut labels based on platform (Mac/Windows) - Integrate with existing menu visibility logic (IFS menu item)
1 parent 38c4448 commit b9f7e71

3 files changed

Lines changed: 242 additions & 26 deletions

File tree

src/index.css

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ body {
143143
position: absolute;
144144
top: calc(100% + 6px);
145145
left: 0;
146-
width: 200px;
146+
width: 240px;
147147
padding: 8px 0;
148148
background-color: var(--card-bg);
149149
border: 1px solid var(--border-color-light);
@@ -170,6 +170,8 @@ body {
170170
.menu-item {
171171
display: flex;
172172
align-items: center;
173+
justify-content: space-between;
174+
gap: 16px;
173175
width: 100%;
174176
height: 36px;
175177
padding: 8px 12px;
@@ -195,11 +197,17 @@ body {
195197
display: none;
196198
}
197199

200+
.menu-item-content {
201+
display: inline-flex;
202+
align-items: center;
203+
min-width: 0;
204+
gap: 8px;
205+
}
206+
198207
.menu-item-icon {
199208
width: 20px;
200209
height: 20px;
201210
min-width: 20px;
202-
margin-right: 8px;
203211
display: inline-flex;
204212
color: var(--text-color);
205213
}
@@ -210,6 +218,17 @@ body {
210218
white-space: nowrap;
211219
}
212220

221+
.menu-item-shortcut {
222+
margin-left: auto;
223+
font-size: 12px;
224+
color: var(--text-color-muted);
225+
letter-spacing: 0.5px;
226+
}
227+
228+
.menu-item:hover .menu-item-shortcut {
229+
color: var(--text-color-secondary);
230+
}
231+
213232
.menu-divider {
214233
height: 1px;
215234
margin: 4px 8px;

src/index.html

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,46 @@
2626

2727
<!-- Dropdown Menu -->
2828
<div id="main-menu-dropdown" class="main-menu-dropdown hidden" role="menu" aria-label="Primary actions">
29-
<button class="menu-item" data-action="connect" role="menuitem">
30-
<i data-lucide="printer" class="menu-item-icon" aria-hidden="true"></i>
31-
<span class="menu-item-label">Connect</span>
29+
<button class="menu-item" data-action="connect" role="menuitem" aria-keyshortcuts="Control+K">
30+
<span class="menu-item-content">
31+
<i data-lucide="printer" class="menu-item-icon" aria-hidden="true"></i>
32+
<span class="menu-item-label">Connect</span>
33+
</span>
34+
<span class="menu-item-shortcut" data-shortcut-id="connect">Ctrl+K</span>
3235
</button>
3336

34-
<button class="menu-item" data-action="settings" role="menuitem">
35-
<i data-lucide="settings" class="menu-item-icon" aria-hidden="true"></i>
36-
<span class="menu-item-label">Settings</span>
37+
<button class="menu-item" data-action="settings" role="menuitem" aria-keyshortcuts="Control+,">
38+
<span class="menu-item-content">
39+
<i data-lucide="settings" class="menu-item-icon" aria-hidden="true"></i>
40+
<span class="menu-item-label">Settings</span>
41+
</span>
42+
<span class="menu-item-shortcut" data-shortcut-id="settings">Ctrl+,</span>
3743
</button>
3844

39-
<button class="menu-item" data-action="status" role="menuitem">
40-
<i data-lucide="bar-chart-3" class="menu-item-icon" aria-hidden="true"></i>
41-
<span class="menu-item-label">Status</span>
45+
<button class="menu-item" data-action="status" role="menuitem" aria-keyshortcuts="Control+I">
46+
<span class="menu-item-content">
47+
<i data-lucide="bar-chart-3" class="menu-item-icon" aria-hidden="true"></i>
48+
<span class="menu-item-label">Status</span>
49+
</span>
50+
<span class="menu-item-shortcut" data-shortcut-id="status">Ctrl+I</span>
4251
</button>
4352

44-
<button id="menu-item-ifs" class="menu-item hidden" data-action="ifs" role="menuitem">
45-
<i data-lucide="grid-3x3" class="menu-item-icon" aria-hidden="true"></i>
46-
<span class="menu-item-label">IFS</span>
53+
<button id="menu-item-ifs" class="menu-item hidden" data-action="ifs" role="menuitem" aria-keyshortcuts="Control+M">
54+
<span class="menu-item-content">
55+
<i data-lucide="grid-3x3" class="menu-item-icon" aria-hidden="true"></i>
56+
<span class="menu-item-label">IFS</span>
57+
</span>
58+
<span class="menu-item-shortcut" data-shortcut-id="ifs">Ctrl+M</span>
4759
</button>
4860

4961
<div class="menu-divider" role="presentation"></div>
5062

51-
<button class="menu-item" data-action="pin-config" role="menuitem">
52-
<i data-lucide="pin" class="menu-item-icon" aria-hidden="true"></i>
53-
<span class="menu-item-label">Configure Shortcuts</span>
63+
<button class="menu-item" data-action="pin-config" role="menuitem" aria-keyshortcuts="Control+P">
64+
<span class="menu-item-content">
65+
<i data-lucide="pin" class="menu-item-icon" aria-hidden="true"></i>
66+
<span class="menu-item-label">Configure Shortcuts</span>
67+
</span>
68+
<span class="menu-item-shortcut" data-shortcut-id="pin-config">Ctrl+P</span>
5469
</button>
5570
</div>
5671

src/renderer.ts

Lines changed: 191 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,192 @@ let mainMenuButton: HTMLButtonElement | null = null;
9191
let mainMenuDropdown: HTMLDivElement | null = null;
9292
let mainMenuCloseTimeout: number | null = null;
9393

94+
const MAIN_MENU_ACTIONS = ['connect', 'settings', 'status', 'ifs', 'pin-config'] as const;
95+
type MainMenuAction = typeof MAIN_MENU_ACTIONS[number];
96+
97+
const MAIN_MENU_ACTION_CHANNELS: Record<MainMenuAction, string> = {
98+
connect: 'open-printer-selection',
99+
settings: 'open-settings-window',
100+
status: 'open-status-dialog',
101+
ifs: 'open-ifs-dialog',
102+
'pin-config': 'shortcut-config:open'
103+
};
104+
105+
const MAIN_MENU_SHORTCUTS: Record<MainMenuAction, { key: string; label: string }> = {
106+
connect: { key: 'k', label: 'K' },
107+
settings: { key: ',', label: ',' },
108+
status: { key: 'i', label: 'I' },
109+
ifs: { key: 'm', label: 'M' },
110+
'pin-config': { key: 'p', label: 'P' }
111+
};
112+
113+
const TEXT_INPUT_TYPES = new Set([
114+
'text',
115+
'email',
116+
'search',
117+
'password',
118+
'url',
119+
'tel',
120+
'number'
121+
]);
122+
123+
function isMainMenuAction(action: string | null): action is MainMenuAction {
124+
return MAIN_MENU_ACTIONS.includes(action as MainMenuAction);
125+
}
126+
127+
class MenuShortcutManager {
128+
private initialized = false;
129+
private isMac = false;
130+
private enabledActions: Record<MainMenuAction, boolean> = {
131+
connect: true,
132+
settings: true,
133+
status: true,
134+
ifs: false,
135+
'pin-config': true
136+
};
137+
138+
initialize(): void {
139+
this.isMac = window.PLATFORM === 'darwin';
140+
this.enabledActions.ifs = ifsMenuItemVisible;
141+
this.updateShortcutLabels();
142+
143+
if (this.initialized) {
144+
return;
145+
}
146+
147+
document.addEventListener('keydown', this.handleKeydown);
148+
this.initialized = true;
149+
}
150+
151+
dispose(): void {
152+
if (!this.initialized) {
153+
return;
154+
}
155+
156+
document.removeEventListener('keydown', this.handleKeydown);
157+
this.initialized = false;
158+
}
159+
160+
setActionEnabled(action: MainMenuAction, enabled: boolean): void {
161+
this.enabledActions[action] = enabled;
162+
}
163+
164+
updateShortcutLabels(): void {
165+
const displayPrefix = this.isMac ? '⌘' : 'Ctrl+';
166+
const ariaPrefix = this.isMac ? 'Meta+' : 'Control+';
167+
168+
MAIN_MENU_ACTIONS.forEach((action) => {
169+
const config = MAIN_MENU_SHORTCUTS[action];
170+
const displayValue = this.isMac ? `${displayPrefix}${config.label}` : `${displayPrefix}${config.label}`;
171+
const ariaValue = `${ariaPrefix}${config.label}`;
172+
173+
const shortcutEl = document.querySelector<HTMLSpanElement>(
174+
`.menu-item-shortcut[data-shortcut-id="${action}"]`
175+
);
176+
if (shortcutEl) {
177+
shortcutEl.textContent = displayValue;
178+
}
179+
180+
const button = document.querySelector<HTMLButtonElement>(`.menu-item[data-action="${action}"]`);
181+
if (button) {
182+
button.setAttribute('aria-keyshortcuts', ariaValue);
183+
}
184+
});
185+
}
186+
187+
private readonly handleKeydown = (event: KeyboardEvent): void => {
188+
if (!this.initialized) {
189+
return;
190+
}
191+
192+
if (event.defaultPrevented || event.repeat) {
193+
return;
194+
}
195+
196+
if (!this.isRelevantModifier(event)) {
197+
return;
198+
}
199+
200+
if (event.altKey || event.shiftKey) {
201+
return;
202+
}
203+
204+
if (this.isEditableContext()) {
205+
return;
206+
}
207+
208+
const action = this.getActionFromEvent(event);
209+
if (!action || !this.enabledActions[action]) {
210+
return;
211+
}
212+
213+
const channel = MAIN_MENU_ACTION_CHANNELS[action];
214+
if (!channel || !window.api?.send) {
215+
return;
216+
}
217+
218+
event.preventDefault();
219+
220+
window.api.send(channel);
221+
closeMainMenu();
222+
};
223+
224+
private isRelevantModifier(event: KeyboardEvent): boolean {
225+
return this.isMac ? event.metaKey : event.ctrlKey;
226+
}
227+
228+
private isEditableContext(): boolean {
229+
const activeElement = document.activeElement;
230+
if (!(activeElement instanceof HTMLElement)) {
231+
return false;
232+
}
233+
234+
if (activeElement instanceof HTMLInputElement) {
235+
if (!TEXT_INPUT_TYPES.has(activeElement.type)) {
236+
return false;
237+
}
238+
239+
return !activeElement.readOnly && !activeElement.disabled;
240+
}
241+
242+
if (activeElement instanceof HTMLTextAreaElement) {
243+
return !activeElement.readOnly && !activeElement.disabled;
244+
}
245+
246+
if (activeElement instanceof HTMLSelectElement) {
247+
return !activeElement.disabled;
248+
}
249+
250+
if (activeElement.isContentEditable) {
251+
return true;
252+
}
253+
254+
return Boolean(activeElement.closest('[contenteditable="true"]'));
255+
}
256+
257+
private getActionFromEvent(event: KeyboardEvent): MainMenuAction | null {
258+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
259+
260+
for (const action of MAIN_MENU_ACTIONS) {
261+
const shortcut = MAIN_MENU_SHORTCUTS[action];
262+
if (shortcut.key === ',') {
263+
if (event.key === ',') {
264+
return action;
265+
}
266+
continue;
267+
}
268+
269+
if (key === shortcut.key) {
270+
return action;
271+
}
272+
}
273+
274+
return null;
275+
}
276+
}
277+
278+
const menuShortcutManager = new MenuShortcutManager();
279+
94280
// Track filtration availability from backend
95281
let filtrationAvailable = false;
96282

@@ -1536,19 +1722,11 @@ function initializeMainMenu(): void {
15361722
toggleMainMenu();
15371723
});
15381724

1539-
const menuActions: Record<string, string> = {
1540-
connect: 'open-printer-selection',
1541-
settings: 'open-settings-window',
1542-
status: 'open-status-dialog',
1543-
ifs: 'open-ifs-dialog',
1544-
'pin-config': 'shortcut-config:open'
1545-
};
1546-
15471725
const menuItems = mainMenuDropdown.querySelectorAll<HTMLButtonElement>('.menu-item');
15481726
menuItems.forEach((item) => {
15491727
item.addEventListener('click', () => {
15501728
const action = item.getAttribute('data-action');
1551-
const channel = action ? menuActions[action] : undefined;
1729+
const channel = isMainMenuAction(action) ? MAIN_MENU_ACTION_CHANNELS[action] : undefined;
15521730
if (channel && window.api?.send) {
15531731
window.api.send(channel);
15541732
}
@@ -1739,6 +1917,8 @@ function updateIFSMenuItemVisibility(): void {
17391917
} else {
17401918
ifsMenuItem.classList.add('hidden');
17411919
}
1920+
1921+
menuShortcutManager.setActionEnabled('ifs', ifsMenuItemVisible);
17421922
}
17431923

17441924
/**
@@ -2038,6 +2218,7 @@ document.addEventListener('DOMContentLoaded', async () => {
20382218
setupLoadingEventListeners();
20392219
initializeUI();
20402220
initializeMainMenu();
2221+
menuShortcutManager.initialize();
20412222

20422223
// Initialize shortcut button system
20432224
initializeShortcutButtons();
@@ -2092,6 +2273,7 @@ document.addEventListener('DOMContentLoaded', async () => {
20922273
*/
20932274
window.addEventListener('beforeunload', () => {
20942275
console.log('Cleaning up resources in enhanced renderer with GridStack and component system');
2276+
menuShortcutManager.dispose();
20952277

20962278
// Clean up GridStack system
20972279
try {

0 commit comments

Comments
 (0)