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:
committed by
GitHub
parent
7187e77b34
commit
e6cdae5c27
@ -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();
|
||||
|
||||
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const SLASH_MENU_LIST_ID = 'editor-slash-menu-list';
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user