Merge changes I1fba88e7,I5202c808,I96fb1e3a

* changes:
  Add addShortcut() utility as a replacement for keyBindings()
  Move modifierPressed() method from mixin to service
  Move combo key handling from mixin into service
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index ac44908..a3db699 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -407,7 +407,7 @@
   }
 
   _nextChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -419,7 +419,7 @@
   }
 
   _prevChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -431,7 +431,7 @@
   }
 
   _openChange(e: IronKeyboardEvent) {
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
     this.openChange(e.detail.keyboardEvent);
   }
 
@@ -445,7 +445,7 @@
   _nextPage(e: IronKeyboardEvent) {
     if (
       this.shortcuts.shouldSuppress(e) ||
-      (this.modifierPressed(e) && !isShiftPressed(e))
+      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -457,7 +457,7 @@
   _prevPage(e: IronKeyboardEvent) {
     if (
       this.shortcuts.shouldSuppress(e) ||
-      (this.modifierPressed(e) && !isShiftPressed(e))
+      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -472,7 +472,7 @@
   }
 
   _toggleChangeReviewed(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -500,7 +500,7 @@
   }
 
   _toggleChangeStar(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index fc2cbe5..f8bdbef 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -752,7 +752,7 @@
   }
 
   _handleToggleDiffMode(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1496,7 +1496,7 @@
   }
 
   _handleOpenReplyDialog(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
     this._getLoggedIn().then(isLoggedIn => {
@@ -1511,7 +1511,7 @@
   }
 
   _handleOpenDownloadDialogShortcut(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1520,7 +1520,7 @@
   }
 
   _handleEditTopic(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1672,7 +1672,7 @@
   }
 
   _handleToggleChangeStar(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
     e.preventDefault();
@@ -1680,7 +1680,7 @@
   }
 
   _handleUpToDashboard(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1689,7 +1689,7 @@
   }
 
   _handleExpandAllMessages(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1700,7 +1700,7 @@
   }
 
   _handleCollapseAllMessages(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1711,7 +1711,7 @@
   }
 
   _handleOpenDiffPrefsShortcut(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index b78c78f..74c079b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -902,7 +902,7 @@
   _handleToggleInlineDiff(e: IronKeyboardEvent) {
     if (
       this.shortcuts.shouldSuppress(e) ||
-      this.modifierPressed(e) ||
+      this.shortcuts.modifierPressed(e) ||
       e.detail?.keyboardEvent?.repeat ||
       this.fileCursor.index === -1
     ) {
@@ -923,7 +923,7 @@
   }
 
   _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -932,7 +932,7 @@
   }
 
   _handleCursorNext(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -952,7 +952,7 @@
   }
 
   _handleCursorPrev(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -972,7 +972,7 @@
   }
 
   _handleNewComment(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
     e.preventDefault();
@@ -1001,7 +1001,7 @@
   }
 
   _handleOpenFile(e: IronKeyboardEvent) {
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
     this.handleOpenFile(e.detail.keyboardEvent);
   }
 
@@ -1022,7 +1022,7 @@
   _handleNextChunk(e: IronKeyboardEvent) {
     if (
       this.shortcuts.shouldSuppress(e) ||
-      (this.modifierPressed(e) && !isShiftPressed(e)) ||
+      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
       this._noDiffsExpanded()
     ) {
       return;
@@ -1039,7 +1039,7 @@
   _handlePrevChunk(e: IronKeyboardEvent) {
     if (
       this.shortcuts.shouldSuppress(e) ||
-      (this.modifierPressed(e) && !isShiftPressed(e)) ||
+      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
       this._noDiffsExpanded()
     ) {
       return;
@@ -1054,7 +1054,7 @@
   }
 
   _handleToggleFileReviewed(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -1543,7 +1543,7 @@
   }
 
   _handleEscKey(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) {
+    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
       return;
     }
     e.preventDefault();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index f4064da..062d6a2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -597,7 +597,6 @@
         let interact;
 
         setup(() => {
-          sinon.stub(element, 'modifierPressed').returns(false);
           const openCursorStub = sinon.stub(element, '_openCursorFile');
           const openSelectedStub = sinon.stub(element, '_openSelectedFile');
           const expandStub = sinon.stub(element, '_toggleFileExpanded');
@@ -1708,12 +1707,15 @@
     });
 
     test('_displayLine', () => {
-      sinon.stub(element, 'modifierPressed')
-          .callsFake(() => false);
       element.filesExpanded = FilesExpandedState.ALL;
       const mockEvent = {
         preventDefault() {},
         composedPath() { return []; },
+        detail: {
+          keyboardEvent: {
+            composedPath() { return []; },
+          },
+        },
       };
 
       element._displayLine = false;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index aaeb924..dd1ebd1 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -400,7 +400,7 @@
     const keyboardEvent = e.detail.keyboardEvent;
     if (
       this.shortcuts.shouldSuppress(e) ||
-      (this.modifierPressed(e) && !keyboardEvent.shiftKey)
+      (this.shortcuts.modifierPressed(e) && !keyboardEvent.shiftKey)
     ) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index c7a462e..295e41f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -534,7 +534,7 @@
 
   _handleToggleFileReviewed(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     e.preventDefault();
     this._setReviewed(!this.$.reviewed.checked);
@@ -542,7 +542,7 @@
 
   _handleEscKey(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     e.preventDefault();
     this.$.diffHost.displayLine = false;
@@ -573,7 +573,7 @@
       this._moveToPreviousFileWithComment();
       return;
     }
-    if (this.modifierPressed(e)) {
+    if (this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -604,7 +604,7 @@
       this._moveToNextFileWithComment();
       return;
     }
-    if (this.modifierPressed(e)) {
+    if (this.shortcuts.modifierPressed(e)) {
       return;
     }
 
@@ -654,7 +654,7 @@
 
   _handleNewComment(ike: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(ike)) return;
-    if (this.modifierPressed(ike)) return;
+    if (this.shortcuts.modifierPressed(ike)) return;
 
     ike.preventDefault();
     this.classList.remove('hideComments');
@@ -695,7 +695,7 @@
         this._navigateToNextFileWithCommentThread();
       }
     } else {
-      if (this.modifierPressed(e)) return;
+      if (this.shortcuts.modifierPressed(e)) return;
       const result = this.cursor.moveToNextChunk();
       // navigate to next file if key is not being held down
       if (
@@ -753,7 +753,7 @@
     if (e.detail.keyboardEvent?.shiftKey) {
       this.cursor.moveToPreviousCommentThread();
     } else {
-      if (this.modifierPressed(e)) return;
+      if (this.shortcuts.modifierPressed(e)) return;
       this.cursor.moveToPreviousChunk();
       if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
         this.showToastAndNavigateFile('previous', 'p');
@@ -764,7 +764,7 @@
   // Similar to gr-change-view._handleOpenReplyDialog
   _handleOpenReplyDialog(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
         fireEvent(this, 'show-auth-required');
@@ -787,7 +787,7 @@
 
   _handleOpenDownloadDialog(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     this.set('changeViewState.showDownloadDialog', true);
     e.preventDefault();
@@ -796,7 +796,7 @@
 
   _handleUpToChange(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     e.preventDefault();
     this._navToChangeView();
@@ -804,7 +804,7 @@
 
   _handleCommaKey(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
     if (this._diffPrefsDisabled) return;
 
     e.preventDefault();
@@ -813,7 +813,7 @@
 
   _handleToggleDiffMode(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     e.preventDefault();
     if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
@@ -1703,21 +1703,21 @@
 
   _handleToggleBlame(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     this._toggleBlame();
   }
 
   _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
 
     toggleClass(this, 'hideComments');
   }
 
   _handleOpenFileList(e: IronKeyboardEvent) {
     if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.modifierPressed(e)) return;
+    if (this.shortcuts.modifierPressed(e)) return;
     this.$.dropdown.open();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index f1b74ac..e7417f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -21,17 +21,16 @@
 import '../gr-copy-clipboard/gr-copy-clipboard';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-thread_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   computeDiffFromContext,
   computeId,
+  DraftInfo,
   isDraft,
   isRobot,
   sortComments,
   UIComment,
   UIDraft,
   UIRobot,
-  DraftInfo,
 } from '../../../utils/comment-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
@@ -54,15 +53,14 @@
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {IronKeyboardEvent} from '../../../types/events';
-import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
+import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
 import {
-  check,
   assertIsDefined,
+  check,
   queryAndAssert,
 } from '../../../utils/common-util';
 import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
@@ -72,6 +70,7 @@
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
+import {addShortcut} from '../../../utils/dom-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -83,11 +82,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-comment-thread')
-export class GrCommentThread extends base {
+export class GrCommentThread extends PolymerElement {
   // KeyboardShortcutMixin Not used in this element rather other elements tests
 
   static get template() {
@@ -123,9 +119,6 @@
   @property({type: Object, reflectToAttribute: true})
   range?: CommentRange;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
   @property({type: String, reflectToAttribute: true})
   diffSide?: Side;
 
@@ -210,11 +203,8 @@
   @property({type: Array})
   layers: DiffLayer[] = [];
 
-  get keyBindings() {
-    return {
-      'e shift+e': '_handleEKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   private readonly reporting = appContext.reportingService;
 
@@ -240,8 +230,20 @@
     });
   }
 
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
   override connectedCallback() {
     super.connectedCallback();
+    this.cleanups.push(
+      addShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
+    );
+    this.cleanups.push(
+      addShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
+    );
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
@@ -499,21 +501,14 @@
     return this._orderedComments[this._orderedComments.length - 1] || {};
   }
 
-  _handleEKey(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  private handleExpandShortcut(e: KeyboardEvent) {
+    if (this.shortcuts.shouldSuppress(e)) return;
+    this._expandCollapseComments(false);
+  }
 
-    // Don’t preventDefault in this case because it will render the event
-    // useless for other handlers (other gr-comment-thread elements).
-    if (e.detail.keyboardEvent?.shiftKey) {
-      this._expandCollapseComments(true);
-    } else {
-      if (this.modifierPressed(e)) {
-        return;
-      }
-      this._expandCollapseComments(false);
-    }
+  private handleCollapseShortcut(e: KeyboardEvent) {
+    if (this.shortcuts.shouldSuppress(e)) return;
+    this._expandCollapseComments(true);
   }
 
   _expandCollapseComments(actionIsCollapse: boolean) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 06d25b3..595de34 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -673,7 +673,7 @@
       pressAndReleaseKeyOn(element, 69, null, 'e');
       assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
 
-      pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      pressAndReleaseKeyOn(element, 69, 'shift', 'E');
       assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
     });
 
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 3d1e120..02cfa9f 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -19,7 +19,6 @@
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
 import {check, Constructor} from '../../utils/common-util';
-import {isModifierPressed} from '../../utils/dom-util';
 import {IronKeyboardEvent} from '../../types/events';
 import {appContext} from '../../services/app-context';
 import {
@@ -28,8 +27,9 @@
   SPECIAL_SHORTCUT,
 } from '../../services/shortcuts/shortcuts-config';
 import {
-  ShortcutListener,
+  ComboKey,
   SectionView,
+  ShortcutListener,
 } from '../../services/shortcuts/shortcuts-service';
 
 export {
@@ -40,12 +40,6 @@
   SectionView,
 };
 
-// The maximum age of a keydown event to be used in a jump navigation. This
-// is only for cases when the keyup event is lost.
-const GO_KEY_TIMEOUT_MS = 1000;
-
-const V_KEY_TIMEOUT_MS = 1000;
-
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -65,12 +59,6 @@
    * @mixinClass
    */
   class Mixin extends superClass {
-    @property({type: Number})
-    _shortcut_go_key_last_pressed: number | null = null;
-
-    @property({type: Number})
-    _shortcut_v_key_last_pressed: number | null = null;
-
     @property({type: Object})
     _shortcut_go_table: Map<string, string> = new Map<string, string>();
 
@@ -100,16 +88,6 @@
     /** Are shortcuts currently enabled? True only when element is visible. */
     private bindingsEnabled = false;
 
-    modifierPressed(e: IronKeyboardEvent) {
-      /* We are checking for g/v as modifiers pressed. There are cases such as
-       * pressing v and then /, where we want the handler for / to be triggered.
-       * TODO(dhruvsri): find a way to support that keyboard combination
-       */
-      return (
-        isModifierPressed(e) || !!this._inGoKeyMode() || !!this.inVKeyMode()
-      );
-    }
-
     _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
       const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
       if (!bindings) {
@@ -188,11 +166,6 @@
         this._addOwnKeyBindings(key as Shortcut, value);
       }
 
-      // each component that uses this behaviour must be aware if go key is
-      // pressed or not, since it needs to check it as a modifier
-      this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-      this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
-
       // If any of the shortcuts utilized GO_KEY, then they are handled
       // directly by this behavior.
       if (this._shortcut_go_table.size > 0) {
@@ -201,8 +174,6 @@
         });
       }
 
-      this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
-      this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
       if (this._shortcut_v_table.size > 0) {
         this._shortcut_v_table.forEach((_, key) => {
           this.addOwnKeyBinding(key, '_handleVAction');
@@ -231,27 +202,9 @@
       return {};
     }
 
-    _handleVKeyDown(e: IronKeyboardEvent) {
-      if (this.shortcuts.shouldSuppress(e)) return;
-      this._shortcut_v_key_last_pressed = Date.now();
-    }
-
-    _handleVKeyUp() {
-      setTimeout(() => {
-        this._shortcut_v_key_last_pressed = null;
-      }, V_KEY_TIMEOUT_MS);
-    }
-
-    private inVKeyMode() {
-      return !!(
-        this._shortcut_v_key_last_pressed &&
-        Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
-      );
-    }
-
     _handleVAction(e: IronKeyboardEvent) {
       if (
-        !this.inVKeyMode() ||
+        !this.shortcuts.isInSpecificComboKeyMode(ComboKey.V) ||
         !this._shortcut_v_table.has(e.detail.key) ||
         this.shortcuts.shouldSuppress(e)
       ) {
@@ -266,29 +219,9 @@
       }
     }
 
-    _handleGoKeyDown(e: IronKeyboardEvent) {
-      if (this.shortcuts.shouldSuppress(e)) return;
-      this._shortcut_go_key_last_pressed = Date.now();
-    }
-
-    _handleGoKeyUp() {
-      // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
-      // so that users can trigger `g + i` by pressing g and i quickly.
-      setTimeout(() => {
-        this._shortcut_go_key_last_pressed = null;
-      }, GO_KEY_TIMEOUT_MS);
-    }
-
-    _inGoKeyMode() {
-      return !!(
-        this._shortcut_go_key_last_pressed &&
-        Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
-      );
-    }
-
     _handleGoAction(e: IronKeyboardEvent) {
       if (
-        !this._inGoKeyMode() ||
+        !this.shortcuts.isInSpecificComboKeyMode(ComboKey.G) ||
         !this._shortcut_go_table.has(e.detail.key) ||
         this.shortcuts.shouldSuppress(e)
       ) {
@@ -304,10 +237,7 @@
     }
   }
 
-  return Mixin as T &
-    Constructor<
-      KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
-    >;
+  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
 };
 
 // The following doesn't work (IronA11yKeysBehavior crashes):
@@ -320,10 +250,7 @@
 // This is a workaround
 export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
-): T &
-  Constructor<
-    KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
-  > =>
+): T & Constructor<KeyboardShortcutMixinInterface> =>
   InternalKeyboardShortcutMixin(
     // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
     // which will fail the type check due to missing IronA11yKeysBehavior interface
@@ -334,13 +261,4 @@
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
   keyboardShortcuts(): {[key: string]: string | null};
-  modifierPressed(event: IronKeyboardEvent): boolean;
-}
-
-export interface KeyboardShortcutMixinInterfaceTesting {
-  _shortcut_go_key_last_pressed: number | null;
-  _shortcut_v_key_last_pressed: number | null;
-  _shortcut_go_table: Map<string, string>;
-  _shortcut_v_table: Map<string, string>;
-  _handleGoAction: (e: IronKeyboardEvent) => void;
 }
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
deleted file mode 100644
index 01ad6cc..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../test/common-test-setup-karma';
-import {KeyboardShortcutMixin} from './keyboard-shortcut-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import '../../elements/shared/gr-overlay/gr-overlay';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {IronKeyboardEvent} from '../../types/events';
-
-class GrKeyboardShortcutMixinTestElement extends KeyboardShortcutMixin(
-  PolymerElement
-) {
-  static get is() {
-    return 'keyboard-shortcut-mixin-test-element';
-  }
-
-  get keyBindings() {
-    return {
-      k: '_handleKey',
-      enter: '_handleKey',
-    };
-  }
-
-  _handleKey(_: any) {}
-
-  _handleA(_: any) {}
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'keyboard-shortcut-mixin-test-element': GrKeyboardShortcutMixinTestElement;
-  }
-}
-
-customElements.define(
-  GrKeyboardShortcutMixinTestElement.is,
-  GrKeyboardShortcutMixinTestElement
-);
-
-const basicFixture = fixtureFromElement('keyboard-shortcut-mixin-test-element');
-
-suite('keyboard-shortcut-mixin tests', () => {
-  let element: GrKeyboardShortcutMixinTestElement;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  test('modifierPressed returns accurate values', () => {
-    const spy = sinon.spy(element, 'modifierPressed');
-    element._handleKey = e => {
-      element.modifierPressed(e);
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-  });
-
-  suite('GO_KEY timing', () => {
-    let handlerStub: sinon.SinonStub;
-
-    setup(() => {
-      element._shortcut_go_table.set('a', '_handleA');
-      handlerStub = element._handleA = sinon.stub();
-      sinon.stub(Date, 'now').returns(10000);
-    });
-
-    test('success', () => {
-      const e = {
-        detail: {key: 'a'},
-        preventDefault: () => {},
-        composedPath: () => [],
-      } as unknown as IronKeyboardEvent;
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isTrue(handlerStub.calledOnce);
-      assert.strictEqual(handlerStub.lastCall.args[0], e);
-    });
-
-    test('go key not pressed', () => {
-      const e = {
-        detail: {key: 'a'},
-        preventDefault: () => {},
-        composedPath: () => [],
-      } as unknown as IronKeyboardEvent;
-      element._shortcut_go_key_last_pressed = null;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('go key pressed too long ago', () => {
-      const e = {
-        detail: {key: 'a'},
-        preventDefault: () => {},
-        composedPath: () => [],
-      } as unknown as IronKeyboardEvent;
-      element._shortcut_go_key_last_pressed = 3000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('unrecognized key', () => {
-      const e = {
-        detail: {key: 'f'},
-        preventDefault: () => {},
-        composedPath: () => [],
-      } as unknown as IronKeyboardEvent;
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index d0e2d49..edc31a4 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -23,7 +23,7 @@
 } from './shortcuts-config';
 import {disableShortcuts$} from '../user/user-model';
 import {IronKeyboardEvent, isIronKeyboardEvent} from '../../types/events';
-import {isElementTarget} from '../../utils/dom-util';
+import {isElementTarget, isModifierPressed} from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
@@ -35,7 +35,16 @@
   viewMap?: Map<ShortcutSection, SectionView>
 ) => void;
 
-const COMBO_KEYS = ['g', 'v'];
+export enum ComboKey {
+  G = 'g',
+  V = 'v',
+}
+
+function isComboKey(key: string): key is ComboKey {
+  return Object.values(ComboKey).includes(key as ComboKey);
+}
+
+export const COMBO_TIMEOUT_MS = 1000;
 
 /**
  * Shortcuts service, holds all hosts, bindings and listeners.
@@ -54,12 +63,12 @@
   private readonly listeners = new Set<ShortcutListener>();
 
   /**
-   * Maps keys (e.g. 'g') to the timestamp when they have last been pressed.
+   * Stores the timestamp of the last combo key being pressed.
    * This enabled key combinations like 'g+o' where we can check whether 'g' was
    * pressed recently when 'o' is processed. Keys of this map must be items of
    * COMBO_KEYS. Values are Date timestamps in milliseconds.
    */
-  private readonly keyLastPressed = new Map<string, number>();
+  private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
 
   /** Keeps track of the corresponding user preference. */
   private shortcutsDisabled = false;
@@ -73,9 +82,9 @@
     }
     disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x));
     document.addEventListener('keydown', (e: KeyboardEvent) => {
-      if (!COMBO_KEYS.includes(e.key)) return;
+      if (!isComboKey(e.key)) return;
       if (this.shouldSuppress(e)) return;
-      this.keyLastPressed.set(e.key, Date.now());
+      this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
     });
   }
 
@@ -83,6 +92,25 @@
     return this.activeHosts.size === 0 && this.listeners.size === 0;
   }
 
+  isInComboKeyMode() {
+    return Object.values(ComboKey).some(key =>
+      this.isInSpecificComboKeyMode(key)
+    );
+  }
+
+  isInSpecificComboKeyMode(comboKey: ComboKey) {
+    const {key, timestampMs} = this.comboKeyLastPressed;
+    return (
+      key === comboKey &&
+      timestampMs &&
+      Date.now() - timestampMs < COMBO_TIMEOUT_MS
+    );
+  }
+
+  modifierPressed(e: IronKeyboardEvent) {
+    return isModifierPressed(e) || this.isInComboKeyMode();
+  }
+
   shouldSuppress(event: IronKeyboardEvent | KeyboardEvent) {
     if (this.shortcutsDisabled) return true;
     const e = isIronKeyboardEvent(event) ? event.detail.keyboardEvent : event;
@@ -116,9 +144,8 @@
     }
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
-    // TODO(brohlfs): Re-enable reporting of g- and v-keys.
-    // if (this._inGoKeyMode()) key = 'g+' + key;
-    // if (this.inVKeyMode()) key = 'v+' + key;
+    if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
+    if (this.isInSpecificComboKeyMode(ComboKey.V)) key = 'v+' + key;
     if (e.shiftKey) key = 'shift+' + key;
     if (e.ctrlKey) key = 'ctrl+' + key;
     if (e.metaKey) key = 'meta+' + key;
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 0998a4c..d8aa11e 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -15,9 +15,13 @@
  * limitations under the License.
  */
 import '../../test/common-test-setup-karma';
-import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {
+  ShortcutsService,
+  COMBO_TIMEOUT_MS,
+} from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {SinonFakeTimers} from 'sinon';
 
 async function keyEventOn(
   el: HTMLElement,
@@ -290,4 +294,48 @@
       });
     });
   });
+
+  suite('combo keys', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+      clock.tick(1000);
+    });
+
+    teardown(() => {
+      clock.restore();
+    });
+
+    test('not in combo key mode initially', () => {
+      assert.isFalse(service.isInComboKeyMode());
+    });
+
+    test('pressing f does not switch into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'f'});
+      document.dispatchEvent(event);
+      assert.isFalse(service.isInComboKeyMode());
+    });
+
+    test('pressing g switches into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'g'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+    });
+
+    test('pressing v switches into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'v'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+    });
+
+    test('combo key mode timeout', () => {
+      const event = new KeyboardEvent('keydown', {key: 'g'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+      clock.tick(COMBO_TIMEOUT_MS / 2);
+      assert.isTrue(service.isInComboKeyMode());
+      clock.tick(COMBO_TIMEOUT_MS);
+      assert.isFalse(service.isInComboKeyMode());
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index ead47bb..e3e342e 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -304,6 +304,54 @@
   }
 }
 
+/**
+ * For matching the `key` property of KeyboardEvents. These are known to work
+ * with Firefox, Safari and Chrome.
+ */
+export enum Key {
+  ENTER = 'Enter',
+  ESC = 'Escape',
+  TAB = 'Tab',
+  SPACE = ' ',
+  LEFT = 'ArrowLeft',
+  RIGHT = 'ArrowRight',
+  UP = 'ArrowUp',
+  DOWN = 'ArrowDown',
+}
+
+export enum Modifier {
+  ALT_KEY,
+  CTRL_KEY,
+  META_KEY,
+  SHIFT_KEY,
+}
+
+export interface Shortcut {
+  key: string;
+  modifiers?: Modifier[];
+}
+
+export function addShortcut(
+  shortcut: Shortcut,
+  listener: (e: KeyboardEvent) => void
+) {
+  const wrappedListener = (e: KeyboardEvent) => {
+    if (e.key !== shortcut.key) return;
+    const modifiers = shortcut.modifiers ?? [];
+    if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return;
+    if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return;
+    // TODO(brohlfs): Refine the matching of modifiers. For "normal" keys we
+    // don't want to check ALT and SHIFT, because we don't know what the
+    // keyboard layout looks like. The user may have to use ALT and/or SHIFT for
+    // certain keys. Comparing the `key` string is sufficient in that case.
+    // if (e.altKey !== modifiers.includes(Modifier.ALT_KEY)) return;
+    // if (e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)) return;
+    listener(e);
+  };
+  document.addEventListener('keydown', wrappedListener);
+  return () => document.removeEventListener('keydown', wrappedListener);
+}
+
 export function modifierPressed(e: KeyboardEvent) {
   return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
 }