Add animations on widget buttons and on action buttons (#15631)

Animated:
- Grip
- Trash can
- Action buttons


https://github.com/user-attachments/assets/1cac7a5e-2036-4308-9622-3b4809ae90a3
This commit is contained in:
Raphaël Bosi
2025-11-06 17:38:05 +01:00
committed by GitHub
parent 137aba049d
commit 931d12c77b
8 changed files with 152 additions and 69 deletions
@@ -1,13 +1,46 @@
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useContext } from 'react';
const StyledActionContainer = styled(motion.div)`
display: flex;
align-items: center;
justify-content: center;
`;
export const PageHeaderActionMenuButtons = () => {
const { actions } = useContext(ActionMenuContext);
const theme = useTheme();
const pinnedActions = actions.filter((entry) => entry.isPinned);
return pinnedActions.map((action) => (
<ActionComponent key={action.key} action={action} />
));
const actionsWithPositionForAnimation = pinnedActions.map(
(action, index) => ({
action,
position: pinnedActions.length - index - 1,
}),
);
return (
<>
{actionsWithPositionForAnimation.map(({ action, position }) => (
<StyledActionContainer
key={position}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<ActionComponent action={action} />
</StyledActionContainer>
))}
</>
);
};
@@ -51,6 +51,7 @@ export const WidgetPlaceholder = () => {
isDragging={false}
>
<WidgetCardHeader
isWidgetCardHovered={false}
isInEditMode={isPageLayoutInEditMode}
title={t`Add Widget`}
isEmpty
@@ -12,7 +12,7 @@ import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/
import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { type MouseEvent } from 'react';
import { useState, type MouseEvent } from 'react';
import { IconLock } from 'twenty-ui/display';
import { PageLayoutType, type PageLayoutWidget } from '~/generated/graphql';
@@ -61,6 +61,16 @@ export const WidgetRenderer = ({
deletePageLayoutWidget(widget.id);
};
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return (
<WidgetCard
onClick={isPageLayoutInEditMode ? handleClick : undefined}
@@ -68,9 +78,12 @@ export const WidgetRenderer = ({
pageLayoutType={pageLayoutType}
layoutMode={layoutMode}
isEditing={isEditing}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{layoutMode !== 'canvas' && (
<WidgetCardHeader
isWidgetCardHovered={isHovered}
isInEditMode={isPageLayoutInEditMode}
title={widget.title}
onRemove={handleRemove}
@@ -15,6 +15,8 @@ export type WidgetCardProps = {
isEditing: boolean;
isDragging: boolean;
className?: string;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
const StyledWidgetCard = styled.div<{
@@ -61,12 +63,8 @@ const StyledWidgetCard = styled.div<{
!isEditing &&
css`
&:hover {
cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
border: 1px solid ${theme.border.color.strong};
.widget-card-remove-button {
display: flex !important;
}
cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
}
`}
@@ -101,12 +99,8 @@ const StyledWidgetCard = styled.div<{
!isEditing &&
css`
&:hover {
cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
border: 1px solid ${theme.border.color.strong};
.widget-card-remove-button {
display: flex !important;
}
cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
}
`}
@@ -142,6 +136,8 @@ export const WidgetCard = ({
isEditing,
isDragging,
className,
onMouseEnter,
onMouseLeave,
}: WidgetCardProps) => {
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
@@ -156,6 +152,8 @@ export const WidgetCard = ({
isEditing={isEditing}
isDragging={isDragging}
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</StyledWidgetCard>
@@ -1,3 +1,4 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
@@ -5,9 +6,11 @@ import { IconTrash, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { WidgetGrip } from '@/page-layout/widgets/widget-card/components/WidgetGrip';
import { AnimatePresence, motion } from 'framer-motion';
import { isDefined } from 'twenty-shared/utils';
export type WidgetCardHeaderProps = {
isWidgetCardHovered: boolean;
isInEditMode: boolean;
isEmpty?: boolean;
title: string;
@@ -39,11 +42,14 @@ const StyledRightContainer = styled.div`
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledIconButton = styled(IconButton)`
display: none;
const StyledIconButtonContainer = styled(motion.div)`
display: flex;
align-items: center;
justify-content: center;
`;
export const WidgetCardHeader = ({
isWidgetCardHovered = false,
isEmpty = false,
isInEditMode = false,
title,
@@ -51,28 +57,43 @@ export const WidgetCardHeader = ({
forbiddenDisplay,
className,
}: WidgetCardHeaderProps) => {
const theme = useTheme();
return (
<StyledWidgetCardHeader className={className}>
{!isEmpty && isInEditMode && (
<WidgetGrip
className="drag-handle"
onClick={(e) => e.stopPropagation()}
/>
)}
<AnimatePresence>
{!isEmpty && isInEditMode && (
<WidgetGrip
className="drag-handle"
onClick={(e) => e.stopPropagation()}
/>
)}
</AnimatePresence>
<StyledTitleContainer>
<OverflowingTextWithTooltip text={isEmpty ? t`Add Widget` : title} />
</StyledTitleContainer>
<StyledRightContainer>
{isDefined(forbiddenDisplay) && forbiddenDisplay}
{!isEmpty && isInEditMode && onRemove && (
<StyledIconButton
onClick={onRemove}
Icon={IconTrash}
variant="tertiary"
size="small"
className="widget-card-remove-button"
/>
)}
<AnimatePresence>
{!isEmpty && isInEditMode && onRemove && isWidgetCardHovered && (
<StyledIconButtonContainer
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<IconButton
onClick={onRemove}
Icon={IconTrash}
variant="tertiary"
size="small"
/>
</StyledIconButtonContainer>
)}
</AnimatePresence>
</StyledRightContainer>
</StyledWidgetCardHeader>
);
@@ -1,8 +1,9 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { IconGripVertical } from 'twenty-ui/display';
const StyledGripContainer = styled.div`
const StyledGripContainer = styled(motion.div)`
width: 20px;
height: 20px;
display: flex;
@@ -32,7 +33,18 @@ export const WidgetGrip = ({ className, onClick }: WidgetGripProps) => {
const theme = useTheme();
return (
<StyledGripContainer className={className} onClick={onClick}>
<StyledGripContainer
layout
className={className}
onClick={onClick}
initial={{ width: 0, opacity: 0 }}
animate={{ width: 20, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<IconGripVertical
size={theme.icon.size.sm}
color={theme.font.color.extraLight}
@@ -84,6 +84,7 @@ export const Default: Story = {
isDragging={args.isDragging}
>
<WidgetCardHeader
isWidgetCardHovered={false}
isInEditMode={true}
onRemove={() => {}}
title="Widget name"
@@ -189,6 +190,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetCard> = {
isInEditMode={!isReadMode}
onRemove={!isReadMode ? () => {} : undefined}
title="Widget name"
isWidgetCardHovered={args.state === 'Hover'}
/>
<WidgetCardContent
pageLayoutType={pageLayoutType}
@@ -9,6 +9,7 @@ import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constan
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { AnimatePresence } from 'framer-motion';
import {
type IconComponent,
IconX,
@@ -100,43 +101,45 @@ export const PageHeader = ({
);
return (
<StyledTopBarContainer className={className} isMobile={isMobile}>
<StyledLeftContainer>
{!isMobile && !isNavigationDrawerExpanded && (
<NavigationDrawerCollapseButton direction="right" />
)}
{hasClosePageButton && (
<LightIconButton
Icon={IconX}
size="small"
accent="tertiary"
onClick={() => onClosePage?.()}
/>
)}
<StyledTopBarIconStyledTitleContainer>
{Icon && (
<StyledIconContainer>
<Icon size={theme.icon.size.md} />
</StyledIconContainer>
<AnimatePresence initial={false}>
<StyledTopBarContainer className={className} isMobile={isMobile}>
<StyledLeftContainer>
{!isMobile && !isNavigationDrawerExpanded && (
<NavigationDrawerCollapseButton direction="right" />
)}
{title && (
<StyledTitleContainer data-testid="top-bar-title">
{typeof title === 'string' ? (
<OverflowingTextWithTooltip text={title} />
) : (
title
)}
</StyledTitleContainer>
{hasClosePageButton && (
<LightIconButton
Icon={IconX}
size="small"
accent="tertiary"
onClick={() => onClosePage?.()}
/>
)}
</StyledTopBarIconStyledTitleContainer>
</StyledLeftContainer>
<StyledPageActionContainer
data-click-outside-id={PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID}
>
{children}
</StyledPageActionContainer>
</StyledTopBarContainer>
<StyledTopBarIconStyledTitleContainer>
{Icon && (
<StyledIconContainer>
<Icon size={theme.icon.size.md} />
</StyledIconContainer>
)}
{title && (
<StyledTitleContainer data-testid="top-bar-title">
{typeof title === 'string' ? (
<OverflowingTextWithTooltip text={title} />
) : (
title
)}
</StyledTitleContainer>
)}
</StyledTopBarIconStyledTitleContainer>
</StyledLeftContainer>
<StyledPageActionContainer
data-click-outside-id={PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID}
>
{children}
</StyledPageActionContainer>
</StyledTopBarContainer>
</AnimatePresence>
);
};