Skip to content

Commit

Permalink
406 animate the command menu button (#10305)
Browse files Browse the repository at this point in the history
Closes twentyhq/core-team-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
  • Loading branch information
bosiraphael authored Feb 18, 2025
1 parent fef6a7d commit aeed1c9
Show file tree
Hide file tree
Showing 6 changed files with 615 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -114,20 +115,22 @@ export const CommandMenuContainer = ({
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
)}
<ActionMenuConfirmationModals />
{isCommandMenuOpened && (
<StyledCommandMenu
data-testid="command-menu"
ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
transition={{ duration: theme.animation.duration.normal }}
>
{children}
</StyledCommandMenu>
)}
<AnimatePresence mode="wait">
{isCommandMenuOpened && (
<StyledCommandMenu
data-testid="command-menu"
ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
transition={{ duration: theme.animation.duration.normal }}
>
{children}
</StyledCommandMenu>
)}
</AnimatePresence>
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,9 +22,21 @@ export const CommandMenuRouter = () => {
<></>
);

const theme = useTheme();

return (
<CommandMenuContainer>
<CommandMenuTopBar />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
delay: 0.1,
}}
>
<CommandMenuTopBar />
</motion.div>
<StyledCommandMenuContent>
{commandMenuPageComponent}
</StyledCommandMenuContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<StyledInputContainer>
<StyledContentContainer>
Expand Down Expand Up @@ -162,7 +172,7 @@ export const CommandMenuTopBar = () => {
)}
</StyledContentContainer>
{!isMobile && (
<>
<StyledCloseButtonWrapper isVisible={isButtonVisible}>
{isCommandMenuV2Enabled ? (
<Button
Icon={IconX}
Expand All @@ -184,7 +194,7 @@ export const CommandMenuTopBar = () => {
/>
</StyledCloseButtonContainer>
)}
</>
</StyledCloseButtonWrapper>
)}
</StyledInputContainer>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,146 @@
import {
Button,
AnimatedButton,
IconButton,
IconDotsVertical,
getOsControlSymbol,
useIsMobile,
} 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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width={theme.icon.size.sm}
height={theme.icon.size.sm}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={theme.icon.stroke.md}
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Center dot */}
<motion.circle
cx={12}
cy={12}
r="1"
animate={{
scale: isCommandMenuOpened ? 0 : 1,
opacity: isCommandMenuOpened ? 0 : 1,
}}
transition={{ duration: theme.animation.duration.fast }}
/>

{/* X lines expanding from center */}
{Object.values(xPaths).map((path, index) => (
<motion.path
key={index}
d={path}
initial={{ pathLength: 0 }}
animate={{
pathLength: isCommandMenuOpened ? 1 : 0,
opacity: isCommandMenuOpened ? 1 : 0,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
delay: isCommandMenuOpened ? 0.1 : 0,
}}
/>
))}

{/* Top dot */}
<motion.circle
cx="12"
cy="5"
r="1"
animate={{
scale: isCommandMenuOpened ? 0 : 1,
opacity: isCommandMenuOpened ? 0 : 1,
}}
transition={{ duration: theme.animation.duration.fast }}
/>

{/* Bottom dot */}
<motion.circle
cx="12"
cy="19"
r="1"
animate={{
scale: isCommandMenuOpened ? 0 : 1,
opacity: isCommandMenuOpened ? 0 : 1,
}}
transition={{ duration: theme.animation.duration.fast }}
/>
</svg>
);
};

export const PageHeaderOpenCommandMenuButton = () => {
const { openRootCommandMenu } = useCommandMenu();
const { toggleCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);

const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);

const isMobile = useIsMobile();

const ariaLabel = isCommandMenuOpened
? t`Close command menu`
: t`Open command menu`;

const theme = useTheme();

return (
<>
<StyledButtonWrapper>
{isCommandMenuV2Enabled ? (
<Button
Icon={IconDotsVertical}
dataTestId="page-header-open-command-menu-button"
<AnimatedButton
animatedSvg={
<AnimatedIcon isCommandMenuOpened={isCommandMenuOpened} />
}
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',
}}
/>
) : (
<IconButton
Expand All @@ -39,9 +149,9 @@ export const PageHeaderOpenCommandMenuButton = () => {
dataTestId="more-showpage-button"
accent="default"
variant="secondary"
onClick={openRootCommandMenu}
onClick={toggleCommandMenu}
/>
)}
</>
</StyledButtonWrapper>
);
};
Loading

0 comments on commit aeed1c9

Please sign in to comment.