Merge changes I0a1d7b85,I61e0b678,I2c5b014d,I8a70ca14

* changes:
  Remove bindShortcut()
  Move shortcut listener into service
  Move createTitle() from keyboard mixin into service
  Move ShortcutManager to services/ directory
diff --git a/package.json b/package.json
index a47ba9f..ebfadc8 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
     "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
+    "test:single:nocompile": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
     "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
     "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
   },
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 7b226e7..4956380 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -19,8 +19,7 @@
 import './gr-change-list.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {mockPromise, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {mockPromise} from '../../../test/test-utils.js';
 import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-change-list');
@@ -28,22 +27,6 @@
 suite('gr-change-list basic tests', () => {
   let element;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    kb.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'n');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'p');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   setup(() => {
     element = basicFixture.instantiate();
   });
@@ -495,11 +478,11 @@
         assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
             'Should navigate to /c/4/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        MockInteractions.keyUpOn(element, 82); // 'r'
         const change = element._changeForIndex(element.selectedIndex);
         assert.equal(change.reviewed, true,
             'Should mark change as reviewed');
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        MockInteractions.keyUpOn(element, 82); // 'r'
         assert.equal(change.reviewed, false,
             'Should mark change as unreviewed');
         promise.resolve();
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 3396e13..0400030 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
@@ -48,6 +48,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
@@ -563,6 +564,8 @@
 
   private readonly commentsService = appContext.commentsService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   private replyDialogResizeObserver?: ResizeObserver;
 
   override keyboardShortcuts() {
@@ -2644,6 +2647,10 @@
       '#relatedChanges'
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 6668e15..74d3cfc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -35,12 +35,7 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {
-  mockPromise,
-  stubRestApi,
-  TestKeyboardShortcutBinder,
-} from '../../../test/test-utils';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {mockPromise, stubRestApi} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -87,6 +82,7 @@
 } from '../../../types/common';
 import {
   pressAndReleaseKeyOn,
+  keyUpOn,
   tap,
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
@@ -111,25 +107,6 @@
     typeof GerritNav.navigateToChange
   >;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   const ROBOT_COMMENTS_LIMIT = 10;
 
   // TODO: should have a mock service to generate VALID fake data
@@ -682,7 +659,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      pressAndReleaseKeyOn(element, 65, null, 'a');
+      keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isFalse(element.$.replyOverlay.opened);
       assert.isTrue(loggedInErrorSpy.called);
@@ -715,7 +692,7 @@
 
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
-      pressAndReleaseKeyOn(element, 65, null, 'a');
+      keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.$.replyOverlay.opened);
       element.$.replyOverlay.close();
@@ -828,7 +805,7 @@
       const stub = sinon
         .stub(element.$.downloadOverlay, 'open')
         .returns(Promise.resolve());
-      pressAndReleaseKeyOn(element, 68, null, 'd');
+      keyUpOn(element, 68, null, 'd');
       assert.isTrue(stub.called);
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index bdc6a43..8aef3c0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -44,7 +44,12 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -144,6 +149,8 @@
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   setDiffViewMode(mode: DiffViewMode) {
     this.$.modeSelect.setMode(mode);
   }
@@ -217,4 +224,8 @@
     }
     return 'patchInfoOldPatchSet';
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
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 b8c6cde..85e6f25 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
@@ -27,7 +27,6 @@
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {
-  TestKeyboardShortcutBinder,
   stubRestApi,
   spyRestApi,
   listenOnce,
@@ -35,7 +34,6 @@
   query,
 } from '../../../test/test-utils.js';
 import {EditPatchSetNum} from '../../../types/common.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
 import {
   createChange,
@@ -68,30 +66,6 @@
 
   let saveStub;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    kb.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-    kb.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    kb.bindShortcut(Shortcut.OPEN_FILE, 'o');
-    kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   suite('basic tests', () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -566,27 +540,27 @@
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[0]);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
         element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[1]);
 
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
@@ -595,7 +569,7 @@
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
@@ -610,12 +584,12 @@
         assert.equal(getNumReviewed(), 0);
 
         // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         flush();
         assert.equal(getNumReviewed(), 1);
 
         // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.equal(getNumReviewed(), 0);
       });
 
@@ -1574,7 +1548,7 @@
     });
 
     test('cursor with individually opened files', async () => {
-      MockInteractions.keyUpOn(element, 73, null, 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
       await flush();
       let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
@@ -1601,7 +1575,7 @@
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
 
-      MockInteractions.keyUpOn(element, 73, null, 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
       await flush();
       diffs = await renderAndGetNewDiffs(1);
 
@@ -1616,7 +1590,7 @@
     });
 
     test('cursor with toggle all files', async () => {
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
       await flush();
 
       const diffs = await renderAndGetNewDiffs(0);
@@ -1663,7 +1637,7 @@
       });
 
       test('n key with some files expanded and no shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
 
         // Handle N key should return before calling diff cursor functions.
@@ -1677,7 +1651,7 @@
       });
 
       test('n key with some files expanded and shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
         assert.equal(nextChunkStub.callCount, 0);
 
@@ -1691,7 +1665,7 @@
       });
 
       test('n key without all files expanded and shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
         await flush();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
@@ -1704,7 +1678,7 @@
       });
 
       test('n key without all files expanded and no shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
         await flush();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
@@ -1759,13 +1733,13 @@
         const saveReviewStub = sinon.stub(element, '_saveReviewedState');
 
         element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
         await flush();
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index fdd7c79..cd79bba 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -257,6 +257,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
@@ -384,13 +386,13 @@
 
   _computeExpandAllTitle(_expandAllState?: string) {
     if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
     if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 0b191f1..541d877 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -27,6 +27,7 @@
   SectionView,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement} from '@polymer/decorators';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -60,13 +61,14 @@
   @property({type: Array})
   _right?: SectionShortcut[];
 
-  private keyboardShortcutDirectoryListener: ShortcutListener;
+  private readonly shortcutListener: ShortcutListener;
+
+  private readonly shortcuts = appContext.shortcutsService;
 
   constructor() {
     super();
-    this.keyboardShortcutDirectoryListener = (
-      d?: Map<ShortcutSection, SectionView>
-    ) => this._onDirectoryUpdated(d);
+    this.shortcutListener = (d?: Map<ShortcutSection, SectionView>) =>
+      this._onDirectoryUpdated(d);
   }
 
   override ready() {
@@ -76,15 +78,11 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.addKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+    this.shortcuts.addListener(this.shortcutListener);
   }
 
   override disconnectedCallback() {
-    this.removeKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+    this.shortcuts.removeListener(this.shortcutListener);
     super.disconnectedCallback();
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index b5b0124..b6d0579 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -19,12 +19,7 @@
 import './gr-search-bar';
 import '../../../scripts/util';
 import {GrSearchBar} from './gr-search-bar';
-import {
-  TestKeyboardShortcutBinder,
-  stubRestApi,
-  mockPromise,
-} from '../../../test/test-utils';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {stubRestApi, mockPromise} from '../../../test/test-utils';
 import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
@@ -39,15 +34,6 @@
 suite('gr-search-bar tests', () => {
   let element: GrSearchBar;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEARCH, '/');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   setup(async () => {
     element = basicFixture.instantiate();
     await flush();
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 bb5ce94..978d98f 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
@@ -37,6 +37,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GeneratedWebLink,
@@ -341,6 +342,8 @@
 
   private readonly commentsService = appContext.commentsService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
@@ -1898,6 +1901,10 @@
   _computeTruncatedPath(path?: string) {
     return path ? computeTruncatedPath(path) : '';
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index a3de30a..9aca719 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,8 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
@@ -41,42 +40,6 @@
     let clock;
     let diffCommentsStub;
 
-    suiteSetup(() => {
-      const kb = TestKeyboardShortcutBinder.push();
-      kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-      kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-      kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-      kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-      kb.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      kb.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-      kb.bindShortcut(Shortcut.SAVE_COMMENT, 'ctrl+s');
-      kb.bindShortcut(Shortcut.NEXT_FILE, ']');
-      kb.bindShortcut(Shortcut.PREV_FILE, '[');
-      kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-      kb.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-      kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-      kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-      kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-      kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-      kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
-      kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
-      kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-      kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
-      kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
-      kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-    });
-
-    suiteTeardown(() => {
-      TestKeyboardShortcutBinder.pop();
-    });
-
     const PARENT = 'PARENT';
 
     function getFilesFromFileList(fileList) {
@@ -504,16 +467,16 @@
       sinon.stub(element, '_setReviewed');
       sinon.spy(element, '_handleToggleFileReviewed');
       element.$.reviewed.checked = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      MockInteractions.keyUpOn(element, 82, 'shift', 'r');
       assert.isFalse(element._setReviewed.called);
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      MockInteractions.keyUpOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
       clock.tick(1000);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      MockInteractions.keyUpOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledTwice);
       assert.isTrue(element._setReviewed.called);
       assert.equal(element._setReviewed.lastCall.args[0], true);
@@ -682,7 +645,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      MockInteractions.keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
         'should only work when the user is logged in.');
@@ -708,7 +671,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      MockInteractions.keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.changeViewState.showReplyDialog);
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
@@ -734,7 +697,7 @@
           sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
           const loggedInErrorSpy = sinon.spy();
           element.addEventListener('show-auth-required', loggedInErrorSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+          MockInteractions.keyUpOn(element, 65, null, 'a');
           await flush();
           assert.isTrue(element.changeViewState.showReplyDialog);
           assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
@@ -798,7 +761,7 @@
       'Should navigate to /c/42/5..10');
 
       assert.isUndefined(element.changeViewState.showDownloadDialog);
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      MockInteractions.keyUpOn(element, 68, null, 'd');
       assert.isTrue(element.changeViewState.showDownloadDialog);
     });
 
@@ -1732,7 +1695,7 @@
       test('toggle blame with shortcut', () => {
         const toggleBlame = sinon.stub(
             element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
-        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        MockInteractions.keyUpOn(element, 66, null, 'b');
         assert.isTrue(toggleBlame.calledOnce);
       });
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index b6fe60b..3bbcd5b 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -43,7 +43,6 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  SPECIAL_SHORTCUT,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
@@ -232,7 +231,6 @@
     // model changes and updates the config model, but at the moment the service
     // is not called from anywhere.
     appContext.configService;
-    this._bindKeyboardShortcuts();
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this._handlePageError(e);
     });
@@ -307,159 +305,6 @@
     };
   }
 
