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 e72f4118d..5b1120a75 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 775a4b4f7..d99712590 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 f67b3db68..fa47d4928 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 57edee8de..510694369 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 000000000..4eabda7f7 --- /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 4278883a8..339215d6c 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 8151628f5..68a89348d 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 b018f500f..0834c04b4 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 000000000..23643452d --- /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 8f3ad47a8..20283baf3 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 000000000..9a586a69c --- /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 000000000..c05921d24 --- /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 5d307d279..3d7fdb9ec 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 000000000..3c66d6033 --- /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 09cada125..43f8062f2 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 000000000..cab82d746 --- /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 e1a1f6f03..1363ff88c 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 54272d089..0fa9d56e3 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 98e7b90ab..08424ac9c 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 000000000..61afee3cc --- /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 c4f7a4eca..20c488cbf 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 50b5273fb..f649f0a5c 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 f48c05abb..48097b0e7 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 2243a41e6..3dc2cac90 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 881ef648c..355b63efa 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 4f7769ddb..329049d7d 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 f90bf23de..6015c3024 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 db5f3bee7..3bddfb32a 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 6461e3f1d..8cf482847 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 994b24269..f195ec216 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 5cdd9ce4e..b7d2a95e3 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 b5acdedf9..32c2007e5 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 000000000..54056f15b --- /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 a7aa53d9b..301580143 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 000000000..4b5dd9ebc --- /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 000000000..2f9b4a862 --- /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 000000000..f332feb6d --- /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 000000000..21e9288d5 --- /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 36466471d..5752e9c26 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 000000000..aad8da42b --- /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, + });