From a4ed561e115b4bd78f9390cde4cc33fa202408f7 Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Sun, 5 Jul 2026 01:19:39 +0200 Subject: [PATCH] Add focus-safe side panel shortcuts (#22499) ## Summary - Add side-panel-owned Escape and Backspace behavior for the side-panel search input. - Keep side-panel Escape scoped to side-panel focus and avoid left-content fallback behavior. - Add a Side Panel group to the keyboard shortcut menu. - Reuse the side-panel focus id for AI chat thread-list shortcuts. ## Demo https://github.com/user-attachments/assets/80a632d6-7ff7-496b-905f-a3f95f9cfc14 --------- Co-authored-by: Charles Bochet --- .../ai/components/AiChatThreadsList.tsx | 7 +- .../AiChatThreadsListFocusEffect.tsx | 8 +- .../__tests__/AiChatThreadsList.test.tsx | 98 +++++++++++++ .../ai/constants/AiChatThreadsListFocusId.ts | 1 + .../hooks/useCommandMenuHotKeys.ts | 34 ++--- .../KeyboardShortcutMenuOpenContent.tsx | 10 +- .../constants/KeyboardShortcutsSidePanel.ts | 32 +++++ .../keyboard-shortcut-menu/types/Shortcut.ts | 3 +- .../components/SidePanelToggleButton.tsx | 5 +- .../side-panel/components/SidePanelTopBar.tsx | 31 ++++- .../__tests__/SidePanelToggleButton.test.tsx | 23 +++- .../__tests__/SidePanelTopBar.test.tsx | 129 +++++++++++++++++- .../hooks/useCanGoBackOneSidePanelStep.ts | 18 +++ .../hooks/useHandleSidePanelBackspace.ts | 22 +++ .../hooks/useHandleSidePanelEscape.ts | 28 ++++ 15 files changed, 410 insertions(+), 39 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts create mode 100644 packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts create mode 100644 packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts create mode 100644 packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts create mode 100644 packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts diff --git a/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx b/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx index 750fac186b..0ed7d3106c 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx @@ -7,6 +7,7 @@ import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem'; import { AiChatThreadsListFocusEffect } from '@/ai/components/AiChatThreadsListFocusEffect'; import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader'; import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy'; +import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId'; import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface'; import { useChatThreads } from '@/ai/hooks/useChatThreads'; import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat'; @@ -51,12 +52,10 @@ const StyledButtonsContainer = styled.div` export const AiChatThreadsList = () => { const { switchToNewChat } = useSwitchToNewAiChat(); - const focusId = 'threads-list'; - useHotkeysOnFocusedElement({ keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`], callback: () => switchToNewChat(), - focusId, + focusId: AI_CHAT_THREADS_LIST_FOCUS_ID, dependencies: [switchToNewChat], }); @@ -79,7 +78,7 @@ export const AiChatThreadsList = () => { return ( <> - + {shouldRenderDateGroups ? ( diff --git a/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx b/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx index ed33ea8cc4..480bb8c723 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx @@ -1,7 +1,8 @@ +import { useEffect } from 'react'; + import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; -import { useEffect } from 'react'; export const AiChatThreadsListFocusEffect = ({ focusId, @@ -19,6 +20,9 @@ export const AiChatThreadsListFocusEffect = ({ type: FocusComponentType.SIDE_PANEL, instanceId: focusId, }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, }); return () => { @@ -26,5 +30,5 @@ export const AiChatThreadsListFocusEffect = ({ }; }, [pushFocusItemToFocusStack, removeFocusItemFromFocusStackById, focusId]); - return <>; + return null; }; diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx new file mode 100644 index 0000000000..e1c2471756 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; +import { act } from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { AiChatThreadsList } from '@/ai/components/AiChatThreadsList'; +import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId'; +import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; + +const mockSwitchToNewChat = jest.fn(); + +jest.mock('@/ai/hooks/useSwitchToNewAiChat', () => ({ + useSwitchToNewAiChat: () => ({ + switchToNewChat: mockSwitchToNewChat, + }), +})); + +jest.mock('@/ai/hooks/useChatThreads', () => ({ + useChatThreads: () => ({ + threads: [], + hasNextPage: false, + loading: false, + fetchMoreRef: jest.fn(), + }), +})); + +jest.mock('@/ai/components/AiChatThreadFilterDropdown', () => ({ + AiChatThreadFilterDropdown: () => null, +})); + +jest.mock('@/ai/components/AiChatThreadDeleteConfirmationModal', () => ({ + AiChatThreadDeleteConfirmationModal: () => null, +})); + +describe('AiChatThreadsList', () => { + beforeEach(() => { + mockSwitchToNewChat.mockClear(); + }); + + it('keeps a distinct side panel focus for the new chat hotkey', async () => { + const store = createStore(); + + store.set(focusStackState.atom, [ + { + focusId: SIDE_PANEL_FOCUS_ID, + componentInstance: { + componentType: FocusComponentType.SIDE_PANEL, + componentInstanceId: SIDE_PANEL_FOCUS_ID, + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }, + ]); + + render( + + + + + , + ); + + expect(screen.getByText('New chat')).toBeInTheDocument(); + + await waitFor(() => { + expect(store.get(focusStackState.atom).at(-1)?.focusId).toBe( + AI_CHAT_THREADS_LIST_FOCUS_ID, + ); + }); + + act(() => { + store.set( + focusStackState.atom, + store + .get(focusStackState.atom) + .filter((item) => item.focusId !== SIDE_PANEL_FOCUS_ID), + ); + }); + + expect(store.get(focusStackState.atom).at(-1)?.focusId).toBe( + AI_CHAT_THREADS_LIST_FOCUS_ID, + ); + + act(() => { + fireEvent.keyDown(document, { + key: 'Enter', + code: 'Enter', + metaKey: true, + }); + }); + + expect(mockSwitchToNewChat).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts b/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts new file mode 100644 index 0000000000..04dbd082cd --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts @@ -0,0 +1 @@ +export const AI_CHAT_THREADS_LIST_FOCUS_ID = 'ai-chat-threads-list'; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts index 6ed3d30893..f7955a8d44 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts @@ -1,17 +1,13 @@ +import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId'; import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu'; import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; +import { useHandleSidePanelEscape } from '@/side-panel/hooks/useHandleSidePanelEscape'; import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel'; import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel'; -import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; -import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; -import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { isNonEmptyString } from '@sniptt/guards'; import { Key } from 'ts-key-enum'; -import { SidePanelPages } from 'twenty-shared/types'; export const useCommandMenuHotKeys = () => { const { toggleSidePanelMenu } = useSidePanelMenu(); @@ -20,15 +16,10 @@ export const useCommandMenuHotKeys = () => { const { openAskAiPage } = useOpenAskAiPageInSidePanel(); - const { goBackFromSidePanel, goBackOneSubPageOrMainPage } = - useSidePanelHistory(); - - const sidePanelSearch = useAtomStateValue(sidePanelSearchState); + const handleSidePanelEscape = useHandleSidePanelEscape(); const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); - const sidePanelPage = useAtomStateValue(sidePanelPageState); - useGlobalHotkeys({ keys: ['ctrl+k', 'meta+k'], callback: () => { @@ -66,30 +57,23 @@ export const useCommandMenuHotKeys = () => { useHotkeysOnFocusedElement({ keys: [Key.Escape], callback: () => { - goBackFromSidePanel(); + handleSidePanelEscape(); }, focusId: SIDE_PANEL_FOCUS_ID, - dependencies: [goBackFromSidePanel], + dependencies: [handleSidePanelEscape], options: { enableOnFormTags: false, }, }); useHotkeysOnFocusedElement({ - keys: [Key.Backspace, Key.Delete], + keys: [Key.Escape], callback: () => { - if (isNonEmptyString(sidePanelSearch)) { - return; - } - - if (sidePanelPage !== SidePanelPages.CommandMenuDisplay) { - goBackOneSubPageOrMainPage(); - } + handleSidePanelEscape(); }, - focusId: SIDE_PANEL_FOCUS_ID, - dependencies: [sidePanelPage, sidePanelSearch, goBackOneSubPageOrMainPage], + focusId: AI_CHAT_THREADS_LIST_FOCUS_ID, + dependencies: [handleSidePanelEscape], options: { - preventDefault: false, enableOnFormTags: false, }, }); diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx index 7d412c7793..bf0eeee21b 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx @@ -1,6 +1,7 @@ import { Key } from 'ts-key-enum'; import { KEYBOARD_SHORTCUTS_GENERAL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsGeneral'; +import { KEYBOARD_SHORTCUTS_SIDE_PANEL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel'; import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable'; import { @@ -35,9 +36,14 @@ export const KeyboardShortcutMenuOpenContent = () => { ))} + + {KEYBOARD_SHORTCUTS_SIDE_PANEL.map((SidePanelShortcut, index) => ( + + ))} + - {KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut) => ( - + {KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut, index) => ( + ))} diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts new file mode 100644 index 0000000000..c07ffbf7ee --- /dev/null +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts @@ -0,0 +1,32 @@ +import { + type Shortcut, + ShortcutType, +} from '@/keyboard-shortcut-menu/types/Shortcut'; + +export const KEYBOARD_SHORTCUTS_SIDE_PANEL: Shortcut[] = [ + { + label: 'Clear search, go back, or close', + type: ShortcutType.SidePanel, + firstHotKey: 'esc', + areSimultaneous: true, + }, + { + label: 'Go back when search is empty', + type: ShortcutType.SidePanel, + firstHotKey: '⌫', + areSimultaneous: true, + }, + { + label: 'Move through list items', + type: ShortcutType.SidePanel, + firstHotKey: '↑', + secondHotKey: '↓', + areSimultaneous: true, + }, + { + label: 'Open selected list item', + type: ShortcutType.SidePanel, + firstHotKey: '↵', + areSimultaneous: true, + }, +]; diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts index baa7cf1f67..6f17e2a290 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts @@ -1,11 +1,12 @@ export enum ShortcutType { Table = 'Table', General = 'General', + SidePanel = 'SidePanel', } export type Shortcut = { label: string; - type: ShortcutType.Table | ShortcutType.General; + type: ShortcutType; firstHotKey?: string; secondHotKey?: string; areSimultaneous: boolean; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx index 4f0c4d7f71..bd0dcac87f 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx @@ -13,7 +13,7 @@ import { t } from '@lingui/core/macro'; import { IconDotsVertical } from 'twenty-ui/icon'; import { IconButton } from 'twenty-ui/input'; import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces'; -import { useIsMobile } from 'twenty-ui/utilities'; +import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledButtonWrapper = styled.div<{ alignToTop: boolean }>` @@ -64,6 +64,7 @@ export const SidePanelToggleButton = () => { } const ariaLabel = t`Command Menu`; + const tooltipContent = t`Command menu | ${getOsControlSymbol()}K`; return ( @@ -85,7 +86,7 @@ export const SidePanelToggleButton = () => { { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { removeFocusItemFromFocusStackById } = useRemoveFocusItemFromFocusStackById(); + const handleSidePanelBackspace = useHandleSidePanelBackspace(); + const handleSidePanelEscape = useHandleSidePanelEscape(); const handleInputFocus = () => { pushFocusItemToFocusStack({ @@ -130,6 +135,29 @@ export const SidePanelTopBar = () => { }); }; + const handleInputKeyDownCapture = ( + event: React.KeyboardEvent, + ) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } + + if (event.key === Key.Escape) { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + + handleSidePanelEscape(); + return; + } + + if (event.key === Key.Backspace && handleSidePanelBackspace()) { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + } + }; + const currentPage = sidePanelNavigationStack.at(-1)?.page; const previousPage = sidePanelNavigationStack.at(-2)?.page; @@ -173,6 +201,7 @@ export const SidePanelTopBar = () => { value={sidePanelSearch} placeholder={t`Type anything...`} onChange={handleSearchChange} + onKeyDownCapture={handleInputKeyDownCapture} onFocus={handleInputFocus} onBlur={handleInputBlur} /> diff --git a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx index 0a5c821763..0eb3ff6ffd 100644 --- a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx @@ -14,13 +14,20 @@ import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page import { SidePanelPages } from 'twenty-shared/types'; import { IconDotsVertical } from 'twenty-ui/icon'; +const mockAppTooltip = jest.fn(); + jest.mock('twenty-ui/utilities', () => ({ useIsMobile: () => false, + getOsControlSymbol: () => '⌘', })); jest.mock('twenty-ui/surfaces', () => ({ ...jest.requireActual('twenty-ui/surfaces'), - AppTooltip: () => null, + AppTooltip: (props: { content: string }) => { + mockAppTooltip(props); + + return null; + }, })); const renderSidePanelToggleButton = ({ @@ -68,6 +75,10 @@ const renderSidePanelToggleButton = ({ }; describe('SidePanelToggleButton', () => { + beforeEach(() => { + mockAppTooltip.mockClear(); + }); + it('opens the command menu when the side panel is closed', () => { const { store } = renderSidePanelToggleButton(); @@ -147,6 +158,16 @@ describe('SidePanelToggleButton', () => { expect(screen.getByTestId('page-header-side-panel-button')).toBeVisible(); }); + it('shows the command menu keyboard shortcut in the tooltip', () => { + renderSidePanelToggleButton(); + + expect(mockAppTooltip).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Command menu | ⌘K', + }), + ); + }); + it('marks the command menu button as a click-outside exclusion', () => { renderSidePanelToggleButton(); diff --git a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx index d04bfddeac..107e813092 100644 --- a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx @@ -9,7 +9,9 @@ import { SidePanelList } from '@/side-panel/components/SidePanelList'; import { SidePanelTopBar } from '@/side-panel/components/SidePanelTopBar'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState'; +import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState'; import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; import { PageFocusId } from '@/types/PageFocusId'; @@ -26,13 +28,15 @@ jest.mock('@/side-panel/components/SidePanelTopBarRightCornerIcon', () => ({ SidePanelTopBarRightCornerIcon: () => null, })); +const mockCloseSidePanelMenu = jest.fn(); + jest.mock('@/side-panel/hooks/useSidePanelContextChips', () => ({ useSidePanelContextChips: () => ({ contextChips: [] }), })); jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({ useSidePanelMenu: () => ({ - closeSidePanelMenu: jest.fn(), + closeSidePanelMenu: mockCloseSidePanelMenu, }), })); @@ -78,6 +82,11 @@ const createSidePanelTopBarStore = ({ store.set(isSidePanelOpenedState.atom, true); store.set(sidePanelPageState.atom, sidePanelPage); store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack); + store.set(sidePanelPageInfoState.atom, { + title: sidePanelNavigationStack.at(-1)?.pageTitle, + Icon: sidePanelNavigationStack.at(-1)?.pageIcon, + instanceId: sidePanelNavigationStack.at(-1)?.pageId ?? '', + }); store.set(focusStackState.atom, [recordIndexFocusItem]); return store; @@ -105,6 +114,7 @@ const renderSidePanelCommandMenu = (store = createSidePanelTopBarStore()) => { describe('SidePanelTopBar', () => { beforeEach(() => { + mockCloseSidePanelMenu.mockClear(); mockIsMobile = false; }); @@ -150,6 +160,123 @@ describe('SidePanelTopBar', () => { }); }); + it('clears search with Escape without navigating', () => { + const { store } = renderSidePanelCommandMenu(); + + const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID); + + fireEvent.change(input, { + target: { value: 'company' }, + }); + + fireEvent.keyDown(input, { + key: 'Escape', + code: 'Escape', + }); + + expect(store.get(sidePanelSearchState.atom)).toBe(''); + expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1); + expect(mockCloseSidePanelMenu).not.toHaveBeenCalled(); + }); + + it('closes the side panel with Escape from an empty root search', () => { + renderSidePanelCommandMenu(); + + const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID); + + fireEvent.keyDown(input, { + key: 'Escape', + code: 'Escape', + }); + + expect(mockCloseSidePanelMenu).toHaveBeenCalledTimes(1); + }); + + it('does not navigate with Backspace while search has text', () => { + const { store } = renderSidePanelCommandMenu( + createSidePanelTopBarStore({ + sidePanelPage: SidePanelPages.SearchRecords, + sidePanelNavigationStack: [ + { + page: SidePanelPages.CommandMenuDisplay, + pageTitle: 'Command Menu', + pageIcon: IconDotsVertical, + pageId: 'command-menu', + }, + { + page: SidePanelPages.SearchRecords, + pageTitle: 'Search', + pageIcon: IconDotsVertical, + pageId: 'search-records', + }, + ], + }), + ); + + const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID); + + fireEvent.change(input, { + target: { value: 'company' }, + }); + + fireEvent.keyDown(input, { + key: 'Backspace', + code: 'Backspace', + }); + + expect(store.get(sidePanelSearchState.atom)).toBe('company'); + expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(2); + }); + + it('goes back with Backspace from an empty search when side panel history exists', () => { + const { store } = renderSidePanelCommandMenu( + createSidePanelTopBarStore({ + sidePanelPage: SidePanelPages.SearchRecords, + sidePanelNavigationStack: [ + { + page: SidePanelPages.CommandMenuDisplay, + pageTitle: 'Command Menu', + pageIcon: IconDotsVertical, + pageId: 'command-menu', + }, + { + page: SidePanelPages.SearchRecords, + pageTitle: 'Search', + pageIcon: IconDotsVertical, + pageId: 'search-records', + }, + ], + }), + ); + + const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID); + + fireEvent.keyDown(input, { + key: 'Backspace', + code: 'Backspace', + }); + + expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1); + expect(store.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); + expect(mockCloseSidePanelMenu).not.toHaveBeenCalled(); + }); + + it('does not close the root side panel with Backspace from an empty search', () => { + const { store } = renderSidePanelCommandMenu(); + + const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID); + + fireEvent.keyDown(input, { + key: 'Backspace', + code: 'Backspace', + }); + + expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1); + expect(mockCloseSidePanelMenu).not.toHaveBeenCalled(); + }); + it('renders the close button after the command menu content', () => { renderSidePanelCommandMenu(); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts b/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts new file mode 100644 index 0000000000..afeaeb23da --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts @@ -0,0 +1,18 @@ +import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState'; +import { sidePanelSubPageStackForActiveSidePanelPageSelector } from '@/side-panel/states/sidePanelSubPageStackForActiveSidePanelPageSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyArray } from '@sniptt/guards'; + +export const useCanGoBackOneSidePanelStep = () => { + const sidePanelNavigationStack = useAtomStateValue( + sidePanelNavigationStackState, + ); + const sidePanelSubPageStackForActiveSidePanelPage = useAtomStateValue( + sidePanelSubPageStackForActiveSidePanelPageSelector, + ); + + return ( + isNonEmptyArray(sidePanelSubPageStackForActiveSidePanelPage) || + sidePanelNavigationStack.length > 1 + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts new file mode 100644 index 0000000000..80af101b19 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts @@ -0,0 +1,22 @@ +import { useCanGoBackOneSidePanelStep } from '@/side-panel/hooks/useCanGoBackOneSidePanelStep'; +import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; + +export const useHandleSidePanelBackspace = () => { + const sidePanelSearch = useAtomStateValue(sidePanelSearchState); + const canGoBackOneSidePanelStep = useCanGoBackOneSidePanelStep(); + + const { goBackOneSubPageOrMainPage } = useSidePanelHistory(); + + return () => { + if (!canGoBackOneSidePanelStep || isNonEmptyString(sidePanelSearch)) { + return false; + } + + goBackOneSubPageOrMainPage(); + + return true; + }; +}; diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts new file mode 100644 index 0000000000..15d894efcb --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts @@ -0,0 +1,28 @@ +import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages'; +import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory'; +import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; + +export const useHandleSidePanelEscape = () => { + const [sidePanelSearch, setSidePanelSearch] = + useAtomState(sidePanelSearchState); + const sidePanelPage = useAtomStateValue(sidePanelPageState); + + const { goBackOneSubPageOrMainPage } = useSidePanelHistory(); + + return () => { + const canClearSidePanelSearch = + COMMAND_MENU_SIDE_PANEL_PAGES.includes(sidePanelPage) && + isNonEmptyString(sidePanelSearch); + + if (canClearSidePanelSearch) { + setSidePanelSearch(''); + return; + } + + goBackOneSubPageOrMainPage(); + }; +};