-  _bindKeyboardShortcuts() {
-    this.bindShortcut(
-      Shortcut.SEND_REPLY,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'ctrl+enter',
-      'meta+enter'
-    );
-    this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
-
-    this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-    this.bindShortcut(
-      Shortcut.GO_TO_USER_DASHBOARD,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'i'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'o'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_MERGED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'm'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_ABANDONED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_WATCHED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'w'
-    );
-
-    this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
-    this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-    this.bindShortcut(Shortcut.OPEN_SUBMIT_DIALOG, 'shift+s');
-    this.bindShortcut(Shortcut.TOGGLE_ATTENTION_SET, 'shift+t');
-
-    this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
-    this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
-    this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_BASE,
-      SPECIAL_SHORTCUT.V_KEY,
-      'down',
-      's'
-    );
-    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'up',
-      'w'
-    );
-    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LEFT,
-      SPECIAL_SHORTCUT.V_KEY,
-      'left',
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'right',
-      'd'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'b'
-    );
-
-    this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    if (this._isCursorManagerSupportMoveToVisibleLine()) {
-      this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
-    }
-    this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
-    this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'e'
-    );
-    this.bindShortcut(
-      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'shift+e'
-    );
-    this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    this.bindShortcut(
-      Shortcut.SAVE_COMMENT,
-      'ctrl+enter',
-      'meta+enter',
-      'ctrl+s',
-      'meta+s'
-    );
-    this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
-    this.bindShortcut(Shortcut.NEXT_FILE, ']');
-    this.bindShortcut(Shortcut.PREV_FILE, '[');
-    this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
-    this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i');
-    this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i');
-    this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-    this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-
-    this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-
-    this.bindShortcut(Shortcut.SEARCH, '/');
-  }
-
-  _isCursorManagerSupportMoveToVisibleLine() {
-    // This method is a copy-paste from the
-    // method _isIntersectionObserverSupported of gr-cursor-manager.js
-    // It is better share this method with gr-cursor-manager,
-    // but doing it require a lot if changes instead of 1-line copied code
-    return 'IntersectionObserver' in window;
-  }
-
   _accountChanged(account?: AccountDetailInfo) {
     if (!account) return;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 5069ba4..a23621e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -21,6 +21,11 @@
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
 import {fireAlert} from '../../../utils/event-util';
+import {
+  Shortcut,
+  ShortcutSection,
+} from '../../../services/shortcuts/shortcuts-config';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,6 +53,8 @@
   @property({type: Object, notify: true})
   change?: ChangeInfo;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   _computeStarClass(starred?: boolean) {
     return starred ? 'active' : '';
   }
@@ -83,4 +90,8 @@
       })
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9dce127..9f65dd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -179,9 +179,6 @@
   private async getVisibleEntries(
     filter?: (el: Element) => boolean
   ): Promise<IntersectionObserverEntry[]> {
-    if (!this._isIntersectionObserverSupported()) {
-      throw new Error('Intersection observing not supported');
-    }
     if (!this.stops) {
       return [];
     }
@@ -218,14 +215,6 @@
     });
   }
 
-  _isIntersectionObserverSupported() {
-    // The copy of this method exists in gr-app-element.js under the
-    // name _isCursorManagerSupportMoveToVisibleLine
-    // If you update this method, you must update gr-app-element.js
-    // as well.
-    return 'IntersectionObserver' in window;
-  }
-
   /**
    * Set the cursor to an arbitrary stop - if the given element is not one of
    * the stops, unset the cursor.
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 40434a9..2f249e7 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
@@ -14,88 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/*
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
-  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
-  2. Documentation for the keyboard shortcut help dialog
-  3. A binding between key combos and the semantic identifier
-  4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
-  const Shortcut = {
-    // ...
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    // ...
-  };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
-  // Ordinary shortcut with a single binding.
-  this.bindShortcut(
-      Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  // Ordinary shortcut with multiple bindings.
-  this.bindShortcut(
-      Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
-  // A "go-key" keyboard shortcut, which is combined with a previously and
-  // continuously pressed "go" key (the go-key is hard-coded as 'g').
-  this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
-
-  // A "doc-only" keyboard shortcut. This declares the key-binding for help
-  // dialog purposes, but doesn't actually implement the binding. It is up
-  // to some element to implement this binding using iron-a11y-keys-behavior's
-  // keyBindings property.
-  this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-    };
-  },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
-*/
-
 import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
@@ -105,13 +23,23 @@
 import {getKeyboardEvent, isModifierPressed} from '../../utils/dom-util';
 import {CustomKeyboardEvent} from '../../types/events';
 import {appContext} from '../../services/app-context';
+import {
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+} from '../../services/shortcuts/shortcuts-config';
+import {
+  ShortcutListener,
+  SectionView,
+} from '../../services/shortcuts/shortcuts-service';
 
