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 <bordeau.lucas@gmail.com>
This commit is contained in:
Jean-Baptiste Ronssin
2025-07-09 19:01:07 +02:00
committed by GitHub
parent 7187e77b34
commit e6cdae5c27
7 changed files with 114 additions and 49 deletions

View File

@ -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();

View File

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

View File

@ -0,0 +1 @@
export const SLASH_MENU_LIST_ID = 'editor-slash-menu-list';

View File

@ -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<SuggestionItem>;
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 (
<StyledContainer ref={refs.setReference}>
{createPortal(
@ -50,21 +59,19 @@ export const CustomSlashMenu = (props: CustomSlashMenuProps) => {
style={floatingStyles}
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<StyledInnerContainer>
<DropdownContent>
<DropdownMenuItemsContainer>
{props.items.map((item, index) => (
<MenuItemSuggestion
key={item.title}
onClick={() => item.onItemClick()}
text={item.title}
LeftIcon={item.Icon}
selected={props.selectedIndex === index}
/>
<DropdownContent>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
focusId={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
selectableListInstanceId={SLASH_MENU_LIST_ID}
selectableItemIdArray={items.map((item) => item.title)}
>
{items.map((item) => (
<CustomSlashMenuListItem key={item.title} item={item} />
))}
</DropdownMenuItemsContainer>
</DropdownContent>
</StyledInnerContainer>
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>,
document.body,

View File

@ -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 (
<SelectableListItem itemId={item.title} onEnter={handleClick}>
<MenuItemSuggestion
selected={isSelectedItem}
onClick={handleClick}
LeftIcon={item.Icon}
text={item.title}
/>
</SelectableListItem>
);
};

View File

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

View File

@ -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<HTMLDivElement>(null);
const listItemRef = useRef<HTMLDivElement>(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) && (
<SelectableListItemHotkeyEffect itemId={itemId} onEnter={onEnter} />
)}
{children}
<StyledListItemContainer ref={listItemRef}>
{children}
</StyledListItemContainer>
</>
);
};