From 4d352cb4e48c1549e22fbcdd0543cdb74ab3049e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Mon, 12 May 2025 19:02:14 +0200
Subject: [PATCH] Keyboard Navigation and Shortcuts Implementation on the board
(#11930)
# Keyboard Navigation and Shortcuts Implementation on the board
This PR implements keyboard navigation and shortcuts for the Record
Board component, enabling users to navigate and interact with board
cards using keyboard inputs.
## Key changes
### Navigation Architecture
- Added `useBoardCardNavigation` hook for directional navigation (arrow
keys)
- Implemented scroll behavior to automatically focus visible cards
- Created card focus/active states with component-level management
### State Management
- Added new component states: `focusedBoardCardIndexesComponentState`,
`activeBoardCardIndexesComponentState`,
`isBoardCardActiveComponentFamilyState`
- Extended index tracking with column/row position indicators
- Create hooks to manage these states
### Hotkey Implementation
- Created new `RecordBoardHotkeyEffect` component for hotkey handling
- Added `BoardHotkeyScope`
- Implemented Escape key handling to clear selections
- Replaced table-specific `TableHotkeyScope` with more generic
`RecordIndexHotkeyScope`. This is because, before, the hotkey scope was
always set to Table inside the page change effect, and we used the table
hotkey scope for board shortcuts, which doesn't make a lot of sense.
Since we don't know upon navigation on which type of view we are
navigating, I introduced this generic hotkey scope which can be used on
the table and on the board.
### Page Navigation Integration
- Modified `PageChangeEffect` to handle both table and board view types
- Added cleanup for board state upon navigating away from record pages
### Component Updates
- Updated `RecordBoardColumn` to track indexes for position-based
navigation
- Added `RecordBoardScrollToFocusedCardEffect` for auto-scrolling to
focused cards
- Added `RecordBoardDeactivateBoardCardEffect` for cleanup
This implementation maintains feature parity with table row navigation
while accounting for the 2D navigation needs of the board view.
## New behaviors
### Arrow keys navigation
https://github.com/user-attachments/assets/929ee00d-2f82-43b9-8cde-f7bc8818052f
### Record selection with X
https://github.com/user-attachments/assets/0b534c4d-2865-43ac-8ba3-09cb8c121f06
### Command + Enter opens the record
https://github.com/user-attachments/assets/0df01d1c-0437-4444-beb1-ce74bcfb91a4
### Escape unselect the records and unfocus the card
https://github.com/user-attachments/assets/e2bb176b-b6f7-49ca-9549-803eb31bfc23
---
.../actions/components/ActionModal.tsx | 3 +-
.../effect-components/PageChangeEffect.tsx | 35 ++-
.../hooks/useNavigateCommandMenu.ts | 8 +-
.../useKeyboardShortcutMenu.test.tsx | 3 +-
.../hooks/useKeyboardShortcutMenu.ts | 66 +++---
.../record-board/components/RecordBoard.tsx | 38 ++-
.../RecordBoardBodyEscapeHotkeyEffect.tsx | 52 +++++
.../components/RecordBoardHeader.tsx | 3 +-
.../components/RecordBoardHotkeyEffect.tsx | 121 ++++++++++
.../RecordBoardScrollToFocusedCardEffect.tsx | 45 ++++
.../hooks/useActiveRecordBoardCard.ts | 64 ++++++
.../hooks/useFocusedRecordBoardCard.ts | 70 ++++++
.../hooks/useRecordBoardCardNavigation.ts | 216 ++++++++++++++++++
.../hooks/useRecordBoardSelection.ts | 11 +-
.../components/RecordBoardCard.tsx | 72 ++++--
.../RecordBoardCardDraggableContainer.tsx | 40 +++-
.../RecordBoardCardFocusHotkeyEffect.tsx | 57 +++++
.../RecordBoardDeactivateBoardCardEffect.tsx | 15 ++
.../contexts/RecordBoardCardContext.ts | 2 +
.../components/RecordBoardColumn.tsx | 3 +
.../components/RecordBoardColumnCardsMemo.tsx | 2 +-
.../RecordBoardColumnHeaderWrapper.tsx | 3 +
.../contexts/RecordBoardColumnContext.ts | 1 +
...iveRecordBoardCardIndexesComponentState.ts | 10 +
...sedRecordBoardCardIndexesComponentState.ts | 10 +
...cordBoardCardActiveComponentFamilyState.ts | 10 +
...ecordBoardCardFocusActiveComponentState.ts | 9 +
...ordBoardCardFocusedComponentFamilyState.ts | 10 +
.../record-board/types/BoardCardIndexes.ts | 4 +
.../record-board/types/BoardHotkeyScope.ts | 3 +
.../components/RecordIndexBoardContainer.tsx | 5 +-
.../types/RecordIndexHotkeyScope.ts | 3 +
.../components/RecordTableWithWrappers.tsx | 13 +-
.../hooks/internal/useLeaveTableFocus.ts | 4 +-
.../record-table/hooks/useRecordTable.ts | 5 +-
.../RecordTableBodyEscapeHotkeyEffect.tsx | 7 +-
.../RecordTableBodyFocusKeyboardEffect.tsx | 3 +-
.../hooks/useMoveHoverToCurrentCell.ts | 5 +-
.../components/RecordTableTr.tsx | 10 +-
.../record-table/types/TableHotkeyScope.ts | 1 -
40 files changed, 933 insertions(+), 109 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useActiveRecordBoardCard.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useFocusedRecordBoardCard.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardNavigation.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/activeRecordBoardCardIndexesComponentState.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/BoardCardIndexes.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/BoardHotkeyScope.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-index/types/RecordIndexHotkeyScope.ts
diff --git a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx
index 14787efc4..c21113dea 100644
--- a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx
+++ b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx
@@ -33,9 +33,8 @@ export const ActionModal = ({
const { closeActionMenu } = useCloseActionMenu();
const handleConfirmClick = () => {
- closeActionMenu();
onConfirmClick();
- setIsOpen(false);
+ closeActionMenu();
};
const actionConfig = useContext(ActionConfigContext);
diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
index d99712590..0749116df 100644
--- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
@@ -17,11 +17,16 @@ import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoad
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
+import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
+import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
+import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
+import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
+import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
+import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow';
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
-import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
@@ -64,6 +69,11 @@ export const PageChangeEffect = () => {
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
+ const contextStoreCurrentViewType = useRecoilComponentValueV2(
+ contextStoreCurrentViewTypeComponentState,
+ MAIN_CONTEXT_STORE_INSTANCE_ID,
+ );
+
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectNamePlural,
contextStoreCurrentViewId || '',
@@ -73,6 +83,10 @@ export const PageChangeEffect = () => {
const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId);
const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId);
+ const { resetRecordSelection } = useRecordBoardSelection(recordIndexId);
+ const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId);
+ const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId);
+
const { executeTasksOnAnyLocationChange } =
useExecuteTasksOnAnyLocationChange();
@@ -100,9 +114,16 @@ export const PageChangeEffect = () => {
);
if (isLeavingRecordIndexPage) {
- resetTableSelections();
- unfocusRecordTableRow();
- deactivateRecordTableRow();
+ if (contextStoreCurrentViewType === ContextStoreViewType.Table) {
+ resetTableSelections();
+ unfocusRecordTableRow();
+ deactivateRecordTableRow();
+ }
+ if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) {
+ resetRecordSelection();
+ deactivateBoardCard();
+ unfocusBoardCard();
+ }
}
}, [
isMatchingLocation,
@@ -110,12 +131,16 @@ export const PageChangeEffect = () => {
resetTableSelections,
unfocusRecordTableRow,
deactivateRecordTableRow,
+ contextStoreCurrentViewType,
+ resetRecordSelection,
+ deactivateBoardCard,
+ unfocusBoardCard,
]);
useEffect(() => {
switch (true) {
case isMatchingLocation(AppPath.RecordIndexPage): {
- setHotkeyScope(TableHotkeyScope.Table, {
+ setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, {
goto: true,
keyboardShortcutMenu: true,
});
diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts
index 36bb69231..c92fb987d 100644
--- a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts
+++ b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts
@@ -51,6 +51,10 @@ export const useNavigateCommandMenu = () => {
commandMenuCloseAnimationCompleteCleanup();
}
+ if (isCommandMenuOpened) {
+ return;
+ }
+
setHotkeyScopeAndMemorizePreviousScope(
CommandMenuHotkeyScope.CommandMenuFocused,
{
@@ -58,10 +62,6 @@ export const useNavigateCommandMenu = () => {
},
);
- if (isCommandMenuOpened) {
- return;
- }
-
copyContextStoreStates({
instanceIdToCopyFrom: MAIN_CONTEXT_STORE_INSTANCE_ID,
instanceIdToCopyTo: COMMAND_MENU_COMPONENT_INSTANCE_ID,
diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx
index 49d98de15..d38b3b577 100644
--- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx
+++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx
@@ -1,5 +1,6 @@
import { expect } from '@storybook/test';
-import { act, renderHook } from '@testing-library/react';
+import { renderHook } from '@testing-library/react';
+import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { isKeyboardShortcutMenuOpenedState } from '@/keyboard-shortcut-menu/states/isKeyboardShortcutMenuOpenedState';
diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts
index 347e40db0..0d568a473 100644
--- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts
+++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts
@@ -1,4 +1,4 @@
-import { useRecoilState, useRecoilValue } from 'recoil';
+import { useRecoilCallback } from 'recoil';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
@@ -6,38 +6,52 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState';
export const useKeyboardShortcutMenu = () => {
- const [, setIsKeyboardShortcutMenuOpened] = useRecoilState(
- isKeyboardShortcutMenuOpenedState,
- );
- const isKeyboardShortcutMenuOpened = useRecoilValue(
- isKeyboardShortcutMenuOpenedState,
- );
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
- const toggleKeyboardShortcutMenu = () => {
- if (isKeyboardShortcutMenuOpened === false) {
- setIsKeyboardShortcutMenuOpened(true);
- setHotkeyScopeAndMemorizePreviousScope(
- AppHotkeyScope.KeyboardShortcutMenu,
- );
- } else {
- setIsKeyboardShortcutMenuOpened(false);
- goBackToPreviousHotkeyScope();
- }
- };
+ const openKeyboardShortcutMenu = useRecoilCallback(
+ ({ set }) =>
+ () => {
+ set(isKeyboardShortcutMenuOpenedState, true);
+ setHotkeyScopeAndMemorizePreviousScope(
+ AppHotkeyScope.KeyboardShortcutMenu,
+ );
+ },
+ [setHotkeyScopeAndMemorizePreviousScope],
+ );
- const openKeyboardShortcutMenu = () => {
- setIsKeyboardShortcutMenuOpened(true);
- setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.KeyboardShortcutMenu);
- };
+ const closeKeyboardShortcutMenu = useRecoilCallback(
+ ({ set, snapshot }) =>
+ () => {
+ const isKeyboardShortcutMenuOpened = snapshot
+ .getLoadable(isKeyboardShortcutMenuOpenedState)
+ .getValue();
- const closeKeyboardShortcutMenu = () => {
- setIsKeyboardShortcutMenuOpened(false);
- goBackToPreviousHotkeyScope();
- };
+ if (isKeyboardShortcutMenuOpened) {
+ set(isKeyboardShortcutMenuOpenedState, false);
+ goBackToPreviousHotkeyScope();
+ }
+ },
+ [goBackToPreviousHotkeyScope],
+ );
+
+ const toggleKeyboardShortcutMenu = useRecoilCallback(
+ ({ snapshot }) =>
+ () => {
+ const isKeyboardShortcutMenuOpened = snapshot
+ .getLoadable(isKeyboardShortcutMenuOpenedState)
+ .getValue();
+
+ if (isKeyboardShortcutMenuOpened === false) {
+ openKeyboardShortcutMenu();
+ } else {
+ closeKeyboardShortcutMenu();
+ }
+ },
+ [closeKeyboardShortcutMenu, openKeyboardShortcutMenu],
+ );
return {
toggleKeyboardShortcutMenu,
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx
index c87033b9b..04b57f57f 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx
@@ -5,10 +5,14 @@ import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader';
+import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect';
import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect';
import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
+import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
+import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
+import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect';
import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn';
import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope';
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
@@ -16,14 +20,11 @@ import { getDraggedRecordPosition } from '@/object-record/record-board/utils/get
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
-import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
-import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
-import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -71,6 +72,9 @@ export const RecordBoard = () => {
const { closeDropdown } = useDropdownV2();
+ const { deactivateBoardCard } = useActiveRecordBoardCard(recordBoardId);
+ const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId);
+
const handleDragSelectionStart = () => {
closeDropdown(actionMenuId);
@@ -91,10 +95,6 @@ export const RecordBoard = () => {
recordIndexRecordIdsByGroupComponentFamilyState,
);
- const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2(
- recordIndexAllRecordIdsComponentSelector,
- );
-
const { resetRecordSelection, setRecordAsSelected } =
useRecordBoardSelection(recordBoardId);
@@ -115,26 +115,11 @@ export const RecordBoard = () => {
refs: [],
callback: () => {
resetRecordSelection();
+ deactivateBoardCard();
+ unfocusBoardCard();
},
});
- const selectAll = useRecoilCallback(
- ({ snapshot }) =>
- () => {
- const allRecordIds = getSnapshotValue(
- snapshot,
- recordIndexAllRecordIdsState,
- );
-
- for (const recordId of allRecordIds) {
- setRecordAsSelected(recordId, true);
- }
- },
- [recordIndexAllRecordIdsState, setRecordAsSelected],
- );
-
- useScopedHotkeys('ctrl+a,meta+a', selectAll, TableHotkeyScope.Table);
-
const setIsRemoveSortingModalOpen = useSetRecoilState(
isRemoveSortingModalOpenState,
);
@@ -228,16 +213,19 @@ export const RecordBoard = () => {
componentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
>
+
+
- {visibleRecordGroupIds.map((recordGroupId) => (
+ {visibleRecordGroupIds.map((recordGroupId, index) => (
))}
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx
new file mode 100644
index 000000000..717aaf11f
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx
@@ -0,0 +1,52 @@
+import { useContext } from 'react';
+import { Key } from 'ts-key-enum';
+
+import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
+import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
+import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
+import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector';
+import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope';
+import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
+import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
+import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
+
+export const RecordBoardBodyEscapeHotkeyEffect = () => {
+ const { recordBoardId } = useContext(RecordBoardContext);
+
+ const { resetRecordSelection } = useRecordBoardSelection(recordBoardId);
+
+ const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId);
+
+ const selectedRecordIds = useRecoilComponentValueV2(
+ recordBoardSelectedRecordIdsComponentSelector,
+ recordBoardId,
+ );
+
+ const isAtLeastOneRecordSelected = selectedRecordIds.length > 0;
+
+ useScopedHotkeys(
+ [Key.Escape],
+ () => {
+ unfocusBoardCard();
+ if (isAtLeastOneRecordSelected) {
+ resetRecordSelection();
+ }
+ },
+ RecordIndexHotkeyScope.RecordIndex,
+ [isAtLeastOneRecordSelected, resetRecordSelection, unfocusBoardCard],
+ );
+
+ useScopedHotkeys(
+ [Key.Escape],
+ () => {
+ unfocusBoardCard();
+ if (isAtLeastOneRecordSelected) {
+ resetRecordSelection();
+ }
+ },
+ BoardHotkeyScope.BoardFocus,
+ [isAtLeastOneRecordSelected, resetRecordSelection, unfocusBoardCard],
+ );
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx
index 3ca0b6e68..187606a64 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx
@@ -31,9 +31,10 @@ export const RecordBoardHeader = () => {
return (