diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx
index 4b7d30c5ff..f1927c9a4c 100644
--- a/frontend/src/AppRoutes/index.tsx
+++ b/frontend/src/AppRoutes/index.tsx
@@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import useAnalytics from 'hooks/analytics/useAnalytics';
+import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useThemeConfig } from 'hooks/useDarkMode';
import useGetFeatureFlag from 'hooks/useGetFeatureFlag';
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
@@ -177,22 +178,24 @@ function App(): JSX.Element {
-
- }>
-
- {routes.map(({ path, component, exact }) => (
-
- ))}
+
+
+ }>
+
+ {routes.map(({ path, component, exact }) => (
+
+ ))}
-
-
-
-
+
+
+
+
+
diff --git a/frontend/src/constants/shortcuts/globalShortcuts.ts b/frontend/src/constants/shortcuts/globalShortcuts.ts
new file mode 100644
index 0000000000..0a439cee77
--- /dev/null
+++ b/frontend/src/constants/shortcuts/globalShortcuts.ts
@@ -0,0 +1,3 @@
+export const GlobalShortcuts = {
+ SidebarCollapse: '\\+meta',
+};
diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx
index b7a06ff0e5..aaf516835e 100644
--- a/frontend/src/container/SideNav/SideNav.tsx
+++ b/frontend/src/container/SideNav/SideNav.tsx
@@ -8,7 +8,9 @@ import cx from 'classnames';
import { IS_SIDEBAR_COLLAPSED } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
+import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { ToggleButton } from 'container/Header/styles';
+import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import useComponentPermission from 'hooks/useComponentPermission';
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from 'hooks/useLicense';
@@ -98,6 +100,8 @@ function SideNav({
const [inviteMembers] = useComponentPermission(['invite_members'], role);
+ const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
+
useEffect(() => {
if (inviteMembers) {
const updatedUserManagementMenuItems = [
@@ -156,6 +160,15 @@ function SideNav({
dispatch(sideBarCollapse(collapsed));
}, [collapsed, dispatch]);
+ useEffect(() => {
+ registerShortcut(GlobalShortcuts.SidebarCollapse, onCollapse);
+
+ return (): void => {
+ deregisterShortcut(GlobalShortcuts.SidebarCollapse);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const isLicenseActive =
licenseData?.payload?.licenses?.find((e: License) => e.isCurrent)?.status ===
LICENSE_PLAN_STATUS.VALID;
diff --git a/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx b/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx
new file mode 100644
index 0000000000..11e26b06f1
--- /dev/null
+++ b/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx
@@ -0,0 +1,75 @@
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {
+ KeyboardHotkeysProvider,
+ useKeyboardHotkeys,
+} from '../useKeyboardHotkeys';
+
+function TestComponentWithRegister({
+ handleShortcut,
+}: {
+ handleShortcut: () => void;
+}): JSX.Element {
+ const { registerShortcut } = useKeyboardHotkeys();
+
+ registerShortcut('a', handleShortcut);
+
+ return (
+
+ Test Component
+
+ );
+}
+function TestComponentWithDeRegister({
+ handleShortcut,
+}: {
+ handleShortcut: () => void;
+}): JSX.Element {
+ const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
+
+ registerShortcut('b', handleShortcut);
+
+ // Deregister the shortcut before triggering it
+ deregisterShortcut('b');
+
+ return (
+
+ Test Component
+
+ );
+}
+
+describe('KeyboardHotkeysProvider', () => {
+ it('registers and triggers shortcuts correctly', async () => {
+ const handleShortcut = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ // Trigger the registered shortcut
+ await userEvent.keyboard('a');
+
+ // Assert that the handleShortcut function has been called
+ expect(handleShortcut).toHaveBeenCalled();
+ });
+
+ it('deregisters shortcuts correctly', () => {
+ const handleShortcut = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ // Try to trigger the deregistered shortcut
+ userEvent.keyboard('b');
+
+ // Assert that the handleShortcut function has NOT been called
+ expect(handleShortcut).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx
new file mode 100644
index 0000000000..ec1b861664
--- /dev/null
+++ b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx
@@ -0,0 +1,121 @@
+import { noop, unset } from 'lodash-es';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
+
+interface KeyboardHotkeysContextReturnValue {
+ /**
+ * @param keyCombination provide the string for which the subsequent callback should be triggered. Example 'ctrl+a'
+ * @param callback the callback that should be triggered when the above key combination is being pressed
+ * @returns void
+ */
+ registerShortcut: (keyCombination: string, callback: () => void) => void;
+
+ /**
+ *
+ * @param keyCombination provide the string for which we want to deregister the callback
+ * @returns void
+ */
+ deregisterShortcut: (keyCombination: string) => void;
+}
+
+const KeyboardHotkeysContext = createContext(
+ {
+ registerShortcut: noop,
+ deregisterShortcut: noop,
+ },
+);
+
+const IGNORE_INPUTS = ['input', 'textarea']; // Inputs in which hotkey events will be ignored
+
+const useKeyboardHotkeys = (): KeyboardHotkeysContextReturnValue => {
+ const context = useContext(KeyboardHotkeysContext);
+ if (!context) {
+ throw new Error(
+ 'useKeyboardHotkeys must be used within a KeyboardHotkeysProvider',
+ );
+ }
+
+ return context;
+};
+
+function KeyboardHotkeysProvider({
+ children,
+}: {
+ children: JSX.Element;
+}): JSX.Element {
+ const shortcuts = useRef void>>({});
+
+ const handleKeyPress = (event: KeyboardEvent): void => {
+ const { key, ctrlKey, altKey, shiftKey, metaKey, target } = event;
+
+ if (IGNORE_INPUTS.includes((target as HTMLElement).tagName.toLowerCase())) {
+ return;
+ }
+
+ // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
+ const modifiers = { ctrlKey, altKey, shiftKey, metaKey };
+
+ let shortcutKey = `${key.toLowerCase()}`;
+
+ const isAltKey = `${modifiers.altKey ? '+alt' : ''}`;
+ const isShiftKey = `${modifiers.shiftKey ? '+shift' : ''}`;
+
+ // ctrl and cmd have the same functionality for mac and windows parity
+ const isMetaKey = `${modifiers.metaKey || modifiers.ctrlKey ? '+meta' : ''}`;
+
+ shortcutKey = shortcutKey + isAltKey + isShiftKey + isMetaKey;
+
+ if (shortcuts.current[shortcutKey]) {
+ shortcuts.current[shortcutKey]();
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyPress);
+ return (): void => {
+ document.removeEventListener('keydown', handleKeyPress);
+ };
+ }, []);
+
+ const registerShortcut = useCallback(
+ (keyCombination: string, callback: () => void): void => {
+ if (!shortcuts.current[keyCombination]) {
+ shortcuts.current[keyCombination] = callback;
+ } else {
+ throw new Error('This shortcut is already present in current scope');
+ }
+ },
+ [shortcuts],
+ );
+
+ const deregisterShortcut = useCallback(
+ (keyCombination: string): void => {
+ if (shortcuts.current[keyCombination]) {
+ unset(shortcuts.current, keyCombination);
+ }
+ },
+ [shortcuts],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ registerShortcut,
+ deregisterShortcut,
+ }),
+ [registerShortcut, deregisterShortcut],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { KeyboardHotkeysProvider, useKeyboardHotkeys };