-/** Enum for all special shortcuts */
-export enum SPECIAL_SHORTCUT {
-  DOC_ONLY = 'DOC_ONLY',
-  GO_KEY = 'GO_KEY',
-  V_KEY = 'V_KEY',
-}
+export {
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+  ShortcutListener,
+  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.
@@ -119,631 +47,6 @@
 
 const V_KEY_TIMEOUT_MS = 1000;
 
-/**
- * Enum for all shortcut sections, where that shortcut should be applied to.
- */
-export enum ShortcutSection {
-  ACTIONS = 'Actions',
-  DIFFS = 'Diffs',
-  EVERYWHERE = 'Global Shortcuts',
-  FILE_LIST = 'File list',
-  NAVIGATION = 'Navigation',
-  REPLY_DIALOG = 'Reply dialog',
-}
-
-/**
- * Enum for all possible shortcut names.
- */
-export enum Shortcut {
-  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
-  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
-  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
-  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
-  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
-  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
-
-  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
-  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
-  OPEN_CHANGE = 'OPEN_CHANGE',
-  NEXT_PAGE = 'NEXT_PAGE',
-  PREV_PAGE = 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
-  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
-  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
-  OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
-  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
-
-  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
-  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
-  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
-  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
-  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
-  UP_TO_CHANGE = 'UP_TO_CHANGE',
-  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
-  REFRESH_CHANGE = 'REFRESH_CHANGE',
-  EDIT_TOPIC = 'EDIT_TOPIC',
-  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
-  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
-  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
-
-  NEXT_LINE = 'NEXT_LINE',
-  PREV_LINE = 'PREV_LINE',
-  VISIBLE_LINE = 'VISIBLE_LINE',
-  NEXT_CHUNK = 'NEXT_CHUNK',
-  PREV_CHUNK = 'PREV_CHUNK',
-  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
-  NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
-  PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
-  EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
-  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
-  LEFT_PANE = 'LEFT_PANE',
-  RIGHT_PANE = 'RIGHT_PANE',
-  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
-  NEW_COMMENT = 'NEW_COMMENT',
-  SAVE_COMMENT = 'SAVE_COMMENT',
-  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
-  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
-
-  NEXT_FILE = 'NEXT_FILE',
-  PREV_FILE = 'PREV_FILE',
-  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
-  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
-  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
-  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
-  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
-  OPEN_FILE = 'OPEN_FILE',
-  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
-  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
-  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
-  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
-  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
-
-  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
-  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
-
-  SEARCH = 'SEARCH',
-  SEND_REPLY = 'SEND_REPLY',
-  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
-  TOGGLE_BLAME = 'TOGGLE_BLAME',
-}
-
-export type SectionView = Array<{binding: string[][]; text: string}>;
-
-/**
- * The interface for listener for shortcut events.
- */
-export type ShortcutListener = (
-  viewMap?: Map<ShortcutSection, SectionView>
-) => void;
-
-interface ShortcutHelpItem {
-  shortcut: Shortcut;
-  text: string;
-}
-
-// TODO(TS): rename to something more meaningful
-const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
-
-function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
-  if (!_help.has(section)) {
-    _help.set(section, []);
-  }
-  const shortcuts = _help.get(section);
-  if (shortcuts) {
-    shortcuts.push({shortcut, text});
-  }
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(
-  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
-  ShortcutSection.EVERYWHERE,
-  'Show this dialog'
-);
-_describe(
-  Shortcut.GO_TO_USER_DASHBOARD,
-  ShortcutSection.EVERYWHERE,
-  'Go to User Dashboard'
-);
-_describe(
-  Shortcut.GO_TO_OPENED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Opened Changes'
-);
-_describe(
-  Shortcut.GO_TO_MERGED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Merged Changes'
-);
-_describe(
-  Shortcut.GO_TO_ABANDONED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Abandoned Changes'
-);
-_describe(
-  Shortcut.GO_TO_WATCHED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Watched Changes'
-);
-
-_describe(
-  Shortcut.CURSOR_NEXT_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select next change'
-);
-_describe(
-  Shortcut.CURSOR_PREV_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select previous change'
-);
-_describe(
-  Shortcut.OPEN_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Show selected change'
-);
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(
-  Shortcut.OPEN_REPLY_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open reply dialog to publish comments and add reviewers'
-);
-_describe(
-  Shortcut.OPEN_DOWNLOAD_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open download overlay'
-);
-_describe(
-  Shortcut.EXPAND_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Expand all messages'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Collapse all messages'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Reload the change at the latest patch'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Mark/unmark change as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_FILE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Toggle review flag on selected file'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE_LIST,
-  ShortcutSection.ACTIONS,
-  'Refresh list of changes'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_STAR,
-  ShortcutSection.ACTIONS,
-  'Star/unstar change'
-);
-_describe(
-  Shortcut.OPEN_SUBMIT_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open submit dialog'
-);
-_describe(
-  Shortcut.TOGGLE_ATTENTION_SET,
-  ShortcutSection.ACTIONS,
-  'Toggle attention set status'
-);
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.ACTIONS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.ACTIONS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff base against latest'
-);
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.DIFFS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.DIFFS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff base against latest'
-);
-_describe(
-  Shortcut.VISIBLE_LINE,
-  ShortcutSection.DIFFS,
-  'Move cursor to currently visible code'
-);
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
-_describe(
-  Shortcut.PREV_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to previous diff chunk'
-);
-_describe(
-  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
-  ShortcutSection.DIFFS,
-  'Toggle all diff context'
-);
-_describe(
-  Shortcut.NEXT_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to next comment thread'
-);
-_describe(
-  Shortcut.PREV_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to previous comment thread'
-);
-_describe(
-  Shortcut.EXPAND_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Expand all comment threads'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Collapse all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Hide/Display all comment threads'
-);
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(
-  Shortcut.TOGGLE_LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Hide/show left diff'
-);
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(
-  Shortcut.OPEN_DIFF_PREFS,
-  ShortcutSection.DIFFS,
-  'Show diff preferences'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_REVIEWED,
-  ShortcutSection.DIFFS,
-  'Mark/unmark file as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_MODE,
-  ShortcutSection.DIFFS,
-  'Toggle unified/side-by-side diff'
-);
-_describe(
-  Shortcut.NEXT_UNREVIEWED_FILE,
-  ShortcutSection.DIFFS,
-  'Mark file as reviewed and go to next unreviewed file'
-);
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(
-  Shortcut.PREV_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file'
-);
-_describe(
-  Shortcut.NEXT_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to next file that has comments'
-);
-_describe(
-  Shortcut.PREV_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file that has comments'
-);
-_describe(
-  Shortcut.OPEN_FIRST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to first file'
-);
-_describe(
-  Shortcut.OPEN_LAST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to last file'
-);
-_describe(
-  Shortcut.UP_TO_DASHBOARD,
-  ShortcutSection.NAVIGATION,
-  'Up to dashboard'
-);
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(
-  Shortcut.CURSOR_NEXT_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select next file'
-);
-_describe(
-  Shortcut.CURSOR_PREV_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select previous file'
-);
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
-_describe(
-  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-  ShortcutSection.FILE_LIST,
-  'Show/hide all inline diffs'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.FILE_LIST,
-  'Hide/Display all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_INLINE_DIFF,
-  ShortcutSection.FILE_LIST,
-  'Show/hide selected inline diff'
-);
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(
-  Shortcut.EMOJI_DROPDOWN,
-  ShortcutSection.REPLY_DIALOG,
-  'Emoji dropdown'
-);
-
-/**
- * Shortcut manager, holds all hosts, bindings and listeners.
- */
-export class ShortcutManager {
-  private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
-
-  private readonly bindings = new Map<Shortcut, string[]>();
-
-  public _testOnly_getBindings() {
-    return this.bindings;
-  }
-
-  public _testOnly_isEmpty() {
-    return this.activeHosts.size === 0 && this.listeners.size === 0;
-  }
-
-  private readonly listeners = new Set<ShortcutListener>();
-
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-    this.bindings.set(shortcut, bindings);
-  }
-
-  getBindingsForShortcut(shortcut: Shortcut) {
-    return this.bindings.get(shortcut);
-  }
-
-  attachHost(host: PolymerElement, shortcuts: Map<string, string>) {
-    this.activeHosts.set(host, shortcuts);
-    this.notifyListeners();
-  }
-
-  detachHost(host: PolymerElement) {
-    if (this.activeHosts.delete(host)) {
-      this.notifyListeners();
-      return true;
-    }
-    return false;
-  }
-
-  addListener(listener: ShortcutListener) {
-    this.listeners.add(listener);
-    listener(this.directoryView());
-  }
-
-  removeListener(listener: ShortcutListener) {
-    return this.listeners.delete(listener);
-  }
-
-  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
-    const bindings = _help.get(section);
-    let desc = '';
-    if (bindings) {
-      const binding = bindings.find(
-        binding => binding.shortcut === shortcutName
-      );
-      desc = binding ? binding.text : '';
-    }
-    return desc;
-  }
-
-  getShortcut(shortcutName: Shortcut) {
-    const bindings = this.bindings.get(shortcutName);
-    return bindings
-      ? bindings
-          .map(binding => this.describeBinding(binding).join('+'))
-          .join(',')
-      : '';
-  }
-
-  activeShortcutsBySection() {
-    const activeShortcuts = new Set<string>();
-    this.activeHosts.forEach(shortcuts => {
-      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-    });
-
-    const activeShortcutsBySection = new Map<
-      ShortcutSection,
-      ShortcutHelpItem[]
-    >();
-    _help.forEach((shortcutList, section) => {
-      shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
-          if (!activeShortcutsBySection.has(section)) {
-            activeShortcutsBySection.set(section, []);
-          }
-          // From previous condition, the `get(section)`
-          // should always return a valid result
-          activeShortcutsBySection.get(section)!.push(shortcutHelp);
-        }
-      });
-    });
-    return activeShortcutsBySection;
-  }
-
-  directoryView() {
-    const view = new Map<ShortcutSection, SectionView>();
-    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
-      const sectionView: Array<{binding: string[][]; text: string}> = [];
-      shortcutHelps.forEach(shortcutHelp => {
-        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
-        if (!bindingDesc) {
-          return;
-        }
-        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
-          sectionView.push({
-            binding: bindingDesc,
-            text: shortcutHelp.text,
-          });
-        });
-      });
-      view.set(section, sectionView);
-    });
-    return view;
-  }
-
-  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
-    if (
-      bindingDesc.length === 1 ||
-      this.comboSetDisplayWidth(bindingDesc) < 21
-    ) {
-      return [bindingDesc];
-    }
-    // Find the largest prefix of bindings that is under the
-    // size threshold.
-    const head = [bindingDesc[0]];
-    for (let i = 1; i < bindingDesc.length; i++) {
-      head.push(bindingDesc[i]);
-      if (this.comboSetDisplayWidth(head) >= 21) {
-        head.pop();
-        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
-      }
-    }
-    return [];
-  }
-
-  comboSetDisplayWidth(bindingDesc: string[][]) {
-    const bindingSizer = (binding: string[]) =>
-      binding.reduce((acc, key) => acc + key.length, 0);
-    // Width is the sum of strings + (n-1) * 2 to account for the word
-    // "or" joining them.
-    return (
-      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
-      2 * (bindingDesc.length - 1)
-    );
-  }
-
-  describeBindings(shortcut: Shortcut): string[][] | null {
-    const bindings = this.bindings.get(shortcut);
-    if (!bindings) {
-      return null;
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['g'].concat(binding));
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['v'].concat(binding));
-    }
-
-    return bindings
-      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
-      .map(binding => this.describeBinding(binding));
-  }
-
-  _describeKey(key: string) {
-    switch (key) {
-      case 'shift':
-        return 'Shift';
-      case 'meta':
-        return 'Meta';
-      case 'ctrl':
-        return 'Ctrl';
-      case 'enter':
-        return 'Enter';
-      case 'up':
-        return '\u2191'; // ↑
-      case 'down':
-        return '\u2193'; // ↓
-      case 'left':
-        return '\u2190'; // ←
-      case 'right':
-        return '\u2192'; // →
-      default:
-        return key;
-    }
-  }
-
-  describeBinding(binding: string) {
-    // single key bindings
-    if (binding.length === 1) {
-      return [binding];
-    }
-    return binding
-      .split(':')[0]
-      .split('+')
-      .map(part => this._describeKey(part));
-  }
-
-  notifyListeners() {
-    const view = this.directoryView();
-    this.listeners.forEach(listener => listener(view));
-  }
-}
-
-const shortcutManager = new ShortcutManager();
-
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -783,7 +86,9 @@
 
     private readonly restApiService = appContext.restApiService;
 
