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
@@ -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();