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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user