Introduce focus stack to handle hotkeys (#12166)

# Introduce focus stack to handle hotkeys

This PR introduces a focus stack to track the order in which the
elements are focused:
- Each focused element has a unique focus id
- When an element is focused, it is pushed on top of the stack
- When an element loses focus, we remove it from the stack 

This focus stack is then used to determine which hotkeys are available.
The previous implementation lead to many regressions because of race
conditions, of wrong order of open and close operations and by
overwriting previous states. This implementation should be way more
robust than the previous one.

The new api can be incrementally implemented since it preserves
backwards compatibility by writing to the old hotkey scopes states.
For now, it has been implemented on the modal components.

To test this PR, verify that the shortcuts still work correctly,
especially for the modal components.
This commit is contained in:
Raphaël Bosi
2025-05-21 15:52:40 +02:00
committed by GitHub
parent 7f1d6f5c7f
commit c982bcdb52
69 changed files with 1233 additions and 267 deletions

View File

@ -12,7 +12,9 @@ export const useHotkeyScopeOnMount = (hotkeyScope: string) => {
} = usePreviousHotkeyScope();
useEffect(() => {
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
});
return () => {
goBackToPreviousHotkeyScope();
};

View File

@ -353,9 +353,9 @@ export const ActivityRichTextEditor = ({
);
const handleBlockEditorFocus = () => {
setHotkeyScopeAndMemorizePreviousScope(
ActivityEditorHotkeyScope.ActivityBody,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: ActivityEditorHotkeyScope.ActivityBody,
});
};
const handlerBlockEditorBlur = () => {

View File

@ -84,9 +84,9 @@ export const useOpenActivityTargetCellEditMode = () => {
),
});
setHotkeyScopeAndMemorizePreviousScope(
MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
});
},
[multipleRecordPickerPerformSearch, setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -71,12 +71,13 @@ describe('useCommandMenu', () => {
});
expect(result.current.isCommandMenuOpened).toBe(true);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
CommandMenuHotkeyScope.CommandMenuFocused,
{
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: CommandMenuHotkeyScope.CommandMenuFocused,
memoizeKey: 'command-menu',
customScopes: {
commandMenuOpen: true,
},
);
});
act(() => {
result.current.commandMenu.closeCommandMenu();
@ -95,12 +96,13 @@ describe('useCommandMenu', () => {
});
expect(result.current.isCommandMenuOpened).toBe(true);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
CommandMenuHotkeyScope.CommandMenuFocused,
{
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: CommandMenuHotkeyScope.CommandMenuFocused,
memoizeKey: 'command-menu',
customScopes: {
commandMenuOpen: true,
},
);
});
act(() => {
result.current.commandMenu.toggleCommandMenu();

View File

@ -16,9 +16,7 @@ import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
export const useCommandMenu = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown();
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const closeCommandMenu = useRecoilCallback(
({ set, snapshot }) =>
@ -32,7 +30,7 @@ export const useCommandMenu = () => {
set(isCommandMenuClosingState, true);
set(isDragSelectionStartEnabledState, true);
closeAnyOpenDropdown();
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(COMMAND_MENU_COMPONENT_INSTANCE_ID);
}
},
[closeAnyOpenDropdown, goBackToPreviousHotkeyScope],

View File

@ -9,7 +9,7 @@ import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeySc
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isNonEmptyString } from '@sniptt/guards';
@ -36,21 +36,23 @@ export const useCommandMenuHotKeys = () => {
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
useScopedHotkeys(
useGlobalHotkeys(
'ctrl+k,meta+k',
() => {
closeKeyboardShortcutMenu();
toggleCommandMenu();
},
true,
AppHotkeyScope.CommandMenu,
[closeKeyboardShortcutMenu, toggleCommandMenu],
);
useScopedHotkeys(
useGlobalHotkeys(
['/'],
() => {
openRecordsSearchPage();
},
false,
AppHotkeyScope.KeyboardShortcutMenu,
[openRecordsSearchPage],
{
@ -58,16 +60,17 @@ export const useCommandMenuHotKeys = () => {
},
);
useScopedHotkeys(
useGlobalHotkeys(
[Key.Escape],
() => {
goBackFromCommandMenu();
},
true,
CommandMenuHotkeyScope.CommandMenuFocused,
[goBackFromCommandMenu],
);
useScopedHotkeys(
useGlobalHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (isNonEmptyString(commandMenuSearch)) {
@ -88,6 +91,7 @@ export const useCommandMenuHotKeys = () => {
goBackFromCommandMenu();
}
},
true,
CommandMenuHotkeyScope.CommandMenuFocused,
[
commandMenuPage,

View File

@ -27,9 +27,7 @@ export type CommandMenuNavigationStackItem = {
};
export const useNavigateCommandMenu = () => {
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const { copyContextStoreStates } = useCopyContextStoreStates();
@ -55,12 +53,13 @@ export const useNavigateCommandMenu = () => {
return;
}
setHotkeyScopeAndMemorizePreviousScope(
CommandMenuHotkeyScope.CommandMenuFocused,
{
setHotkeyScopeAndMemorizePreviousScope({
scope: CommandMenuHotkeyScope.CommandMenuFocused,
customScopes: {
commandMenuOpen: true,
},
);
memoizeKey: COMMAND_MENU_COMPONENT_INSTANCE_ID,
});
copyContextStoreStates({
instanceIdToCopyFrom: MAIN_CONTEXT_STORE_INSTANCE_ID,

View File

@ -1,13 +1,13 @@
import { useRecoilValue } from 'recoil';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useKeyboardShortcutMenu } from '../hooks/useKeyboardShortcutMenu';
import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState';
import { KeyboardShortcutMenuOpenContent } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
export const KeyboardShortcutMenu = () => {
const { toggleKeyboardShortcutMenu } = useKeyboardShortcutMenu();
@ -16,12 +16,13 @@ export const KeyboardShortcutMenu = () => {
);
const { closeCommandMenu } = useCommandMenu();
useScopedHotkeys(
useGlobalHotkeys(
'shift+?,meta+?',
() => {
closeCommandMenu();
toggleKeyboardShortcutMenu();
},
true,
AppHotkeyScope.KeyboardShortcutMenu,
[toggleKeyboardShortcutMenu],
);

View File

@ -2,11 +2,11 @@ import { Key } from 'ts-key-enum';
import { KEYBOARD_SHORTCUTS_GENERAL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsGeneral';
import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useKeyboardShortcutMenu } from '../hooks/useKeyboardShortcutMenu';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
import { KeyboardMenuDialog } from './KeyboardShortcutMenuDialog';
import { KeyboardMenuGroup } from './KeyboardShortcutMenuGroup';
import { KeyboardMenuItem } from './KeyboardShortcutMenuItem';
@ -15,11 +15,12 @@ export const KeyboardShortcutMenuOpenContent = () => {
const { toggleKeyboardShortcutMenu, closeKeyboardShortcutMenu } =
useKeyboardShortcutMenu();
useScopedHotkeys(
useGlobalHotkeys(
[Key.Escape],
() => {
closeKeyboardShortcutMenu();
},
false,
AppHotkeyScope.KeyboardShortcutMenuOpen,
[closeKeyboardShortcutMenu],
);

View File

@ -48,18 +48,18 @@ describe('useKeyboardShortcutMenu', () => {
result.current.toggleKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
AppHotkeyScope.KeyboardShortcutMenu,
);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(true);
act(() => {
result.current.toggleKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
AppHotkeyScope.KeyboardShortcutMenu,
);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(false);
});
@ -69,9 +69,9 @@ describe('useKeyboardShortcutMenu', () => {
result.current.openKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
AppHotkeyScope.KeyboardShortcutMenu,
);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(true);
act(() => {

View File

@ -15,9 +15,9 @@ export const useKeyboardShortcutMenu = () => {
({ set }) =>
() => {
set(isKeyboardShortcutMenuOpenedState, true);
setHotkeyScopeAndMemorizePreviousScope(
AppHotkeyScope.KeyboardShortcutMenu,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: AppHotkeyScope.KeyboardShortcutMenu,
});
},
[setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -52,7 +52,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowLeft,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('left');
},
RecordIndexHotkeyScope.RecordIndex,
@ -61,7 +63,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowRight,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('right');
},
RecordIndexHotkeyScope.RecordIndex,
@ -70,7 +74,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowUp,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('up');
},
RecordIndexHotkeyScope.RecordIndex,
@ -79,7 +85,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowDown,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('down');
},
RecordIndexHotkeyScope.RecordIndex,

View File

@ -79,12 +79,12 @@ export const RecordBoardColumnHeader = () => {
const handleBoardColumnMenuOpen = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope(
RecordBoardColumnHotkeyScope.BoardColumn,
{
setHotkeyScopeAndMemorizePreviousScope({
scope: RecordBoardColumnHotkeyScope.BoardColumn,
customScopes: {
goto: false,
},
);
});
};
const handleBoardColumnMenuClose = () => {

View File

@ -60,9 +60,9 @@ export const FormFieldInputInnerContainer = forwardRef(
onFocus?.(e);
if (!preventSetHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope(
FormFieldInputHotKeyScope.FormFieldInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: FormFieldInputHotKeyScope.FormFieldInput,
});
}
};

View File

@ -119,7 +119,9 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
});
};
const onOptionSelected = (value: FieldMultiSelectValue) => {

View File

@ -31,9 +31,7 @@ export const useOpenFieldInputEditMode = () => {
const { openActivityTargetCellEditMode } =
useOpenActivityTargetCellEditMode();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const openFieldInput = useRecoilCallback(
({ snapshot }) =>
@ -105,10 +103,11 @@ export const useOpenFieldInputEditMode = () => {
}
}
setHotkeyScopeAndMemorizePreviousScope(
DEFAULT_CELL_SCOPE.scope,
DEFAULT_CELL_SCOPE.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: DEFAULT_CELL_SCOPE.scope,
customScopes: DEFAULT_CELL_SCOPE.customScopes,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
},
[
openActivityTargetCellEditMode,

View File

@ -88,9 +88,9 @@ export const useOpenRelationFromManyFieldInput = () => {
forcePickableMorphItems: pickableMorphItems,
});
setHotkeyScopeAndMemorizePreviousScope(
MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
});
},
[performSearch, setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -34,9 +34,9 @@ export const useOpenRelationToOneFieldInput = () => {
);
}
setHotkeyScopeAndMemorizePreviousScope(
SingleRecordPickerHotkeyScope.SingleRecordPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: SingleRecordPickerHotkeyScope.SingleRecordPicker,
});
},
[setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -35,9 +35,7 @@ export const useInlineCell = (
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const initFieldInputDraftValue = useInitDraftValueV2();
@ -45,7 +43,7 @@ export const useInlineCell = (
onCloseEditMode?.();
setIsInlineCellInEditMode(false);
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
goBackToPreviousDropdownFocusId();
};

View File

@ -31,7 +31,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => {
useScopedHotkeys(
[Key.ArrowUp],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: TableHotkeyScope.TableFocus,
});
move('up');
},
RecordIndexHotkeyScope.RecordIndex,
@ -41,7 +43,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => {
useScopedHotkeys(
[Key.ArrowDown],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: TableHotkeyScope.TableFocus,
});
move('down');
},
RecordIndexHotkeyScope.RecordIndex,

View File

@ -42,16 +42,15 @@ export const RecordTitleCellSingleTextDisplayMode = () => {
const { openInlineCell } = useInlineCell();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return (
<StyledDiv
onClick={() => {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
openInlineCell();
}}
>

View File

@ -45,15 +45,14 @@ export const RecordTitleFullNameFieldDisplay = () => {
.join(' ')
.trim();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return (
<StyledDiv
onClick={() => {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
openInlineCell();
}}
>

View File

@ -18,7 +18,7 @@ export const useRecordTitleCell = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
} = usePreviousHotkeyScope();
const closeRecordTitleCell = useRecoilCallback(
({ set }) =>
@ -38,7 +38,7 @@ export const useRecordTitleCell = () => {
false,
);
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
goBackToPreviousDropdownFocusId();
},
@ -61,14 +61,16 @@ export const useRecordTitleCell = () => {
customEditHotkeyScopeForField?: HotkeyScope;
}) => {
if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope(
customEditHotkeyScopeForField.scope,
customEditHotkeyScopeForField.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: customEditHotkeyScopeForField.scope,
customScopes: customEditHotkeyScopeForField.customScopes,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
} else {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
}
const recordTitleCellId = getRecordTitleCellId(

View File

@ -1,11 +1,13 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useCallback } from 'react';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
@ -69,7 +71,6 @@ export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
title?: string;
message?: string;
buttons?: DialogButtonOptions[];
allowDismiss?: boolean;
children?: React.ReactNode;
className?: string;
onClose?: () => void;
@ -79,16 +80,11 @@ export const Dialog = ({
title,
message,
buttons = [],
allowDismiss = true,
children,
className,
onClose,
id,
}: DialogProps) => {
const closeSnackbar = useCallback(() => {
onClose && onClose();
}, [onClose]);
const dialogVariants = {
open: { opacity: 1 },
closed: { opacity: 0 },
@ -108,7 +104,7 @@ export const Dialog = ({
if (isDefined(confirmButton)) {
confirmButton?.onClick?.(event);
closeSnackbar();
onClose?.();
}
},
DialogHotkeyScope.Dialog,
@ -119,30 +115,35 @@ export const Dialog = ({
Key.Escape,
(event: KeyboardEvent) => {
event.preventDefault();
closeSnackbar();
onClose?.();
},
DialogHotkeyScope.Dialog,
[],
);
const dialogRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [dialogRef],
callback: () => {
onClose?.();
},
listenerId: DIALOG_LISTENER_ID,
});
return (
<StyledDialogOverlay
variants={dialogVariants}
initial="closed"
animate="open"
exit="closed"
onClick={(e) => {
if (allowDismiss) {
e.stopPropagation();
closeSnackbar();
}
}}
className={className}
>
<StyledDialogContainer
variants={containerVariants}
transition={{ damping: 15, stiffness: 100 }}
id={id}
ref={dialogRef}
>
{title && <StyledDialogTitle>{title}</StyledDialogTitle>}
{message && <StyledDialogMessage>{message}</StyledDialogMessage>}
@ -150,8 +151,8 @@ export const Dialog = ({
{buttons.map(({ accent, onClick, role, title: key, variant }) => (
<StyledDialogButton
onClick={(event) => {
onClose?.();
onClick?.(event);
closeSnackbar();
}}
fullWidth={true}
variant={variant ?? 'secondary'}

View File

@ -15,6 +15,7 @@ export const DialogManager = ({ children }: React.PropsWithChildren) => {
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
<Dialog
key={id}
className="dialog-manager-dialog"
{...{ title, message, buttons, id, children }}
onClose={() => closeDialog(id)}
/>

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey';
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
@ -15,7 +16,10 @@ export const DialogManagerEffect = () => {
return;
}
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
setHotkeyScopeAndMemorizePreviousScope({
scope: DialogHotkeyScope.Dialog,
memoizeKey: DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY,
});
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
return <></>;

View File

@ -0,0 +1 @@
export const DIALOG_LISTENER_ID = 'DIALOG_LISTENER_ID';

View File

@ -0,0 +1 @@
export const DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY = 'dialog-manager';

View File

@ -4,6 +4,7 @@ import { v4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey';
import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext';
import { dialogInternalScopedState } from '../states/dialogInternalScopedState';
import { DialogOptions } from '../types/DialogOptions';
@ -25,9 +26,10 @@ export const useDialogManager = (props?: useDialogManagerProps) => {
(id: string) => {
set(dialogInternalScopedState({ scopeId: scopeId }), (prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
queue: prevState.queue.filter((dialog) => dialog.id !== id),
}));
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY);
},
[goBackToPreviousHotkeyScope, scopeId],
);

View File

@ -7,9 +7,9 @@ import { Key } from 'ts-key-enum';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
import { Button, RoundedIconButton } from 'twenty-ui/input';
import { IconArrowRight } from 'twenty-ui/display';
import { Button, RoundedIconButton } from 'twenty-ui/input';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5;
@ -197,7 +197,9 @@ export const AutosizeTextInput = ({
const handleFocus = () => {
onFocus?.();
setIsFocused(true);
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
setHotkeyScopeAndMemorizePreviousScope({
scope: InputHotkeyScope.TextInput,
});
};
const handleBlur = () => {

View File

@ -197,9 +197,9 @@ export const IconPicker = ({
<DropdownMenuSeparator />
<div
onMouseEnter={() => {
setHotkeyScopeAndMemorizePreviousScope(
IconPickerHotkeyScope.IconPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: IconPickerHotkeyScope.IconPicker,
});
}}
onMouseLeave={goBackToPreviousHotkeyScope}
>

View File

@ -3,9 +3,9 @@ import { FocusEventHandler, useId } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { RGBA } from 'twenty-ui/theme';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
import { RGBA } from 'twenty-ui/theme';
const MAX_ROWS = 5;
@ -89,7 +89,9 @@ export const TextArea = ({
} = usePreviousHotkeyScope();
const handleFocus: FocusEventHandler<HTMLTextAreaElement> = () => {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
setHotkeyScopeAndMemorizePreviousScope({
scope: InputHotkeyScope.TextInput,
});
};
const handleBlur: FocusEventHandler<HTMLTextAreaElement> = () => {

View File

@ -55,7 +55,9 @@ export const TextInput = ({
setIsFocused(true);
if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
setHotkeyScopeAndMemorizePreviousScope({
scope: InputHotkeyScope.TextInput,
});
}
};

View File

@ -170,7 +170,9 @@ export const TitleInput = ({
onClick={() => {
if (!disabled) {
setIsOpened(true);
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
});
}
}}
>

View File

@ -65,10 +65,10 @@ export const useDropdown = (dropdownId?: string) => {
dropdownHotkeyScopeFromProps ?? dropdownHotkeyScope;
if (isDefined(dropdownHotkeyScopeForOpening)) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScopeForOpening.scope,
dropdownHotkeyScopeForOpening.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: dropdownHotkeyScopeForOpening.scope,
customScopes: dropdownHotkeyScopeForOpening.customScopes,
});
}
}
},

View File

@ -56,15 +56,15 @@ export const useDropdownV2 = () => {
);
if (isDefined(customHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
customHotkeyScope.scope,
customHotkeyScope.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: customHotkeyScope.scope,
customScopes: customHotkeyScope.customScopes,
});
} else if (isDefined(dropdownHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: dropdownHotkeyScope.scope,
customScopes: dropdownHotkeyScope.customScopes,
});
}
},
[setHotkeyScopeAndMemorizePreviousScope],

View File

@ -19,7 +19,9 @@ export const useOpenDropdownFromOutside = () => {
);
setActiveDropdownFocusIdAndMemorizePrevious(dropdownId);
setHotkeyScopeAndMemorizePreviousScope(dropdownId);
setHotkeyScopeAndMemorizePreviousScope({
scope: dropdownId,
});
set(dropdownOpenState, true);
};

View File

@ -1,6 +1,6 @@
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Key } from 'ts-key-enum';
@ -19,27 +19,36 @@ export const ModalHotkeysAndClickOutsideEffect = ({
onClose,
modalId,
}: ModalHotkeysAndClickOutsideEffectProps) => {
useScopedHotkeys(
[Key.Enter],
() => {
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: () => {
onEnter?.();
},
ModalHotkeyScope.ModalFocus,
);
focusId: modalId,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: ModalHotkeyScope.ModalFocus,
dependencies: [onEnter],
});
useScopedHotkeys(
[Key.Escape],
() => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
if (isClosable && onClose !== undefined) {
onClose();
}
},
ModalHotkeyScope.ModalFocus,
);
focusId: modalId,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: ModalHotkeyScope.ModalFocus,
dependencies: [isClosable, onClose],
});
useListenClickOutside({
refs: [modalRef],
excludeClassNames: [MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME],
excludeClassNames: [
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME,
'dialog-manager-dialog',
],
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
callback: () => {
if (isClosable && onClose !== undefined) {

View File

@ -2,6 +2,8 @@ import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { ComponentDecorator } from 'twenty-ui/testing';
@ -29,6 +31,20 @@ const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => {
});
set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]);
set(focusStackState, [
{
focusId: 'confirmation-modal',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'confirmation-modal',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
},
]);
};
const meta: Meta<typeof ConfirmationModal> = {

View File

@ -2,6 +2,8 @@ import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import { ComponentDecorator } from 'twenty-ui/testing';
@ -29,6 +31,20 @@ const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => {
});
set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]);
set(focusStackState, [
{
focusId: 'modal-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'modal-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
},
]);
};
const meta: Meta<typeof Modal> = {

View File

@ -4,7 +4,6 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { act } from 'react';
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope');
@ -13,13 +12,6 @@ const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn();
const mockGoBackToPreviousHotkeyScope = jest.fn();
const modalId = 'test-modal-id';
const customHotkeyScope: HotkeyScope = {
scope: 'test-scope',
customScopes: {
goto: true,
commandMenu: true,
},
};
describe('useModal', () => {
beforeEach(() => {
@ -52,31 +44,6 @@ describe('useModal', () => {
expect(result.current.isModalOpened).toBe(true);
});
it('should open a modal with custom hotkey scope', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.modal.openModal(modalId, customHotkeyScope);
});
expect(result.current.isModalOpened).toBe(true);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
customHotkeyScope.scope,
customHotkeyScope.customScopes,
);
});
it('should close a modal', () => {
const { result } = renderHook(
() => {
@ -153,29 +120,4 @@ describe('useModal', () => {
expect(result.current.isModalOpened).toBe(false);
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled();
});
it('should toggle a modal with custom hotkey scope', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.modal.toggleModal(modalId, customHotkeyScope);
});
expect(result.current.isModalOpened).toBe(true);
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
customHotkeyScope.scope,
customHotkeyScope.customScopes,
);
});
});

View File

@ -1,16 +1,13 @@
import { useRecoilCallback } from 'recoil';
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDefined } from 'twenty-shared/utils';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
export const useModal = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope('modal');
const pushFocusItem = usePushFocusItemToFocusStack();
const removeFocusId = useRemoveFocusIdFromFocusStack();
const closeModal = useRecoilCallback(
({ set, snapshot }) =>
@ -21,20 +18,26 @@ export const useModal = () => {
)
.getValue();
if (isModalOpen) {
goBackToPreviousHotkeyScope();
set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
false,
);
if (!isModalOpen) {
return;
}
removeFocusId({
focusId: modalId,
memoizeKey: modalId,
});
set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
false,
);
},
[goBackToPreviousHotkeyScope],
[removeFocusId],
);
const openModal = useRecoilCallback(
({ set, snapshot }) =>
(modalId: string, customHotkeyScope?: HotkeyScope) => {
(modalId: string) => {
const isModalOpened = snapshot
.getLoadable(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
@ -50,26 +53,35 @@ export const useModal = () => {
true,
);
if (isDefined(customHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
customHotkeyScope.scope,
customHotkeyScope.customScopes,
);
} else {
setHotkeyScopeAndMemorizePreviousScope(ModalHotkeyScope.ModalFocus, {
goto: false,
commandMenu: false,
commandMenuOpen: false,
keyboardShortcutMenu: false,
});
}
pushFocusItem({
focusId: modalId,
component: {
type: FocusComponentType.MODAL,
instanceId: modalId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
// TODO: Remove this once we've migrated hotkey scopes to the new api
hotkeyScope: {
scope: ModalHotkeyScope.ModalFocus,
customScopes: {
goto: false,
commandMenu: false,
commandMenuOpen: false,
keyboardShortcutMenu: false,
},
},
memoizeKey: modalId,
});
},
[setHotkeyScopeAndMemorizePreviousScope],
[pushFocusItem],
);
const toggleModal = useRecoilCallback(
({ snapshot }) =>
(modalId: string, customHotkeyScope?: HotkeyScope) => {
(modalId: string) => {
const isModalOpen = snapshot
.getLoadable(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
@ -79,7 +91,7 @@ export const useModal = () => {
if (isModalOpen) {
closeModal(modalId);
} else {
openModal(modalId, customHotkeyScope);
openModal(modalId);
}
},
[closeModal, openModal],

View File

@ -0,0 +1,90 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('usePushFocusItemToFocusStack', () => {
it('should push focus item to the stack', async () => {
const { result } = renderHooks();
expect(result.current.focusStack).toEqual([]);
const focusItem = {
focusId: 'test-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'test-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,
instanceId: focusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem]);
expect(result.current.currentFocusId).toEqual(focusItem.focusId);
const anotherFocusItem = {
focusId: 'another-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'another-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: anotherFocusItem.focusId,
component: {
type: anotherFocusItem.componentInstance.componentType,
instanceId: anotherFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem, anotherFocusItem]);
expect(result.current.currentFocusId).toEqual(anotherFocusItem.focusId);
});
});

View File

@ -0,0 +1,101 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const removeFocusId = useRemoveFocusIdFromFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
removeFocusId,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useRemoveFocusIdFromFocusStack', () => {
it('should remove focus id from the stack', async () => {
const { result } = renderHooks();
const firstFocusItem = {
focusId: 'first-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'first-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
const secondFocusItem = {
focusId: 'second-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'second-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
instanceId: firstFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
await act(async () => {
result.current.pushFocusItem({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,
instanceId: secondFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([
firstFocusItem,
secondFocusItem,
]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
await act(async () => {
result.current.removeFocusId({
focusId: firstFocusItem.focusId,
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([secondFocusItem]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
});
});

View File

@ -0,0 +1,71 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useResetFocusStack } from '@/ui/utilities/focus/hooks/useResetFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStack = useResetFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
resetFocusStack,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useResetFocusStack', () => {
it('should reset the focus stack', async () => {
const { result } = renderHooks();
const focusItem = {
focusId: 'test-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'test-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,
instanceId: focusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem]);
expect(result.current.currentFocusId).toEqual(focusItem.focusId);
await act(async () => {
result.current.resetFocusStack();
});
expect(result.current.focusStack).toEqual([]);
expect(result.current.currentFocusId).toEqual(undefined);
});
});

View File

@ -0,0 +1,102 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStackToFocusItem = useResetFocusStackToFocusItem();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
resetFocusStackToFocusItem,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useResetFocusStackToFocusItem', () => {
it('should reset the focus stack to a specific focus item', async () => {
const { result } = renderHooks();
const firstFocusItem = {
focusId: 'first-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'first-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
const secondFocusItem = {
focusId: 'second-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'second-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
instanceId: firstFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
await act(async () => {
result.current.pushFocusItem({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,
instanceId: secondFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([
firstFocusItem,
secondFocusItem,
]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
await act(async () => {
result.current.resetFocusStackToFocusItem({
focusStackItem: firstFocusItem,
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([firstFocusItem]);
expect(result.current.currentFocusId).toEqual(firstFocusItem.focusId);
});
});

View File

@ -0,0 +1,71 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
export const usePushFocusItemToFocusStack = () => {
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const addOrMoveItemToTheTopOfTheStack = useRecoilCallback(
({ set }) =>
(focusStackItem: FocusStackItem) => {
set(focusStackState, (currentFocusStack) => [
...currentFocusStack.filter(
(currentFocusStackItem) =>
currentFocusStackItem.focusId !== focusStackItem.focusId,
),
focusStackItem,
]);
},
[],
);
return useRecoilCallback(
() =>
({
focusId,
component,
hotkeyScope,
memoizeKey = 'global',
globalHotkeysConfig,
}: {
focusId: string;
component: {
type: FocusComponentType;
instanceId: string;
};
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>;
// TODO: Remove this once we've migrated hotkey scopes to the new api
hotkeyScope: HotkeyScope;
memoizeKey: string;
}) => {
const focusStackItem: FocusStackItem = {
focusId,
componentInstance: {
componentType: component.type,
componentInstanceId: component.instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers:
globalHotkeysConfig?.enableGlobalHotkeysWithModifiers ?? true,
enableGlobalHotkeysConflictingWithKeyboard:
globalHotkeysConfig?.enableGlobalHotkeysConflictingWithKeyboard ??
true,
},
};
addOrMoveItemToTheTopOfTheStack(focusStackItem);
// TODO: Remove this once we've migrated hotkey scopes to the new api
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope.scope,
customScopes: hotkeyScope.customScopes,
memoizeKey,
});
},
[setHotkeyScopeAndMemorizePreviousScope, addOrMoveItemToTheTopOfTheStack],
);
};

View File

@ -0,0 +1,22 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilCallback } from 'recoil';
export const useRemoveFocusIdFromFocusStack = () => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
return useRecoilCallback(
({ set }) =>
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
set(focusStackState, (previousFocusStack) =>
previousFocusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
),
);
// TODO: Remove this once we've migrated hotkey scopes to the new api
goBackToPreviousHotkeyScope(memoizeKey);
},
[goBackToPreviousHotkeyScope],
);
};

View File

@ -0,0 +1,18 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { useRecoilCallback } from 'recoil';
export const useResetFocusStack = () => {
return useRecoilCallback(
({ reset }) =>
(memoizeKey = 'global') => {
reset(focusStackState);
// TODO: Remove this once we've migrated hotkey scopes to the new api
reset(previousHotkeyScopeFamilyState(memoizeKey as string));
reset(currentHotkeyScopeState);
},
[],
);
};

View File

@ -0,0 +1,28 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
export const useResetFocusStackToFocusItem = () => {
return useRecoilCallback(
({ set }) =>
({
focusStackItem,
hotkeyScope,
memoizeKey,
}: {
focusStackItem: FocusStackItem;
hotkeyScope: HotkeyScope;
memoizeKey: string;
}) => {
set(focusStackState, [focusStackItem]);
// TODO: Remove this once we've migrated hotkey scopes to the new api
set(previousHotkeyScopeFamilyState(memoizeKey), null);
set(currentHotkeyScopeState, hotkeyScope);
},
[],
);
};

View File

@ -0,0 +1,11 @@
import { selector } from 'recoil';
import { focusStackState } from './focusStackState';
export const currentFocusIdSelector = selector<string | undefined>({
key: 'currentFocusIdSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
return focusStack.at(-1)?.focusId;
},
});

View File

@ -0,0 +1,21 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { DEFAULT_GLOBAL_HOTKEYS_CONFIG } from '@/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { selector } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const currentGlobalHotkeysConfigSelector = selector<GlobalHotkeysConfig>(
{
key: 'currentGlobalHotkeysConfigSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
const lastFocusStackItem = focusStack.at(-1);
if (!isDefined(lastFocusStackItem)) {
return DEFAULT_GLOBAL_HOTKEYS_CONFIG;
}
return lastFocusStackItem.globalHotkeysConfig;
},
},
);

View File

@ -0,0 +1,7 @@
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { createState } from 'twenty-ui/utilities';
export const focusStackState = createState<FocusStackItem[]>({
key: 'focusStackState',
defaultValue: [],
});

View File

@ -0,0 +1,6 @@
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
export type FocusComponentInstance = {
componentType: FocusComponentType;
componentInstanceId: string;
};

View File

@ -0,0 +1,3 @@
export enum FocusComponentType {
MODAL = 'modal',
}

View File

@ -0,0 +1,8 @@
import { FocusComponentInstance } from '@/ui/utilities/focus/types/FocusComponentInstance';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
export type FocusStackItem = {
focusId: string;
componentInstance: FocusComponentInstance;
globalHotkeysConfig: GlobalHotkeysConfig;
};

View File

@ -0,0 +1 @@
export const DEBUG_HOTKEY_SCOPE = false;

View File

@ -0,0 +1,6 @@
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
export const DEFAULT_GLOBAL_HOTKEYS_CONFIG: GlobalHotkeysConfig = {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
};

View File

@ -0,0 +1,74 @@
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useGlobalHotkeys = (
keys: Keys,
callback: HotkeyCallback,
containsModifier: boolean,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: string,
dependencies?: unknown[],
options?: UseHotkeysOptionsWithoutBuggyOptions,
) => {
const callGlobalHotkeysCallback = useGlobalHotkeysCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
? options.enableOnContentEditable
: true;
const enableOnFormTags = isDefined(options?.enableOnFormTags)
? options.enableOnFormTags
: true;
const preventDefault = isDefined(options?.preventDefault)
? options.preventDefault === true
: true;
const ignoreModifiers = isDefined(options?.ignoreModifiers)
? options.ignoreModifiers === true
: false;
const handleCallback = useRecoilCallback(
({ snapshot, set }) =>
async (keyboardEvent: KeyboardEvent, hotkeysEvent: any) => {
const pendingHotkey = snapshot
.getLoadable(pendingHotkeyState)
.getValue();
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
}
set(pendingHotkeyState, null);
},
[callback],
);
return useHotkeys(
keys,
(keyboardEvent, hotkeysEvent) => {
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
handleCallback(keyboardEvent, hotkeysEvent);
},
scope,
preventDefault,
containsModifier,
});
},
{
enableOnContentEditable,
enableOnFormTags,
ignoreModifiers,
},
dependencies,
);
};

View File

@ -0,0 +1,119 @@
import { currentGlobalHotkeysConfigSelector } from '@/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import {
Hotkey,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
export const useGlobalHotkeysCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
containsModifier,
hotkeysEvent,
keyboardEvent,
preventDefault,
scope,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
containsModifier: boolean;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
preventDefault?: boolean;
scope: string;
}) => {
// TODO: Remove this once we've migrated hotkey scopes to the new api
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const currentGlobalHotkeysConfig = snapshot
.getLoadable(currentGlobalHotkeysConfigSelector)
.getValue();
if (
containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysWithModifiers
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys with modifiers are disabled`,
'color: gray; ',
);
}
return;
}
if (
!containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysConflictingWithKeyboard
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys conflicting with keyboard are disabled`,
'color: gray; ',
);
}
return;
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
if (!currentHotkeyScopes.includes(scope)) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: gray; ',
);
}
return;
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: green;',
);
}
if (preventDefault === true) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
);
};

View File

@ -4,10 +4,10 @@ import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { isDefined } from 'twenty-shared/utils';
export const useSequenceHotkeys = (
export const useGlobalHotkeysSequence = (
firstKey: Keys,
secondKey: Keys,
sequenceCallback: () => void,
@ -21,14 +21,15 @@ export const useSequenceHotkeys = (
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback();
const callGlobalHotkeysCallback = useGlobalHotkeysCallback();
useHotkeys(
firstKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
containsModifier: false,
callback: () => {
setPendingHotkey(firstKey);
},
@ -46,9 +47,10 @@ export const useSequenceHotkeys = (
useHotkeys(
secondKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
containsModifier: false,
callback: () => {
if (pendingHotkey !== firstKey) {
return;

View File

@ -1,10 +1,9 @@
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useNavigate } from 'react-router-dom';
import { useGlobalHotkeysSequence } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence';
import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
type GoToHotkeysProps = {
key: Keys;
location: string;
@ -18,7 +17,7 @@ export const useGoToHotkeys = ({
}: GoToHotkeysProps) => {
const navigate = useNavigate();
useSequenceHotkeys(
useGlobalHotkeysSequence(
'g',
key,
() => {

View File

@ -0,0 +1,73 @@
import { useHotkeysOnFocusedElementCallback } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useHotkeysOnFocusedElement = ({
keys,
callback,
focusId,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope,
dependencies,
options,
}: {
keys: Keys;
callback: HotkeyCallback;
focusId: string;
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: string;
dependencies?: unknown[];
options?: UseHotkeysOptionsWithoutBuggyOptions;
}) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback =
useHotkeysOnFocusedElementCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
? options.enableOnContentEditable
: true;
const enableOnFormTags = isDefined(options?.enableOnFormTags)
? options.enableOnFormTags
: true;
const preventDefault = isDefined(options?.preventDefault)
? options.preventDefault === true
: true;
const ignoreModifiers = isDefined(options?.ignoreModifiers)
? options.ignoreModifiers === true
: false;
return useHotkeys(
keys,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
return;
}
setPendingHotkey(null);
},
focusId,
scope,
preventDefault,
});
},
{
enableOnContentEditable,
enableOnFormTags,
ignoreModifiers,
},
dependencies,
);
};

View File

@ -0,0 +1,89 @@
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import {
Hotkey,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { currentFocusIdSelector } from '../../focus/states/currentFocusIdSelector';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
export const useHotkeysOnFocusedElementCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
hotkeysEvent,
keyboardEvent,
focusId,
scope,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
focusId: string;
scope: string;
preventDefault?: boolean;
}) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
// TODO: Remove this once we've migrated hotkey scopes to the new api
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
if (
currentFocusId !== focusId ||
!currentHotkeyScopes.includes(scope)
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}] and the current focus identifier is [${focusId}]`,
'color: gray; ',
);
}
return;
}
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}], and the current focus identifier is [${focusId}]`,
'color: green;',
);
}
if (preventDefault === true) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
);
};

View File

@ -1,7 +1,7 @@
import { useRecoilCallback } from 'recoil';
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '../states/internal/previousHotkeyScopeFamilyState';
@ -9,14 +9,14 @@ import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { useSetHotkeyScope } from './useSetHotkeyScope';
export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
export const usePreviousHotkeyScope = () => {
const setHotkeyScope = useSetHotkeyScope();
const goBackToPreviousHotkeyScope = useRecoilCallback(
({ snapshot, set }) =>
() => {
(memoizeKey = 'global') => {
const previousHotkeyScope = snapshot
.getLoadable(previousHotkeyScopeFamilyState(memoizeKey))
.getLoadable(previousHotkeyScopeFamilyState(memoizeKey as string))
.getValue();
if (!previousHotkeyScope) {
@ -39,14 +39,22 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
previousHotkeyScope.customScopes,
);
set(previousHotkeyScopeFamilyState(memoizeKey), null);
set(previousHotkeyScopeFamilyState(memoizeKey as string), null);
},
[setHotkeyScope, memoizeKey],
[setHotkeyScope],
);
const setHotkeyScopeAndMemorizePreviousScope = useRecoilCallback(
({ snapshot, set }) =>
(scope: string, customScopes?: CustomHotkeyScopes) => {
({
scope,
customScopes,
memoizeKey = 'global',
}: {
scope: string;
customScopes?: CustomHotkeyScopes;
memoizeKey?: string;
}) => {
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.getValue();
@ -63,7 +71,7 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
set(previousHotkeyScopeFamilyState(memoizeKey), currentHotkeyScope);
},
[setHotkeyScope, memoizeKey],
[setHotkeyScope],
);
return {

View File

@ -6,10 +6,9 @@ import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
export const DEBUG_HOTKEY_SCOPE = false;
export const useScopedHotkeyCallback = (
dependencies?: OptionsOrDependencyArray,
) => {

View File

@ -2,9 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;

View File

@ -1,6 +1,6 @@
import { useRecoilCallback } from 'recoil';
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { isDefined } from 'twenty-shared/utils';
import { logDebug } from '~/utils/logDebug';
@ -11,7 +11,7 @@ import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { HotkeyScope } from '../types/HotkeyScope';
const isCustomScopesEqual = (
const areCustomScopesEqual = (
customScopesA: CustomHotkeyScopes | undefined,
customScopesB: CustomHotkeyScopes | undefined,
) => {
@ -34,7 +34,7 @@ export const useSetHotkeyScope = () =>
if (currentHotkeyScope.scope === hotkeyScopeToSet) {
if (!isDefined(customScopes)) {
if (
isCustomScopesEqual(
areCustomScopesEqual(
currentHotkeyScope?.customScopes,
DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES,
)
@ -43,7 +43,7 @@ export const useSetHotkeyScope = () =>
}
} else {
if (
isCustomScopesEqual(
areCustomScopesEqual(
currentHotkeyScope?.customScopes,
customScopes,
)

View File

@ -0,0 +1,4 @@
export type GlobalHotkeysConfig = {
enableGlobalHotkeysWithModifiers: boolean;
enableGlobalHotkeysConflictingWithKeyboard: boolean;
};