From 83b800a0772eefbed410e7c3cf8b2a1919311517 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Fri, 13 Feb 2026 12:41:25 +0100 Subject: [PATCH] Drag and drop fields of Fields widgets (#17910) https://github.com/user-attachments/assets/97fad3f3-8654-4216-b58c-b7411119c8ce --- .../ChartManualSortSubMenuContent.tsx | 2 +- .../RecordGroupMenuItemDraggable.tsx | 2 +- .../components/FieldsConfigurationEditor.tsx | 186 ++++++++++++++++-- .../FieldsConfigurationFieldEditor.tsx | 6 +- .../FieldsConfigurationSectionEditor.tsx | 147 +++++++++----- .../ViewFieldsVisibleDropdownSection.tsx | 4 +- .../host/generated/host-component-registry.ts | 22 +++ .../remote/generated/remote-components.ts | 10 + .../remote/generated/remote-elements.ts | 53 ++++- .../display/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/display/index.ts | 1 + packages/twenty-ui/src/navigation/index.ts | 9 + .../components/MenuItemDraggable.tsx | 14 +- .../__stories__/MenuItemDraggable.stories.tsx | 15 +- .../internals/components/MenuItemIcon.tsx | 30 +++ .../components/MenuItemIconBoxContainer.tsx | 12 ++ .../components/MenuItemIconWithGripSwap.tsx | 63 ++++++ .../components/MenuItemLeftContent.tsx | 69 ++++--- .../components/StyledMenuItemBase.tsx | 9 + .../types/MenuItemDraggableGripMode.ts | 1 + 20 files changed, 543 insertions(+), 113 deletions(-) create mode 100644 packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIcon.tsx create mode 100644 packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconBoxContainer.tsx create mode 100644 packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconWithGripSwap.tsx create mode 100644 packages/twenty-ui/src/navigation/menu/menu-item/types/MenuItemDraggableGripMode.ts diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx index 9eee99f2d3..374ca103de 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx @@ -101,7 +101,7 @@ export const ChartManualSortSubMenuContent = ({ isDragDisabled={sortedOptions.length === 1} itemComponent={ ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx index 08501f2236..81b4d2d64e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx @@ -1,10 +1,25 @@ +import styled from '@emotion/styled'; +import { + DragDropContext, + Draggable, + Droppable, + type DropResult, +} from '@hello-pangea/dnd'; + import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { type FieldsConfiguration, + type FieldsConfigurationFieldItem, type FieldsConfigurationSection, } from '@/page-layout/types/FieldsConfiguration'; import { FieldsConfigurationSectionEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationSectionEditor'; +const StyledSectionsDroppable = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + type FieldsConfigurationEditorProps = { configuration: FieldsConfiguration; onChange: (configuration: FieldsConfiguration) => void; @@ -30,6 +45,131 @@ export const FieldsConfigurationEditor = ({ }); }; + const handleDragEnd = (result: DropResult) => { + const { source, destination, type } = result; + + if (!destination) { + return; + } + + if ( + source.droppableId === destination.droppableId && + source.index === destination.index + ) { + return; + } + + if (type === 'SECTION') { + handleSectionReorder(source.index, destination.index); + } else if (type === 'FIELD') { + handleFieldMove( + source.droppableId, + destination.droppableId, + source.index, + destination.index, + ); + } + }; + + const handleSectionReorder = ( + sourceIndex: number, + destinationIndex: number, + ) => { + const sortedSections = [...configuration.sections].sort( + (a, b) => a.position - b.position, + ); + + const [movedSection] = sortedSections.splice(sourceIndex, 1); + sortedSections.splice(destinationIndex, 0, movedSection); + + const updatedSections = sortedSections.map((section, index) => ({ + ...section, + position: index, + })); + + onChange({ + ...configuration, + sections: updatedSections, + }); + }; + + const handleFieldMove = ( + sourceSectionId: string, + destinationSectionId: string, + sourceIndex: number, + destinationIndex: number, + ) => { + const sourceSection = configuration.sections.find( + (s) => `section-${s.id}` === sourceSectionId, + ); + const destinationSection = configuration.sections.find( + (s) => `section-${s.id}` === destinationSectionId, + ); + + if (!sourceSection || !destinationSection) { + return; + } + + const sourceSortedFields = [...sourceSection.fields].sort( + (a, b) => a.position - b.position, + ); + + if (sourceSectionId === destinationSectionId) { + // Reorder within the same section + const [movedField] = sourceSortedFields.splice(sourceIndex, 1); + sourceSortedFields.splice(destinationIndex, 0, movedField); + + const updatedFields = sourceSortedFields.map((field, index) => ({ + ...field, + position: index, + })); + + handleSectionChange(sourceSection.id, { + ...sourceSection, + fields: updatedFields, + }); + } else { + // Move field between sections + const [movedField] = sourceSortedFields.splice(sourceIndex, 1); + + const destinationSortedFields = [...destinationSection.fields].sort( + (a, b) => a.position - b.position, + ); + destinationSortedFields.splice(destinationIndex, 0, movedField); + + const updatedSourceFields = sourceSortedFields.map( + (field, index) => + ({ + ...field, + position: index, + }) satisfies FieldsConfigurationFieldItem, + ); + + const updatedDestinationFields = destinationSortedFields.map( + (field, index) => + ({ + ...field, + position: index, + }) satisfies FieldsConfigurationFieldItem, + ); + + const updatedSections = configuration.sections.map((section) => { + if (section.id === sourceSection.id) { + return { ...section, fields: updatedSourceFields }; + } + if (section.id === destinationSection.id) { + return { ...section, fields: updatedDestinationFields }; + } + return section; + }); + + onChange({ + ...configuration, + sections: updatedSections, + }); + } + }; + const sortedSections = [...configuration.sections].sort( (a, b) => a.position - b.position, ); @@ -39,18 +179,38 @@ export const FieldsConfigurationEditor = ({ } return ( - <> - {sortedSections.map((section, index) => ( - - handleSectionChange(section.id, updatedSection) - } - /> - ))} - + + + {(provided) => ( + + {sortedSections.map((section, index) => ( + + {(draggableProvided, snapshot) => ( + + handleSectionChange(section.id, updatedSection) + } + draggableProvided={draggableProvided} + isDragging={snapshot.isDragging} + /> + )} + + ))} + {provided.placeholder} + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor.tsx index f51f37d10c..cb6abdae07 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor.tsx @@ -6,7 +6,6 @@ import { MenuItemDraggable } from 'twenty-ui/navigation'; type FieldsConfigurationFieldEditorProps = { field: FieldsConfigurationFieldItem; fieldMetadata: FieldMetadataItem; - index: number; onToggleVisibility: () => void; }; @@ -16,14 +15,15 @@ export const FieldsConfigurationFieldEditor = ({ onToggleVisibility, }: FieldsConfigurationFieldEditorProps) => { const { getIcon } = useIcons(); - const isVisible = field.isVisible !== false; + const isVisible = field.conditionalDisplay !== false; const FieldIcon = getIcon(fieldMetadata.icon); return ( ` + background: ${({ isDragging, theme }) => + isDragging ? theme.background.primary : 'transparent'}; + border: 1px solid + ${({ isDragging, theme }) => + isDragging ? theme.color.blue : 'transparent'}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex-direction: column; + width: 100%; +`; + type FieldsConfigurationSectionEditorProps = { section: FieldsConfigurationSection; index: number; objectMetadataItem: ObjectMetadataItem; onSectionChange: (section: FieldsConfigurationSection) => void; + draggableProvided: DraggableProvided; + isDragging: boolean; }; export const FieldsConfigurationSectionEditor = ({ section, objectMetadataItem, onSectionChange, + draggableProvided, + isDragging, }: FieldsConfigurationSectionEditorProps) => { - const [isExpanded, setIsExpanded] = useState(true); + const { t } = useLingui(); const handleFieldChange = ( fieldMetadataId: string, @@ -51,11 +71,9 @@ export const FieldsConfigurationSectionEditor = ({ return; } - const currentlyVisible = field.isVisible !== false; - const updatedField: FieldsConfigurationFieldItem = { ...field, - isVisible: !currentlyVisible, + conditionalDisplay: false, }; handleFieldChange(fieldMetadataId, updatedField); @@ -66,47 +84,78 @@ export const FieldsConfigurationSectionEditor = ({ ); return ( - <> - { - e.stopPropagation(); - // TODO: Add section menu + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ { + e.stopPropagation(); + // TODO: Add section menu + }, }, - }, - ]} - onClick={() => setIsExpanded(!isExpanded)} - hasSubMenu - isSubMenuOpened={isExpanded} + ]} + /> +
+ + + {(droppableProvided) => ( + + {sortedFields.map((field, fieldIndex) => { + const fieldMetadata = objectMetadataItem.fields.find( + (f) => f.id === field.fieldMetadataId, + ); + + if (!fieldMetadata) { + return null; + } + + return ( + + handleToggleFieldVisibility(field.fieldMetadataId) + } + /> + } + /> + ); + })} + {droppableProvided.placeholder} + + )} + + + { + // TODO: Implement add section + }} /> - {isExpanded && ( - - {sortedFields.map((field, fieldIndex) => { - const fieldMetadata = objectMetadataItem.fields.find( - (f) => f.id === field.fieldMetadataId, - ); - - if (!fieldMetadata) { - return null; - } - - return ( - - handleToggleFieldVisibility(field.fieldMetadataId) - } - /> - ); - })} - - )} - +
); }; diff --git a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx index aaaed9fc77..c654011a6b 100644 --- a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibleDropdownSection.tsx @@ -89,7 +89,7 @@ export const ViewFieldsVisibleDropdownSection = () => { LeftIcon={getIcon(fieldMetadataItemLabelIdentifier.icon)} text={fieldMetadataItemLabelIdentifier.label} accent="placeholder" - showGrip={true} + gripMode="always" isDragDisabled /> )} @@ -131,7 +131,7 @@ export const ViewFieldsVisibleDropdownSection = () => { }, ]} text={fieldMetadataItem.label} - showGrip + gripMode="always" /> } /> diff --git a/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts b/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts index 62f08ff846..3675673ef1 100644 --- a/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts +++ b/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts @@ -161,6 +161,8 @@ import { MenuItemSelectTag, MenuItemSuggestion, MenuItemToggle, + MenuItemIcon, + MenuItemIconWithGripSwap, MenuItemLeftContent, NavigationBar, NavigationBarItem, @@ -188,6 +190,8 @@ import { type MenuItemSelectTagProps, type MenuItemSuggestionProps, type MenuItemToggleProps, + type MenuItemIconProps, + type MenuItemIconWithGripSwapProps, type MenuItemLeftContentProps, type NavigationBarProps, type NavigationBarItemProps, @@ -1212,6 +1216,16 @@ const TwentyUiMenuItemToggleWrapper = ( ) => { return React.createElement(MenuItemToggle, filterUiProps(props)); }; +const TwentyUiMenuItemIconWrapper = ( + props: MenuItemIconProps & { children?: React.ReactNode }, +) => { + return React.createElement(MenuItemIcon, filterUiProps(props)); +}; +const TwentyUiMenuItemIconWithGripSwapWrapper = ( + props: MenuItemIconWithGripSwapProps & { children?: React.ReactNode }, +) => { + return React.createElement(MenuItemIconWithGripSwap, filterUiProps(props)); +}; const TwentyUiMenuItemLeftContentWrapper = ( props: MenuItemLeftContentProps & { children?: React.ReactNode }, ) => { @@ -1546,6 +1560,14 @@ export const componentRegistry: Map = new Map([ 'twenty-ui-menu-item-toggle', createRemoteComponentRenderer(TwentyUiMenuItemToggleWrapper), ], + [ + 'twenty-ui-menu-item-icon', + createRemoteComponentRenderer(TwentyUiMenuItemIconWrapper), + ], + [ + 'twenty-ui-menu-item-icon-with-grip-swap', + createRemoteComponentRenderer(TwentyUiMenuItemIconWithGripSwapWrapper), + ], [ 'twenty-ui-menu-item-left-content', createRemoteComponentRenderer(TwentyUiMenuItemLeftContentWrapper), diff --git a/packages/twenty-sdk/src/front-component/remote/generated/remote-components.ts b/packages/twenty-sdk/src/front-component/remote/generated/remote-components.ts index b9dc8fc301..cdfd740496 100644 --- a/packages/twenty-sdk/src/front-component/remote/generated/remote-components.ts +++ b/packages/twenty-sdk/src/front-component/remote/generated/remote-components.ts @@ -132,6 +132,8 @@ import { TwentyUiMenuItemSelectTagElement, TwentyUiMenuItemSuggestionElement, TwentyUiMenuItemToggleElement, + TwentyUiMenuItemIconElement, + TwentyUiMenuItemIconWithGripSwapElement, TwentyUiMenuItemLeftContentElement, TwentyUiNavigationBarElement, TwentyUiNavigationBarItemElement, @@ -1635,6 +1637,14 @@ export const TwentyUiMenuItemToggle = createRemoteComponent( 'twenty-ui-menu-item-toggle', TwentyUiMenuItemToggleElement, ); +export const TwentyUiMenuItemIcon = createRemoteComponent( + 'twenty-ui-menu-item-icon', + TwentyUiMenuItemIconElement, +); +export const TwentyUiMenuItemIconWithGripSwap = createRemoteComponent( + 'twenty-ui-menu-item-icon-with-grip-swap', + TwentyUiMenuItemIconWithGripSwapElement, +); export const TwentyUiMenuItemLeftContent = createRemoteComponent( 'twenty-ui-menu-item-left-content', TwentyUiMenuItemLeftContentElement, diff --git a/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts b/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts index 36394538dc..2361bad102 100644 --- a/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts +++ b/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts @@ -6679,7 +6679,7 @@ export type TwentyUiMenuItemDraggableProperties = { onClick?: (...args: unknown[]) => unknown; className?: string; isIconDisplayedOnHoverOnly?: boolean; - showGrip?: boolean; + gripMode?: string; isDragDisabled?: boolean; isHoverDisabled?: boolean; }; @@ -6699,7 +6699,7 @@ export const TwentyUiMenuItemDraggableElement = createRemoteElement< onClick: { type: Function }, className: { type: String }, isIconDisplayedOnHoverOnly: { type: Boolean }, - showGrip: { type: Boolean }, + gripMode: { type: String }, isDragDisabled: { type: Boolean }, isHoverDisabled: { type: Boolean }, }, @@ -7000,10 +7000,44 @@ export const TwentyUiMenuItemToggleElement = createRemoteElement< }, }); +export type TwentyUiMenuItemIconProperties = { + withContainer?: boolean; +}; + +export const TwentyUiMenuItemIconElement = createRemoteElement< + TwentyUiMenuItemIconProperties, + Record, + { Icon: true }, + Record +>({ + slots: ['Icon'], + properties: { + withContainer: { type: Boolean }, + }, +}); + +export type TwentyUiMenuItemIconWithGripSwapProperties = { + withIconContainer?: boolean; + gripIconColor: string; +}; + +export const TwentyUiMenuItemIconWithGripSwapElement = createRemoteElement< + TwentyUiMenuItemIconWithGripSwapProperties, + Record, + { LeftIcon: true }, + Record +>({ + slots: ['LeftIcon'], + properties: { + withIconContainer: { type: Boolean }, + gripIconColor: { type: String }, + }, +}); + export type TwentyUiMenuItemLeftContentProperties = { className?: string; withIconContainer?: boolean; - showGrip?: boolean; + gripMode?: string; disabled?: boolean; contextualTextPosition?: string; }; @@ -7018,7 +7052,7 @@ export const TwentyUiMenuItemLeftContentElement = createRemoteElement< properties: { className: { type: String }, withIconContainer: { type: Boolean }, - showGrip: { type: Boolean }, + gripMode: { type: String }, disabled: { type: Boolean }, contextualTextPosition: { type: String }, }, @@ -7314,6 +7348,11 @@ customElements.define( 'twenty-ui-menu-item-toggle', TwentyUiMenuItemToggleElement, ); +customElements.define('twenty-ui-menu-item-icon', TwentyUiMenuItemIconElement); +customElements.define( + 'twenty-ui-menu-item-icon-with-grip-swap', + TwentyUiMenuItemIconWithGripSwapElement, +); customElements.define( 'twenty-ui-menu-item-left-content', TwentyUiMenuItemLeftContentElement, @@ -7531,6 +7570,12 @@ declare global { 'twenty-ui-menu-item-toggle': InstanceType< typeof TwentyUiMenuItemToggleElement >; + 'twenty-ui-menu-item-icon': InstanceType< + typeof TwentyUiMenuItemIconElement + >; + 'twenty-ui-menu-item-icon-with-grip-swap': InstanceType< + typeof TwentyUiMenuItemIconWithGripSwapElement + >; 'twenty-ui-menu-item-left-content': InstanceType< typeof TwentyUiMenuItemLeftContentElement >; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index b3c6fcc1d1..39931ee16a 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -264,6 +264,7 @@ export { IconMoon, IconMouse2, IconNorthStar, + IconNewSection, IconNoteOff, IconNotes, IconNumber, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 03f4059210..98a4b91b0f 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -371,6 +371,7 @@ export { IconMoon, IconMouse2, IconNorthStar, + IconNewSection, IconNoteOff, IconNotes, IconNumber, diff --git a/packages/twenty-ui/src/navigation/index.ts b/packages/twenty-ui/src/navigation/index.ts index 5a11857174..e5bf7bbc9b 100644 --- a/packages/twenty-ui/src/navigation/index.ts +++ b/packages/twenty-ui/src/navigation/index.ts @@ -65,6 +65,14 @@ export type { MenuItemSuggestionProps } from './menu/menu-item/components/MenuIt export { MenuItemSuggestion } from './menu/menu-item/components/MenuItemSuggestion'; export type { MenuItemToggleProps } from './menu/menu-item/components/MenuItemToggle'; export { MenuItemToggle } from './menu/menu-item/components/MenuItemToggle'; +export type { MenuItemIconProps } from './menu/menu-item/internals/components/MenuItemIcon'; +export { MenuItemIcon } from './menu/menu-item/internals/components/MenuItemIcon'; +export { + StyledIconContainer, + MenuItemIconBoxContainer, +} from './menu/menu-item/internals/components/MenuItemIconBoxContainer'; +export type { MenuItemIconWithGripSwapProps } from './menu/menu-item/internals/components/MenuItemIconWithGripSwap'; +export { MenuItemIconWithGripSwap } from './menu/menu-item/internals/components/MenuItemIconWithGripSwap'; export type { MenuItemLeftContentProps } from './menu/menu-item/internals/components/MenuItemLeftContent'; export { MenuItemLeftContent } from './menu/menu-item/internals/components/MenuItemLeftContent'; export type { MenuItemBaseProps } from './menu/menu-item/internals/components/StyledMenuItemBase'; @@ -82,6 +90,7 @@ export { StyledRightMenuItemContextualText, } from './menu/menu-item/internals/components/StyledMenuItemBase'; export type { MenuItemAccent } from './menu/menu-item/types/MenuItemAccent'; +export type { MenuItemDraggableGripMode } from './menu/menu-item/types/MenuItemDraggableGripMode'; export type { NavigationBarProps } from './navigation-bar/components/NavigationBar'; export { NavigationBar } from './navigation-bar/components/NavigationBar'; export type { NavigationBarItemProps } from './navigation-bar/components/NavigationBarItem'; diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemDraggable.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemDraggable.tsx index 5984f31f48..976e846451 100644 --- a/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemDraggable.tsx +++ b/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemDraggable.tsx @@ -5,6 +5,7 @@ import { type MenuItemAccent } from '../types/MenuItemAccent'; import { type IconComponent } from '@ui/display'; import { LightIconButtonGroup } from '@ui/input'; import { type ReactNode } from 'react'; +import { type MenuItemDraggableGripMode } from '../types/MenuItemDraggableGripMode'; import { type MenuItemIconButton } from './MenuItem'; export type MenuItemDraggableProps = { @@ -17,7 +18,7 @@ export type MenuItemDraggableProps = { text: ReactNode; className?: string; isIconDisplayedOnHoverOnly?: boolean; - showGrip?: boolean; + gripMode?: MenuItemDraggableGripMode; isDragDisabled?: boolean; isHoverDisabled?: boolean; }; @@ -32,15 +33,12 @@ export const MenuItemDraggable = ({ isDragDisabled = false, className, isIconDisplayedOnHoverOnly = true, - showGrip = false, + gripMode = 'never', }: MenuItemDraggableProps) => { const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0; - const cursorType = showGrip - ? isDragDisabled - ? 'default' - : 'drag' - : 'default'; + const cursorType = + gripMode !== 'never' ? (isDragDisabled ? 'default' : 'drag') : 'default'; return ( {showIconButtons && ( { + const theme = useTheme(); + + if (!Icon) { + return null; + } + + const iconElement = ( + + ); + + if (withContainer) { + return {iconElement}; + } + + return iconElement; +}; diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconBoxContainer.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconBoxContainer.tsx new file mode 100644 index 0000000000..2583ab24e7 --- /dev/null +++ b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconBoxContainer.tsx @@ -0,0 +1,12 @@ +import styled from '@emotion/styled'; + +export const StyledIconContainer = styled.div` + align-items: flex-start; + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +export { StyledIconContainer as MenuItemIconBoxContainer }; diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconWithGripSwap.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconWithGripSwap.tsx new file mode 100644 index 0000000000..42b3552db5 --- /dev/null +++ b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemIconWithGripSwap.tsx @@ -0,0 +1,63 @@ +import { useTheme } from '@emotion/react'; + +import styled from '@emotion/styled'; +import { type IconComponent, IconGripVertical } from '@ui/display'; +import { MenuItemIconBoxContainer } from './MenuItemIconBoxContainer'; + +const StyledIconSwapContainer = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: center; +`; + +const StyledDefaultIcon = styled.div` + display: flex; + transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease; +`; + +const StyledHoverIcon = styled.div` + position: absolute; + display: flex; + opacity: 0; + transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease; +`; + +export type MenuItemIconWithGripSwapProps = { + LeftIcon: IconComponent | null | undefined; + withIconContainer?: boolean; + gripIconColor: string; +}; + +export const MenuItemIconWithGripSwap = ({ + LeftIcon, + withIconContainer = false, + gripIconColor, +}: MenuItemIconWithGripSwapProps) => { + const theme = useTheme(); + + if (!LeftIcon) { + return null; + } + + const iconContent = ( + + + + + + + + + ); + + if (withIconContainer) { + return {iconContent}; + } + + return iconContent; +}; diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemLeftContent.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemLeftContent.tsx index 56e860518b..580149f9c5 100644 --- a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemLeftContent.tsx +++ b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/MenuItemLeftContent.tsx @@ -8,6 +8,10 @@ import { IconGripVertical, OverflowingTextWithTooltip, } from '@ui/display'; +import { type MenuItemDraggableGripMode } from '../../types/MenuItemDraggableGripMode'; +import { MenuItemIcon } from './MenuItemIcon'; +import { MenuItemIconBoxContainer } from './MenuItemIconBoxContainer'; +import { MenuItemIconWithGripSwap } from './MenuItemIconWithGripSwap'; import { StyledDraggableItem, StyledMenuItemContextualText, @@ -24,15 +28,6 @@ const StyledMainText = styled.div` max-width: 100%; `; -const StyledIconContainer = styled.div` - align-items: flex-start; - background: ${({ theme }) => theme.background.transparent.light}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - display: flex; - flex-direction: column; - padding: ${({ theme }) => theme.spacing(1)}; -`; - const StyledMenuItemLabelRight = styled(StyledMenuItemLabel)` margin-left: auto; `; @@ -42,7 +37,7 @@ export type MenuItemLeftContentProps = { LeftComponent?: ReactNode; LeftIcon: IconComponent | null | undefined; withIconContainer?: boolean; - showGrip?: boolean; + gripMode?: MenuItemDraggableGripMode; disabled?: boolean; text: ReactNode; contextualText?: ReactNode; @@ -57,36 +52,48 @@ export const MenuItemLeftContent = ({ text, contextualText, contextualTextPosition = 'left', - showGrip = false, + gripMode = 'never', disabled = false, }: MenuItemLeftContentProps) => { const theme = useTheme(); + const gripIconColor = withIconContainer + ? theme.font.color.tertiary + : disabled + ? theme.font.color.extraLight + : theme.font.color.light; + return ( - {showGrip && ( - - - - )} - {LeftIcon && + {gripMode === 'always' && (withIconContainer ? ( - - - + + + + + ) : ( - + + + ))} + {gripMode === 'onHover' ? ( + + ) : ( + + )} {LeftComponent} {isString(text) ? ( diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/StyledMenuItemBase.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/StyledMenuItemBase.tsx index a474a927c3..67463c5d88 100644 --- a/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/StyledMenuItemBase.tsx +++ b/packages/twenty-ui/src/navigation/menu/menu-item/internals/components/StyledMenuItemBase.tsx @@ -161,6 +161,15 @@ export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{ transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease; } + &:hover { + & .grip-swap-default-icon { + opacity: 0; + } + & .grip-swap-hover-icon { + opacity: 1; + } + } + cursor: ${({ cursor, disabled }) => { if (!isUndefined(disabled) && disabled !== false) { return 'default'; diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/types/MenuItemDraggableGripMode.ts b/packages/twenty-ui/src/navigation/menu/menu-item/types/MenuItemDraggableGripMode.ts new file mode 100644 index 0000000000..11b5df8a09 --- /dev/null +++ b/packages/twenty-ui/src/navigation/menu/menu-item/types/MenuItemDraggableGripMode.ts @@ -0,0 +1 @@ +export type MenuItemDraggableGripMode = 'always' | 'onHover' | 'never';