-    private reporting = appContext.reportingService;
+    private readonly reporting = appContext.reportingService;
+
+    private readonly shortcuts = appContext.shortcutsService;
 
     /** Used to disable shortcuts when the element is not visible. */
     private observer?: IntersectionObserver;
@@ -860,18 +165,8 @@
       return getKeyboardEvent(e);
     }
 
-    bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-      shortcutManager.bindShortcut(shortcut, ...bindings);
-    }
-
-    createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-      const desc = shortcutManager.getDescription(section, shortcutName);
-      const shortcut = shortcutManager.getShortcut(shortcutName);
-      return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
-    }
-
     _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
-      const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+      const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
       if (!bindings) {
         return;
       }
@@ -947,7 +242,7 @@
       const shortcuts = new Map<string, string>(
         Object.entries(this.keyboardShortcuts())
       );
-      shortcutManager.attachHost(this, shortcuts);
+      this.shortcuts.attachHost(this, shortcuts);
 
       for (const [key, value] of shortcuts.entries()) {
         this._addOwnKeyBindings(key as Shortcut, value);
@@ -983,7 +278,7 @@
     private disableBindings() {
       if (!this.bindingsEnabled) return;
       this.bindingsEnabled = false;
-      if (shortcutManager.detachHost(this)) {
+      if (this.shortcuts.detachHost(this)) {
         this.removeOwnKeyBindings();
       }
     }
@@ -996,14 +291,6 @@
       return {};
     }
 
-    addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-      shortcutManager.addListener(listener);
-    }
-
-    removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-      shortcutManager.removeListener(listener);
-    }
-
     _handleVKeyDown(e: CustomKeyboardEvent) {
       if (this.shouldSuppressKeyboardShortcut(e)) return;
       this._shortcut_v_key_last_pressed = Date.now();
@@ -1077,7 +364,10 @@
     }
   }
 
-  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
+  return Mixin as T &
+    Constructor<
+      KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+    >;
 };
 
 // The following doesn't work (IronA11yKeysBehavior crashes):
@@ -1090,7 +380,10 @@
 // This is a workaround
 export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
+): T &
+  Constructor<
+    KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+  > =>
   InternalKeyboardShortcutMixin(
     // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
     // which will fail the type check due to missing IronA11yKeysBehavior interface
@@ -1101,14 +394,14 @@
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
   keyboardShortcuts(): {[key: string]: string | null};
-  createTitle(name: Shortcut, section: ShortcutSection): string;
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
   modifierPressed(event: CustomKeyboardEvent): boolean;
-  addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
-  removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
 }
 
