From ecff27f90c4a6a96d218588f47b9d497f320f7cc Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 29 May 2024 12:45:29 +0200 Subject: [PATCH] Improved hotkey scopes docs (#5647) We have a lot of contributors that are not aware of our method for implementing hotkey listeners. I updated the documentation to provide clear examples so that users can refer to it and maintainers and reviewers can point to it when they see onKeyDown implementations. --- .../docs/contributor/frontend/hotkeys.mdx | 180 ++++++++++++++++-- 1 file changed, 168 insertions(+), 12 deletions(-) diff --git a/packages/twenty-docs/docs/contributor/frontend/hotkeys.mdx b/packages/twenty-docs/docs/contributor/frontend/hotkeys.mdx index 2666eafcdf..15884bebf3 100644 --- a/packages/twenty-docs/docs/contributor/frontend/hotkeys.mdx +++ b/packages/twenty-docs/docs/contributor/frontend/hotkeys.mdx @@ -5,19 +5,175 @@ sidebar_custom_props: icon: TbKeyboard --- -You can intercept any hotkey combination and execute a custom action. +## Introduction -There's a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders. +When you need to listen to a hotkey, you would normally use the `onKeyDown` event listener. -There's also a wrapper hook `useScopedHotkeys` that makes it easy to manage scopes. +In `twenty-front` however, you might have conflicts between same hotkeys that are used in different components, mounted at the same time. -```ts -useScopedHotkeys( - 'ctrl+k,meta+k', - () => { - openCommandMenu(); - }, - AppHotkeyScope.CommandMenu, - [openCommandMenu], -); +For example, if you have a page that listens for the Enter key, and a modal that listens for the Enter key, with a Select component inside that modal that listens for the Enter key, you might have a conflict when all are mounted at the same time. + +## The `useScopedHotkeys` hook + +To handle this problem, we have a custom hook that makes it possible to listen to hotkeys without any conflict. + +You place it in a component and it will listen to the hotkeys only when the component is mounted AND when the specified **hotkey scope** is active. + +## How to listen for hotkeys in practice ? + +There are two steps involved in setting up hotkey listening : +1. Set the [hotkey scope](#what-is-a-hotkey-scope-) that will listen to hotkeys +2. Use the `useScopedHotkeys` hook to listen to hotkeys + +Setting up hotkey scopes is required even in simple pages, because other UI elements like left menu or command menu might also listen to hotkeys. + +## Use cases for hotkeys + +In general, you'll have two use cases that require hotkeys : +1. In a page or a component mounted in a page +2. In a modal-type component that takes the focus due to a user action + +The second use case can happen recursively : a dropdown in a modal for example. + +### Listening to hotkeys in a page + +Example : + +```tsx +const PageListeningEnter = () => { + const { + setHotkeyScopeAndMemorizePreviousScope, + goBackToPreviousHotkeyScope, + } = usePreviousHotkeyScope(); + + // 1. Set the hotkey scope in a useEffect + useEffect(() => { + setHotkeyScopeAndMemorizePreviousScope( + ExampleHotkeyScopes.ExampleEnterPage, + ); + + // Revert to the previous hotkey scope when the component is unmounted + return () => { + goBackToPreviousHotkeyScope(); + }; + }, [goBackToPreviousHotkeyScope, setHotkeyScopeAndMemorizePreviousScope]); + + // 2. Use the useScopedHotkeys hook + useScopedHotkeys( + Key.Enter, + () => { + // Some logic executed on this page when the user presses Enter + // ... + }, + ExampleHotkeyScopes.ExampleEnterPage, + ); + + return
My page that listens for Enter
; +}; ``` + +### Listening to hotkeys in a modal-type component + +For this example we'll use a modal component that listens for the Escape key to tell it's parent to close it. + +Here the user interaction is changing the scope. + +```tsx +const ExamplePageWithModal = () => { + const [showModal, setShowModal] = useState(false); + + const { + setHotkeyScopeAndMemorizePreviousScope, + goBackToPreviousHotkeyScope, + } = usePreviousHotkeyScope(); + + const handleOpenModalClick = () => { + // 1. Set the hotkey scope when user opens the modal + setShowModal(true); + setHotkeyScopeAndMemorizePreviousScope( + ExampleHotkeyScopes.ExampleModal, + ); + }; + + const handleModalClose = () => { + // 1. Revert to the previous hotkey scope when the modal is closed + setShowModal(false); + goBackToPreviousHotkeyScope(); + }; + + return
+

My page with a modal

+ + {showModal && } +
; +}; +``` + +Then in the modal component : + +```tsx +const MyDropdownComponent = ({ onClose }: { onClose: () => void }) => { + // 2. Use the useScopedHotkeys hook to listen for Escape. + // Note that escape is a common hotkey that could be used by many other components + // So it's important to use a hotkey scope to avoid conflicts + useScopedHotkeys( + Key.Escape, + () => { + onClose() + }, + ExampleHotkeyScopes.ExampleModal, + ); + + return
My modal component
; +}; +``` + +It's important to use this pattern when you're not sure that just using a useEffect with mount/unmount will be enough to avoid conflicts. + +Those conflicts can be hard to debug, and it might happen more often than not with useEffects. + +## What is a hotkey scope ? + +A hotkey scope is a string that represents a context in which the hotkeys are active. It is generally encoded as an enum. + +When you change the hotkey scope, the hotkeys that are listening to this scope will be enabled and the hotkeys that are listening to other scopes will be disabled. + +You can set only one scope at a time. + +As an example, the hotkey scopes for each page are defined in the `PageHotkeyScope` enum: + +```tsx +export enum PageHotkeyScope { + Settings = 'settings', + CreateWokspace = 'create-workspace', + SignInUp = 'sign-in-up', + CreateProfile = 'create-profile', + PlanRequired = 'plan-required', + ShowPage = 'show-page', + PersonShowPage = 'person-show-page', + CompanyShowPage = 'company-show-page', + CompaniesPage = 'companies-page', + PeoplePage = 'people-page', + OpportunitiesPage = 'opportunities-page', + ProfilePage = 'profile-page', + WorkspaceMemberPage = 'workspace-member-page', + TaskPage = 'task-page', +} +``` + +Internally, the currently selected scope is stored in a Recoil state that is shared across the application : + +```tsx +export const currentHotkeyScopeState = createState({ + key: 'currentHotkeyScopeState', + defaultValue: INITIAL_HOTKEYS_SCOPE, +}); +``` + +But this Recoil state should never be handled manually ! We'll see how to use it in the next section. + +## How is it working internally ? + +We made a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders. + +We also create a Recoil state to handle the hotkey scope state and make it available everywhere in the application.