From b3f5a3f75f4283b20945bf591c8eecabbb7d12ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 6 May 2025 14:52:05 +0200 Subject: [PATCH] Record Table Row Navigation (#11879) # Record Table Row Navigation This PR improves the table accessibility by adding a row navigation and new shortcuts to the table. Closes #896. # Introduce focused active row states on the table This PR implements the focused and active row states feature for the record table, allowing users to navigate through the table with keyboard arrows and providing visual feedback for selection. ## Implementation details: - Added new component states to track focused and active row positions and states. - Implemented dedicated hooks for row state management - Updated UI styling for active and focused rows: - Applied blue border (Adaptive Colors Blue 3) - Added highlight background (Accent Quaternary) - Added styling for focused rows to clearly indicate selection state - Added row state cleanup: - `RecordTableDeactivateRecordTableRowEffect` component to reset states - Added row state reset logic upon navigation ## Bug fixes - Fixed record table unselection in the page change effect - Fixed a hack introduced by https://github.com/twentyhq/twenty/pull/8489 which duplicated the last table column # Shortcuts ## Arrow keys and J+K navigation https://github.com/user-attachments/assets/6b46f6b5-cd98-4053-aaef-f8bf2b9584b5 ## Record selection with X https://github.com/user-attachments/assets/44ab7397-e00c-4dfe-8dd1-b3ffc53b3e5f ## Enter allows for cell navigation, Escape goes back to row navigation https://github.com/user-attachments/assets/890d7e25-2d81-47e3-972f-043a1879b8cc ## Command + Enter opens the record https://github.com/user-attachments/assets/cf8cdbd5-7cf0-4d78-909f-dc6be88b9e25 --- .../RecordShowRightDrawerOpenRecordButton.tsx | 5 +- .../effect-components/PageChangeEffect.tsx | 33 +++++- .../object-record/components/RecordChip.tsx | 1 + .../record-table/components/RecordTable.tsx | 19 ++- .../RecordTableBodyEffectsWrapper.tsx | 19 ++- ...ordTableDeactivateRecordTableRowEffect.tsx | 12 ++ ...dTableNoRecordGroupBodyContextProvider.tsx | 4 +- ...ordTableRecordGroupBodyContextProvider.tsx | 6 +- ... RecordTableScrollToFocusedCellEffect.tsx} | 11 +- .../RecordTableScrollToFocusedRowEffect.tsx | 63 ++++++++++ .../hooks/internal/useLeaveTableFocus.ts | 20 ++++ .../hooks/useActiveRecordTableRow.ts | 62 ++++++++++ .../hooks/useFocusedRecordTableRow.ts | 110 ++++++++++++++++++ .../record-table/hooks/useRecordTable.ts | 68 ++++++----- .../record-table/hooks/useRecordTableMove.ts | 39 +++++++ ...us.ts => useRecordTableMoveFocusedCell.ts} | 2 +- .../hooks/useRecordTableMoveFocusedRow.ts | 93 +++++++++++++++ .../components/RecordTableBody.tsx | 6 +- .../RecordTableBodyEscapeHotkeyEffect.tsx | 16 ++- .../RecordTableBodyFocusKeyboardEffect.tsx | 42 +++++++ .../RecordTableBodyRowFocusKeyboardEffect.tsx | 31 +++++ .../components/RecordTableCellCheckbox.tsx | 8 +- .../RecordTableCellHoveredPortal.tsx | 29 ++++- .../components/RecordTableCellPortals.tsx | 4 +- .../components/RecordTableLastEmptyCell.tsx | 7 +- .../components/RecordTableTd.tsx | 2 +- .../useSetIsRecordTableFocusActive.test.tsx | 8 +- .../hooks/useOpenRecordTableCellFromCell.ts | 5 - .../hooks/useOpenRecordTableCellV2.ts | 30 +++++ .../hooks/useSetIsRecordTableFocusActive.ts | 4 +- .../components/RecordTableHeaderCell.tsx | 25 +++- .../RecordTableHeaderCheckboxColumn.tsx | 28 ++++- .../RecordTableHeaderLastColumn.tsx | 59 ++++++---- .../components/RecordTableRow.tsx | 13 +++ .../components/RecordTableRowHotkeyEffect.tsx | 63 ++++++++++ .../components/RecordTableTr.tsx | 96 ++++++++++++++- ...activeRecordTableRowIndexComponentState.ts | 10 ++ ...ocusedRecordTableRowIndexComponentState.ts | 10 ++ ...ecordTableCellFocusActiveComponentState.ts | 9 ++ ...ecordTableRowActiveComponentFamilyState.ts | 9 ++ ...ecordTableRowFocusActiveComponentState.ts} | 4 +- ...cordTableRowFocusedComponentFamilyState.ts | 9 ++ 42 files changed, 964 insertions(+), 130 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect.tsx rename packages/twenty-front/src/modules/object-record/record-table/components/{RecordTableScrollToFocusedElementEffect.tsx => RecordTableScrollToFocusedCellEffect.tsx} (77%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedRowEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useActiveRecordTableRow.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useFocusedRecordTableRow.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMove.ts rename packages/twenty-front/src/modules/object-record/record-table/hooks/{useRecordTableMoveFocus.ts => useRecordTableMoveFocusedCell.ts} (98%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedRow.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRowFocusKeyboardEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/activeRecordTableRowIndexComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/focusedRecordTableRowIndexComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableCellFocusActiveComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState.ts rename packages/twenty-front/src/modules/object-record/record-table/states/{isRecordTableFocusActiveComponentState.ts => isRecordTableRowFocusActiveComponentState.ts} (77%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState.ts diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerOpenRecordButton.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerOpenRecordButton.tsx index e72f4118d9..5b1120a75c 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerOpenRecordButton.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerOpenRecordButton.tsx @@ -21,11 +21,10 @@ import { useCallback } from 'react'; import { Link } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -import { useNavigateApp } from '~/hooks/useNavigateApp'; -import { Button } from 'twenty-ui/input'; import { IconBrowserMaximize } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; import { getOsControlSymbol } from 'twenty-ui/utilities'; - +import { useNavigateApp } from '~/hooks/useNavigateApp'; const StyledLink = styled(Link)` text-decoration: none; `; 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 775a4b4f70..d997125906 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -15,21 +15,26 @@ import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksO import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; 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 { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; 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'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { SettingsPath } from '@/types/SettingsPath'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isDefined } from 'twenty-shared/utils'; +import { AnalyticsType } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState'; -import { AnalyticsType } from '~/generated/graphql'; import { getPageTitleFromPath } from '~/utils/title-utils'; - // TODO: break down into smaller functions and / or hooks // - moved usePageChangeEffectNavigateLocation into dedicated hook export const PageChangeEffect = () => { @@ -54,7 +59,19 @@ export const PageChangeEffect = () => { const objectNamePlural = useParams().objectNamePlural ?? CoreObjectNamePlural.Person; - const resetTableSelections = useResetTableRowSelection(objectNamePlural); + const contextStoreCurrentViewId = useRecoilComponentValueV2( + contextStoreCurrentViewIdComponentState, + MAIN_CONTEXT_STORE_INSTANCE_ID, + ); + + const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( + objectNamePlural, + contextStoreCurrentViewId || '', + ); + + const resetTableSelections = useResetTableRowSelection(recordIndexId); + const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId); + const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId); const { executeTasksOnAnyLocationChange } = useExecuteTasksOnAnyLocationChange(); @@ -84,8 +101,16 @@ export const PageChangeEffect = () => { if (isLeavingRecordIndexPage) { resetTableSelections(); + unfocusRecordTableRow(); + deactivateRecordTableRow(); } - }, [isMatchingLocation, previousLocation, resetTableSelections]); + }, [ + isMatchingLocation, + previousLocation, + resetTableSelections, + unfocusRecordTableRow, + deactivateRecordTableRow, + ]); useEffect(() => { switch (true) { diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index f67b3db684..fa47d4928f 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -68,6 +68,7 @@ export const RecordChip = ({ const isSidePanelViewOpenRecordInType = recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL; + const onClick = isSidePanelViewOpenRecordInType ? () => openRecordInCommandMenu({ diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 57edee8de2..510694369a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -6,12 +6,14 @@ import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record import { RecordTableBodyEffectsWrapper } from '@/object-record/record-table/components/RecordTableBodyEffectsWrapper'; import { RecordTableContent } from '@/object-record/record-table/components/RecordTableContent'; import { RecordTableEmpty } from '@/object-record/record-table/components/RecordTableEmpty'; -import { RecordTableScrollToFocusedElementEffect } from '@/object-record/record-table/components/RecordTableScrollToFocusedElementEffect'; +import { RecordTableScrollToFocusedCellEffect } from '@/object-record/record-table/components/RecordTableScrollToFocusedCellEffect'; +import { RecordTableScrollToFocusedRowEffect } from '@/object-record/record-table/components/RecordTableScrollToFocusedRowEffect'; import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; -import { isRecordTableFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableFocusActiveComponentState'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; @@ -46,8 +48,13 @@ export const RecordTable = () => { const recordTableIsEmpty = !isRecordTableInitialLoading && allRecordIds.length === 0; - const isRecordTableFocusActive = useRecoilComponentValueV2( - isRecordTableFocusActiveComponentState, + const isRecordTableCellFocusActive = useRecoilComponentValueV2( + isRecordTableCellFocusActiveComponentState, + recordTableId, + ); + + const isRecordTableRowFocusActive = useRecoilComponentValueV2( + isRecordTableRowFocusActiveComponentState, recordTableId, ); @@ -71,7 +78,9 @@ export const RecordTable = () => { tableBodyRef={tableBodyRef} /> - {isRecordTableFocusActive && } + {isRecordTableCellFocusActive && } + + {isRecordTableRowFocusActive && } {recordTableIsEmpty && !hasRecordGroups ? ( { - const isAtLeastOneRecordSelected = useRecoilComponentValueV2( - isAtLeastOneTableRowSelectedSelector, - ); - - const isRecordTableFocusActive = useRecoilComponentValueV2( - isRecordTableFocusActiveComponentState, + const isRecordTableRowFocusActive = useRecoilComponentValueV2( + isRecordTableRowFocusActiveComponentState, ); return ( @@ -31,9 +28,11 @@ export const RecordTableBodyEffectsWrapper = ({ ) : ( )} - {isAtLeastOneRecordSelected && } - {isRecordTableFocusActive && } + + + {isRecordTableRowFocusActive && } + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect.tsx new file mode 100644 index 0000000000..4eabda7f7f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect.tsx @@ -0,0 +1,12 @@ +import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; +import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose'; + +export const RecordTableDeactivateRecordTableRowEffect = () => { + const { deactivateRecordTableRow } = useActiveRecordTableRow(); + + useListenRightDrawerClose(() => { + deactivateRecordTableRow(); + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx index 4278883a83..339215d6c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx @@ -1,7 +1,7 @@ import { RecordTableBodyContextProvider } from '@/object-record/record-table/contexts/RecordTableBodyContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useHandleContainerMouseEnter } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter'; -import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus'; +import { useRecordTableMoveFocusedCell } from '@/object-record/record-table/hooks/useRecordTableMoveFocusedCell'; import { useCloseRecordTableCellNoGroup } from '@/object-record/record-table/record-table-cell/hooks/internal/useCloseRecordTableCellNoGroup'; import { useMoveHoverToCurrentCell } from '@/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell'; import { @@ -28,7 +28,7 @@ export const RecordTableNoRecordGroupBodyContextProvider = ({ openTableCell(args); }; - const { moveFocus } = useRecordTableMoveFocus(recordTableId); + const { moveFocus } = useRecordTableMoveFocusedCell(recordTableId); const handleMoveFocus = (direction: MoveFocusDirection) => { moveFocus(direction); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx index 8151628f50..68a89348d3 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx @@ -1,7 +1,7 @@ import { RecordTableBodyContextProvider } from '@/object-record/record-table/contexts/RecordTableBodyContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useHandleContainerMouseEnter } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter'; -import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus'; +import { useRecordTableMove } from '@/object-record/record-table/hooks/useRecordTableMove'; import { useCloseRecordTableCellInGroup } from '@/object-record/record-table/record-table-cell/hooks/internal/useCloseRecordTableCellInGroup'; import { useMoveHoverToCurrentCell } from '@/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell'; import { @@ -29,10 +29,10 @@ export const RecordTableRecordGroupBodyContextProvider = ({ openTableCell(args); }; - const { moveFocus } = useRecordTableMoveFocus(recordTableId); + const { move } = useRecordTableMove(recordTableId); const handleMoveFocus = (direction: MoveFocusDirection) => { - moveFocus(direction); + move(direction); }; const { closeTableCellInGroup } = useCloseRecordTableCellInGroup(); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedElementEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedCellEffect.tsx similarity index 77% rename from packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedElementEffect.tsx rename to packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedCellEffect.tsx index b018f500f0..0834c04b4a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedElementEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedCellEffect.tsx @@ -1,13 +1,18 @@ +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { recordTableFocusPositionComponentState } from '@/object-record/record-table/states/recordTableFocusPositionComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useEffect } from 'react'; import { isDefined } from 'twenty-shared/utils'; -export const RecordTableScrollToFocusedElementEffect = () => { +export const RecordTableScrollToFocusedCellEffect = () => { + const { recordTableId } = useRecordTableContextOrThrow(); + const focusPosition = useRecoilComponentValueV2( recordTableFocusPositionComponentState, + recordTableId, ); + // Handle cell focus useEffect(() => { if (!focusPosition) { return; @@ -36,11 +41,15 @@ export const RecordTableScrollToFocusedElementEffect = () => { } } + focusElement.style.scrollMarginTop = '32px'; + focusElement.style.scrollMarginBottom = '32px'; + focusElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return () => { if (isDefined(focusElement)) { focusElement.style.scrollMarginLeft = ''; + focusElement.style.scrollMarginBottom = ''; } }; }, [focusPosition]); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedRowEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedRowEffect.tsx new file mode 100644 index 0000000000..23643452da --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableScrollToFocusedRowEffect.tsx @@ -0,0 +1,63 @@ +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { focusedRecordTableRowIndexComponentState } from '@/object-record/record-table/states/focusedRecordTableRowIndexComponentState'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const RecordTableScrollToFocusedRowEffect = () => { + const { recordTableId } = useRecordTableContextOrThrow(); + + const focusedRowIndex = useRecoilComponentValueV2( + focusedRecordTableRowIndexComponentState, + recordTableId, + ); + + const isRowFocusActive = useRecoilComponentValueV2( + isRecordTableRowFocusActiveComponentState, + recordTableId, + ); + + const allRecordIds = useRecoilComponentValueV2( + recordIndexAllRecordIdsComponentSelector, + recordTableId, + ); + + useEffect(() => { + if ( + !isRowFocusActive || + !isDefined(focusedRowIndex) || + !allRecordIds?.length + ) { + return; + } + + const recordId = allRecordIds[focusedRowIndex]; + + if (!recordId) { + return; + } + + const focusElement = document.getElementById( + `record-table-cell-0-${focusedRowIndex}`, + ); + + if (!focusElement) { + return; + } + + focusElement.style.scrollMarginBottom = '32px'; + focusElement.style.scrollMarginTop = '32px'; + + focusElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + return () => { + if (isDefined(focusElement)) { + focusElement.style.scrollMarginBottom = ''; + } + }; + }, [focusedRowIndex, isRowFocusActive, allRecordIds]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts index 8f3ad47a8f..20283baf3c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts @@ -1,7 +1,11 @@ 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'; @@ -24,11 +28,27 @@ export const useLeaveTableFocus = (recordTableId?: string) => { recordTableIdFromContext, ); + const { unfocusRecordTableRow } = useFocusedRecordTableRow( + recordTableIdFromContext, + ); + + const { deactivateRecordTableRow } = useActiveRecordTableRow( + recordTableIdFromContext, + ); + + const setHotkeyScope = useSetHotkeyScope(); + return () => { resetTableRowSelection(); setIsFocusActiveForCurrentPosition(false); + unfocusRecordTableRow(); + + deactivateRecordTableRow(); + setRecordTableHoverPosition(null); + + setHotkeyScope(TableHotkeyScope.Table); }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useActiveRecordTableRow.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useActiveRecordTableRow.ts new file mode 100644 index 0000000000..9a586a69c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useActiveRecordTableRow.ts @@ -0,0 +1,62 @@ +import { activeRecordTableRowIndexComponentState } from '@/object-record/record-table/states/activeRecordTableRowIndexComponentState'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useActiveRecordTableRow = (recordTableId?: string) => { + const isRowActiveState = useRecoilComponentCallbackStateV2( + isRecordTableRowActiveComponentFamilyState, + recordTableId, + ); + + const activeRowIndexState = useRecoilComponentCallbackStateV2( + activeRecordTableRowIndexComponentState, + recordTableId, + ); + + const deactivateRecordTableRow = useRecoilCallback( + ({ set, snapshot }) => + () => { + const activeRowIndex = snapshot + .getLoadable(activeRowIndexState) + .getValue(); + + if (!isDefined(activeRowIndex)) { + return; + } + + set(activeRowIndexState, null); + + set(isRowActiveState(activeRowIndex), false); + }, + [activeRowIndexState, isRowActiveState], + ); + + const activateRecordTableRow = useRecoilCallback( + ({ set, snapshot }) => + (rowIndex: number) => { + const activeRowIndex = snapshot + .getLoadable(activeRowIndexState) + .getValue(); + + if (activeRowIndex === rowIndex) { + return; + } + + if (isDefined(activeRowIndex)) { + set(isRowActiveState(activeRowIndex), false); + } + + set(activeRowIndexState, rowIndex); + + set(isRowActiveState(rowIndex), true); + }, + [activeRowIndexState, isRowActiveState], + ); + + return { + activateRecordTableRow, + deactivateRecordTableRow, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useFocusedRecordTableRow.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useFocusedRecordTableRow.ts new file mode 100644 index 0000000000..c05921d248 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useFocusedRecordTableRow.ts @@ -0,0 +1,110 @@ +import { focusedRecordTableRowIndexComponentState } from '@/object-record/record-table/states/focusedRecordTableRowIndexComponentState'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; +import { recordTableFocusPositionComponentState } from '@/object-record/record-table/states/recordTableFocusPositionComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useFocusedRecordTableRow = (recordTableId?: string) => { + const isRowFocusedState = useRecoilComponentCallbackStateV2( + isRecordTableRowFocusedComponentFamilyState, + recordTableId, + ); + + const focusedRowIndexState = useRecoilComponentCallbackStateV2( + focusedRecordTableRowIndexComponentState, + recordTableId, + ); + + const isRowFocusActiveState = useRecoilComponentCallbackStateV2( + isRecordTableRowFocusActiveComponentState, + recordTableId, + ); + + const focusedCellPositionState = useRecoilComponentCallbackStateV2( + recordTableFocusPositionComponentState, + recordTableId, + ); + + const isRecordTableCellFocusActiveState = useRecoilComponentCallbackStateV2( + isRecordTableCellFocusActiveComponentState, + recordTableId, + ); + + const unfocusRecordTableRow = useRecoilCallback( + ({ set, snapshot }) => + () => { + const focusedRowIndex = snapshot + .getLoadable(focusedRowIndexState) + .getValue(); + + if (!isDefined(focusedRowIndex)) { + return; + } + + set(focusedRowIndexState, null); + set(isRowFocusedState(focusedRowIndex), false); + set(isRowFocusActiveState, false); + }, + [focusedRowIndexState, isRowFocusedState, isRowFocusActiveState], + ); + + const focusRecordTableRow = useRecoilCallback( + ({ set, snapshot }) => + (rowIndex: number) => { + const focusedRowIndex = snapshot + .getLoadable(focusedRowIndexState) + .getValue(); + + if (isDefined(focusedRowIndex) && focusedRowIndex !== rowIndex) { + set(isRowFocusedState(focusedRowIndex), false); + } + + set(focusedRowIndexState, rowIndex); + set(isRowFocusedState(rowIndex), true); + set(isRowFocusActiveState, true); + }, + [focusedRowIndexState, isRowFocusedState, isRowFocusActiveState], + ); + + const restoreRecordTableRowFocusFromCellPosition = useRecoilCallback( + ({ snapshot }) => + () => { + const focusedRowIndex = snapshot + .getLoadable(focusedRowIndexState) + .getValue(); + + const focusedCellPosition = snapshot + .getLoadable(focusedCellPositionState) + .getValue(); + + const isRecordTableCellFocusActive = snapshot + .getLoadable(isRecordTableCellFocusActiveState) + .getValue(); + + if ( + !isDefined(focusedRowIndex) || + !isDefined(focusedCellPosition) || + !isRecordTableCellFocusActive + ) { + return; + } + + focusRecordTableRow(focusedCellPosition.row); + }, + [ + focusedRowIndexState, + focusedCellPositionState, + isRecordTableCellFocusActiveState, + focusRecordTableRow, + ], + ); + + return { + focusRecordTableRow, + unfocusRecordTableRow, + restoreRecordTableRowFocusFromCellPosition, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts index 5d307d2793..3d7fdb9ec3 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts @@ -3,9 +3,7 @@ import { Key } from 'ts-key-enum'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useSetHasUserSelectedAllRows } from '@/object-record/record-table/hooks/internal/useSetAllRowSelectedState'; -import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -13,15 +11,16 @@ import { useUpsertRecordFromState } from '../../hooks/useUpsertRecordFromState'; import { ColumnDefinition } from '../types/ColumnDefinition'; import { TableHotkeyScope } from '../types/TableHotkeyScope'; -import { useSetIsRecordTableFocusActive } from '@/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive'; import { availableTableColumnsComponentState } from '@/object-record/record-table/states/availableTableColumnsComponentState'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; import { onColumnsChangeComponentState } from '@/object-record/record-table/states/onColumnsChangeComponentState'; import { onEntityCountChangeComponentState } from '@/object-record/record-table/states/onEntityCountChangeComponentState'; +import { useRecordTableMove } from '@/object-record/record-table/hooks/useRecordTableMove'; import { onToggleColumnSortComponentState } from '@/object-record/record-table/states/onToggleColumnSortComponentState'; import { tableLastRowVisibleComponentState } from '@/object-record/record-table/states/tableLastRowVisibleComponentState'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; @@ -31,7 +30,6 @@ import { useSelectAllRows } from './internal/useSelectAllRows'; import { useSetRecordTableData } from './internal/useSetRecordTableData'; import { useSetRecordTableFocusPosition } from './internal/useSetRecordTableFocusPosition'; import { useSetRowSelectedState } from './internal/useSetRowSelectedState'; - type useRecordTableProps = { recordTableId?: string; }; @@ -141,62 +139,65 @@ export const useRecordTable = (props?: useRecordTableProps) => { const setFocusPosition = useSetRecordTableFocusPosition(recordTableId); - const { setIsFocusActiveForCurrentPosition } = - useSetIsRecordTableFocusActive(recordTableId); + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); - const { moveDown, moveLeft, moveRight, moveUp } = - useRecordTableMoveFocus(recordTableId); + const { move } = useRecordTableMove(recordTableId); const useMapKeyboardToFocus = () => { - const setHotkeyScope = useSetHotkeyScope(); - useScopedHotkeys( [Key.ArrowUp, `${Key.Shift}+${Key.Enter}`], () => { - moveUp(); + move('up'); }, TableHotkeyScope.TableFocus, - [moveUp], + [move], ); useScopedHotkeys( Key.ArrowDown, () => { - moveDown(); + move('down'); }, TableHotkeyScope.TableFocus, - [moveDown], + [move], + ); + + useScopedHotkeys( + [Key.ArrowUp, 'k'], + () => { + setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); + move('up'); + }, + TableHotkeyScope.Table, + [move], + ); + + useScopedHotkeys( + [Key.ArrowDown, 'j'], + () => { + setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); + move('down'); + }, + TableHotkeyScope.Table, + [move], ); useScopedHotkeys( [Key.ArrowLeft, `${Key.Shift}+${Key.Tab}`], () => { - moveLeft(); + move('left'); }, TableHotkeyScope.TableFocus, - [moveLeft], + [move], ); useScopedHotkeys( [Key.ArrowRight, Key.Tab], () => { - moveRight(); + move('right'); }, TableHotkeyScope.TableFocus, - [moveRight], - ); - - useScopedHotkeys( - [Key.Escape], - () => { - setHotkeyScope(TableHotkeyScope.Table, { - goto: true, - keyboardShortcutMenu: true, - }); - setIsFocusActiveForCurrentPosition(false); - }, - TableHotkeyScope.TableFocus, - [setIsFocusActiveForCurrentPosition], + [move], ); }; @@ -211,10 +212,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { setRowSelected, resetTableRowSelection, upsertRecordTableItem, - moveDown, - moveLeft, - moveRight, - moveUp, + move, useMapKeyboardToFocus, selectAllRows, setOnColumnsChange, diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMove.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMove.ts new file mode 100644 index 0000000000..3c66d6033d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMove.ts @@ -0,0 +1,39 @@ +import { useRecordTableMoveFocusedCell } from '@/object-record/record-table/hooks/useRecordTableMoveFocusedCell'; +import { useRecordTableMoveFocusedRow } from '@/object-record/record-table/hooks/useRecordTableMoveFocusedRow'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; +import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection'; +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 useRecordTableMove = (recordTableId?: string) => { + const { moveFocusedRow } = useRecordTableMoveFocusedRow(recordTableId); + + const { moveFocus } = useRecordTableMoveFocusedCell(recordTableId); + + const isRecordTableFocusActiveState = useRecoilComponentCallbackStateV2( + isRecordTableCellFocusActiveComponentState, + recordTableId, + ); + + const move = useRecoilCallback( + ({ snapshot }) => + (direction: MoveFocusDirection) => { + const isRecordTableFocusActive = getSnapshotValue( + snapshot, + isRecordTableFocusActiveState, + ); + + if (isRecordTableFocusActive) { + moveFocus(direction); + } else { + moveFocusedRow(direction); + } + }, + [isRecordTableFocusActiveState, moveFocusedRow, moveFocus], + ); + + return { + move, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedCell.ts similarity index 98% rename from packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts rename to packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedCell.ts index 09cada1251..43f8062f21 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedCell.ts @@ -9,7 +9,7 @@ import { recordTableFocusPositionComponentState } from '@/object-record/record-t import { numberOfTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; -export const useRecordTableMoveFocus = (recordTableId?: string) => { +export const useRecordTableMoveFocusedCell = (recordTableId?: string) => { const setFocusPosition = useSetRecordTableFocusPosition(recordTableId); const focusPositionState = useRecoilComponentCallbackStateV2( diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedRow.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedRow.ts new file mode 100644 index 0000000000..cab82d7461 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocusedRow.ts @@ -0,0 +1,93 @@ +import { useRecoilCallback } from 'recoil'; + +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; +import { focusedRecordTableRowIndexComponentState } from '@/object-record/record-table/states/focusedRecordTableRowIndexComponentState'; +import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { isDefined } from 'twenty-shared/utils'; + +export const useRecordTableMoveFocusedRow = (recordTableId?: string) => { + const { focusRecordTableRow } = useFocusedRecordTableRow(recordTableId); + + const focusedRowIndexState = useRecoilComponentCallbackStateV2( + focusedRecordTableRowIndexComponentState, + recordTableId, + ); + + const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackStateV2( + recordIndexAllRecordIdsComponentSelector, + recordTableId, + ); + + const moveFocusedRowUp = useRecoilCallback( + ({ snapshot }) => + () => { + const focusedRowIndex = getSnapshotValue( + snapshot, + focusedRowIndexState, + ); + + if (!isDefined(focusedRowIndex)) { + focusRecordTableRow(0); + return; + } + + let newRowIndex = focusedRowIndex - 1; + + if (newRowIndex < 0) { + newRowIndex = 0; + } + + focusRecordTableRow(newRowIndex); + }, + [focusedRowIndexState, focusRecordTableRow], + ); + + const moveFocusedRowDown = useRecoilCallback( + ({ snapshot }) => + () => { + const allRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsSelector, + ); + const focusedRowIndex = getSnapshotValue( + snapshot, + focusedRowIndexState, + ); + + if (!isDefined(focusedRowIndex)) { + focusRecordTableRow(0); + return; + } + + let newRowIndex = focusedRowIndex + 1; + + if (newRowIndex >= allRecordIds.length) { + newRowIndex = allRecordIds.length - 1; + } + + focusRecordTableRow(newRowIndex); + }, + [ + recordIndexAllRecordIdsSelector, + focusedRowIndexState, + focusRecordTableRow, + ], + ); + + const moveFocusedRow = (direction: MoveFocusDirection) => { + if (direction === 'up') { + moveFocusedRowUp(); + } else if (direction === 'down') { + moveFocusedRowDown(); + } + }; + + return { + moveFocusedRowUp, + moveFocusedRowDown, + moveFocusedRow, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx index e1a1f6f031..1363ff88c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBody.tsx @@ -6,20 +6,20 @@ const StyledTbody = styled.tbody` position: sticky; left: 0; z-index: 6; - transition: 0.3s ease; + transition: transform 0.3s ease; } td:nth-of-type(2) { position: sticky; left: 11px; z-index: 6; - transition: 0.3s ease; + transition: transform 0.3s ease; } tr:not(:last-child) td:nth-of-type(3) { // Last row is aggregate footer position: sticky; left: 43px; z-index: 6; - transition: 0.3s ease; + transition: transform 0.3s ease; &:not(.disable-shadow)::after { content: ''; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx index 54272d0893..0fa9d56e31 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx @@ -1,10 +1,12 @@ import { Key } from 'ts-key-enum'; 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(); @@ -12,12 +14,22 @@ export const RecordTableBodyEscapeHotkeyEffect = () => { recordTableId, }); + const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordTableId); + + const isAtLeastOneRecordSelected = useRecoilComponentValueV2( + isAtLeastOneTableRowSelectedSelector, + ); + useScopedHotkeys( [Key.Escape], () => { - resetTableRowSelection(); + unfocusRecordTableRow(); + if (isAtLeastOneRecordSelected) { + resetTableRowSelection(); + } }, TableHotkeyScope.Table, + [isAtLeastOneRecordSelected, resetTableRowSelection, unfocusRecordTableRow], ); return <>; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx index 98e7b90ab4..08424ac9cf 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx @@ -1,5 +1,13 @@ 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 { useSetIsRecordTableFocusActive } from '@/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { Key } from 'ts-key-enum'; export const RecordTableBodyFocusKeyboardEffect = () => { const { recordTableId } = useRecordTableContextOrThrow(); @@ -8,7 +16,41 @@ export const RecordTableBodyFocusKeyboardEffect = () => { recordTableId, }); + const setHotkeyScope = useSetHotkeyScope(); + + const { restoreRecordTableRowFocusFromCellPosition } = + useFocusedRecordTableRow(recordTableId); + + const { setIsFocusActiveForCurrentPosition } = + useSetIsRecordTableFocusActive(recordTableId); + + const isRecordTableFocusActive = useRecoilComponentValueV2( + isRecordTableCellFocusActiveComponentState, + ); + useMapKeyboardToFocus(); + useScopedHotkeys( + [Key.Escape], + () => { + if (isRecordTableFocusActive) { + restoreRecordTableRowFocusFromCellPosition(); + setIsFocusActiveForCurrentPosition(false); + } else { + setHotkeyScope(TableHotkeyScope.Table, { + goto: true, + keyboardShortcutMenu: true, + }); + } + }, + TableHotkeyScope.TableFocus, + [ + setIsFocusActiveForCurrentPosition, + restoreRecordTableRowFocusFromCellPosition, + setHotkeyScope, + isRecordTableFocusActive, + ], + ); + return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRowFocusKeyboardEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRowFocusKeyboardEffect.tsx new file mode 100644 index 0000000000..61afee3cc6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRowFocusKeyboardEffect.tsx @@ -0,0 +1,31 @@ +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useRecordTableMoveFocusedRow } from '@/object-record/record-table/hooks/useRecordTableMoveFocusedRow'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { Key } from 'ts-key-enum'; + +export const RecordTableBodyRowFocusKeyboardEffect = () => { + const { recordTableId } = useRecordTableContextOrThrow(); + + const { moveFocusedRow } = useRecordTableMoveFocusedRow(recordTableId); + + useScopedHotkeys( + [Key.ArrowUp, 'k'], + () => { + moveFocusedRow('up'); + }, + TableHotkeyScope.TableFocus, + [moveFocusedRow], + ); + + useScopedHotkeys( + [Key.ArrowDown, 'j'], + () => { + moveFocusedRow('down'); + }, + TableHotkeyScope.TableFocus, + [moveFocusedRow], + ); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx index c4f7a4eca6..20c488cbf8 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx @@ -17,6 +17,10 @@ const StyledContainer = styled.div` min-width: ${TABLE_CELL_CHECKBOX_MIN_WIDTH}; `; +const StyledRecordTableTd = styled(RecordTableTd)` + border-left: 1px solid transparent; +`; + export const RecordTableCellCheckbox = () => { const { isSelected } = useRecordTableRowContextOrThrow(); @@ -27,10 +31,10 @@ export const RecordTableCellCheckbox = () => { }, [isSelected, setCurrentRowSelected]); return ( - + - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx index 50b5273fb1..f649f0a5c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal.tsx @@ -6,20 +6,25 @@ import styled from '@emotion/styled'; import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; +import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { RecordTableCellDisplayMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode'; import { RecordTableCellEditButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditButton'; import { RecordTableCellEditMode } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditMode'; import { RecordTableCellFieldInput } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useContext } from 'react'; import { BORDER_COMMON } from 'twenty-ui/theme'; import { useIsMobile } from 'twenty-ui/utilities'; const StyledRecordTableCellHoveredPortalContent = styled.div<{ isReadOnly: boolean; + isRowActive: boolean; }>` align-items: center; background: ${({ theme }) => theme.background.transparent.secondary}; - background-color: ${({ theme }) => theme.background.primary}; + background-color: ${({ theme, isRowActive }) => + isRowActive ? theme.accent.quaternary : theme.background.primary}; border-radius: ${({ isReadOnly }) => !isReadOnly ? BORDER_COMMON.radius.sm : 'none'}; box-sizing: border-box; @@ -28,10 +33,12 @@ const StyledRecordTableCellHoveredPortalContent = styled.div<{ height: 32px; - outline: ${({ theme, isReadOnly }) => - isReadOnly - ? `1px solid ${theme.border.color.medium}` - : `1px solid ${theme.font.color.extraLight}`}; + outline: ${({ theme, isReadOnly, isRowActive }) => + isRowActive + ? 'none' + : isReadOnly + ? `1px solid ${theme.border.color.medium}` + : `1px solid ${theme.font.color.extraLight}`}; position: relative; user-select: none; @@ -53,8 +60,18 @@ const RecordTableCellHoveredPortalContent = () => { const showButton = !isFieldInputOnly && !isReadOnly && !(isMobile && isFirstColumn); + const { rowIndex } = useRecordTableRowContextOrThrow(); + + const isRowActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + rowIndex, + ); + return ( - + {isFieldInputOnly ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx index f48c05abb6..48097b0e7d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortals.tsx @@ -1,14 +1,14 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableCellEditModePortal } from '@/object-record/record-table/record-table-cell/components/RecordTableCellEditModePortal'; import { RecordTableCellHoveredPortal } from '@/object-record/record-table/record-table-cell/components/RecordTableCellHoveredPortal'; -import { isRecordTableFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableFocusActiveComponentState'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; export const RecordTableCellPortals = () => { const { recordTableId } = useRecordTableContextOrThrow(); const isRecordTableFocusActive = useRecoilComponentValueV2( - isRecordTableFocusActiveComponentState, + isRecordTableCellFocusActiveComponentState, recordTableId, ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell.tsx index 2243a41e6d..3dc2cac90a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell.tsx @@ -4,10 +4,5 @@ import { RecordTableTd } from '@/object-record/record-table/record-table-cell/co export const RecordTableLastEmptyCell = () => { const { isSelected } = useRecordTableRowContextOrThrow(); - return ( - <> - - - - ); + return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx index 881ef648cd..355b63efa0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableTd.tsx @@ -26,7 +26,7 @@ const StyledTd = styled.td<{ hasRightBorder && !isDragging ? `1px solid ${borderColor}` : 'none'}; padding: 0; - transition: 0.3s ease; + transition: transform 0.3s ease; text-align: left; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSetIsRecordTableFocusActive.test.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSetIsRecordTableFocusActive.test.tsx index 4f7769ddb4..329049d7dd 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSetIsRecordTableFocusActive.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSetIsRecordTableFocusActive.test.tsx @@ -4,7 +4,7 @@ import { RecoilRoot, useRecoilValue } from 'recoil'; import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance'; import { useSetIsRecordTableFocusActive } from '@/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive'; -import { isRecordTableFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableFocusActiveComponentState'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; import { recordTableFocusPositionComponentState } from '@/object-record/record-table/states/recordTableFocusPositionComponentState'; import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; @@ -19,7 +19,7 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => ( { set( - isRecordTableFocusActiveComponentState.atomFamily({ + isRecordTableCellFocusActiveComponentState.atomFamily({ instanceId: 'test-table-id', }), false, @@ -50,7 +50,7 @@ const renderHooks = () => { const { setIsFocusActive, setIsFocusActiveForCurrentPosition } = useSetIsRecordTableFocusActive('test-table-id'); const isRecordTableFocusActive = useRecoilValue( - isRecordTableFocusActiveComponentState.atomFamily({ + isRecordTableCellFocusActiveComponentState.atomFamily({ instanceId: 'test-table-id', }), ); @@ -99,7 +99,7 @@ describe('useSetIsRecordTableFocusActive', () => { expect(result.current.focusPosition).toEqual(cellPosition); }); - it('should remove focus-active class when focus is deactivated and update isRecordTableFocusActiveComponentState', () => { + it('should remove focus-active class when focus is deactivated and update isRecordTableCellFocusActiveComponentState', () => { const { result } = renderHooks(); const cellPosition: TableCellPosition = { column: 1, row: 0 }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell.ts index f90bf23de9..6015c30249 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell.ts @@ -9,7 +9,6 @@ import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; -import { useSetRecordTableFocusPosition } from '@/object-record/record-table/hooks/internal/useSetRecordTableFocusPosition'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; export const DEFAULT_CELL_SCOPE: HotkeyScope = { @@ -36,8 +35,6 @@ export const useOpenRecordTableCellFromCell = () => { const { cellPosition } = useContext(RecordTableCellContext); - const setFocusPosition = useSetRecordTableFocusPosition(); - const openTableCell = ( initialValue?: string, isActionButtonClick = false, @@ -54,8 +51,6 @@ export const useOpenRecordTableCellFromCell = () => { isActionButtonClick, isNavigating, }); - - setFocusPosition(cellPosition); }; return { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts index db5f3bee79..3bddfb32a1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts @@ -25,6 +25,10 @@ import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropd import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId'; import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious'; +import { useSetRecordTableFocusPosition } from '@/object-record/record-table/hooks/internal/useSetRecordTableFocusPosition'; +import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; +import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; import { clickOutsideListenerIsActivatedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; @@ -82,6 +86,18 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { const { openFieldInput } = useOpenFieldInputEditMode(); + const { activateRecordTableRow, deactivateRecordTableRow } = + useActiveRecordTableRow(tableScopeId); + + const { unfocusRecordTableRow } = useFocusedRecordTableRow(tableScopeId); + + const setIsRowFocusActive = useSetRecoilComponentStateV2( + isRecordTableRowFocusActiveComponentState, + tableScopeId, + ); + + const setFocusPosition = useSetRecordTableFocusPosition(); + const openTableCell = useRecoilCallback( ({ snapshot, set }) => ({ @@ -134,6 +150,9 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { recordId, objectNameSingular, }); + + activateRecordTableRow(cellPosition.row); + unfocusRecordTableRow(); } return; @@ -147,6 +166,12 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { return; } + deactivateRecordTableRow(); + + setFocusPosition(cellPosition); + + setIsRowFocusActive(false); + setDragSelectionStartEnabled(false); openFieldInput({ @@ -179,6 +204,9 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { }, [ clickOutsideListenerIsActivatedState, + deactivateRecordTableRow, + setFocusPosition, + setIsRowFocusActive, setDragSelectionStartEnabled, openFieldInput, setCurrentTableCellInEditModePosition, @@ -189,6 +217,8 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { navigate, indexIdentifierUrl, openRecordInCommandMenu, + activateRecordTableRow, + unfocusRecordTableRow, setViewableRecordId, setViewableRecordNameSingular, ], diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive.ts index 6461e3f1d4..8cf482847f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive.ts @@ -1,4 +1,4 @@ -import { isRecordTableFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableFocusActiveComponentState'; +import { isRecordTableCellFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableCellFocusActiveComponentState'; import { recordTableFocusPositionComponentState } from '@/object-record/record-table/states/recordTableFocusPositionComponentState'; import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; @@ -6,7 +6,7 @@ import { useRecoilCallback } from 'recoil'; export const useSetIsRecordTableFocusActive = (recordTableId?: string) => { const isRecordTableFocusActiveState = useRecoilComponentCallbackStateV2( - isRecordTableFocusActiveComponentState, + isRecordTableCellFocusActiveComponentState, recordTableId, ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx index 994b242696..f195ec2169 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx @@ -8,6 +8,8 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns'; import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState'; import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState'; import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; @@ -17,23 +19,27 @@ import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPoin import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { IconPlus } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; +import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; const COLUMN_MIN_WIDTH = 104; const StyledColumnHeaderCell = styled.th<{ columnWidth: number; isResizing?: boolean; + isFirstRowActiveOrFocused: boolean; }>` - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + border-bottom: ${({ isFirstRowActiveOrFocused, theme }) => + isFirstRowActiveOrFocused + ? 'none' + : `1px solid ${theme.border.color.light}`}; color: ${({ theme }) => theme.font.color.tertiary}; padding: 0; text-align: left; - transition: 0.3s ease; background-color: ${({ theme }) => theme.background.primary}; border-right: 1px solid ${({ theme }) => theme.border.color.light}; @@ -215,6 +221,18 @@ export const RecordTableHeaderCell = ({ const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission(); + const isFirstRowActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + 0, + ); + + const isFirstRowFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + 0, + ); + + const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused; + return ( setIconVisibility(true)} onMouseLeave={() => setIconVisibility(false)} + isFirstRowActiveOrFocused={isFirstRowActiveOrFocused} > diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx index 5cdd9ce4e3..b7d2a95e36 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn.tsx @@ -4,7 +4,10 @@ import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; import { allRowsSelectedStatusComponentSelector } from '@/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { Checkbox } from 'twenty-ui/input'; @@ -16,9 +19,14 @@ const StyledContainer = styled.div` background-color: ${({ theme }) => theme.background.primary}; `; -const StyledColumnHeaderCell = styled.th` +const StyledColumnHeaderCell = styled.th<{ + isFirstRowActiveOrFocused: boolean; +}>` background-color: ${({ theme }) => theme.background.primary}; - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + border-bottom: ${({ isFirstRowActiveOrFocused, theme }) => + isFirstRowActiveOrFocused + ? 'none' + : `1px solid ${theme.border.color.light}`}; border-right: transparent; width: 30px; `; @@ -58,8 +66,22 @@ export const RecordTableHeaderCheckboxColumn = () => { } }; + const isFirstRowActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + 0, + ); + + const isFirstRowFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + 0, + ); + + const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused; + return ( - + ` - ${({ theme }) => { - return ` - &:hover { - background: ${theme.background.transparent.light}; - }; - `; - }}; - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + border-bottom: ${({ isFirstRowActiveOrFocused, theme }) => + isFirstRowActiveOrFocused + ? 'none' + : `1px solid ${theme.border.color.light}`}; background-color: ${({ theme }) => theme.background.primary}; border-left: none !important; color: ${({ theme }) => theme.font.color.tertiary}; border-right: none !important; - width: 32px; + cursor: default; ${({ isTableWiderThanScreen, theme }) => isTableWiderThanScreen ? ` background-color: ${theme.background.primary}; + width: 32px; ` - : ''}; + : 'width: 100%'}; z-index: 1; `; -const StyledEmptyHeaderCell = styled.th` - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; - background-color: ${({ theme }) => theme.background.primary}; - width: 100%; -`; - const StyledPlusIconContainer = styled.div` align-items: center; display: flex; height: 32px; justify-content: center; - width: 100%; +`; + +const StyledDropdownContainer = styled.div` + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } + cursor: pointer; `; const HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID = @@ -59,9 +60,24 @@ export const RecordTableHeaderLastColumn = () => { (scrollWrapperHTMLElement?.clientWidth ?? 0) < (scrollWrapperHTMLElement?.scrollWidth ?? 0); + const isFirstRowActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + 0, + ); + + const isFirstRowFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + 0, + ); + + const isFirstRowActiveOrFocused = isFirstRowActive || isFirstRowFocused; + return ( - <> - + + { scope: HIDDEN_TABLE_COLUMN_DROPDOWN_HOTKEY_SCOPE_ID, }} /> - - - + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx index b5acdedf95..32c2007e5d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx @@ -5,8 +5,13 @@ import { RecordTableCellGrip } from '@/object-record/record-table/record-table-c import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; import { RecordTableCells } from '@/object-record/record-table/record-table-row/components/RecordTableCells'; import { RecordTableDraggableTr } from '@/object-record/record-table/record-table-row/components/RecordTableDraggableTr'; +import { RecordTableRowHotkeyEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenUpdatesEffect'; import { getDefaultRecordFieldsToListen } from '@/subscription/utils/getDefaultRecordFieldsToListen.util'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; type RecordTableRowProps = { recordId: string; @@ -23,6 +28,13 @@ export const RecordTableRow = ({ const listenedFields = getDefaultRecordFieldsToListen({ objectNameSingular, }); + const isFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + rowIndexForFocus, + ); + const isRowFocusActive = useRecoilComponentValueV2( + isRecordTableRowFocusActiveComponentState, + ); return ( + {isRowFocusActive && isFocused && } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx new file mode 100644 index 0000000000..54056f15be --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx @@ -0,0 +1,63 @@ +import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu'; +import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { useSetRecordTableFocusPosition } from '@/object-record/record-table/hooks/internal/useSetRecordTableFocusPosition'; +import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; +import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { Key } from 'ts-key-enum'; + +export const RecordTableRowHotkeyEffect = () => { + const { isSelected, recordId, objectNameSingular, rowIndex } = + useRecordTableRowContextOrThrow(); + + const { setCurrentRowSelected } = useSetCurrentRowSelected(); + + const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); + + const { activateRecordTableRow } = useActiveRecordTableRow(); + + const setIsRowFocusActive = useSetRecoilComponentStateV2( + isRecordTableRowFocusActiveComponentState, + ); + + const setFocusPosition = useSetRecordTableFocusPosition(); + + useScopedHotkeys( + 'x', + () => { + setCurrentRowSelected(!isSelected); + }, + TableHotkeyScope.TableFocus, + ); + + useScopedHotkeys( + [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`], + () => { + openRecordInCommandMenu({ + recordId: recordId, + objectNameSingular: objectNameSingular, + isNewRecord: false, + }); + + activateRecordTableRow(rowIndex); + }, + TableHotkeyScope.TableFocus, + ); + + useScopedHotkeys( + Key.Enter, + () => { + setIsRowFocusActive(false); + setFocusPosition({ + row: rowIndex, + column: 0, + }); + }, + TableHotkeyScope.TableFocus, + ); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx index a7aa53d9b2..3015801436 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx @@ -3,17 +3,70 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte import { RecordTableRowContextProvider } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; import { isRowVisibleComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState'; +import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import styled from '@emotion/styled'; import { ReactNode, forwardRef } from 'react'; -const StyledTr = styled.tr<{ isDragging: boolean }>` - position: relative; +const StyledTr = styled.tr<{ + isDragging: boolean; +}>` border: ${({ isDragging, theme }) => isDragging ? `1px solid ${theme.border.color.medium}` : '1px solid transparent'}; + position: relative; transition: border-left-color 0.2s ease-in-out; + + &[data-next-row-active-or-focused='true'] { + td { + border-bottom: none; + } + } + + &[data-focused='true'] { + td { + &:not(:first-of-type) { + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + border-top: 1px solid ${({ theme }) => theme.border.color.medium}; + border-color: ${({ theme }) => theme.border.color.medium}; + background-color: ${({ theme }) => theme.background.tertiary}; + } + &:nth-of-type(2) { + border-left: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm} 0 0 + ${({ theme }) => theme.border.radius.sm}; + } + &:last-of-type { + border-right: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: 0 ${({ theme }) => theme.border.radius.sm} + ${({ theme }) => theme.border.radius.sm} 0; + } + } + } + + &[data-active='true'] { + td { + &:not(:first-of-type) { + border-bottom: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + border-top: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + background-color: ${({ theme }) => theme.accent.quaternary}; + } + &:nth-of-type(2) { + border-left: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + border-radius: ${({ theme }) => theme.border.radius.sm} 0 0 + ${({ theme }) => theme.border.radius.sm}; + } + &:last-of-type { + border-right: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + border-radius: 0 ${({ theme }) => theme.border.radius.sm} + ${({ theme }) => theme.border.radius.sm} 0; + } + } + } `; type RecordTableTrProps = { @@ -21,7 +74,10 @@ type RecordTableTrProps = { recordId: string; focusIndex: number; isDragging?: boolean; -} & React.ComponentProps; +} & Omit< + React.ComponentProps, + 'isActive' | 'isNextRowActiveOrFocused' | 'isFocused' +>; export const RecordTableTr = forwardRef< HTMLTableRowElement, @@ -38,6 +94,33 @@ export const RecordTableTr = forwardRef< recordId, ); + const isActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + focusIndex, + ); + + const isNextRowActive = useRecoilComponentFamilyValueV2( + isRecordTableRowActiveComponentFamilyState, + focusIndex + 1, + ); + + const isFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + focusIndex, + ); + + const isRowFocusActive = useRecoilComponentValueV2( + isRecordTableRowFocusActiveComponentState, + ); + + const isNextRowFocused = useRecoilComponentFamilyValueV2( + isRecordTableRowFocusedComponentFamilyState, + focusIndex + 1, + ); + + const isNextRowActiveOrFocused = + (isRowFocusActive && isNextRowFocused) || isNextRowActive; + return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/activeRecordTableRowIndexComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/activeRecordTableRowIndexComponentState.ts new file mode 100644 index 0000000000..4b5dd9ebc6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/activeRecordTableRowIndexComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const activeRecordTableRowIndexComponentState = createComponentStateV2< + number | null +>({ + key: 'activeRecordTableRowIndexComponentState', + defaultValue: null, + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/focusedRecordTableRowIndexComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/focusedRecordTableRowIndexComponentState.ts new file mode 100644 index 0000000000..2f9b4a862e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/focusedRecordTableRowIndexComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const focusedRecordTableRowIndexComponentState = createComponentStateV2< + number | null +>({ + key: 'focusedRecordTableRowIndexComponentState', + defaultValue: null, + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableCellFocusActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableCellFocusActiveComponentState.ts new file mode 100644 index 0000000000..f332feb6de --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableCellFocusActiveComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const isRecordTableCellFocusActiveComponentState = + createComponentStateV2({ + key: 'isRecordTableCellFocusActiveComponentState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState.ts new file mode 100644 index 0000000000..21e9288d5d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; + +export const isRecordTableRowActiveComponentFamilyState = + createComponentFamilyStateV2({ + key: 'isRecordTableRowActiveComponentFamilyState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableFocusActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusActiveComponentState.ts similarity index 77% rename from packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableFocusActiveComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusActiveComponentState.ts index 36466471d9..5752e9c26a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableFocusActiveComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusActiveComponentState.ts @@ -1,9 +1,9 @@ import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; -export const isRecordTableFocusActiveComponentState = +export const isRecordTableRowFocusActiveComponentState = createComponentStateV2({ - key: 'isRecordTableFocusActiveComponentState', + key: 'isRecordTableRowFocusActiveComponentState', defaultValue: false, componentInstanceContext: RecordTableComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState.ts new file mode 100644 index 0000000000..aad8da42ba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; + +export const isRecordTableRowFocusedComponentFamilyState = + createComponentFamilyStateV2({ + key: 'isRecordTableRowFocusedComponentFamilyState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + });