From 70726741110a688bb7caafcafca4b5213b14b281 Mon Sep 17 00:00:00 2001
From: Robert Dyer <rdyer@unl.edu>
Date: Mon, 15 Jul 2024 10:06:09 -0500
Subject: [PATCH] Add help modal for keyboard shortcuts. (#3033)

* Add help modal for keyboard shortcuts.

* add release note

* fix linter

* fix typecheck

* fix linter

* use component syntax for GroupHeading

* use component syntax for Shortcut

* fix linter

* use component syntax for KeyIcon

* refactor to support different dialogs

* show different help based on current page

* fix linter

* reword help

* capitalize letters

* show cmd on mac

* stop event propagation

* dont show if a modal is already open

* remove unused import

* rename modal

* move where location check happens

* dont stop event

* allow typing '?' in inputs

* better filter

* extract function

* fix linter

* dont show if filter popover is visible

* fix linter

* fix wrong shortcut, support SHIFT

* fix linter

* fix conditional
---
 .../src/browser-preload.browser.js            |  19 +-
 .../desktop-client/src/components/Modals.tsx  |   4 +
 .../modals/KeyboardShortcutModal.tsx          | 262 ++++++++++++++++++
 .../loot-core/src/client/reducers/modals.ts   |  10 +
 upcoming-release-notes/3033.md                |   6 +
 5 files changed, 296 insertions(+), 5 deletions(-)
 create mode 100644 packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
 create mode 100644 upcoming-release-notes/3033.md

diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js
index ca5bc118d..c7e0f0b35 100644
--- a/packages/desktop-client/src/browser-preload.browser.js
+++ b/packages/desktop-client/src/browser-preload.browser.js
@@ -135,6 +135,14 @@ global.Actual = {
   },
 };
 
