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 { isInlineCellInEditModeScopedState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState';
|
||||||
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
|
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
|
||||||
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
|
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 { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||||
import { useIcons } from 'twenty-ui/display';
|
import { useIcons } from 'twenty-ui/display';
|
||||||
@ -129,17 +129,17 @@ export const RecordInlineCell = ({
|
|||||||
const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
|
const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
|
||||||
({ snapshot }) =>
|
({ snapshot }) =>
|
||||||
(persistField, event) => {
|
(persistField, event) => {
|
||||||
const currentFocusId = snapshot
|
const currentDropdownFocusId = snapshot
|
||||||
.getLoadable(currentFocusIdSelector)
|
.getLoadable(activeDropdownFocusIdState)
|
||||||
.getValue();
|
.getValue();
|
||||||
|
|
||||||
const expectedFocusId = getDropdownFocusIdForRecordField(
|
const expectedDropdownFocusId = getDropdownFocusIdForRecordField(
|
||||||
recordId,
|
recordId,
|
||||||
fieldDefinition.fieldMetadataId,
|
fieldDefinition.fieldMetadataId,
|
||||||
'inline-cell',
|
'inline-cell',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentFocusId !== expectedFocusId) {
|
if (currentDropdownFocusId !== expectedDropdownFocusId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-
|
|||||||
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
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 { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
@ -39,10 +38,6 @@ export const MultipleRecordPickerMenuItems = ({
|
|||||||
componentInstanceId,
|
componentInstanceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { resetSelectedItem } = useSelectableList(
|
|
||||||
selectableListComponentInstanceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const multipleRecordPickerPickableMorphItemsState =
|
const multipleRecordPickerPickableMorphItemsState =
|
||||||
useRecoilComponentCallbackStateV2(
|
useRecoilComponentCallbackStateV2(
|
||||||
multipleRecordPickerPickableMorphItemsComponentState,
|
multipleRecordPickerPickableMorphItemsComponentState,
|
||||||
@ -107,7 +102,6 @@ export const MultipleRecordPickerMenuItems = ({
|
|||||||
onChange={(morphItem) => {
|
onChange={(morphItem) => {
|
||||||
handleChange(morphItem);
|
handleChange(morphItem);
|
||||||
onChange?.(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 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_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 { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||||
import { autoUpdate, useFloating } from '@floating-ui/react';
|
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||||
import { motion } from 'framer-motion';
|
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||||
import { createPortal } from 'react-dom';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { IconComponent } from 'twenty-ui/display';
|
|
||||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
|
||||||
|
|
||||||
export type SuggestionItem = {
|
export type { SuggestionItem };
|
||||||
title: string;
|
|
||||||
onItemClick: () => void;
|
|
||||||
aliases?: string[];
|
|
||||||
Icon?: IconComponent;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomSlashMenuProps = SuggestionMenuProps<SuggestionItem>;
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledInnerContainer = styled.div`
|
export const CustomSlashMenu = ({
|
||||||
color: ${({ theme }) => theme.font.color.secondary};
|
items,
|
||||||
height: 250px;
|
selectedIndex,
|
||||||
width: 100%;
|
}: CustomSlashMenuProps) => {
|
||||||
`;
|
|
||||||
|
|
||||||
export const CustomSlashMenu = (props: CustomSlashMenuProps) => {
|
|
||||||
const { refs, floatingStyles } = useFloating({
|
const { refs, floatingStyles } = useFloating({
|
||||||
placement: 'bottom-start',
|
placement: 'bottom-start',
|
||||||
whileElementsMounted: autoUpdate,
|
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 (
|
return (
|
||||||
<StyledContainer ref={refs.setReference}>
|
<StyledContainer ref={refs.setReference}>
|
||||||
{createPortal(
|
{createPortal(
|
||||||
@ -50,21 +59,19 @@ export const CustomSlashMenu = (props: CustomSlashMenuProps) => {
|
|||||||
style={floatingStyles}
|
style={floatingStyles}
|
||||||
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||||
>
|
>
|
||||||
<StyledInnerContainer>
|
|
||||||
<DropdownContent>
|
<DropdownContent>
|
||||||
<DropdownMenuItemsContainer>
|
<DropdownMenuItemsContainer hasMaxHeight>
|
||||||
{props.items.map((item, index) => (
|
<SelectableList
|
||||||
<MenuItemSuggestion
|
focusId={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||||
key={item.title}
|
selectableListInstanceId={SLASH_MENU_LIST_ID}
|
||||||
onClick={() => item.onItemClick()}
|
selectableItemIdArray={items.map((item) => item.title)}
|
||||||
text={item.title}
|
>
|
||||||
LeftIcon={item.Icon}
|
{items.map((item) => (
|
||||||
selected={props.selectedIndex === index}
|
<CustomSlashMenuListItem key={item.title} item={item} />
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
</SelectableList>
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
</DropdownContent>
|
</DropdownContent>
|
||||||
</StyledInnerContainer>
|
|
||||||
</OverlayContainer>
|
</OverlayContainer>
|
||||||
</motion.div>,
|
</motion.div>,
|
||||||
document.body,
|
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 { SelectableListItemHotkeyEffect } from '@/ui/layout/selectable-list/components/SelectableListItemHotkeyEffect';
|
||||||
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
||||||
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
|
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
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 = {
|
export type SelectableListItemProps = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -21,11 +30,14 @@ export const SelectableListItem = ({
|
|||||||
itemId,
|
itemId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const listItemRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelectedItemId) {
|
if (isSelectedItemId) {
|
||||||
scrollRef.current?.scrollIntoView({ block: 'nearest' });
|
listItemRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isSelectedItemId]);
|
}, [isSelectedItemId]);
|
||||||
|
|
||||||
@ -34,7 +46,9 @@ export const SelectableListItem = ({
|
|||||||
{isSelectedItemId && isDefined(onEnter) && (
|
{isSelectedItemId && isDefined(onEnter) && (
|
||||||
<SelectableListItemHotkeyEffect itemId={itemId} onEnter={onEnter} />
|
<SelectableListItemHotkeyEffect itemId={itemId} onEnter={onEnter} />
|
||||||
)}
|
)}
|
||||||
|
<StyledListItemContainer ref={listItemRef}>
|
||||||
{children}
|
{children}
|
||||||
|
</StyledListItemContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user