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
This commit is contained in:
Raphaël Bosi
2025-05-12 19:02:14 +02:00
committed by GitHub
parent 4e39ef832c
commit 4d352cb4e4
40 changed files with 933 additions and 109 deletions

View File

@ -33,9 +33,8 @@ export const ActionModal = ({
const { closeActionMenu } = useCloseActionMenu();
const handleConfirmClick = () => {
closeActionMenu();
onConfirmClick();
setIsOpen(false);
closeActionMenu();
};
const actionConfig = useContext(ActionConfigContext);

View File

@ -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,
});

View File

@ -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,

View File

@ -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';

View File

@ -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,

View File

@ -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}`}
>
<RecordBoardStickyHeaderEffect />
<RecordBoardScrollToFocusedCardEffect />
<RecordBoardDeactivateBoardCardEffect />
<StyledContainerContainer>
<RecordBoardHeader />
<StyledBoardContentContainer>
<StyledContainer ref={boardRef}>
<DragDropContext onDragEnd={handleDragEnd}>
<StyledColumnContainer>
{visibleRecordGroupIds.map((recordGroupId) => (
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordBoardColumn
key={recordGroupId}
recordBoardColumnId={recordGroupId}
recordBoardColumnIndex={index}
/>
))}
</StyledColumnContainer>

View File

@ -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;
};

View File

@ -31,9 +31,10 @@ export const RecordBoardHeader = () => {
return (
<StyledHeaderContainer id="record-board-header">
{visibleRecordGroupIds.map((recordGroupId) => (
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordBoardColumnHeaderWrapper
columnId={recordGroupId}
columnIndex={index}
key={recordGroupId}
/>
))}

View File

@ -0,0 +1,121 @@
import { useContext } from 'react';
import { Key } from 'ts-key-enum';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardCardNavigation } from '@/object-record/record-board/hooks/useRecordBoardCardNavigation';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilCallback } from 'recoil';
export const RecordBoardHotkeyEffect = () => {
const { recordBoardId } = useContext(RecordBoardContext);
const { move } = useRecordBoardCardNavigation(recordBoardId);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2(
recordIndexAllRecordIdsComponentSelector,
);
const { setRecordAsSelected } = useRecordBoardSelection(recordBoardId);
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,
RecordIndexHotkeyScope.RecordIndex,
);
useScopedHotkeys('ctrl+a,meta+a', selectAll, BoardHotkeyScope.BoardFocus);
useScopedHotkeys(
Key.ArrowLeft,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
move('left');
},
RecordIndexHotkeyScope.RecordIndex,
);
useScopedHotkeys(
Key.ArrowRight,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
move('right');
},
RecordIndexHotkeyScope.RecordIndex,
);
useScopedHotkeys(
Key.ArrowUp,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
move('up');
},
RecordIndexHotkeyScope.RecordIndex,
);
useScopedHotkeys(
Key.ArrowDown,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
move('down');
},
RecordIndexHotkeyScope.RecordIndex,
);
useScopedHotkeys(
Key.ArrowLeft,
() => {
move('left');
},
BoardHotkeyScope.BoardFocus,
);
useScopedHotkeys(
Key.ArrowRight,
() => {
move('right');
},
BoardHotkeyScope.BoardFocus,
);
useScopedHotkeys(
Key.ArrowUp,
() => {
move('up');
},
BoardHotkeyScope.BoardFocus,
);
useScopedHotkeys(
Key.ArrowDown,
() => {
move('down');
},
BoardHotkeyScope.BoardFocus,
);
return null;
};

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState';
import { isRecordBoardCardFocusActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordBoardScrollToFocusedCardEffect = () => {
const recordBoardId = useAvailableScopeIdOrThrow(
RecordBoardScopeInternalContext,
);
const focusedCardIndexes = useRecoilComponentValueV2(
focusedRecordBoardCardIndexesComponentState,
recordBoardId,
);
const isFocusActive = useRecoilComponentValueV2(
isRecordBoardCardFocusActiveComponentState,
recordBoardId,
);
useEffect(() => {
if (!isFocusActive || !focusedCardIndexes) {
return;
}
const { rowIndex, columnIndex } = focusedCardIndexes;
const focusElement = document.getElementById(
`record-board-card-${columnIndex}-${rowIndex}`,
);
if (!focusElement) {
return;
}
if (focusElement instanceof HTMLElement) {
focusElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [focusedCardIndexes, isFocusActive]);
return null;
};

View File

@ -0,0 +1,64 @@
import { activeRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/activeRecordBoardCardIndexesComponentState';
import { isRecordBoardCardActiveComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useActiveRecordBoardCard = (recordBoardId?: string) => {
const isCardActiveState = useRecoilComponentCallbackStateV2(
isRecordBoardCardActiveComponentFamilyState,
recordBoardId,
);
const activeBoardCardIndexesState = useRecoilComponentCallbackStateV2(
activeRecordBoardCardIndexesComponentState,
recordBoardId,
);
const deactivateBoardCard = useRecoilCallback(
({ set, snapshot }) =>
() => {
const activeBoardCardIndexes = snapshot
.getLoadable(activeBoardCardIndexesState)
.getValue();
if (!isDefined(activeBoardCardIndexes)) {
return;
}
set(activeBoardCardIndexesState, null);
set(isCardActiveState(activeBoardCardIndexes), false);
},
[activeBoardCardIndexesState, isCardActiveState],
);
const activateBoardCard = useRecoilCallback(
({ set, snapshot }) =>
(boardCardIndexes: BoardCardIndexes) => {
const activeBoardCardIndexes = snapshot
.getLoadable(activeBoardCardIndexesState)
.getValue();
if (
activeBoardCardIndexes?.rowIndex === boardCardIndexes.rowIndex &&
activeBoardCardIndexes?.columnIndex === boardCardIndexes.columnIndex
) {
return;
}
if (isDefined(activeBoardCardIndexes)) {
set(isCardActiveState(activeBoardCardIndexes), false);
}
set(activeBoardCardIndexesState, boardCardIndexes);
set(isCardActiveState(boardCardIndexes), true);
},
[activeBoardCardIndexesState, isCardActiveState],
);
return {
activateBoardCard,
deactivateBoardCard,
};
};

View File

@ -0,0 +1,70 @@
import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState';
import { isRecordBoardCardFocusActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState';
import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useFocusedRecordBoardCard = (recordBoardId?: string) => {
const isCardFocusedState = useRecoilComponentCallbackStateV2(
isRecordBoardCardFocusedComponentFamilyState,
recordBoardId,
);
const focusedBoardCardIndexesState = useRecoilComponentCallbackStateV2(
focusedRecordBoardCardIndexesComponentState,
recordBoardId,
);
const isCardFocusActiveState = useRecoilComponentCallbackStateV2(
isRecordBoardCardFocusActiveComponentState,
recordBoardId,
);
const unfocusBoardCard = useRecoilCallback(
({ set, snapshot }) =>
() => {
const focusedBoardCardIndexes = snapshot
.getLoadable(focusedBoardCardIndexesState)
.getValue();
if (!isDefined(focusedBoardCardIndexes)) {
return;
}
set(focusedBoardCardIndexesState, null);
set(isCardFocusedState(focusedBoardCardIndexes), false);
set(isCardFocusActiveState, false);
},
[focusedBoardCardIndexesState, isCardFocusedState, isCardFocusActiveState],
);
const focusBoardCard = useRecoilCallback(
({ set, snapshot }) =>
(boardCardIndexes: BoardCardIndexes) => {
const focusedBoardCardIndexes = snapshot
.getLoadable(focusedBoardCardIndexesState)
.getValue();
if (
isDefined(focusedBoardCardIndexes) &&
(focusedBoardCardIndexes.rowIndex !== boardCardIndexes.rowIndex ||
focusedBoardCardIndexes.columnIndex !==
boardCardIndexes.columnIndex)
) {
set(isCardFocusedState(focusedBoardCardIndexes), false);
}
set(focusedBoardCardIndexesState, boardCardIndexes);
set(isCardFocusedState(boardCardIndexes), true);
set(isCardFocusActiveState, true);
},
[focusedBoardCardIndexesState, isCardFocusedState, isCardFocusActiveState],
);
return {
focusBoardCard,
unfocusBoardCard,
};
};

View File

@ -0,0 +1,216 @@
import { useRecoilCallback } from 'recoil';
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { ViewType } from '@/views/types/ViewType';
import { isDefined } from 'twenty-shared/utils';
type NavigationDirection = 'up' | 'down' | 'left' | 'right';
export const useRecordBoardCardNavigation = (recordBoardId?: string) => {
const { focusBoardCard } = useFocusedRecordBoardCard(recordBoardId);
const focusedBoardCardIndexesState = useRecoilComponentCallbackStateV2(
focusedRecordBoardCardIndexesComponentState,
recordBoardId,
);
const visibleRecordGroupIds = useRecoilComponentFamilyValueV2(
visibleRecordGroupIdsComponentFamilySelector,
ViewType.Kanban,
);
const recordIdsByGroupState = useRecoilComponentCallbackStateV2(
recordIndexRecordIdsByGroupComponentFamilyState,
);
const moveHorizontally = useRecoilCallback(
({ snapshot }) =>
(direction: 'left' | 'right') => {
const focusedBoardCardIndexes = snapshot
.getLoadable(focusedBoardCardIndexesState)
.getValue();
if (!isDefined(focusedBoardCardIndexes)) {
if (visibleRecordGroupIds.length === 0) {
return;
}
const firstGroupId = visibleRecordGroupIds[0];
const recordIdsInFirstGroup = snapshot
.getLoadable(recordIdsByGroupState(firstGroupId))
.getValue();
if (
!Array.isArray(recordIdsInFirstGroup) ||
recordIdsInFirstGroup.length === 0
) {
return;
}
focusBoardCard({
columnIndex: 0,
rowIndex: 0,
});
return;
}
if (visibleRecordGroupIds.length === 0) {
return;
}
let newColumnIndex =
direction === 'right'
? focusedBoardCardIndexes.columnIndex + 1
: focusedBoardCardIndexes.columnIndex - 1;
if (newColumnIndex < 0) {
newColumnIndex = 0;
} else if (newColumnIndex >= visibleRecordGroupIds.length) {
newColumnIndex = visibleRecordGroupIds.length - 1;
}
if (newColumnIndex === focusedBoardCardIndexes.columnIndex) {
return;
}
let foundColumnWithRecords = false;
const initialColumnIndex = newColumnIndex;
while (!foundColumnWithRecords) {
const currentGroupId = visibleRecordGroupIds[newColumnIndex];
const recordIdsInGroup = snapshot
.getLoadable(recordIdsByGroupState(currentGroupId))
.getValue();
if (Array.isArray(recordIdsInGroup) && recordIdsInGroup.length > 0) {
foundColumnWithRecords = true;
} else {
newColumnIndex =
direction === 'right' ? newColumnIndex + 1 : newColumnIndex - 1;
if (
newColumnIndex < 0 ||
newColumnIndex >= visibleRecordGroupIds.length
) {
return;
}
if (
(direction === 'right' && newColumnIndex <= initialColumnIndex) ||
(direction === 'left' && newColumnIndex >= initialColumnIndex)
) {
return;
}
}
}
const currentGroupId = visibleRecordGroupIds[newColumnIndex];
const recordIdsInGroup = snapshot
.getLoadable(recordIdsByGroupState(currentGroupId))
.getValue();
let newRowIndex = focusedBoardCardIndexes.rowIndex;
if (newRowIndex >= recordIdsInGroup.length) {
newRowIndex = recordIdsInGroup.length - 1;
}
focusBoardCard({
columnIndex: newColumnIndex,
rowIndex: newRowIndex,
});
},
[
focusedBoardCardIndexesState,
visibleRecordGroupIds,
recordIdsByGroupState,
focusBoardCard,
],
);
const moveVertically = useRecoilCallback(
({ snapshot }) =>
(direction: 'up' | 'down') => {
const focusedBoardCardIndexes = snapshot
.getLoadable(focusedBoardCardIndexesState)
.getValue();
if (!isDefined(focusedBoardCardIndexes)) {
if (visibleRecordGroupIds.length === 0) return;
const firstGroupId = visibleRecordGroupIds[0];
const recordIdsInFirstGroup = snapshot
.getLoadable(recordIdsByGroupState(firstGroupId))
.getValue();
if (
!Array.isArray(recordIdsInFirstGroup) ||
recordIdsInFirstGroup.length === 0
) {
return;
}
focusBoardCard({
columnIndex: 0,
rowIndex: 0,
});
return;
}
if (visibleRecordGroupIds.length === 0) return;
const currentGroupId =
visibleRecordGroupIds[focusedBoardCardIndexes.columnIndex];
const recordIdsInGroup = snapshot
.getLoadable(recordIdsByGroupState(currentGroupId))
.getValue();
if (!Array.isArray(recordIdsInGroup) || recordIdsInGroup.length === 0) {
return;
}
let newRowIndex =
direction === 'down'
? focusedBoardCardIndexes.rowIndex + 1
: focusedBoardCardIndexes.rowIndex - 1;
if (newRowIndex < 0) {
newRowIndex = 0;
} else if (newRowIndex >= recordIdsInGroup.length) {
newRowIndex = recordIdsInGroup.length - 1;
}
if (newRowIndex === focusedBoardCardIndexes.rowIndex) {
return;
}
focusBoardCard({
columnIndex: focusedBoardCardIndexes.columnIndex,
rowIndex: newRowIndex,
});
},
[
focusedBoardCardIndexesState,
visibleRecordGroupIds,
recordIdsByGroupState,
focusBoardCard,
],
);
const move = (direction: NavigationDirection) => {
if (direction === 'left' || direction === 'right') {
moveHorizontally(direction);
} else if (direction === 'up' || direction === 'down') {
moveVertically(direction);
}
};
return {
move,
};
};

View File

@ -2,13 +2,20 @@ import { useRecoilCallback } from 'recoil';
import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
export const useRecordBoardSelection = (recordBoardId: string) => {
export const useRecordBoardSelection = (recordBoardId?: string) => {
const instanceIdFromProps = useAvailableComponentInstanceIdOrThrow(
RecordBoardComponentInstanceContext,
recordBoardId,
);
const isRecordBoardCardSelectedFamilyState =
useRecoilComponentCallbackStateV2(
isRecordBoardCardSelectedComponentFamilyState,
@ -24,7 +31,7 @@ export const useRecordBoardSelection = (recordBoardId: string) => {
const { closeDropdown } = useDropdownV2();
const dropdownId = getActionMenuDropdownIdFromActionMenuId(
getActionMenuIdFromRecordIndexId(recordBoardId),
getActionMenuIdFromRecordIndexId(instanceIdFromProps),
);
const resetRecordSelection = useRecoilCallback(

View File

@ -3,6 +3,8 @@ import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/get
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { isRecordBoardCardActiveComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState';
import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector';
@ -19,6 +21,7 @@ import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
@ -30,27 +33,44 @@ import { AnimatedEaseInOut } from 'twenty-ui/utilities';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
selected ? theme.accent.quaternary : theme.background.secondary};
border: 1px solid
${({ theme, selected }) =>
selected ? theme.adaptiveColors.blue3 : theme.border.color.medium};
const StyledBoardCard = styled.div<{
isDragging?: boolean;
}>`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.font.color.primary};
&:hover {
background-color: ${({ theme, selected }) =>
selected && theme.accent.tertiary};
border: 1px solid
${({ theme, selected }) =>
selected ? theme.adaptiveColors.blue3 : theme.border.color.strong};
}
cursor: pointer;
&[data-selected='true'] {
background-color: ${({ theme }) => theme.accent.quaternary};
}
&[data-focused='true'] {
background-color: ${({ theme }) => theme.background.tertiary};
}
&[data-active='true'] {
background-color: ${({ theme }) => theme.accent.quaternary};
border: 1px solid ${({ theme }) => theme.adaptiveColors.blue3};
}
&:hover {
border: 1px solid ${({ theme }) => theme.border.color.strong};
&[data-active='true'] {
border: 1px solid ${({ theme }) => theme.adaptiveColors.blue3};
}
}
.checkbox-container {
transition: all ease-in-out 160ms;
opacity: ${({ selected }) => (selected ? 1 : 0)};
opacity: 0;
}
&[data-selected='true'] .checkbox-container {
opacity: 1;
}
&:hover .checkbox-container {
@ -75,7 +95,9 @@ export const RecordBoardCard = () => {
const navigate = useNavigateApp();
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const { recordId } = useContext(RecordBoardCardContext);
const { recordId, rowIndex, columnIndex } = useContext(
RecordBoardCardContext,
);
const visibleFieldDefinitions = useRecoilComponentValueV2(
recordBoardVisibleFieldDefinitionsComponentSelector,
@ -93,6 +115,22 @@ export const RecordBoardCard = () => {
recordId,
);
const isCurrentCardFocused = useRecoilComponentFamilyValueV2(
isRecordBoardCardFocusedComponentFamilyState,
{
rowIndex,
columnIndex,
},
);
const isCurrentCardActive = useRecoilComponentFamilyValueV2(
isRecordBoardCardActiveComponentFamilyState,
{
rowIndex,
columnIndex,
},
);
const { objectNameSingular } = useRecordIndexContextOrThrow();
const recordBoardId = useAvailableScopeIdOrThrow(
@ -167,7 +205,9 @@ export const RecordBoardCard = () => {
<InView>
<StyledBoardCard
ref={cardRef}
selected={isCurrentCardSelected}
data-selected={isCurrentCardSelected}
data-focused={isCurrentCardFocused}
data-active={isCurrentCardActive}
onMouseLeave={onMouseLeaveBoard}
onClick={handleCardClick}
>

View File

@ -1,25 +1,50 @@
import styled from '@emotion/styled';
import { Draggable } from '@hello-pangea/dnd';
import { useContext } from 'react';
import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard';
import { RecordBoardCardFocusHotkeyEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
const StyledDraggableContainer = styled.div`
scroll-margin-left: 8px;
scroll-margin-right: 8px;
scroll-margin-top: 40px;
`;
export const RecordBoardCardDraggableContainer = ({
recordId,
index,
rowIndex,
}: {
recordId: string;
index: number;
rowIndex: number;
}) => {
const isRecordReadOnly = useIsRecordReadOnly({
recordId,
});
const { columnIndex } = useContext(RecordBoardColumnContext);
const isRecordBoardCardFocusActive = useRecoilComponentFamilyValueV2(
isRecordBoardCardFocusedComponentFamilyState,
{
rowIndex,
columnIndex,
},
);
return (
<RecordBoardCardContext.Provider value={{ recordId, isRecordReadOnly }}>
<Draggable key={recordId} draggableId={recordId} index={index}>
<RecordBoardCardContext.Provider
value={{ recordId, isRecordReadOnly, rowIndex, columnIndex }}
>
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided) => (
<div
<StyledDraggableContainer
id={`record-board-card-${columnIndex}-${rowIndex}`}
ref={draggableProvided?.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.dragHandleProps}
@ -29,8 +54,11 @@ export const RecordBoardCardDraggableContainer = ({
data-selectable-id={recordId}
data-select-disable
>
{isRecordBoardCardFocusActive && (
<RecordBoardCardFocusHotkeyEffect />
)}
<RecordBoardCard />
</div>
</StyledDraggableContainer>
)}
</Draggable>
</RecordBoardCardContext.Provider>

View File

@ -0,0 +1,57 @@
import { useContext } from 'react';
import { Key } from 'ts-key-enum';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
export const RecordBoardCardFocusHotkeyEffect = () => {
const { objectMetadataItem } = useContext(RecordBoardContext);
const { recordId, rowIndex, columnIndex } = useContext(
RecordBoardCardContext,
);
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const { activateBoardCard } = useActiveRecordBoardCard();
const { setRecordAsSelected } = useRecordBoardSelection();
const isRecordBoardCardSelected = useRecoilComponentFamilyValueV2(
isRecordBoardCardSelectedComponentFamilyState,
recordId,
);
useScopedHotkeys(
'x',
() => {
setRecordAsSelected(recordId, !isRecordBoardCardSelected);
},
BoardHotkeyScope.BoardFocus,
);
useScopedHotkeys(
[Key.Enter, `${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
() => {
openRecordInCommandMenu({
recordId: recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isNewRecord: false,
});
activateBoardCard({
rowIndex,
columnIndex,
});
},
BoardHotkeyScope.BoardFocus,
);
return null;
};

View File

@ -0,0 +1,15 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
import { useContext } from 'react';
export const RecordBoardDeactivateBoardCardEffect = () => {
const { recordBoardId } = useContext(RecordBoardContext);
const { deactivateBoardCard } = useActiveRecordBoardCard(recordBoardId);
useListenRightDrawerClose(() => {
deactivateBoardCard();
});
return null;
};

View File

@ -3,6 +3,8 @@ import { createContext } from 'react';
type RecordBoardCardContextProps = {
recordId: string;
isRecordReadOnly: boolean;
rowIndex: number;
columnIndex: number;
};
export const RecordBoardCardContext =

View File

@ -22,10 +22,12 @@ const StyledColumn = styled.div`
type RecordBoardColumnProps = {
recordBoardColumnId: string;
recordBoardColumnIndex: number;
};
export const RecordBoardColumn = ({
recordBoardColumnId,
recordBoardColumnIndex,
}: RecordBoardColumnProps) => {
const recordGroupDefinition = useRecoilValue(
recordGroupDefinitionFamilyState(recordBoardColumnId),
@ -46,6 +48,7 @@ export const RecordBoardColumn = ({
columnDefinition: recordGroupDefinition,
columnId: recordBoardColumnId,
recordIds: recordIdsByGroup,
columnIndex: recordBoardColumnIndex,
}}
>
<Droppable droppableId={recordBoardColumnId}>

View File

@ -12,7 +12,7 @@ export const RecordBoardColumnCardsMemo = React.memo(
<RecordBoardCardDraggableContainer
key={recordId}
recordId={recordId}
index={index}
rowIndex={index}
/>
));
},

View File

@ -8,10 +8,12 @@ import { isDefined } from 'twenty-shared/utils';
type RecordBoardColumnHeaderWrapperProps = {
columnId: string;
columnIndex: number;
};
export const RecordBoardColumnHeaderWrapper = ({
columnId,
columnIndex,
}: RecordBoardColumnHeaderWrapperProps) => {
const recordGroupDefinition = useRecoilValue(
recordGroupDefinitionFamilyState(columnId),
@ -32,6 +34,7 @@ export const RecordBoardColumnHeaderWrapper = ({
columnId,
columnDefinition: recordGroupDefinition,
recordIds: recordIdsByGroup,
columnIndex,
}}
>
<RecordBoardColumnHeader />

View File

@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = {
columnDefinition: RecordGroupDefinition;
columnId: string;
recordIds: string[];
columnIndex: number;
};
export const RecordBoardColumnContext =

View File

@ -0,0 +1,10 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const activeRecordBoardCardIndexesComponentState =
createComponentStateV2<BoardCardIndexes | null>({
key: 'activeRecordBoardCardIndexesComponentState',
defaultValue: null,
componentInstanceContext: RecordBoardComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const focusedRecordBoardCardIndexesComponentState =
createComponentStateV2<BoardCardIndexes | null>({
key: 'focusedRecordBoardCardIndexesComponentState',
defaultValue: null,
componentInstanceContext: RecordBoardComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
export const isRecordBoardCardActiveComponentFamilyState =
createComponentFamilyStateV2<boolean, BoardCardIndexes>({
key: 'isRecordBoardCardActiveComponentFamilyState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isRecordBoardCardFocusActiveComponentState =
createComponentStateV2<boolean>({
key: 'isRecordBoardCardFocusActiveComponentState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
export const isRecordBoardCardFocusedComponentFamilyState =
createComponentFamilyStateV2<boolean, BoardCardIndexes>({
key: 'isRecordBoardCardFocusedComponentFamilyState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});

View File

@ -0,0 +1,4 @@
export type BoardCardIndexes = {
rowIndex: number;
columnIndex: number;
};

View File

@ -0,0 +1,3 @@
export enum BoardHotkeyScope {
BoardFocus = 'board-focus',
}

View File

@ -5,10 +5,11 @@ import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordBoard } from '@/object-record/record-board/components/RecordBoard';
import { RecordBoardBodyEscapeHotkeyEffect } from '@/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect';
import { RecordBoardHotkeyEffect } from '@/object-record/record-board/components/RecordBoardHotkeyEffect';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
type RecordIndexBoardContainerProps = {
recordBoardId: string;
viewBarId: string;
@ -55,6 +56,8 @@ export const RecordIndexBoardContainer = ({
>
<RecordBoard />
<RecordIndexRemoveSortingModal />
<RecordBoardHotkeyEffect />
<RecordBoardBodyEscapeHotkeyEffect />
</RecordBoardContext.Provider>
);
};

View File

@ -0,0 +1,3 @@
export enum RecordIndexHotkeyScope {
RecordIndex = 'record-index',
}

View File

@ -10,6 +10,7 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
import { mapColumnDefinitionsToViewFields } from '@/views/utils/mapColumnDefinitionToViewField';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
@ -50,7 +51,17 @@ export const RecordTableWithWrappers = ({
useScopedHotkeys(
'ctrl+a,meta+a',
handleSelectAllRows,
TableHotkeyScope.Table,
RecordIndexHotkeyScope.RecordIndex,
[],
{
enableOnFormTags: false,
},
);
useScopedHotkeys(
'ctrl+a,meta+a',
handleSelectAllRows,
TableHotkeyScope.TableFocus,
[],
{
enableOnFormTags: false,

View File

@ -1,10 +1,10 @@
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 { useSetIsRecordTableFocusActive } from '@/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive';
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
@ -49,6 +49,6 @@ export const useLeaveTableFocus = (recordTableId?: string) => {
setRecordTableHoverPosition(null);
setHotkeyScope(TableHotkeyScope.Table);
setHotkeyScope(RecordIndexHotkeyScope.RecordIndex);
};
};

View File

@ -7,6 +7,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useUpsertRecordFromState } from '../../hooks/useUpsertRecordFromState';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
@ -168,7 +169,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
move('up');
},
TableHotkeyScope.Table,
RecordIndexHotkeyScope.RecordIndex,
[move],
);
@ -178,7 +179,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
move('down');
},
TableHotkeyScope.Table,
RecordIndexHotkeyScope.RecordIndex,
[move],
);

View File

@ -1,12 +1,13 @@
import { Key } from 'ts-key-enum';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { isAtLeastOneTableRowSelectedSelector } from '@/object-record/record-table/record-table-row/states/isAtLeastOneTableRowSelectedSelector';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordTableBodyEscapeHotkeyEffect = () => {
const { recordTableId } = useRecordTableContextOrThrow();
@ -28,9 +29,9 @@ export const RecordTableBodyEscapeHotkeyEffect = () => {
resetTableRowSelection();
}
},
TableHotkeyScope.Table,
RecordIndexHotkeyScope.RecordIndex,
[isAtLeastOneRecordSelected, resetTableRowSelection, unfocusRecordTableRow],
);
return <></>;
return null;
};

View File

@ -1,3 +1,4 @@
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
@ -37,7 +38,7 @@ export const RecordTableBodyFocusKeyboardEffect = () => {
restoreRecordTableRowFocusFromCellPosition();
setIsFocusActiveForCurrentPosition(false);
} else {
setHotkeyScope(TableHotkeyScope.Table, {
setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, {
goto: true,
keyboardShortcutMenu: true,
});

View File

@ -4,6 +4,7 @@ import { TableCellPosition } from '@/object-record/record-table/types/TableCellP
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState';
import { isSomeCellInEditModeComponentSelector } from '@/object-record/record-table/states/selectors/isSomeCellInEditModeComponentSelector';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
@ -38,8 +39,8 @@ export const useMoveHoverToCurrentCell = (recordTableId: string) => {
if (
currentHotkeyScope.scope !== TableHotkeyScope.TableFocus &&
currentHotkeyScope.scope !== TableHotkeyScope.CellEditMode &&
currentHotkeyScope.scope !== TableHotkeyScope.Table &&
currentHotkeyScope.scope !== AppHotkeyScope.CommandMenuOpen
currentHotkeyScope.scope !== AppHotkeyScope.CommandMenuOpen &&
currentHotkeyScope.scope !== RecordIndexHotkeyScope.RecordIndex
) {
return;
}

View File

@ -139,13 +139,9 @@ export const RecordTableTr = forwardRef<
data-virtualized-id={recordId}
isDragging={isDragging}
ref={ref}
data-active={isActive ? 'true' : 'false'}
data-focused={
isRowFocusActive && isFocused && !isActive ? 'true' : 'false'
}
data-next-row-active-or-focused={
isNextRowActiveOrFocused ? 'true' : 'false'
}
data-active={isActive}
data-focused={isRowFocusActive && isFocused && !isActive}
data-next-row-active-or-focused={isNextRowActiveOrFocused}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>

View File

@ -3,5 +3,4 @@ export enum TableHotkeyScope {
CellEditMode = 'cell-edit-mode',
CellDateEditMode = 'cell-date-edit-mode',
TableFocus = 'table-focus',
Table = 'table',
}