From e6cdae5c272c2666f7fc57a11c0fd7a788f08ccf Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:01:07 +0200 Subject: [PATCH] feat: add auto-scroll to selected menu items in CustomSlashMenu (#13048) to resolve the issue #13047 It's my first contribution, I hope it's good enough! When using the slash command (/) in the notes editor, a menu of commands appears. When navigating this menu with the up and down arrow keys, the selection changes, but the menu itself does not scroll. This means that as the list of commands is long, items outside the visible area cannot be seen when selected. The issue was located in the [CustomSlashMenu.tsx] component. The menu container didn't have vertical scrolling enabled, and there was no logic to scroll the active item into the visible area. The fix involved: Adding overflow-y: auto to the menu's styled container in [CustomSlashMenu.tsx]. Modifying the [DropdownMenuItemsContainer] component to accept and forward a ref using React.forwardRef. Implementing a useEffect hook in [CustomSlashMenu.tsx] that triggers on selection change. This hook uses a ref to the items container to call scrollIntoView({ block: 'nearest' }) on the currently selected menu item. --------- Co-authored-by: Lucas Bordeau --- .../components/RecordInlineCell.tsx | 10 +-- .../MultipleRecordPickerMenuItems.tsx | 6 -- .../ui/input/constants/SlashMenuListId.ts | 1 + .../editor/components/CustomSlashMenu.tsx | 77 ++++++++++--------- .../components/CustomSlashMenuListItem.tsx | 38 +++++++++ .../ui/input/editor/components/types.ts | 11 +++ .../components/SelectableListItem.tsx | 20 ++++- 7 files changed, 114 insertions(+), 49 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/input/constants/SlashMenuListId.ts create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/types.ts diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index 092119ec4..d8ac5328a 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -17,7 +17,7 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel import { isInlineCellInEditModeScopedState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState'; import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId'; -import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { useIcons } from 'twenty-ui/display'; @@ -129,17 +129,17 @@ export const RecordInlineCell = ({ const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( ({ snapshot }) => (persistField, event) => { - const currentFocusId = snapshot - .getLoadable(currentFocusIdSelector) + const currentDropdownFocusId = snapshot + .getLoadable(activeDropdownFocusIdState) .getValue(); - const expectedFocusId = getDropdownFocusIdForRecordField( + const expectedDropdownFocusId = getDropdownFocusIdForRecordField( recordId, fieldDefinition.fieldMetadataId, 'inline-cell', ); - if (currentFocusId !== expectedFocusId) { + if (currentDropdownFocusId !== expectedDropdownFocusId) { return; } event.preventDefault(); diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx index c424f98e1..1c515a69b 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx @@ -12,7 +12,6 @@ import { getMultipleRecordPickerSelectableListId } from '@/object-record/record- import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; -import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; @@ -39,10 +38,6 @@ export const MultipleRecordPickerMenuItems = ({ componentInstanceId, ); - const { resetSelectedItem } = useSelectableList( - selectableListComponentInstanceId, - ); - const multipleRecordPickerPickableMorphItemsState = useRecoilComponentCallbackStateV2( multipleRecordPickerPickableMorphItemsComponentState, @@ -107,7 +102,6 @@ export const MultipleRecordPickerMenuItems = ({ onChange={(morphItem) => { handleChange(morphItem); onChange?.(morphItem); - resetSelectedItem(); }} /> ); diff --git a/packages/twenty-front/src/modules/ui/input/constants/SlashMenuListId.ts b/packages/twenty-front/src/modules/ui/input/constants/SlashMenuListId.ts new file mode 100644 index 000000000..ac1e4f518 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/constants/SlashMenuListId.ts @@ -0,0 +1 @@ +export const SLASH_MENU_LIST_ID = 'editor-slash-menu-list'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx index aa360c8bc..080e2b8fe 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx @@ -1,42 +1,51 @@ -import type { SuggestionMenuProps } from '@blocknote/react'; import styled from '@emotion/styled'; +import { autoUpdate, useFloating } from '@floating-ui/react'; +import { motion } from 'framer-motion'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId'; +import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId'; +import { CustomSlashMenuListItem } from '@/ui/input/editor/components/CustomSlashMenuListItem'; +import { + CustomSlashMenuProps, + SuggestionItem, +} from '@/ui/input/editor/components/types'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; -import { autoUpdate, useFloating } from '@floating-ui/react'; -import { motion } from 'framer-motion'; -import { createPortal } from 'react-dom'; -import { IconComponent } from 'twenty-ui/display'; -import { MenuItemSuggestion } from 'twenty-ui/navigation'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { isDefined } from 'twenty-shared/utils'; -export type SuggestionItem = { - title: string; - onItemClick: () => void; - aliases?: string[]; - Icon?: IconComponent; -}; - -type CustomSlashMenuProps = SuggestionMenuProps; +export type { SuggestionItem }; const StyledContainer = styled.div` height: 1px; width: 1px; `; -const StyledInnerContainer = styled.div` - color: ${({ theme }) => theme.font.color.secondary}; - height: 250px; - width: 100%; -`; - -export const CustomSlashMenu = (props: CustomSlashMenuProps) => { +export const CustomSlashMenu = ({ + items, + selectedIndex, +}: CustomSlashMenuProps) => { const { refs, floatingStyles } = useFloating({ placement: 'bottom-start', whileElementsMounted: autoUpdate, }); + const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID); + + useEffect(() => { + if (!isDefined(selectedIndex)) return; + + const selectedItem = items[selectedIndex]; + + if (isDefined(selectedItem)) { + setSelectedItemId(selectedItem.title); + } + }, [items, selectedIndex, setSelectedItemId]); + return ( {createPortal( @@ -50,21 +59,19 @@ export const CustomSlashMenu = (props: CustomSlashMenuProps) => { style={floatingStyles} data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID} > - - - - {props.items.map((item, index) => ( - item.onItemClick()} - text={item.title} - LeftIcon={item.Icon} - selected={props.selectedIndex === index} - /> + + + item.title)} + > + {items.map((item) => ( + ))} - - - + + + , document.body, diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx new file mode 100644 index 000000000..7414a644a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx @@ -0,0 +1,38 @@ +import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId'; +import { SuggestionItem } from '@/ui/input/editor/components/types'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { MenuItemSuggestion } from 'twenty-ui/navigation'; + +export type CustomSlashMenuListItemProps = { + item: SuggestionItem; +}; + +export const CustomSlashMenuListItem = ({ + item, +}: CustomSlashMenuListItemProps) => { + const { resetSelectedItem } = useSelectableList(SLASH_MENU_LIST_ID); + + const isSelectedItem = useRecoilComponentFamilyValueV2( + isSelectedItemIdComponentFamilySelector, + item.title, + ); + + const handleClick = () => { + resetSelectedItem(); + item.onItemClick(); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/types.ts b/packages/twenty-front/src/modules/ui/input/editor/components/types.ts new file mode 100644 index 000000000..df22163b5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/types.ts @@ -0,0 +1,11 @@ +import type { SuggestionMenuProps } from '@blocknote/react'; +import { IconComponent } from 'twenty-ui/display'; + +export type SuggestionItem = { + title: string; + onItemClick: () => void; + aliases?: string[]; + Icon?: IconComponent; +}; + +export type CustomSlashMenuProps = SuggestionMenuProps; diff --git a/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableListItem.tsx b/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableListItem.tsx index 8dc5d04ea..9cc38d274 100644 --- a/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableListItem.tsx +++ b/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableListItem.tsx @@ -3,8 +3,17 @@ import { ReactNode, useEffect, useRef } from 'react'; import { SelectableListItemHotkeyEffect } from '@/ui/layout/selectable-list/components/SelectableListItemHotkeyEffect'; import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import styled from '@emotion/styled'; import { isDefined } from 'twenty-shared/utils'; +const StyledListItemContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + box-sizing: border-box; +`; + export type SelectableListItemProps = { itemId: string; children: ReactNode; @@ -21,11 +30,14 @@ export const SelectableListItem = ({ itemId, ); - const scrollRef = useRef(null); + const listItemRef = useRef(null); useEffect(() => { if (isSelectedItemId) { - scrollRef.current?.scrollIntoView({ block: 'nearest' }); + listItemRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); } }, [isSelectedItemId]); @@ -34,7 +46,9 @@ export const SelectableListItem = ({ {isSelectedItemId && isDefined(onEnter) && ( )} - {children} + + {children} + ); };