feat: setup the context for keyboard hotkeys (#4493)

* feat: setup the context for keyboard hotkeys

* feat: add error handling for duplicate callbacks

* feat: supported added for caps and document the return value

* feat: added shortcut for cmd+b for sideNav open and close

* feat: added jest test

* fix: address review comments

* fix: block the browser default actions wherever possible

* fix: remove browser ovverides prevention code
This commit is contained in:
Vikrant Gupta 2024-02-06 14:17:27 +05:30 committed by GitHub
parent 554c4332c4
commit f6ab060545
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 230 additions and 15 deletions

View File

@ -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 {
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<KeyboardHotkeysProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
</ResourceProvider>

View File

@ -0,0 +1,3 @@
export const GlobalShortcuts = {
SidebarCollapse: '\\+meta',
};

View File

@ -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;

View File

@ -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 (
<div>
<span>Test Component</span>
</div>
);
}
function TestComponentWithDeRegister({
handleShortcut,
}: {
handleShortcut: () => void;
}): JSX.Element {
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
registerShortcut('b', handleShortcut);
// Deregister the shortcut before triggering it
deregisterShortcut('b');
return (
<div>
<span>Test Component</span>
</div>
);
}
describe('KeyboardHotkeysProvider', () => {
it('registers and triggers shortcuts correctly', async () => {
const handleShortcut = jest.fn();
render(
<KeyboardHotkeysProvider>
<TestComponentWithRegister handleShortcut={handleShortcut} />
</KeyboardHotkeysProvider>,
);
// 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(
<KeyboardHotkeysProvider>
<TestComponentWithDeRegister handleShortcut={handleShortcut} />
</KeyboardHotkeysProvider>,
);
// Try to trigger the deregistered shortcut
userEvent.keyboard('b');
// Assert that the handleShortcut function has NOT been called
expect(handleShortcut).not.toHaveBeenCalled();
});
});

View File

@ -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<KeyboardHotkeysContextReturnValue>(
{
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<Record<string, () => 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 (
<KeyboardHotkeysContext.Provider value={contextValue}>
{children}
</KeyboardHotkeysContext.Provider>
);
}
export { KeyboardHotkeysProvider, useKeyboardHotkeys };