From aeed1c9f15906adf89a90561c9fe379dbd1f9380 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 18 Feb 2025 18:07:11 +0100
Subject: [PATCH] 406 animate the command menu button (#10305)
Closes https://github.com/twentyhq/core-team-issues/issues/406
- Added animation on the Icon (The dots rotate and transform into an a
cross)
- Introduced a new component `AnimatedButton`. All the button styling
could be extracted to another file so we don't duplicate the code, but
since `AnimatedLightIconButton` duplicates the style from
`LightIconButton`, I did the same here.
- Added an animate presence component on the command menu to have a
smooth transition from `open` to `close` state
- Merged the open and close command menu button
- For all the pages that are not an index page or a record page, we want
the old behavior because there is no button in the page header to open
the command menu
# Before
https://github.com/user-attachments/assets/5ec7d9eb-9d8b-4838-af1b-c04382694342
# After
https://github.com/user-attachments/assets/f700deec-1c52-4afd-b294-f9ee7b9206e9
---
.../components/CommandMenuContainer.tsx | 33 +-
.../components/CommandMenuRouter.tsx | 16 +-
.../components/CommandMenuTopBar.tsx | 14 +-
.../PageHeaderOpenCommandMenuButton.tsx | 130 ++++-
.../button/components/AnimatedButton.tsx | 449 ++++++++++++++++++
packages/twenty-ui/src/input/index.ts | 1 +
6 files changed, 615 insertions(+), 28 deletions(-)
create mode 100644 packages/twenty-ui/src/input/button/components/AnimatedButton.tsx
diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx
index 3a646f947..60df5e421 100644
--- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx
+++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx
@@ -20,7 +20,7 @@ import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/wo
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
-import { motion } from 'framer-motion';
+import { AnimatePresence, motion } from 'framer-motion';
import { useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIsMobile } from 'twenty-ui';
@@ -65,6 +65,7 @@ export const CommandMenuContainer = ({
callback: closeCommandMenu,
listenerId: 'COMMAND_MENU_LISTENER_ID',
hotkeyScope: AppHotkeyScope.CommandMenuOpen,
+ excludeClassNames: ['page-header-command-menu-button'],
});
const isMobile = useIsMobile();
@@ -114,20 +115,22 @@ export const CommandMenuContainer = ({
)}
- {isCommandMenuOpened && (
-
- {children}
-
- )}
+
+ {isCommandMenuOpened && (
+
+ {children}
+
+ )}
+
diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx
index dcae344a6..570e2f34e 100644
--- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx
+++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx
@@ -2,7 +2,9 @@ import { CommandMenuContainer } from '@/command-menu/components/CommandMenuConta
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
+import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import { motion } from 'framer-motion';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
@@ -20,9 +22,21 @@ export const CommandMenuRouter = () => {
<>>
);
+ const theme = useTheme();
+
return (
-
+
+
+
{commandMenuPageComponent}
diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx
index 68033af5b..5848b6147 100644
--- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx
+++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx
@@ -16,6 +16,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import {
@@ -80,6 +81,10 @@ const StyledCloseButtonContainer = styled.div`
justify-content: center;
`;
+const StyledCloseButtonWrapper = styled.div<{ isVisible: boolean }>`
+ visibility: ${({ isVisible }) => (isVisible ? 'visible' : 'hidden')};
+`;
+
export const CommandMenuTopBar = () => {
const [commandMenuSearch, setCommandMenuSearch] = useRecoilState(
commandMenuSearchState,
@@ -123,6 +128,11 @@ export const CommandMenuTopBar = () => {
});
}, [commandMenuNavigationStack, theme.icon.size.sm]);
+ const location = useLocation();
+ const isButtonVisible =
+ !location.pathname.startsWith('/objects/') &&
+ !location.pathname.startsWith('/object/');
+
return (
@@ -162,7 +172,7 @@ export const CommandMenuTopBar = () => {
)}
{!isMobile && (
- <>
+
{isCommandMenuV2Enabled ? (
)}
);
diff --git a/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderOpenCommandMenuButton.tsx b/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderOpenCommandMenuButton.tsx
index d444f4738..8d2a51bd3 100644
--- a/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderOpenCommandMenuButton.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderOpenCommandMenuButton.tsx
@@ -1,5 +1,5 @@
import {
- Button,
+ AnimatedButton,
IconButton,
IconDotsVertical,
getOsControlSymbol,
@@ -7,11 +7,105 @@ import {
} from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
+import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { t } from '@lingui/core/macro';
+import { motion } from 'framer-motion';
+import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
+const StyledButtonWrapper = styled.div`
+ z-index: 30;
+`;
+
+const xPaths = {
+ topLeft: `M12 12 L6 6`,
+ topRight: `M12 12 L18 6`,
+ bottomLeft: `M12 12 L6 18`,
+ bottomRight: `M12 12 L18 18`,
+};
+
+const AnimatedIcon = ({
+ isCommandMenuOpened,
+}: {
+ isCommandMenuOpened: boolean;
+}) => {
+ const theme = useTheme();
+ return (
+
+ );
+};
+
export const PageHeaderOpenCommandMenuButton = () => {
- const { openRootCommandMenu } = useCommandMenu();
+ const { toggleCommandMenu } = useCommandMenu();
+ const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
@@ -19,18 +113,34 @@ export const PageHeaderOpenCommandMenuButton = () => {
const isMobile = useIsMobile();
+ const ariaLabel = isCommandMenuOpened
+ ? t`Close command menu`
+ : t`Open command menu`;
+
+ const theme = useTheme();
+
return (
- <>
+
{isCommandMenuV2Enabled ? (
-
+ }
+ className="page-header-command-menu-button"
+ dataTestId="page-header-command-menu-button"
size={isMobile ? 'medium' : 'small'}
variant="secondary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
- ariaLabel="Open command menu"
- onClick={openRootCommandMenu}
+ ariaLabel={ariaLabel}
+ onClick={toggleCommandMenu}
+ animate={{
+ rotate: isCommandMenuOpened ? 90 : 0,
+ }}
+ transition={{
+ duration: theme.animation.duration.normal,
+ ease: 'easeInOut',
+ }}
/>
) : (
{
dataTestId="more-showpage-button"
accent="default"
variant="secondary"
- onClick={openRootCommandMenu}
+ onClick={toggleCommandMenu}
/>
)}
- >
+
);
};
diff --git a/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx b/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx
new file mode 100644
index 000000000..d0ee7e7f1
--- /dev/null
+++ b/packages/twenty-ui/src/input/button/components/AnimatedButton.tsx
@@ -0,0 +1,449 @@
+import { css, useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { Pill } from '@ui/components/Pill/Pill';
+import { useIsMobile } from '@ui/utilities';
+import { getOsShortcutSeparator } from '@ui/utilities/device/getOsShortcutSeparator';
+import { MotionProps, motion } from 'framer-motion';
+import { Link } from 'react-router-dom';
+
+import { ButtonAccent, ButtonProps, ButtonSize, ButtonVariant } from './Button';
+
+export type AnimatedButtonProps = ButtonProps &
+ Pick & {
+ animatedSvg: React.ReactNode;
+ };
+
+const StyledButton = styled.button<
+ Pick<
+ ButtonProps,
+ | 'fullWidth'
+ | 'variant'
+ | 'inverted'
+ | 'size'
+ | 'position'
+ | 'accent'
+ | 'focus'
+ | 'justify'
+ | 'to'
+ | 'target'
+ >
+>`
+ align-items: center;
+ ${({ theme, variant, inverted, accent, disabled, focus }) => {
+ switch (variant) {
+ case 'primary':
+ switch (accent) {
+ case 'default':
+ return css`
+ background: ${!inverted
+ ? theme.background.secondary
+ : theme.background.primary};
+ border-color: ${!inverted
+ ? !disabled && focus
+ ? theme.color.blue
+ : theme.background.transparent.light
+ : theme.background.transparent.light};
+ border-width: 1px 1px 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.accent.tertiary
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted
+ ? !disabled
+ ? theme.font.color.secondary
+ : theme.font.color.extraLight
+ : theme.font.color.secondary};
+ &:hover {
+ background: ${!inverted
+ ? theme.background.tertiary
+ : theme.background.secondary};
+ }
+ &:active {
+ background: ${!inverted
+ ? theme.background.quaternary
+ : theme.background.tertiary};
+ }
+ `;
+ case 'blue':
+ return css`
+ background: ${!inverted
+ ? theme.color.blue
+ : theme.background.primary};
+ border-color: ${!inverted
+ ? focus
+ ? theme.color.blue
+ : theme.background.transparent.light
+ : theme.background.transparent.light};
+ border-width: 1px 1px 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.accent.tertiary
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted ? theme.grayScale.gray0 : theme.color.blue};
+ ${disabled
+ ? ''
+ : css`
+ &:hover {
+ background: ${!inverted
+ ? theme.color.blue50
+ : theme.background.secondary};
+ }
+ &:active {
+ background: ${!inverted
+ ? theme.color.blue60
+ : theme.background.tertiary};
+ }
+ `}
+ `;
+ case 'danger':
+ return css`
+ background: ${!inverted
+ ? theme.color.red
+ : theme.background.primary};
+ border-color: ${!inverted
+ ? focus
+ ? theme.color.red
+ : theme.background.transparent.light
+ : theme.background.transparent.light};
+ border-width: 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.color.red10
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted ? theme.background.primary : theme.color.red};
+ ${disabled
+ ? ''
+ : css`
+ &:hover {
+ background: ${!inverted
+ ? theme.color.red40
+ : theme.background.secondary};
+ }
+ &:active {
+ background: ${!inverted
+ ? theme.color.red50
+ : theme.background.tertiary};
+ }
+ `}
+ `;
+ }
+ break;
+ case 'secondary':
+ case 'tertiary':
+ switch (accent) {
+ case 'default':
+ return css`
+ background: transparent;
+ border-color: ${!inverted
+ ? variant === 'secondary'
+ ? !disabled && focus
+ ? theme.color.blue
+ : theme.background.transparent.medium
+ : focus
+ ? theme.color.blue
+ : 'transparent'
+ : variant === 'secondary'
+ ? focus || disabled
+ ? theme.grayScale.gray0
+ : theme.background.transparent.primary
+ : focus
+ ? theme.grayScale.gray0
+ : 'transparent'};
+ border-width: 1px 1px 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.accent.tertiary
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted
+ ? !disabled
+ ? theme.font.color.secondary
+ : theme.font.color.extraLight
+ : theme.font.color.inverted};
+ &:hover {
+ background: ${!inverted
+ ? !disabled
+ ? theme.background.transparent.light
+ : 'transparent'
+ : theme.background.transparent.light};
+ }
+ &:active {
+ background: ${!inverted
+ ? !disabled
+ ? theme.background.transparent.light
+ : 'transparent'
+ : theme.background.transparent.medium};
+ }
+ `;
+ case 'blue':
+ return css`
+ background: transparent;
+ border-color: ${!inverted
+ ? variant === 'secondary'
+ ? focus
+ ? theme.color.blue
+ : theme.accent.primary
+ : focus
+ ? theme.color.blue
+ : 'transparent'
+ : variant === 'secondary'
+ ? focus || disabled
+ ? theme.grayScale.gray0
+ : theme.background.transparent.primary
+ : focus
+ ? theme.grayScale.gray0
+ : 'transparent'};
+ border-width: 1px 1px 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.accent.tertiary
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted
+ ? !disabled
+ ? theme.color.blue
+ : theme.accent.accent4060
+ : theme.font.color.inverted};
+ &:hover {
+ background: ${!inverted
+ ? !disabled
+ ? theme.accent.tertiary
+ : 'transparent'
+ : theme.background.transparent.light};
+ }
+ &:active {
+ background: ${!inverted
+ ? !disabled
+ ? theme.accent.secondary
+ : 'transparent'
+ : theme.background.transparent.medium};
+ }
+ `;
+ case 'danger':
+ return css`
+ background: transparent;
+ border-color: ${!inverted
+ ? variant === 'secondary'
+ ? focus
+ ? theme.color.red
+ : theme.border.color.danger
+ : focus
+ ? theme.color.red
+ : 'transparent'
+ : variant === 'secondary'
+ ? focus || disabled
+ ? theme.grayScale.gray0
+ : theme.background.transparent.primary
+ : focus
+ ? theme.grayScale.gray0
+ : 'transparent'};
+ border-width: 1px 1px 1px 1px !important;
+ box-shadow: ${!disabled && focus
+ ? `0 0 0 3px ${
+ !inverted
+ ? theme.color.red10
+ : theme.background.transparent.medium
+ }`
+ : 'none'};
+ color: ${!inverted
+ ? theme.font.color.danger
+ : theme.font.color.inverted};
+ &:hover {
+ background: ${!inverted
+ ? !disabled
+ ? theme.background.danger
+ : 'transparent'
+ : theme.background.transparent.light};
+ }
+ &:active {
+ background: ${!inverted
+ ? !disabled
+ ? theme.background.danger
+ : 'transparent'
+ : theme.background.transparent.medium};
+ }
+ `;
+ }
+ }
+ }}
+
+ text-decoration: none;
+ border-radius: ${({ position, theme }) => {
+ switch (position) {
+ case 'left':
+ return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
+ case 'right':
+ return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
+ case 'middle':
+ return '0px';
+ case 'standalone':
+ return theme.border.radius.sm;
+ }
+ }};
+ border-style: solid;
+ border-width: ${({ variant, position }) => {
+ switch (variant) {
+ case 'primary':
+ case 'secondary':
+ return position === 'middle' ? '1px 0px' : '1px';
+ case 'tertiary':
+ return '0';
+ }
+ }};
+ cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
+ display: flex;
+ flex-direction: row;
+ font-family: ${({ theme }) => theme.font.family};
+ font-weight: 500;
+ font-size: ${({ theme }) => theme.font.size.md};
+ gap: ${({ theme }) => theme.spacing(1)};
+ height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
+ justify-content: ${({ justify }) => justify};
+ padding: ${({ theme }) => {
+ return `0 ${theme.spacing(2)}`;
+ }};
+
+ transition: background 0.1s ease;
+
+ white-space: nowrap;
+
+ width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
+
+ &:focus {
+ outline: none;
+ }
+`;
+
+const StyledSoonPill = styled(Pill)`
+ margin-left: auto;
+`;
+
+const StyledSeparator = styled.div<{
+ buttonSize: ButtonSize;
+ accent: ButtonAccent;
+}>`
+ background: ${({ theme, accent }) => {
+ switch (accent) {
+ case 'blue':
+ return theme.border.color.blue;
+ case 'danger':
+ return theme.border.color.danger;
+ default:
+ return theme.font.color.light;
+ }
+ }};
+ height: ${({ theme, buttonSize }) =>
+ theme.spacing(buttonSize === 'small' ? 2 : 4)};
+ margin: 0;
+ width: 1px;
+`;
+
+const StyledShortcutLabel = styled.div<{
+ variant: ButtonVariant;
+ accent: ButtonAccent;
+}>`
+ color: ${({ theme, variant, accent }) => {
+ switch (accent) {
+ case 'blue':
+ return theme.border.color.blue;
+ case 'danger':
+ return variant === 'primary'
+ ? theme.border.color.danger
+ : theme.color.red40;
+ default:
+ return theme.font.color.light;
+ }
+ }};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+`;
+
+const StyledIconContainer = styled(motion.div)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export const AnimatedButton = ({
+ className,
+ Icon,
+ animatedSvg,
+ title,
+ fullWidth = false,
+ variant = 'primary',
+ inverted = false,
+ size = 'medium',
+ accent = 'default',
+ position = 'standalone',
+ soon = false,
+ disabled = false,
+ justify = 'flex-start',
+ focus = false,
+ onClick,
+ to,
+ target,
+ dataTestId,
+ hotkeys,
+ ariaLabel,
+ animate,
+ transition,
+}: AnimatedButtonProps) => {
+ const theme = useTheme();
+ const isMobile = useIsMobile();
+
+ const ButtonComponent = to ? Link : 'button';
+
+ return (
+
+ {Icon && (
+
+
+
+ )}
+ {animatedSvg && (
+
+ {animatedSvg}
+
+ )}
+ {title}
+ {hotkeys && !isMobile && (
+ <>
+
+
+ {hotkeys.join(getOsShortcutSeparator())}
+
+ >
+ )}
+ {soon && }
+
+ );
+};
diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts
index 1c9282835..6553c8b19 100644
--- a/packages/twenty-ui/src/input/index.ts
+++ b/packages/twenty-ui/src/input/index.ts
@@ -1,3 +1,4 @@
+export * from './button/components/AnimatedButton';
export * from './button/components/AnimatedLightIconButton';
export * from './button/components/Button';
export * from './button/components/ButtonGroup';