Skip to main content

max / goingson

drop the keyboard item selection and the pending-key hint
Author: Max Johnson <me@maxj.phd> · 2026-08-06 18:49 UTC
Signed with PGP, not checked
Commit: e5e04249f9b747f9d6139212e0aa1a6349777f17
Parent: f279e6d
2 files changed, +21 insertions, -352 deletions
@@ -183,7 +183,7 @@
183 183 `.task-overdue`, `.task-completed`, `.task-started`, `.task-snoozed`, `.task-timer-active`, `.priority-high | -medium | -low`, plus badges `.task-badge.has-items`, `.task-time-badge.over-estimate`.
184 184
185 185 ### Bulk selection
186 - Bar: `.bulk-actions-bar`. Controls: `.bulk-checkbox`, `.bulk-select-all`, `.bulk-count`. Row state: `.selected`, `.keyboard-selected`.
186 + Bar: `.bulk-actions-bar`. Controls: `.bulk-checkbox`, `.bulk-select-all`, `.bulk-count`. Row state: `.selected`.
187 187
188 188 ### Kanban: `.kanban-board`
189 189 Children: `.kanban-column`, `.kanban-card`, `.kanban-card-empty`. Used only by tasks-kanban view.
@@ -1,53 +1,27 @@
1 1 /**
2 2 * GoingsOn - Keyboard Shortcuts Module
3 - * All keyboard shortcuts, overlay, item navigation
3 + * The shortcut table, the help overlay, and quick add.
4 4 */
5 5
6 6 (function() {
7 7 'use strict';
8 8
9 - // Keyboard State
10 -
11 - // State for two-key sequences (like 'g t')
12 - let pendingKey = null;
13 - let pendingKeyTimeout = null;
14 -
15 - // Selected item index for j/k navigation
16 - let selectedItemIndex = -1;
17 -
18 9 // Keyboard Shortcuts Definition
19 10
11 + // Three keys here plus Cmd+K below, and the bar for a fifth is high. A
12 + // binding earns its place by frequency times the cost of its pointer path,
13 + // so view switching and item selection do not qualify: the nav target is
14 + // already on screen, and a selection model is the full-keyboard-navigation
15 + // project in miniature, needing per-view list awareness and taxing every
16 + // view added after it.
17 + //
18 + // What replaced the old j/k set is not a smaller set of shortcuts. It is
19 + // native tab order through the real buttons each row already draws, which
20 + // costs nothing to maintain and is the accessibility floor besides.
20 21 const shortcuts = {
21 - // Single key shortcuts
22 22 '?': { action: showShortcutsOverlay, description: 'Show this help' },
23 23 'Escape': { action: closeShortcutsOverlay, description: 'Close overlay/modal' },
24 24 'q': { action: openQuickAddModal, description: 'Quick add task' },
25 - 'n': { action: newItemForCurrentView, description: 'New item in current view' },
26 - 'j': { action: selectNextItem, description: 'Select next item' },
27 - 'k': { action: selectPrevItem, description: 'Select previous item' },
28 - 'Enter': { action: openSelectedItem, description: 'Open selected item' },
29 - 'a': { action: archiveSelected, description: 'Archive selected (emails)' },
30 - 'c': { action: completeSelected, description: 'Complete selected (tasks)' },
31 - 't': { action: createTaskFromSelected, description: 'Create task from email' },
32 - 'r': { action: replySelected, description: 'Reply to email' },
33 - 'f': { action: forwardSelected, description: 'Forward email' },
34 - 'u': { action: markUnreadSelected, description: 'Mark email unread' },
35 - 's': { action: snoozeSelected, description: 'Snooze selected item' },
36 - 'S': { action: scheduleSelected, description: 'Schedule selected task', shift: true },
37 - '[': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.previousDay(); }, description: 'Previous day (Day Plan)' },
38 - ']': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.nextDay(); }, description: 'Next day (Day Plan)' },
39 -
40 - // Two-key sequences (g + key for "go to")
41 - 'g': {
42 - 't': { action: () => GoingsOn.navigation.switchView('tasks'), description: 'Go to Tasks' },
43 - 'e': { action: () => GoingsOn.navigation.switchView('emails'), description: 'Go to Emails' },
44 - 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' },
45 - 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' },
46 - 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' },
47 - 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' },
48 - 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' },
49 - 'm': { action: () => GoingsOn.navigation.switchView('monthly-review'), description: 'Go to Monthly Review' },
50 - }
51 25 };
52 26
53 27 // Keyboard Handler
@@ -102,78 +76,13 @@
102 76 // handled above, before this guard, so it still opens the palette.
103 77 if (e.metaKey || e.ctrlKey || e.altKey) return;
104 78
105 - // Handle two-key sequences
106 - if (pendingKey) {
107 - clearTimeout(pendingKeyTimeout);
108 - const sequence = shortcuts[pendingKey];
109 - if (sequence && typeof sequence === 'object' && sequence[e.key]) {
110 - e.preventDefault();
111 - sequence[e.key].action();
112 - }
113 - pendingKey = null;
114 - dismissPendingKeyHint();
115 - return;
116 - }
117 -
118 - // Check if this starts a two-key sequence
119 79 const shortcut = shortcuts[e.key];
120 80 if (shortcut) {
121 - // Check if this shortcut requires shift
122 - if (shortcut.shift && !e.shiftKey) {
123 - // Shortcut requires shift but shift not pressed, skip
124 - return;
125 - }
126 - if (!shortcut.shift && e.shiftKey && e.key !== '?') {
127 - // Shortcut doesn't require shift but shift is pressed (except for ?)
128 - // Check if uppercase version exists
129 - const upperKey = e.key.toUpperCase();
130 - if (shortcuts[upperKey] && shortcuts[upperKey].shift) {
131 - e.preventDefault();
132 - shortcuts[upperKey].action();
133 - return;
134 - }
135 - }
136 - if (typeof shortcut.action === 'function') {
137 - e.preventDefault();
138 - shortcut.action();
139 - } else if (typeof shortcut === 'object' && !shortcut.action) {
140 - // This is a two-key sequence starter
141 - e.preventDefault();
142 - pendingKey = e.key;
143 - showPendingKeyHint(e.key, shortcut);
144 - pendingKeyTimeout = setTimeout(() => {
145 - pendingKey = null;
146 - dismissPendingKeyHint();
147 - }, 1000);
148 - }
81 + e.preventDefault();
82 + shortcut.action();
149 83 }
150 84 }
151 85
152 - // Pending Key Hint
153 -
154 - /**
155 - * Show a small hint when a two-key sequence is started (e.g. pressing 'g').
156 - * Lists the available follow-up keys so the user knows what to press next.
157 - */
158 - function showPendingKeyHint(key, sequence) {
159 - dismissPendingKeyHint();
160 - const hint = document.createElement('div');
161 - hint.id = 'pending-key-hint';
162 - hint.className = 'pending-key-hint';
163 -
164 - const destinations = Object.entries(sequence)
165 - .map(([k, v]) => `<kbd>${k}</kbd> ${GoingsOn.utils.escapeHtml(v.description.replace('Go to ', ''))}`)
166 - .join('&nbsp;&nbsp;&middot;&nbsp;&nbsp;');
167 -
168 - hint.innerHTML = `<span class="pending-key-hint-label">Go to:</span> ${destinations}`;
169 - document.body.appendChild(hint);
170 - }
171 -
172 - function dismissPendingKeyHint() {
173 - const hint = document.getElementById('pending-key-hint');
174 - if (hint) hint.remove();
175 - }
176 -
177 86 // Shortcuts Overlay
178 87
179 88 function showShortcutsOverlay() {
@@ -188,40 +97,16 @@
188 97
189 98 <div class="shortcuts-grid">
190 99 <div>
191 - <h3 class="shortcuts-group-heading">WORK</h3>
192 - <div class="shortcut-row"><kbd>g</kbd> <kbd>t</kbd> <span>Tasks</span></div>
193 - <div class="shortcut-row"><kbd>g</kbd> <kbd>p</kbd> <span>Projects</span></div>
194 - <h3 class="shortcuts-group-heading-spaced">TIME</h3>
195 - <div class="shortcut-row"><kbd>g</kbd> <kbd>d</kbd> <span>Day</span></div>
196 - <div class="shortcut-row"><kbd>g</kbd> <kbd>w</kbd> <span>Week</span></div>
197 - <div class="shortcut-row"><kbd>g</kbd> <kbd>m</kbd> <span>Month</span></div>
198 - <div class="shortcut-row"><kbd>g</kbd> <kbd>v</kbd> <span>Events</span></div>
199 - <h3 class="shortcuts-group-heading-spaced">MESSAGES</h3>
200 - <div class="shortcut-row"><kbd>g</kbd> <kbd>e</kbd> <span>Email</span></div>
201 - <div class="shortcut-row"><kbd>g</kbd> <kbd>c</kbd> <span>Contacts</span></div>
202 - <h3 class="shortcuts-group-heading-spaced">MOVEMENT</h3>
203 - <div class="shortcut-row"><kbd>[</kbd> <span>Previous day</span></div>
204 - <div class="shortcut-row"><kbd>]</kbd> <span>Next day</span></div>
205 - <div class="shortcut-row"><kbd>j</kbd> <span>Next item</span></div>
206 - <div class="shortcut-row"><kbd>k</kbd> <span>Previous item</span></div>
207 - <div class="shortcut-row"><kbd>Enter</kbd> <span>Open selected</span></div>
100 + <h3 class="shortcuts-group-heading">SHORTCUTS</h3>
101 + <div class="shortcut-row"><kbd>&#8984;</kbd> <kbd>K</kbd> <span>Command palette</span></div>
102 + <div class="shortcut-row"><kbd>q</kbd> <span>Quick add task</span></div>
103 + <div class="shortcut-row"><kbd>?</kbd> <span>Show this help</span></div>
104 + <div class="shortcut-row"><kbd>Esc</kbd> <span>Close modal</span></div>
208 105 </div>
209 106
210 107 <div>
211 - <h3 class="shortcuts-group-heading">ACTIONS</h3>
212 - <div class="shortcut-row"><kbd>&#8984;</kbd> <kbd>K</kbd> <span>Command palette</span></div>
213 - <div class="shortcut-row"><kbd>n</kbd> <span>New item</span></div>
214 - <div class="shortcut-row"><kbd>q</kbd> <span>Quick add task</span></div>
215 - <div class="shortcut-row"><kbd>c</kbd> <span>Complete task</span></div>
216 - <div class="shortcut-row"><kbd>r</kbd> <span>Reply to email</span></div>
217 - <div class="shortcut-row"><kbd>f</kbd> <span>Forward email</span></div>
218 - <div class="shortcut-row"><kbd>u</kbd> <span>Mark unread</span></div>
219 - <div class="shortcut-row"><kbd>a</kbd> <span>Archive email</span></div>
220 - <div class="shortcut-row"><kbd>t</kbd> <span>Email to task</span></div>
221 - <div class="shortcut-row"><kbd>s</kbd> <span>Snooze item</span></div>
222 - <div class="shortcut-row"><kbd>Shift</kbd> <kbd>S</kbd> <span>Schedule task</span></div>
223 - <div class="shortcut-row"><kbd>?</kbd> <span>Show this help</span></div>
224 - <div class="shortcut-row"><kbd>Esc</kbd> <span>Close modal</span></div>
108 + <h3 class="shortcuts-group-heading">EVERYTHING ELSE</h3>
109 + <p class="shortcuts-note">Tab moves through the controls on a row, Enter and Space press the one you land on. The command palette reaches any view by name.</p>
225 110 </div>
226 111 </div>
227 112
@@ -372,221 +257,6 @@
372 257 }
373 258 }
374 259
375 - function newItemForCurrentView() {
376 - const currentView = GoingsOn.navigation.getCurrentView();
377 - switch (currentView) {
378 - case 'projects': GoingsOn.projects.openNew(); break;
379 - case 'tasks': GoingsOn.tasks.openNew(); break;
380 - case 'events': GoingsOn.events.openNew(); break;
381 - case 'emails': GoingsOn.emails.openCompose(); break;
382 - case 'contacts': GoingsOn.contacts.openNew(); break;
383 - }
384 - }
385 -
386 - // Item Navigation
387 -
388 - function getSelectableItems() {
389 - const currentView = GoingsOn.navigation.getCurrentView();
390 - switch (currentView) {
391 - case 'tasks':
392 - return document.querySelectorAll('#task-list-container .task-row[data-id]');
393 - case 'emails':
394 - return document.querySelectorAll('#email-list .email-item');
395 - case 'projects':
396 - return document.querySelectorAll('#projects-grid .project-card');
397 - case 'events':
398 - return document.querySelectorAll('#event-list-container .event-row-virtual');
399 - default:
400 - return [];
401 - }
402 - }
403 -
404 - function clearItemSelection() {
405 - document.querySelectorAll('.keyboard-selected').forEach(el => {
406 - el.classList.remove('keyboard-selected');
407 - });
408 - }
409 -
410 - function selectNextItem() {
411 - const items = getSelectableItems();
412 - if (items.length === 0) return;
413 -
414 - clearItemSelection();
415 - selectedItemIndex = Math.min(selectedItemIndex + 1, items.length - 1);
416 - if (selectedItemIndex < 0) selectedItemIndex = 0;
417 -
418 - items[selectedItemIndex].classList.add('keyboard-selected');
419 - items[selectedItemIndex].scrollIntoView({ block: 'nearest' });
420 - }
421 -
422 - function selectPrevItem() {
423 - const items = getSelectableItems();
424 - if (items.length === 0) return;
425 -
426 - clearItemSelection();
427 - selectedItemIndex = Math.max(selectedItemIndex - 1, 0);
428 -
429 - items[selectedItemIndex].classList.add('keyboard-selected');
430 - items[selectedItemIndex].scrollIntoView({ block: 'nearest' });
431 - }
432 -
433 - function openSelectedItem() {
434 - const items = getSelectableItems();
435 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
436 -
437 - const item = items[selectedItemIndex];
438 - item.click();
439 - }
440 -
441 - function archiveSelected() {
442 - const currentView = GoingsOn.navigation.getCurrentView();
443 - if (currentView !== 'emails') return;
444 -
445 - const items = getSelectableItems();
446 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
447 -
448 - const item = items[selectedItemIndex];
449 - const id = item.dataset.id;
450 - if (id) {
451 - GoingsOn.emails.archive(id);
452 - }
453 - }
454 -
455 - function completeSelected() {
456 - const currentView = GoingsOn.navigation.getCurrentView();
457 - if (currentView !== 'tasks') return;
458 -
459 - const items = getSelectableItems();
460 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
461 -
462 - const item = items[selectedItemIndex];
463 - const id = item.dataset.id;
464 - if (id) {
465 - GoingsOn.tasks.complete(id);
466 - }
467 - }
468 -
469 - async function createTaskFromSelected() {
470 - const currentView = GoingsOn.navigation.getCurrentView();
471 - if (currentView !== 'emails') {
472 - GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
473 - return;
474 - }
475 -
476 - const items = getSelectableItems();
477 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
478 - GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
479 - return;
480 - }
481 -
482 - const item = items[selectedItemIndex];
483 - const id = item.dataset.id;
484 - if (id) {
485 - GoingsOn.emails.createTaskFromEmail(id);
486 - }
487 - }
488 -
489 - function replySelected() {
490 - const currentView = GoingsOn.navigation.getCurrentView();
491 - if (currentView !== 'emails') return;
492 -
493 - const items = getSelectableItems();
494 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
495 - GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
496 - return;
497 - }
498 -
499 - const id = items[selectedItemIndex].dataset.id;
500 - if (id) GoingsOn.emails.reply(id);
501 - }
502 -
503 - function forwardSelected() {
504 - const currentView = GoingsOn.navigation.getCurrentView();
505 - if (currentView !== 'emails') return;
506 -
507 - const items = getSelectableItems();
508 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
509 - GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
510 - return;
511 - }
512 -
513 - const id = items[selectedItemIndex].dataset.id;
514 - if (id) GoingsOn.emails.forward(id);
515 - }
516 -
517 - function markUnreadSelected() {
518 - const currentView = GoingsOn.navigation.getCurrentView();
519 - if (currentView !== 'emails') return;
520 -
521 - const items = getSelectableItems();
522 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
523 - GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
524 - return;
525 - }
526 -
527 - const id = items[selectedItemIndex].dataset.id;
528 - if (id) GoingsOn.emails.markUnread(id);
529 - }
530 -
531 - function snoozeSelected() {
532 - const items = getSelectableItems();
533 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
534 - GoingsOn.ui.showToast('Select an item first (use j/k to navigate)', 'info');
535 - return;
536 - }
537 -
538 - const item = items[selectedItemIndex];
539 - const currentView = GoingsOn.navigation.getCurrentView();
540 - const id = item.dataset.id;
541 -
542 - if (currentView === 'tasks' && id) {
543 - GoingsOn.snooze.openModal('task', id);
544 - } else if (currentView === 'emails' && id) {
545 - GoingsOn.snooze.openModal('email', id);
546 - } else {
547 - GoingsOn.ui.showToast('Snooze is available for tasks and emails', 'info');
548 - }
549 - }
550 -
551 - function scheduleSelected() {
552 - const currentView = GoingsOn.navigation.getCurrentView();
553 - if (currentView !== 'tasks') {
554 - GoingsOn.ui.showToast('Schedule is available for tasks only', 'info');
555 - return;
556 - }
557 -
558 - const items = getSelectableItems();
559 - if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
560 - GoingsOn.ui.showToast('Select a task first (use j/k to navigate)', 'info');
561 - return;
562 - }
563 -
564 - const item = items[selectedItemIndex];
565 - const id = item.dataset.id;
566 - if (id && GoingsOn.dayPlan?.openScheduleTaskModal) {
567 - GoingsOn.dayPlan.openScheduleTaskModal(id);
568 - }
569 - }
570 -
571 - /**
572 - * Reset the keyboard selection index and clear any visual highlight.
573 - */
574 - function resetSelectedItemIndex() {
575 - selectedItemIndex = -1;
576 - clearItemSelection();
577 - }
578 -
579 - // Style for keyboard-selected
580 -
581 - const keyboardStyle = document.createElement('style');
582 - keyboardStyle.textContent = `
583 - .keyboard-selected {
584 - outline: 2px solid var(--action) !important;
585 - outline-offset: -2px;
586 - }
587 - `;
588 - document.head.appendChild(keyboardStyle);
589 -
590 260 // Register Event Listeners
591 261
592 262 // Register keyboard shortcut handler (skip on touch devices, no physical keyboard)
@@ -603,7 +273,6 @@
603 273 openGettingStarted,
604 274 openQuickAdd: openQuickAddModal,
605 275 submitQuickAdd: submitQuickAdd,
606 - resetSelection: resetSelectedItemIndex,
607 276 _onQuickAddInput: onQuickAddInput,
608 277 };
609 278