Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useLingui } from '@lingui/react/macro';
import { IconX } from 'twenty-ui/icon';
import { IconButtonWithTooltip } from 'twenty-ui/input';

export const SidePanelCloseButton = () => {
const { t } = useLingui();
const { closeSidePanelMenu } = useSidePanelMenu();

const closeSidePanelLabel = t`Close side panel`;

return (
<IconButtonWithTooltip
tooltipContent={closeSidePanelLabel}
Icon={IconX}
size="small"
variant="primary"
onClick={closeSidePanelMenu}
ariaLabel={closeSidePanelLabel}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutSidebarRightExpand } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { IconMaximize } from 'twenty-ui/icon';
import { IconButtonWithTooltip } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';

import { useSidePanelExpandTarget } from '@/side-panel/hooks/useSidePanelExpandTarget';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
Expand Down Expand Up @@ -29,13 +30,18 @@ const SidePanelExpandButtonContent = () => {
return null;
}

const tooltipContent = expandTarget.hasExpandShortcut
? `${expandTarget.label} | ${getOsControlSymbol()}⏎`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On non-mac this renders "Expand | Ctrl⏎" with Ctrl glued to ⏎ — want a separator like the hotkeys arrays elsewhere?

: expandTarget.label;

return (
<>
{expandTarget.hasExpandShortcut && (
<SidePanelExpandShortcutEffect expand={expandTarget.expand} />
)}
<IconButton
Icon={IconLayoutSidebarRightExpand}
<IconButtonWithTooltip
tooltipContent={tooltipContent}
Icon={IconMaximize}
size="small"
variant="tertiary"
onClick={expandTarget.expand}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { SidePanelBackButton } from '@/side-panel/components/SidePanelBackButton';
import { SidePanelCloseButton } from '@/side-panel/components/SidePanelCloseButton';
import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo';
import { SidePanelTopBarEscapeHotkeyEffect } from '@/side-panel/components/SidePanelTopBarEscapeHotkeyEffect';
import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect';
import { SidePanelExpandButton } from '@/side-panel/components/SidePanelExpandButton';
import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon';
Expand All @@ -9,7 +11,6 @@ import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBa
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
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';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
Expand All @@ -24,8 +25,6 @@ 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';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';

Expand Down Expand Up @@ -99,8 +98,6 @@ export const SidePanelTopBar = () => {

const isMobile = useIsMobile();

const { closeSidePanelMenu } = useSidePanelMenu();

const sidePanelPage = useAtomStateValue(sidePanelPageState);

const sidePanelNavigationStack = useAtomStateValue(
Expand Down Expand Up @@ -152,7 +149,12 @@ export const SidePanelTopBar = () => {
return;
}

if (event.key === Key.Backspace && handleSidePanelBackspace()) {
if (
event.key === Key.Backspace &&
!event.metaKey &&
!event.ctrlKey &&
handleSidePanelBackspace()
) {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
Expand All @@ -177,6 +179,10 @@ export const SidePanelTopBar = () => {

return (
<StyledInputContainer isMobile={isMobile}>
<SidePanelTopBarEscapeHotkeyEffect
inputRef={inputRef}
onEscape={handleSidePanelEscape}
/>
<StyledContentContainer>
<AnimatePresence>
{shouldShowBackButton && (
Expand Down Expand Up @@ -213,15 +219,7 @@ export const SidePanelTopBar = () => {
<StyledRightControlsContainer>
<SidePanelTopBarRightCornerIcon />
<SidePanelExpandButton />
{!shouldHideCloseButton && (
<IconButton
Icon={IconX}
size="small"
variant="primary"
onClick={closeSidePanelMenu}
ariaLabel={t`Close side panel`}
/>
)}
{shouldHideCloseButton ? null : <SidePanelCloseButton />}
</StyledRightControlsContainer>
</StyledInputContainer>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type RefObject } from 'react';
import { Key } from 'ts-key-enum';

import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';

type SidePanelTopBarEscapeHotkeyEffectProps = {
inputRef: RefObject<HTMLInputElement | null>;
onEscape: () => void;
};

export const SidePanelTopBarEscapeHotkeyEffect = ({
inputRef,
onEscape,
}: SidePanelTopBarEscapeHotkeyEffectProps) => {
const handleEscape = () => {
if (document.activeElement === inputRef.current) {
return;
}

onEscape();
};

useGlobalHotkeys({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecordTableBodyEscapeHotkeyEffect (and the board/calendar ones) also listen for Escape on PageFocusId.RecordIndex and each side stops immediate propagation when it matches, so whichever registered first swallows the other — the table one even with no rows selected. On a record index with the panel open, which one should win, and is that deterministic today?

keys: [Key.Escape],
callback: handleEscape,
containsModifier: false,
dependencies: [handleEscape],
});

return null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,36 @@ describe('SidePanelTopBar', () => {
expect(mockCloseSidePanelMenu).not.toHaveBeenCalled();
});

it('goes back with Escape while the search input is not focused', () => {
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',
},
],
}),
);

fireEvent.keyDown(document.body, {
key: 'Escape',
code: 'Escape',
});

expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
expect(mockCloseSidePanelMenu).not.toHaveBeenCalled();
});

it('renders the close button after the command menu content', () => {
renderSidePanelCommandMenu();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from 'react';

import { SidePanelTopBarEscapeHotkeyEffect } from '@/side-panel/components/SidePanelTopBarEscapeHotkeyEffect';

describe('SidePanelTopBarEscapeHotkeyEffect', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit · Low-level · frontend tests — effect components

Dedicated .test.tsx on a render-null Effect component is a finding

SidePanelTopBarEscapeHotkeyEffect is a side-effect-only component that renders null, so a standalone test asserting mocked globals is exactly the case the standard says to fold into the host; the SidePanelTopBar test already covers the escape-while-input-not-focused path. Delete this file and cover the effect through SidePanelTopBar.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — host-level Escape coverage remains in SidePanelTopBar.test.tsx.

it('handles Escape only when the search input is not focused', async () => {
const inputRef = createRef<HTMLInputElement>();
const handleEscape = jest.fn();

render(
<>
<input aria-label="Search" ref={inputRef} />
<button type="button">Outside</button>
<SidePanelTopBarEscapeHotkeyEffect
inputRef={inputRef}
onEscape={handleEscape}
/>
</>,
);

const input = screen.getByRole('textbox', { name: 'Search' });
const outsideButton = screen.getByRole('button', { name: 'Outside' });

await userEvent.click(input);
await userEvent.keyboard('{Escape}');

expect(handleEscape).not.toHaveBeenCalled();

await userEvent.click(outsideButton);
await userEvent.keyboard('{Escape}');

expect(handleEscape).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import {
Dropdown,
type DropdownProps,
} from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
Expand All @@ -15,31 +18,22 @@ type OptionsDropdownMenuProps = {
dropdownId?: string;
selectableListId?: string;
selectableItemIdArray?: string[];
clickableComponent?: ReactNode;
dropdownPlacement?: DropdownProps['dropdownPlacement'];
dropdownOffset?: DropdownProps['dropdownOffset'];
shouldRegisterOptionsHotkey?: boolean;
onOpen?: () => void;
children: ReactNode;
};

export const OptionsDropdownMenu = ({
dropdownId: dropdownIdFromProps,
selectableListId,
selectableItemIdArray = [],
onOpen,
children,
}: OptionsDropdownMenuProps) => {
const generatedDropdownId = useId();
const dropdownId = dropdownIdFromProps ?? generatedDropdownId;
const { t } = useLingui();
const { toggleDropdown } = useToggleDropdown();
const DEFAULT_OPTIONS_DROPDOWN_OFFSET = { y: 8 };

const listId = selectableListId ?? dropdownId;
const { setSelectedItemId } = useSelectableList(listId);

const handleOpen = () => {
if (selectableItemIdArray.length > 0) {
setSelectedItemId(selectableItemIdArray[0]);
}
onOpen?.();
};
const OptionsDropdownMenuHotkeyEffect = ({
dropdownId,
}: {
dropdownId: string;
}) => {
const { toggleDropdown } = useToggleDropdown();

const hotkeysConfig = {
keys: ['ctrl+o', 'meta+o'],
Expand All @@ -61,38 +55,73 @@ export const OptionsDropdownMenu = ({
focusId: dropdownId,
});

return null;
};

export const OptionsDropdownMenu = ({
dropdownId: dropdownIdFromProps,
selectableListId,
selectableItemIdArray = [],
clickableComponent,
dropdownPlacement = 'top-end',
dropdownOffset = DEFAULT_OPTIONS_DROPDOWN_OFFSET,
shouldRegisterOptionsHotkey = true,
onOpen,
children,
}: OptionsDropdownMenuProps) => {
const generatedDropdownId = useId();
const dropdownId = dropdownIdFromProps ?? generatedDropdownId;
const { t } = useLingui();

const listId = selectableListId ?? dropdownId;
const { setSelectedItemId } = useSelectableList(listId);

const handleOpen = () => {
if (selectableItemIdArray.length > 0) {
setSelectedItemId(selectableItemIdArray[0]);
}
onOpen?.();
};

return (
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<IconButton
Icon={IconDotsVertical}
ariaLabel={t`Options`}
size="small"
variant="primary"
/>
}
dropdownPlacement="top-end"
dropdownOffset={{ y: 8 }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
onOpen={handleOpen}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={listId}
focusId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
>
{children}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
<>
{shouldRegisterOptionsHotkey ? (
<OptionsDropdownMenuHotkeyEffect dropdownId={dropdownId} />
) : null}
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
clickableComponent ?? (
<IconButton
Icon={IconDotsVertical}
ariaLabel={t`Options`}
size="small"
variant="primary"
/>
)
}
dropdownPlacement={dropdownPlacement}
dropdownOffset={dropdownOffset}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
onOpen={handleOpen}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={listId}
focusId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
>
{children}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</>
);
};
Loading
Loading