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 <charles@twenty.com>
This commit is contained in:
committed by
GitHub
parent
f14e62ec0c
commit
a4ed561e11
@@ -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 (
|
||||
<>
|
||||
<AiChatThreadsListFocusEffect focusId={focusId} />
|
||||
<AiChatThreadsListFocusEffect focusId={AI_CHAT_THREADS_LIST_FOCUS_ID} />
|
||||
<StyledContainer>
|
||||
<StyledThreadsContainer>
|
||||
{shouldRenderDateGroups ? (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<JotaiProvider store={store}>
|
||||
<MemoryRouter>
|
||||
<AiChatThreadsList />
|
||||
</MemoryRouter>
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const AI_CHAT_THREADS_LIST_FOCUS_ID = 'ai-chat-threads-list';
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+8
-2
@@ -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 = () => {
|
||||
<KeyboardMenuItem shortcut={TableShortcut} key={index} />
|
||||
))}
|
||||
</KeyboardMenuGroup>
|
||||
<KeyboardMenuGroup heading={t`Side Panel`}>
|
||||
{KEYBOARD_SHORTCUTS_SIDE_PANEL.map((SidePanelShortcut, index) => (
|
||||
<KeyboardMenuItem shortcut={SidePanelShortcut} key={index} />
|
||||
))}
|
||||
</KeyboardMenuGroup>
|
||||
<KeyboardMenuGroup heading={t`General`}>
|
||||
{KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut) => (
|
||||
<KeyboardMenuItem shortcut={GeneralShortcut} />
|
||||
{KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut, index) => (
|
||||
<KeyboardMenuItem shortcut={GeneralShortcut} key={index} />
|
||||
))}
|
||||
</KeyboardMenuGroup>
|
||||
</KeyboardMenuDialog>
|
||||
|
||||
+32
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<StyledButtonWrapper alignToTop={alignWithSidePanelTopBar}>
|
||||
@@ -85,7 +86,7 @@ export const SidePanelToggleButton = () => {
|
||||
<StyledTooltipWrapper>
|
||||
<AppTooltip
|
||||
anchorSelect="#toggle-side-panel-button"
|
||||
content={ariaLabel}
|
||||
content={tooltipContent}
|
||||
delay={TooltipDelay.longDelay}
|
||||
place={TooltipPosition.Bottom}
|
||||
offset={5}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo';
|
||||
import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect';
|
||||
import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon';
|
||||
import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight';
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
import { useHandleSidePanelBackspace } from '@/side-panel/hooks/useHandleSidePanelBackspace';
|
||||
import { useHandleSidePanelEscape } from '@/side-panel/hooks/useHandleSidePanelEscape';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { useSidePanelContextChips } from '@/side-panel/hooks/useSidePanelContextChips';
|
||||
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
|
||||
@@ -20,6 +22,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext, useRef } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconX } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
@@ -110,6 +113,8 @@ export const SidePanelTopBar = () => {
|
||||
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<HTMLInputElement>,
|
||||
) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
+22
-1
@@ -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();
|
||||
|
||||
|
||||
+128
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user