-export function _testOnly_getShortcutManagerInstance() {
-  return shortcutManager;
+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: CustomKeyboardEvent) => void;
 }
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
deleted file mode 100644
index 4536ecd..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ /dev/null
@@ -1,424 +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.js';
-import {
-  KeyboardShortcutMixin, Shortcut,
-  ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
-} from './keyboard-shortcut-mixin.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {mockPromise} from '../../test/test-utils.js';
-
-const basicFixture =
-    fixtureFromElement('keyboard-shortcut-mixin-test-element');
-
-const withinOverlayFixture = fixtureFromTemplate(html`
-<gr-overlay>
-  <keyboard-shortcut-mixin-test-element>
-  </keyboard-shortcut-mixin-test-element>
-</gr-overlay>
-`);
-
-class GrKeyboardShortcutMixinTestElement extends
-  KeyboardShortcutMixin(PolymerElement) {
-  static get is() {
-    return 'keyboard-shortcut-mixin-test-element';
-  }
-
-  get keyBindings() {
-    return {
-      k: '_handleKey',
-      enter: '_handleKey',
-    };
-  }
-
-  _handleKey() {}
-}
-
-customElements.define(GrKeyboardShortcutMixinTestElement.is,
-    GrKeyboardShortcutMixinTestElement);
-
-suite('keyboard-shortcut-mixin tests', () => {
-  let element;
-  let overlay;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    overlay = withinOverlayFixture.instantiate();
-  });
-
-  suite('ShortcutManager', () => {
-    test('bindings management', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.deepEqual(
-          mgr.getBindingsForShortcut(NEXT_FILE),
-          [']', '}', 'right']);
-    });
-
-    test('getShortcut', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
-    });
-
-    test('getShortcut with modifiers', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
-      assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
-    });
-
-    suite('binding descriptions', () => {
-      function mapToObject(m) {
-        const o = {};
-        m.forEach((v, k) => o[k] = v);
-        return o;
-      }
-
-      test('single combo description', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.describeBinding('a'), ['a']);
-        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-        assert.deepEqual(
-            mgr.describeBinding('ctrl+shift+up:keyup'),
-            ['Ctrl', 'Shift', '↑']);
-      });
-
-      test('combo set description', () => {
-        const mgr = new ShortcutManager();
-        assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
-
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
-            [['g', 'o']]);
-
-        mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
-            ']', 'ctrl+shift+right:keyup');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.NEXT_FILE),
-            [[']'], ['Ctrl', 'Shift', '→']]);
-
-        mgr.bindShortcut(Shortcut.PREV_FILE, '[');
-        assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
-      });
-
-      test('combo set description width', () => {
-        const mgr = new ShortcutManager();
-        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-        assert.strictEqual(
-            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-            12);
-      });
-
-      test('distribute shortcut help', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['g', 'o']]),
-            [[['g', 'o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-            [[['ctrl', 'shift', 'meta', 'enter']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'shift', 'meta', 'enter'],
-              ['o'],
-            ]),
-            [
-              [['ctrl', 'shift', 'meta', 'enter']],
-              [['o']],
-            ]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'enter'],
-              ['meta', 'enter'],
-              ['ctrl', 's'],
-              ['meta', 's'],
-            ]),
-            [
-              [['ctrl', 'enter'], ['meta', 'enter']],
-              [['ctrl', 's'], ['meta', 's']],
-            ]);
-      });
-
-      test('active shortcuts by section', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {});
-
-        mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, null]]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, null]]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({}, new Map([
-          [Shortcut.SEARCH, null],
-          [Shortcut.GO_TO_OPENED_CHANGES, null],
-        ]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {shortcut: Shortcut.SEARCH, text: 'Search'},
-                {
-                  shortcut: Shortcut.GO_TO_OPENED_CHANGES,
-                  text: 'Go to Opened Changes',
-                },
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-      });
-
-      test('directory view', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-        mgr.bindShortcut(
-            Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
-            'ctrl+s', 'meta+s');
-
-        assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-        mgr.attachHost({}, new Map([
-          [Shortcut.GO_TO_OPENED_CHANGES, null],
-          [Shortcut.NEXT_FILE, null],
-          [Shortcut.NEXT_LINE, null],
-          [Shortcut.SAVE_COMMENT, null],
-          [Shortcut.SEARCH, null],
-        ]));
-        assert.deepEqual(
-            mapToObject(mgr.directoryView()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {binding: [['j']], text: 'Go to next line'},
-                {
-                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                  text: 'Save comment',
-                },
-                {
-                  binding: [['Ctrl', 's'], ['Meta', 's']],
-                  text: 'Save comment',
-                },
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {binding: [['/']], text: 'Search'},
-                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {binding: [[']']], text: 'Go to next file'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('doesn’t block kb shortcuts for non-allowed els', async () => {
-    const divEl = document.createElement('div');
-    element.appendChild(divEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for input els', async () => {
-    const inputEl = document.createElement('input');
-    element.appendChild(inputEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('doesn’t block kb shortcuts for checkboxes', async () => {
-    const inputEl = document.createElement('input');
-    inputEl.setAttribute('type', 'checkbox');
-    element.appendChild(inputEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for textarea els', async () => {
-    const textareaEl = document.createElement('textarea');
-    element.appendChild(textareaEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for anything in a gr-overlay', async () => {
-    const divEl = document.createElement('div');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(divEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks enter shortcut on an anchor', async () => {
-    const anchorEl = document.createElement('a');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(anchorEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-    await promise;
-  });
-
-  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;
-
-    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: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      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: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      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: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 3000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('should suppress', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('unrecognized key', () => {
-      const e = {detail: {key: 'f'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..6350bf9
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
@@ -0,0 +1,243 @@
+/**
+ * @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 {html} from '@polymer/polymer/lib/utils/html-tag';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {mockPromise, queryAndAssert} from '../../test/test-utils';
+import '../../elements/shared/gr-overlay/gr-overlay';
+import {GrOverlay} from '../../elements/shared/gr-overlay/gr-overlay';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {CustomKeyboardEvent} 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');
+
+const withinOverlayFixture = fixtureFromTemplate(html`
+  <gr-overlay>
+    <keyboard-shortcut-mixin-test-element>
+    </keyboard-shortcut-mixin-test-element>
+  </gr-overlay>
+`);
+
+suite('keyboard-shortcut-mixin tests', () => {
+  let element: GrKeyboardShortcutMixinTestElement;
+  let overlay: GrOverlay;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    overlay = withinOverlayFixture.instantiate() as GrOverlay;
+    await flush();
+  });
+
+  test('doesn’t block kb shortcuts for non-allowed els', async () => {
+    const divEl = document.createElement('div');
+    element.appendChild(divEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for input els', async () => {
+    const inputEl = document.createElement('input');
+    element.appendChild(inputEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('doesn’t block kb shortcuts for checkboxes', async () => {
+    const inputEl = document.createElement('input');
+    inputEl.setAttribute('type', 'checkbox');
+    element.appendChild(inputEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for textarea els', async () => {
+    const textareaEl = document.createElement('textarea');
+    element.appendChild(textareaEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for anything in a gr-overlay', async () => {
+    const divEl = document.createElement('div');
+    const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+      overlay,
+      'keyboard-shortcut-mixin-test-element'
+    );
+    element.appendChild(divEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks enter shortcut on an anchor', async () => {
+    const anchorEl = document.createElement('a');
+    const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+      overlay,
+      'keyboard-shortcut-mixin-test-element'
+    );
+    element.appendChild(anchorEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+    await promise;
+  });
+
+  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: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      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: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      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: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 3000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('should suppress', () => {
+      const e = {
+        detail: {key: 'a'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('unrecognized key', () => {
+      const e = {
+        detail: {key: 'f'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ade9529..597776d 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -27,6 +27,7 @@
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -82,5 +83,6 @@
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
     userService: () => new UserService(appContext.restApiService),
+    shortcutsService: () => new ShortcutsService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 161378d..e5828d6 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -26,6 +26,7 @@
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -40,6 +41,7 @@
   storageService: StorageService;
   configService: ConfigService;
   userService: UserService;
+  shortcutsService: ShortcutsService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
new file mode 100644
index 0000000..bd004d7
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -0,0 +1,552 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+  DOC_ONLY = 'DOC_ONLY',
+  GO_KEY = 'GO_KEY',
+  V_KEY = 'V_KEY',
+}
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+  ACTIONS = 'Actions',
+  DIFFS = 'Diffs',
+  EVERYWHERE = 'Global Shortcuts',
+  FILE_LIST = 'File list',
+  NAVIGATION = 'Navigation',
+  REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+  OPEN_CHANGE = 'OPEN_CHANGE',
+  NEXT_PAGE = 'NEXT_PAGE',
+  PREV_PAGE = 'PREV_PAGE',
+  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+  OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
+  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
+
+  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+  UP_TO_CHANGE = 'UP_TO_CHANGE',
+  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+  REFRESH_CHANGE = 'REFRESH_CHANGE',
+  EDIT_TOPIC = 'EDIT_TOPIC',
+  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+  NEXT_LINE = 'NEXT_LINE',
+  PREV_LINE = 'PREV_LINE',
+  VISIBLE_LINE = 'VISIBLE_LINE',
+  NEXT_CHUNK = 'NEXT_CHUNK',
+  PREV_CHUNK = 'PREV_CHUNK',
+  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
+  NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
+  PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
+  EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
+  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+  LEFT_PANE = 'LEFT_PANE',
+  RIGHT_PANE = 'RIGHT_PANE',
+  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+  NEW_COMMENT = 'NEW_COMMENT',
+  SAVE_COMMENT = 'SAVE_COMMENT',
+  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+  NEXT_FILE = 'NEXT_FILE',
+  PREV_FILE = 'PREV_FILE',
+  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+  OPEN_FILE = 'OPEN_FILE',
+  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+  SEARCH = 'SEARCH',
+  SEND_REPLY = 'SEND_REPLY',
+  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+export interface ShortcutHelpItem {
+  shortcut: Shortcut;
+  text: string;
+  bindings: string[];
+}
+
+export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function describe(
+  shortcut: Shortcut,
+  section: ShortcutSection,
+  text: string,
+  binding: string,
+  ...moreBindings: string[]
+) {
+  if (!config.has(section)) {
+    config.set(section, []);
+  }
+  const shortcuts = config.get(section);
+  if (shortcuts) {
+    shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
+  }
+}
+
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', '/');
+describe(
+  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+  ShortcutSection.EVERYWHERE,
+  'Show this dialog',
+  '?'
+);
+describe(
+  Shortcut.GO_TO_USER_DASHBOARD,
+  ShortcutSection.EVERYWHERE,
+  'Go to User Dashboard',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'i'
+);
+describe(
+  Shortcut.GO_TO_OPENED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Opened Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'o'
+);
+describe(
+  Shortcut.GO_TO_MERGED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Merged Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'm'
+);
+describe(
+  Shortcut.GO_TO_ABANDONED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Abandoned Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'a'
+);
+describe(
+  Shortcut.GO_TO_WATCHED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Watched Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'w'
+);
+
+describe(
+  Shortcut.CURSOR_NEXT_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select next change',
+  'j'
+);
+describe(
+  Shortcut.CURSOR_PREV_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select previous change',
+  'k'
+);
+describe(
+  Shortcut.OPEN_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Show selected change',
+  'o'
+);
+describe(
+  Shortcut.NEXT_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to next page',
+  'n',
+  ']'
+);
+describe(
+  Shortcut.PREV_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to previous page',
+  'p',
+  '['
+);
+describe(
+  Shortcut.OPEN_REPLY_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open reply dialog to publish comments and add reviewers',
+  'a:keyup'
+);
+describe(
+  Shortcut.OPEN_DOWNLOAD_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open download overlay',
+  'd:keyup'
+);
+describe(
+  Shortcut.EXPAND_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Expand all messages',
+  'x'
+);
+describe(
+  Shortcut.COLLAPSE_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Collapse all messages',
+  'z'
+);
+describe(
+  Shortcut.REFRESH_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Reload the change at the latest patch',
+  'shift+r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Mark/unmark change as reviewed',
+  'r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_FILE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Toggle review flag on selected file',
+  'r:keyup'
+);
+describe(
+  Shortcut.REFRESH_CHANGE_LIST,
+  ShortcutSection.ACTIONS,
+  'Refresh list of changes',
+  'shift+r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_STAR,
+  ShortcutSection.ACTIONS,
+  'Star/unstar change',
+  's:keydown'
+);
+describe(
+  Shortcut.OPEN_SUBMIT_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open submit dialog',
+  'shift+s'
+);
+describe(
+  Shortcut.TOGGLE_ATTENTION_SET,
+  ShortcutSection.ACTIONS,
+  'Toggle attention set status',
+  'shift+t'
+);
+describe(
+  Shortcut.EDIT_TOPIC,
+  ShortcutSection.ACTIONS,
+  'Add a change topic',
+  't'
+);
+describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.DIFFS,
+  'Diff against base',
+  SPECIAL_SHORTCUT.V_KEY,
+  'down',
+  's'
+);
+describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff against latest patchset',
+  SPECIAL_SHORTCUT.V_KEY,
+  'up',
+  'w'
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.DIFFS,
+  'Diff base against left',
+  SPECIAL_SHORTCUT.V_KEY,
+  'left',
+  'a'
+);
+describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff right against latest',
+  SPECIAL_SHORTCUT.V_KEY,
+  'right',
+  'd'
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff base against latest',
+  SPECIAL_SHORTCUT.V_KEY,
+  'b'
+);
+
+describe(
+  Shortcut.NEXT_LINE,
+  ShortcutSection.DIFFS,
+  'Go to next line',
+  'j',
+  'down'
+);
+describe(
+  Shortcut.PREV_LINE,
+  ShortcutSection.DIFFS,
+  'Go to previous line',
+  'k',
+  'up'
+);
+describe(
+  Shortcut.VISIBLE_LINE,
+  ShortcutSection.DIFFS,
+  'Move cursor to currently visible code',
+  '.'
+);
+describe(
+  Shortcut.NEXT_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to next diff chunk',
+  'n'
+);
+describe(
+  Shortcut.PREV_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to previous diff chunk',
+  'p'
+);
+describe(
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+  ShortcutSection.DIFFS,
+  'Toggle all diff context',
+  'shift+x'
+);
+describe(
+  Shortcut.NEXT_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to next comment thread',
+  'shift+n'
+);
+describe(
+  Shortcut.PREV_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to previous comment thread',
+  'shift+p'
+);
+describe(
+  Shortcut.EXPAND_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Expand all comment threads',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'e'
+);
+describe(
+  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Collapse all comment threads',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'shift+e'
+);
+describe(
+  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Hide/Display all comment threads',
+  'h'
+);
+describe(
+  Shortcut.LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Select left pane',
+  'shift+left'
+);
+describe(
+  Shortcut.RIGHT_PANE,
+  ShortcutSection.DIFFS,
+  'Select right pane',
+  'shift+right'
+);
+describe(
+  Shortcut.TOGGLE_LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Hide/show left diff',
+  'shift+a'
+);
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', 'c');
+describe(
+  Shortcut.SAVE_COMMENT,
+  ShortcutSection.DIFFS,
+  'Save comment',
+  'ctrl+enter',
+  'meta+enter',
+  'ctrl+s',
+  'meta+s'
+);
+describe(
+  Shortcut.OPEN_DIFF_PREFS,
+  ShortcutSection.DIFFS,
+  'Show diff preferences',
+  ','
+);
+describe(
+  Shortcut.TOGGLE_DIFF_REVIEWED,
+  ShortcutSection.DIFFS,
+  'Mark/unmark file as reviewed',
+  'r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_DIFF_MODE,
+  ShortcutSection.DIFFS,
+  'Toggle unified/side-by-side diff',
+  'm:keyup'
+);
+describe(
+  Shortcut.NEXT_UNREVIEWED_FILE,
+  ShortcutSection.DIFFS,
+  'Mark file as reviewed and go to next unreviewed file',
+  'shift+m'
+);
+describe(
+  Shortcut.TOGGLE_BLAME,
+  ShortcutSection.DIFFS,
+  'Toggle blame',
+  'b:keyup'
+);
+describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', 'f');
+describe(
+  Shortcut.NEXT_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to next file',
+  ']'
+);
+describe(
+  Shortcut.PREV_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file',
+  '['
+);
+describe(
+  Shortcut.NEXT_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to next file that has comments',
+  'shift+j'
+);
+describe(
+  Shortcut.PREV_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file that has comments',
+  'shift+k'
+);
+describe(
+  Shortcut.OPEN_FIRST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to first file',
+  ']'
+);
+describe(
+  Shortcut.OPEN_LAST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to last file',
+  '['
+);
+describe(
+  Shortcut.UP_TO_DASHBOARD,
+  ShortcutSection.NAVIGATION,
+  'Up to dashboard',
+  'u'
+);
+describe(
+  Shortcut.UP_TO_CHANGE,
+  ShortcutSection.NAVIGATION,
+  'Up to change',
+  'u'
+);
+
+describe(
+  Shortcut.CURSOR_NEXT_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select next file',
+  'j',
+  'down'
+);
+describe(
+  Shortcut.CURSOR_PREV_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select previous file',
+  'k',
+  'up'
+);
+describe(
+  Shortcut.OPEN_FILE,
+  ShortcutSection.FILE_LIST,
+  'Go to selected file',
+  'o',
+  'enter'
+);
+describe(
+  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+  ShortcutSection.FILE_LIST,
+  'Show/hide all inline diffs',
+  'shift+i'
+);
+describe(
+  Shortcut.TOGGLE_INLINE_DIFF,
+  ShortcutSection.FILE_LIST,
+  'Show/hide selected inline diff',
+  'i'
+);
+
+describe(
+  Shortcut.SEND_REPLY,
+  ShortcutSection.REPLY_DIALOG,
+  'Send reply',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'ctrl+enter',
+  'meta+enter'
+);
+describe(
+  Shortcut.EMOJI_DROPDOWN,
+  ShortcutSection.REPLY_DIALOG,
+  'Emoji dropdown',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  ':'
+);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
new file mode 100644
index 0000000..3cf46bd
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -0,0 +1,240 @@
+/**
+ * @license
+ * Copyright (C) 2021 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 {
+  config,
+  Shortcut,
+  ShortcutHelpItem,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+} from './shortcuts-config';
+
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+  viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+/**
+ * Shortcuts service, holds all hosts, bindings and listeners.
+ */
+export class ShortcutsService {
+  private readonly activeHosts = new Map<unknown, Map<string, string>>();
+
+  private readonly bindings = new Map<Shortcut, string[]>();
+
+  private readonly listeners = new Set<ShortcutListener>();
+
+  constructor() {
+    for (const section of config.keys()) {
+      const items = config.get(section) ?? [];
+      for (const item of items) {
+        this.bindings.set(item.shortcut, item.bindings);
+      }
+    }
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeHosts.size === 0 && this.listeners.size === 0;
+  }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    const desc = this.getDescription(section, shortcutName);
+    const shortcut = this.getShortcut(shortcutName);
+    return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+  }
+
+  getBindingsForShortcut(shortcut: Shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host: unknown, shortcuts: Map<string, string>) {
+    this.activeHosts.set(host, shortcuts);
+    this.notifyListeners();
+  }
+
+  detachHost(host: unknown) {
+    if (!this.activeHosts.delete(host)) return false;
+    this.notifyListeners();
+    return true;
+  }
+
+  addListener(listener: ShortcutListener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener: ShortcutListener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+    const bindings = config.get(section);
+    if (!bindings) return '';
+    const binding = bindings.find(binding => binding.shortcut === shortcutName);
+    return binding?.text ?? '';
+  }
+
+  getShortcut(shortcutName: Shortcut) {
+    const bindings = this.bindings.get(shortcutName);
+    if (!bindings) return '';
+    return bindings
+      .map(binding => this.describeBinding(binding).join('+'))
+      .join(',');
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set<string>();
+    this.activeHosts.forEach(shortcuts => {
+      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+    });
+
+    const activeShortcutsBySection = new Map<
+      ShortcutSection,
+      ShortcutHelpItem[]
+    >();
+    config.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          // From previous condition, the `get(section)`
+          // should always return a valid result
+          activeShortcutsBySection.get(section)!.push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map<ShortcutSection, SectionView>();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView: SectionView = [];
+      shortcutHelps.forEach(shortcutHelp => {
+        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+        if (!bindingDesc) {
+          return;
+        }
+        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+          sectionView.push({
+            binding: bindingDesc,
+            text: shortcutHelp.text,
+          });
+        });
+      });
+      view.set(section, sectionView);
+    });
+    return view;
+  }
+
+  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+    if (
+      bindingDesc.length === 1 ||
+      this.comboSetDisplayWidth(bindingDesc) < 21
+    ) {
+      return [bindingDesc];
+    }
+    // Find the largest prefix of bindings that is under the
+    // size threshold.
+    const head = [bindingDesc[0]];
+    for (let i = 1; i < bindingDesc.length; i++) {
+      head.push(bindingDesc[i]);
+      if (this.comboSetDisplayWidth(head) >= 21) {
+        head.pop();
+        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+      }
+    }
+    return [];
+  }
+
+  comboSetDisplayWidth(bindingDesc: string[][]) {
+    const bindingSizer = (binding: string[]) =>
+      binding.reduce((acc, key) => acc + key.length, 0);
+    // Width is the sum of strings + (n-1) * 2 to account for the word
+    // "or" joining them.
+    return (
+      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+      2 * (bindingDesc.length - 1)
+    );
+  }
+
+  describeBindings(shortcut: Shortcut): string[][] | null {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) {
+      return null;
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['g'].concat(binding));
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['v'].concat(binding));
+    }
+
+    return bindings
+      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+      .map(binding => this.describeBinding(binding));
+  }
+
+  _describeKey(key: string) {
+    switch (key) {
+      case 'shift':
+        return 'Shift';
+      case 'meta':
+        return 'Meta';
+      case 'ctrl':
+        return 'Ctrl';
+      case 'enter':
+        return 'Enter';
+      case 'up':
+        return '\u2191'; // ↑
+      case 'down':
+        return '\u2193'; // ↓
+      case 'left':
+        return '\u2190'; // ←
+      case 'right':
+        return '\u2192'; // →
+      default:
+        return key;
+    }
+  }
+
+  describeBinding(binding: string) {
+    // single key bindings
+    if (binding.length === 1) {
+      return [binding];
+    }
+    return binding
+      .split(':')[0]
+      .split('+')
+      .map(part => this._describeKey(part));
+  }
+
+  notifyListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
new file mode 100644
index 0000000..a48fa92
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -0,0 +1,224 @@
+/**
+ * @license
+ * Copyright (C) 2021 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 {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {Shortcut, ShortcutSection} from './shortcuts-config';
+
+suite('shortcuts-service tests', () => {
+  test('getShortcut', () => {
+    const mgr = new ShortcutsService();
+    const NEXT_FILE = Shortcut.NEXT_FILE;
+    assert.equal(mgr.getShortcut(NEXT_FILE), ']');
+  });
+
+  test('getShortcut with modifiers', () => {
+    const mgr = new ShortcutsService();
+    const NEXT_FILE = Shortcut.TOGGLE_LEFT_PANE;
+    assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
+  });
+
+  suite('binding descriptions', () => {
+    function mapToObject<K, V>(m: Map<K, V>) {
+      const o: any = {};
+      m.forEach((v: V, k: K) => (o[k] = v));
+      return o;
+    }
+
+    test('single combo description', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.describeBinding('a'), ['a']);
+      assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+      assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+      assert.deepEqual(mgr.describeBinding('ctrl+shift+up:keyup'), [
+        'Ctrl',
+        'Shift',
+        '↑',
+      ]);
+    });
+
+    test('combo set description', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES), [
+        ['g', 'o'],
+      ]);
+      assert.deepEqual(mgr.describeBindings(Shortcut.SAVE_COMMENT), [
+        ['Ctrl', 'Enter'],
+        ['Meta', 'Enter'],
+        ['Ctrl', 's'],
+        ['Meta', 's'],
+      ]);
+      assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
+    });
+
+    test('combo set description width', () => {
+      const mgr = new ShortcutsService();
+      assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+      assert.strictEqual(
+        mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+        12
+      );
+    });
+
+    test('distribute shortcut help', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+      assert.deepEqual(mgr.distributeBindingDesc([['g', 'o']]), [[['g', 'o']]]);
+      assert.deepEqual(
+        mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+        [[['ctrl', 'shift', 'meta', 'enter']]]
+      );
+      assert.deepEqual(
+        mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter'], ['o']]),
+        [[['ctrl', 'shift', 'meta', 'enter']], [['o']]]
+      );
+      assert.deepEqual(
+        mgr.distributeBindingDesc([
+          ['ctrl', 'enter'],
+          ['meta', 'enter'],
+          ['ctrl', 's'],
+          ['meta', 's'],
+        ]),
+        [
+          [
+            ['ctrl', 'enter'],
+            ['meta', 'enter'],
+          ],
+          [
+            ['ctrl', 's'],
+            ['meta', 's'],
+          ],
+        ]
+      );
+    });
+
+    test('active shortcuts by section', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {});
+
+      mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, 'null']]));
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+
+      mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, 'null']]));
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: ['j', 'down'],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+
+      mgr.attachHost(
+        {},
+        new Map([
+          [Shortcut.SEARCH, 'null'],
+          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+        ])
+      );
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: ['j', 'down'],
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {
+            shortcut: Shortcut.SEARCH,
+            text: 'Search',
+            bindings: ['/'],
+          },
+          {
+            shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+            text: 'Go to Opened Changes',
+            bindings: ['GO_KEY', 'o'],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+    });
+
+    test('directory view', () => {
+      const mgr = new ShortcutsService();
+
+      assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+      mgr.attachHost(
+        {},
+        new Map([
+          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+          [Shortcut.NEXT_FILE, 'null'],
+          [Shortcut.NEXT_LINE, 'null'],
+          [Shortcut.SAVE_COMMENT, 'null'],
+          [Shortcut.SEARCH, 'null'],
+        ])
+      );
+      assert.deepEqual(mapToObject(mgr.directoryView()), {
+        [ShortcutSection.DIFFS]: [
+          {binding: [['j'], ['↓']], text: 'Go to next line'},
+          {
+            binding: [
+              ['Ctrl', 'Enter'],
+              ['Meta', 'Enter'],
+            ],
+            text: 'Save comment',
+          },
+          {
+            binding: [
+              ['Ctrl', 's'],
+              ['Meta', 's'],
+            ],
+            text: 'Save comment',
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {binding: [['/']], text: 'Search'},
+          {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {binding: [[']']], text: 'Go to next file'},
+        ],
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 550d3df..949c268 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -30,9 +30,7 @@
   registerTestCleanup,
   addIronOverlayBackdropStyleEl,
   removeIronOverlayBackdropStyleEl,
-  TestKeyboardShortcutBinder,
 } from './test-utils';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {safeTypesBridge} from '../utils/safe-types-util';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
@@ -45,6 +43,7 @@
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 import {updatePreferences} from '../services/user/user-model';
 import {createDefaultPreferences} from '../constants/constants';
+import {appContext} from '../services/app-context';
 
 declare global {
   interface Window {
@@ -101,14 +100,13 @@
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
+  _testOnlyInitAppContext();
   // The following calls is nessecary to avoid influence of previously executed
   // tests.
-  TestKeyboardShortcutBinder.push();
-  _testOnlyInitAppContext();
   initGlobalVariables();
   _testOnly_initGerritPluginApi();
-  const mgr = _testOnly_getShortcutManagerInstance();
-  assert.isTrue(mgr._testOnly_isEmpty());
+  const shortcuts = appContext.shortcutsService;
+  assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
@@ -197,7 +195,6 @@
 teardown(() => {
   sinon.restore();
   cleanupTestUtils();
-  TestKeyboardShortcutBinder.pop();
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   cancelAllTasks();
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index a60c1d1..1cde372 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,10 +17,6 @@
 import '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
-import {
-  _testOnly_getShortcutManagerInstance,
-  Shortcut,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy} from 'sinon';
@@ -53,44 +49,6 @@
   return getComputedStyle(el).getPropertyValue('display') !== 'none';
 }
 
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
-  private static stack: TestKeyboardShortcutBinder[] = [];
-
-  static push() {
-    const testBinder = new TestKeyboardShortcutBinder();
-    this.stack.push(testBinder);
-    return _testOnly_getShortcutManagerInstance();
-  }
-
-  static pop() {
-    const item = this.stack.pop();
-    if (!item) {
-      throw new Error('stack is empty');
-    }
-    item._restoreShortcuts();
-  }
-
-  private readonly originalBinding: Map<Shortcut, string[]>;
-
-  constructor() {
-    this.originalBinding = new Map(
-      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
-    );
-  }
-
-  _restoreShortcuts() {
-    const bindings =
-      _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
-    bindings.clear();
-    this.originalBinding.forEach((value, key) => {
-      bindings.set(key, value);
-    });
-  }
-}
-
 // Provide reset plugins function to clear installed plugins between tests.
 // No gr-app found (running tests)
 export const resetPlugins = () => {
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 0002254..9e3bc74 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -104,8 +104,11 @@
   selector: string
 ): E | undefined {
   if (!el) return undefined;
-  const root = el.shadowRoot ?? el;
-  return root.querySelector<E>(selector) ?? undefined;
+  if (el.shadowRoot) {
+    const r = el.shadowRoot.querySelector<E>(selector);
+    if (r) return r;
+  }
+  return el.querySelector<E>(selector) ?? undefined;
 }
 
 export function queryAndAssert<E extends Element = Element>(