+function inputFocused(e) {
+  return (
+    e.target.tagName === 'INPUT' ||
+    e.target.tagName === 'TEXTAREA' ||
+    e.target.isContentEditable
+  );
+}
+
 document.addEventListener('keydown', e => {
   if (e.metaKey || e.ctrlKey) {
     // Cmd/Ctrl+o
@@ -144,11 +152,7 @@ document.addEventListener('keydown', e => {
     }
     // Cmd/Ctrl+z
     else if (e.key.toLowerCase() === 'z') {
-      if (
-        e.target.tagName === 'INPUT' ||
-        e.target.tagName === 'TEXTAREA' ||
-        e.target.isContentEditable
-      ) {
+      if (inputFocused(e)) {
         return;
       }
       e.preventDefault();
@@ -160,5 +164,10 @@ document.addEventListener('keydown', e => {
         window.__actionsForMenu.undo();
       }
     }
+  } else if (e.key === '?') {
+    if (inputFocused(e)) {
+      return;
+    }
+    window.__actionsForMenu.pushModal('keyboard-shortcuts');
   }
 });
diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index bbd54ad98..01f1505f4 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -35,6 +35,7 @@ import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg';
 import { GoCardlessInitialise } from './modals/GoCardlessInitialise';
 import { HoldBufferModal } from './modals/HoldBufferModal';
 import { ImportTransactions } from './modals/ImportTransactions';
+import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
 import { LoadBackup } from './modals/LoadBackup';
 import { ManageRulesModal } from './modals/ManageRulesModal';
 import { MergeUnusedPayees } from './modals/MergeUnusedPayees';
@@ -95,6 +96,9 @@ export function Modals() {
       };
 
       switch (name) {
+        case 'keyboard-shortcuts':
+          return <KeyboardShortcutModal modalProps={modalProps} />;
+
         case 'import-transactions':
           return (
             <ImportTransactions
diff --git a/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
new file mode 100644
index 000000000..0f018ac98
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
@@ -0,0 +1,262 @@
+import { useLocation } from 'react-router-dom';
+
+import * as Platform from 'loot-core/src/client/platform';
+
+import { Modal, type ModalProps } from '../common/Modal';
+import { Text } from '../common/Text';
+import { View } from '../common/View';
+
+type KeyboardShortcutsModalProps = {
+  modalProps?: Partial<ModalProps>;
+};
+
+type KeyIconProps = {
+  shortcut: string;
+};
+
+type GroupHeadingProps = {
+  group: string;
+};
+
+type ShortcutProps = {
+  shortcut: string;
+  description: string;
+  meta?: string;
+  shift?: boolean;
+};
+
+function KeyIcon({ shortcut }: KeyIconProps) {
+  return (
+    <div
+      style={{
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        fontWeight: 'bold',
+        backgroundColor: '#fff',
+        color: '#000',
+        border: '1px solid #000',
+        borderRadius: 8,
+        minWidth: 35,
+        minHeight: 35,
+        filter: 'drop-shadow(1px 1px)',
+        padding: 5,
+      }}
+    >
+      {shortcut}
+    </div>
+  );
+}
+
+function GroupHeading({ group }: GroupHeadingProps) {
+  return (
+    <Text
+      style={{
+        fontWeight: 'bold',
+        fontSize: 16,
+        marginTop: 20,
+        marginBottom: 10,
+      }}
+    >
+      {group}:
+    </Text>
+  );
+}
+
+function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
+  return (
+    <div
+      style={{
+        display: 'flex',
+        marginBottom: 10,
+        marginLeft: 20,
+      }}
+    >
+      <div
+        style={{
+          display: 'flex',
+          flexDirection: 'column',
+        }}
+      >
+        <div
+          style={{
+            display: 'flex',
+            flexDirection: 'row',
+            marginRight: 10,
+          }}
+        >
+          {meta && (
+            <>
+              <KeyIcon shortcut={meta} />
+              <Text
+                style={{
+                  display: 'flex',
+                  alignItems: 'center',
+                  textAlign: 'center',
+                  fontSize: 16,
+                  paddingLeft: 2,
+                  paddingRight: 2,
+                }}
+              >
+                +
+              </Text>
+            </>
+          )}
+          {shift && (
+            <>
+              <KeyIcon shortcut="Shift" />
+              <Text
+                style={{
+                  display: 'flex',
+                  alignItems: 'center',
+                  textAlign: 'center',
+                  fontSize: 16,
+                  paddingLeft: 2,
+                  paddingRight: 2,
+                }}
+              >
+                +
+              </Text>
+            </>
+          )}
+          <KeyIcon shortcut={shortcut} />
+        </div>
+        <div
+          style={{
+            display: 'flex',
+            flexDirection: 'row',
+            flex: 1,
+          }}
+        />
+      </div>
+      <div
+        style={{
+          display: 'flex',
+          alignItems: 'center',
+          maxWidth: 300,
+        }}
+      >
+        {description}
+      </div>
+    </div>
+  );
+}
+
+export function KeyboardShortcutModal({
+  modalProps,
+}: KeyboardShortcutsModalProps) {
+  const location = useLocation();
+  const onAccounts = location.pathname.startsWith('/accounts');
+  const ctrl = Platform.OS === 'mac' ? '⌘' : 'Ctrl';
+  return (
+    <Modal title="Keyboard Shortcuts" {...modalProps}>
+      <View
+        style={{
+          flexDirection: 'row',
+        }}
+      >
+        <View>
+          <Shortcut
+            shortcut="O"
+            description="Close the current budget and open another"
+            meta={ctrl}
+          />
+          <Shortcut shortcut="?" description="Show this help dialog" />
+          {onAccounts && (
+            <>
+              <Shortcut shortcut="Enter" description="Move down when editing" />
+              <Shortcut shortcut="Tab" description="Move right when editing" />
+              <GroupHeading group="Select a transaction, then" />
+              <Shortcut
+                shortcut="J"
+                description="Move to the next transaction down"
+              />
+              <Shortcut
+                shortcut="K"
+                description="Move to the next transaction up"
+              />
+              <Shortcut
+                shortcut="↑"
+                description="Move to the next transaction down and scroll"
+              />
+              <Shortcut
+                shortcut="↓"
+                description="Move to the next transaction up and scroll"
+              />
+              <Shortcut
+                shortcut="Space"
+                description="Toggle selection of current transaction"
+              />
+              <Shortcut
+                shortcut="Space"
+                description="Toggle all transactions between current and most recently selected transaction"
+                shift={true}
+              />
+            </>
+          )}
+        </View>
+        <View
+          style={{
+            marginLeft: 20,
+            marginRight: 20,
+          }}
+        >
+          <Shortcut
+            shortcut="Z"
+            description="Undo the last change"
+            meta={ctrl}
+          />
+          <Shortcut
+            shortcut="Z"
+            description="Redo the last undone change"
+            shift={true}
+            meta={ctrl}
+          />
+          {onAccounts && (
+            <>
+              <Shortcut
+                shortcut="Enter"
+                description="Move up when editing"
+                shift={true}
+              />
+              <Shortcut
+                shortcut="Tab"
+                description="Move left when editing"
+                shift={true}
+              />
+              <GroupHeading group="With transaction(s) selected" />
+              <Shortcut
+                shortcut="F"
+                description="Filter to the selected transactions"
+              />
+              <Shortcut
+                shortcut="D"
+                description="Delete selected transactions"
+              />
+              <Shortcut
+                shortcut="A"
+                description="Set account for selected transactions"
+              />
+              <Shortcut
+                shortcut="P"
+                description="Set payee for selected transactions"
+              />
+              <Shortcut
+                shortcut="N"
+                description="Set notes for selected transactions"
+              />
+              <Shortcut
+                shortcut="C"
+                description="Set category for selected transactions"
+              />
+              <Shortcut
+                shortcut="L"
+                description="Toggle cleared for current transaction"
+              />
+            </>
+          )}
+        </View>
+      </View>
+    </Modal>
+  );
+}
diff --git a/packages/loot-core/src/client/reducers/modals.ts b/packages/loot-core/src/client/reducers/modals.ts
index a600b1867..d0e6840fb 100644
--- a/packages/loot-core/src/client/reducers/modals.ts
+++ b/packages/loot-core/src/client/reducers/modals.ts
@@ -10,6 +10,16 @@ const initialState: ModalsState = {
 export function update(state = initialState, action: Action): ModalsState {
   switch (action.type) {
     case constants.PUSH_MODAL:
+      // special case: don't show the keyboard shortcuts modal if there's already a modal open
+      if (
+        action.modal.name.endsWith('keyboard-shortcuts') &&
+        (state.modalStack.length > 0 ||
+          window.document.querySelector(
+            'div[data-testid="filters-menu-tooltip"]',
+          ) !== null)
+      ) {
+        return state;
+      }
       return {
         ...state,
         modalStack: [...state.modalStack, action.modal],
diff --git a/upcoming-release-notes/3033.md b/upcoming-release-notes/3033.md
new file mode 100644
index 000000000..0416374a7
--- /dev/null
+++ b/upcoming-release-notes/3033.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [psybers]
+---
+
+Add help modal for keyboard shortcuts.
-- 
GitLab