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:
Thomas des Francs
2026-07-05 01:19:39 +02:00
committed by GitHub
parent f14e62ec0c
commit a4ed561e11
15 changed files with 410 additions and 39 deletions
@@ -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,
},
});
@@ -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>
@@ -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}
/>
@@ -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();
@@ -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();
};
};