Animated the Sidebar Objects Tree view opening/closing (#9287)
closes #6485 https://github.com/user-attachments/assets/79efca87-1d9b-4fa2-a457-3117be679c6e --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -22,6 +22,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import {
|
import {
|
||||||
|
AnimatedExpandableContainer,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconFolderOpen,
|
IconFolderOpen,
|
||||||
IconHeartOff,
|
IconHeartOff,
|
||||||
@ -158,7 +159,12 @@ export const CurrentWorkspaceMemberFavorites = ({
|
|||||||
</FavoritesDroppable>
|
</FavoritesDroppable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
<AnimatedExpandableContainer
|
||||||
|
isExpanded={isOpen}
|
||||||
|
dimension="height"
|
||||||
|
mode="fit-content"
|
||||||
|
containAnimation
|
||||||
|
>
|
||||||
<Droppable droppableId={`folder-${folder.folderId}`}>
|
<Droppable droppableId={`folder-${folder.folderId}`}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
@ -202,7 +208,7 @@ export const CurrentWorkspaceMemberFavorites = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
)}
|
</AnimatedExpandableContainer>
|
||||||
</NavigationDrawerItemsCollapsableContainer>
|
</NavigationDrawerItemsCollapsableContainer>
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-dr
|
|||||||
import { View } from '@/views/types/View';
|
import { View } from '@/views/types/View';
|
||||||
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
|
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIcons } from 'twenty-ui';
|
import { AnimatedExpandableContainer, useIcons } from 'twenty-ui';
|
||||||
|
|
||||||
export type NavigationDrawerItemForObjectMetadataItemProps = {
|
export type NavigationDrawerItemForObjectMetadataItemProps = {
|
||||||
objectMetadataItem: ObjectMetadataItem;
|
objectMetadataItem: ObjectMetadataItem;
|
||||||
@ -66,8 +66,14 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
|
|||||||
Icon={getIcon(objectMetadataItem.icon)}
|
Icon={getIcon(objectMetadataItem.icon)}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
/>
|
/>
|
||||||
{shouldSubItemsBeDisplayed &&
|
|
||||||
sortedObjectMetadataViews.map((view, index) => (
|
<AnimatedExpandableContainer
|
||||||
|
isExpanded={shouldSubItemsBeDisplayed}
|
||||||
|
dimension="height"
|
||||||
|
mode="fit-content"
|
||||||
|
containAnimation
|
||||||
|
>
|
||||||
|
{sortedObjectMetadataViews.map((view, index) => (
|
||||||
<NavigationDrawerSubItem
|
<NavigationDrawerSubItem
|
||||||
label={view.name}
|
label={view.name}
|
||||||
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
|
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
|
||||||
@ -81,6 +87,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
|
|||||||
key={view.id}
|
key={view.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AnimatedExpandableContainer>
|
||||||
</NavigationDrawerItemsCollapsableContainer>
|
</NavigationDrawerItemsCollapsableContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,26 +1,37 @@
|
|||||||
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
|
import { ADVANCED_SETTINGS_ANIMATION_DURATION } from '@/settings/constants/AdvancedSettingsAnimationDurations';
|
||||||
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { IconPoint, MAIN_COLORS } from 'twenty-ui';
|
import { AnimatedExpandableContainer, IconPoint, MAIN_COLORS } from 'twenty-ui';
|
||||||
|
|
||||||
const StyledAdvancedWrapper = styled.div`
|
const StyledAdvancedWrapper = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledIconContainer = styled.div`
|
const StyledIconContainer = styled.div<{ navigationDrawerItem: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
left: ${({ theme }) => theme.spacing(-4)};
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
|
||||||
|
${({ navigationDrawerItem, theme }) => {
|
||||||
|
if (navigationDrawerItem) {
|
||||||
|
return `
|
||||||
|
height: 100%;
|
||||||
|
left: ${theme.spacing(-5)};
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
left: ${theme.spacing(-4)};
|
||||||
|
top: ${theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledContent = styled.div`
|
const StyledContent = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledIconPoint = styled(IconPoint)`
|
const StyledIconPoint = styled(IconPoint)`
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
`;
|
`;
|
||||||
@ -29,43 +40,37 @@ type AdvancedSettingsWrapperProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
dimension?: 'width' | 'height';
|
dimension?: 'width' | 'height';
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
|
navigationDrawerItem?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdvancedSettingsWrapper = ({
|
export const AdvancedSettingsWrapper = ({
|
||||||
children,
|
children,
|
||||||
dimension = 'height',
|
dimension = 'height',
|
||||||
hideIcon = false,
|
hideIcon = false,
|
||||||
|
navigationDrawerItem = false,
|
||||||
}: AdvancedSettingsWrapperProps) => {
|
}: AdvancedSettingsWrapperProps) => {
|
||||||
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
|
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
|
||||||
const { contentRef, motionAnimationVariants } = useExpandedAnimation(
|
|
||||||
isAdvancedModeEnabled,
|
|
||||||
dimension,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatedExpandableContainer
|
||||||
{isAdvancedModeEnabled && (
|
isExpanded={isAdvancedModeEnabled}
|
||||||
<motion.div
|
dimension={dimension}
|
||||||
ref={contentRef}
|
animationDurations={ADVANCED_SETTINGS_ANIMATION_DURATION}
|
||||||
initial="initial"
|
mode="scroll-height"
|
||||||
animate="animate"
|
containAnimation={false}
|
||||||
exit="exit"
|
>
|
||||||
variants={motionAnimationVariants}
|
<StyledAdvancedWrapper>
|
||||||
>
|
{!hideIcon && (
|
||||||
<StyledAdvancedWrapper>
|
<StyledIconContainer navigationDrawerItem={navigationDrawerItem}>
|
||||||
{!hideIcon && (
|
<StyledIconPoint
|
||||||
<StyledIconContainer>
|
size={12}
|
||||||
<StyledIconPoint
|
color={MAIN_COLORS.yellow}
|
||||||
size={12}
|
fill={MAIN_COLORS.yellow}
|
||||||
color={MAIN_COLORS.yellow}
|
/>
|
||||||
fill={MAIN_COLORS.yellow}
|
</StyledIconContainer>
|
||||||
/>
|
)}
|
||||||
</StyledIconContainer>
|
<StyledContent>{children}</StyledContent>
|
||||||
)}
|
</StyledAdvancedWrapper>
|
||||||
<StyledContent>{children}</StyledContent>
|
</AnimatedExpandableContainer>
|
||||||
</StyledAdvancedWrapper>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,20 +12,18 @@ import {
|
|||||||
IconHierarchy2,
|
IconHierarchy2,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconPoint,
|
|
||||||
IconRocket,
|
IconRocket,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
MAIN_COLORS,
|
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
import { useAuth } from '@/auth/hooks/useAuth';
|
import { useAuth } from '@/auth/hooks/useAuth';
|
||||||
import { currentUserState } from '@/auth/states/currentUserState';
|
import { currentUserState } from '@/auth/states/currentUserState';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
|
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
|
||||||
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
|
||||||
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
|
|
||||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import {
|
import {
|
||||||
@ -35,11 +33,8 @@ import {
|
|||||||
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
|
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
|
||||||
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||||
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
||||||
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
|
||||||
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
|
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import styled from '@emotion/styled';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
|
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
|
||||||
import { FeatureFlagKey } from '~/generated/graphql';
|
import { FeatureFlagKey } from '~/generated/graphql';
|
||||||
|
|
||||||
@ -51,32 +46,7 @@ type SettingsNavigationItem = {
|
|||||||
matchSubPages?: boolean;
|
matchSubPages?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledIconContainer = styled.div`
|
|
||||||
position: absolute;
|
|
||||||
left: ${({ theme }) => theme.spacing(-5)};
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledIconPoint = styled(IconPoint)`
|
|
||||||
margin-right: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SettingsNavigationDrawerItems = () => {
|
export const SettingsNavigationDrawerItems = () => {
|
||||||
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
|
|
||||||
const {
|
|
||||||
contentRef: securityRef,
|
|
||||||
motionAnimationVariants: securityAnimationVariants,
|
|
||||||
} = useExpandedAnimation(isAdvancedModeEnabled);
|
|
||||||
const {
|
|
||||||
contentRef: developersRef,
|
|
||||||
motionAnimationVariants: developersAnimationVariants,
|
|
||||||
} = useExpandedAnimation(isAdvancedModeEnabled);
|
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
|
|
||||||
const billing = useRecoilValue(billingState);
|
const billing = useRecoilValue(billingState);
|
||||||
@ -198,81 +168,36 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
Icon={IconCode}
|
Icon={IconCode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AnimatePresence>
|
<AdvancedSettingsWrapper navigationDrawerItem={true}>
|
||||||
{isAdvancedModeEnabled && (
|
<SettingsNavigationDrawerItem
|
||||||
<motion.div
|
label="Security"
|
||||||
ref={securityRef}
|
path={SettingsPath.Security}
|
||||||
initial="initial"
|
Icon={IconKey}
|
||||||
animate="animate"
|
/>
|
||||||
exit="exit"
|
</AdvancedSettingsWrapper>
|
||||||
variants={securityAnimationVariants}
|
|
||||||
>
|
|
||||||
<StyledContainer>
|
|
||||||
<StyledIconContainer>
|
|
||||||
<StyledIconPoint
|
|
||||||
size={12}
|
|
||||||
color={MAIN_COLORS.yellow}
|
|
||||||
fill={MAIN_COLORS.yellow}
|
|
||||||
/>
|
|
||||||
</StyledIconContainer>
|
|
||||||
<SettingsNavigationDrawerItem
|
|
||||||
label="Security"
|
|
||||||
path={SettingsPath.Security}
|
|
||||||
Icon={IconKey}
|
|
||||||
/>
|
|
||||||
</StyledContainer>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</NavigationDrawerSection>
|
</NavigationDrawerSection>
|
||||||
|
|
||||||
<AnimatePresence>
|
<NavigationDrawerSection>
|
||||||
{isAdvancedModeEnabled && (
|
<AdvancedSettingsWrapper hideIcon>
|
||||||
<motion.div
|
<NavigationDrawerSectionTitle label="Developers" />
|
||||||
ref={developersRef}
|
</AdvancedSettingsWrapper>
|
||||||
initial="initial"
|
<AdvancedSettingsWrapper navigationDrawerItem={true}>
|
||||||
animate="animate"
|
<SettingsNavigationDrawerItem
|
||||||
exit="exit"
|
label="API & Webhooks"
|
||||||
variants={developersAnimationVariants}
|
path={SettingsPath.Developers}
|
||||||
>
|
Icon={IconCode}
|
||||||
<NavigationDrawerSection>
|
/>
|
||||||
<NavigationDrawerSectionTitle label="Developers" />
|
</AdvancedSettingsWrapper>
|
||||||
<StyledContainer>
|
{isFunctionSettingsEnabled && (
|
||||||
<StyledIconContainer>
|
<AdvancedSettingsWrapper navigationDrawerItem={true}>
|
||||||
<StyledIconPoint
|
<SettingsNavigationDrawerItem
|
||||||
size={12}
|
label="Functions"
|
||||||
color={MAIN_COLORS.yellow}
|
path={SettingsPath.ServerlessFunctions}
|
||||||
fill={MAIN_COLORS.yellow}
|
Icon={IconFunction}
|
||||||
/>
|
/>
|
||||||
</StyledIconContainer>
|
</AdvancedSettingsWrapper>
|
||||||
|
|
||||||
<SettingsNavigationDrawerItem
|
|
||||||
label="API & Webhooks"
|
|
||||||
path={SettingsPath.Developers}
|
|
||||||
Icon={IconCode}
|
|
||||||
/>
|
|
||||||
</StyledContainer>
|
|
||||||
{isFunctionSettingsEnabled && (
|
|
||||||
<StyledContainer>
|
|
||||||
<StyledIconContainer>
|
|
||||||
<StyledIconPoint
|
|
||||||
size={12}
|
|
||||||
color={MAIN_COLORS.yellow}
|
|
||||||
fill={MAIN_COLORS.yellow}
|
|
||||||
/>
|
|
||||||
</StyledIconContainer>
|
|
||||||
|
|
||||||
<SettingsNavigationDrawerItem
|
|
||||||
label="Functions"
|
|
||||||
path={SettingsPath.ServerlessFunctions}
|
|
||||||
Icon={IconFunction}
|
|
||||||
/>
|
|
||||||
</StyledContainer>
|
|
||||||
)}
|
|
||||||
</NavigationDrawerSection>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</NavigationDrawerSection>
|
||||||
<NavigationDrawerSection>
|
<NavigationDrawerSection>
|
||||||
<NavigationDrawerSectionTitle label="Other" />
|
<NavigationDrawerSectionTitle label="Other" />
|
||||||
{isAdminPageEnabled && (
|
{isAdminPageEnabled && (
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
import { ADVANCED_SETTINGS_ANIMATION_DURATION } from '@/settings/constants/AdvancedSettingsAnimationDurations';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { isDefined } from 'twenty-ui';
|
|
||||||
|
|
||||||
type AnimationDimension = 'width' | 'height';
|
|
||||||
|
|
||||||
const getTransitionValues = (dimension: AnimationDimension) => ({
|
|
||||||
transition: {
|
|
||||||
opacity: { duration: ADVANCED_SETTINGS_ANIMATION_DURATION.opacity },
|
|
||||||
[dimension]: { duration: ADVANCED_SETTINGS_ANIMATION_DURATION.size },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const commonStyles = (dimension: AnimationDimension) => ({
|
|
||||||
opacity: 0,
|
|
||||||
[dimension]: 0,
|
|
||||||
...getTransitionValues(dimension),
|
|
||||||
});
|
|
||||||
|
|
||||||
const advancedSectionAnimationConfig = (
|
|
||||||
isExpanded: boolean,
|
|
||||||
dimension: AnimationDimension,
|
|
||||||
measuredValue?: number,
|
|
||||||
) => ({
|
|
||||||
initial: {
|
|
||||||
...commonStyles(dimension),
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
[dimension]: isExpanded
|
|
||||||
? dimension === 'width'
|
|
||||||
? '100%'
|
|
||||||
: measuredValue
|
|
||||||
: 0,
|
|
||||||
...getTransitionValues(dimension),
|
|
||||||
},
|
|
||||||
exit: {
|
|
||||||
...commonStyles(dimension),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useExpandedAnimation = (
|
|
||||||
isExpanded: boolean,
|
|
||||||
dimension: AnimationDimension = 'height',
|
|
||||||
) => {
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [measuredValue, setMeasuredValue] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dimension === 'height' && isDefined(contentRef.current)) {
|
|
||||||
setMeasuredValue(contentRef.current.scrollHeight);
|
|
||||||
}
|
|
||||||
}, [isExpanded, dimension]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentRef,
|
|
||||||
motionAnimationVariants: advancedSectionAnimationConfig(
|
|
||||||
isExpanded,
|
|
||||||
dimension,
|
|
||||||
dimension === 'height' ? measuredValue : undefined,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -5,7 +5,7 @@ import { Column } from '@/spreadsheet-import/steps/components/MatchColumnsStep/M
|
|||||||
import { Fields } from '@/spreadsheet-import/types';
|
import { Fields } from '@/spreadsheet-import/types';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ExpandableContainer, isDefined } from 'twenty-ui';
|
import { AnimatedExpandableContainer, isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
const getExpandableContainerTitle = <T extends string>(
|
const getExpandableContainerTitle = <T extends string>(
|
||||||
fields: Fields<T>,
|
fields: Fields<T>,
|
||||||
@ -59,7 +59,12 @@ export const UnmatchColumn = <T extends string>({
|
|||||||
buttonOnClick={() => setIsExpanded(!isExpanded)}
|
buttonOnClick={() => setIsExpanded(!isExpanded)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
/>
|
/>
|
||||||
<ExpandableContainer isExpanded={isExpanded}>
|
<AnimatedExpandableContainer
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
dimension="height"
|
||||||
|
mode="scroll-height"
|
||||||
|
containAnimation
|
||||||
|
>
|
||||||
<StyledContentWrapper>
|
<StyledContentWrapper>
|
||||||
{column.matchedOptions.map((option) => (
|
{column.matchedOptions.map((option) => (
|
||||||
<SubMatchingSelect
|
<SubMatchingSelect
|
||||||
@ -71,7 +76,7 @@ export const UnmatchColumn = <T extends string>({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</StyledContentWrapper>
|
</StyledContentWrapper>
|
||||||
</ExpandableContainer>
|
</AnimatedExpandableContainer>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,164 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ComponentDecorator } from '@ui/testing';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AnimatedExpandableContainer } from '../components/AnimatedExpandableContainer';
|
||||||
|
|
||||||
|
const StyledButton = styled.button`
|
||||||
|
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)};
|
||||||
|
background-color: ${({ theme }) => theme.color.blue50};
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
border: none;
|
||||||
|
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${({ theme }) => theme.color.blue40};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledButtonWrapper = styled.div<{ dimension: 'width' | 'height' }>`
|
||||||
|
${({ dimension }) => dimension === 'height' && `width: 600px;`}
|
||||||
|
${({ dimension }) => dimension === 'width' && `height: 300px;`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledExpandableWrapper = styled.div`
|
||||||
|
background-color: ${({ theme }) => theme.background.primary};
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContent = styled.div<{
|
||||||
|
dimension: 'width' | 'height';
|
||||||
|
mode: 'scroll-height' | 'fit-content';
|
||||||
|
}>`
|
||||||
|
padding: ${({ theme }) => theme.spacing(3)};
|
||||||
|
${({ dimension, mode }) =>
|
||||||
|
dimension === 'height' && mode === 'scroll-height' && `height: 200px;`}
|
||||||
|
${({ dimension }) => dimension === 'width' && `width: 400px;`}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type AnimatedExpandableContainerWithButtonProps = {
|
||||||
|
isExpanded: boolean;
|
||||||
|
dimension: 'width' | 'height';
|
||||||
|
mode: 'scroll-height' | 'fit-content';
|
||||||
|
animationDurations:
|
||||||
|
| {
|
||||||
|
opacity: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
| 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnimatedExpandableContainerWithButton = ({
|
||||||
|
isExpanded: initialIsExpanded,
|
||||||
|
...args
|
||||||
|
}: AnimatedExpandableContainerWithButtonProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(initialIsExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledButtonWrapper dimension={args.dimension}>
|
||||||
|
<StyledButton onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
{isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
</StyledButton>
|
||||||
|
<AnimatedExpandableContainer
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
dimension={args.dimension}
|
||||||
|
mode={args.mode}
|
||||||
|
animationDurations={args.animationDurations}
|
||||||
|
>
|
||||||
|
<StyledExpandableWrapper>
|
||||||
|
<StyledContent dimension={args.dimension} mode={args.mode}>
|
||||||
|
<p>
|
||||||
|
This is some content inside the AnimatedExpandableContainer. It
|
||||||
|
will animate smoothly when expanding or collapsing.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can control the animation duration, dimension, and mode
|
||||||
|
through the Storybook controls.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Try different combinations to see how the container behaves with
|
||||||
|
different settings!
|
||||||
|
</p>
|
||||||
|
</StyledContent>
|
||||||
|
</StyledExpandableWrapper>
|
||||||
|
</AnimatedExpandableContainer>
|
||||||
|
</StyledButtonWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof AnimatedExpandableContainerWithButton> = {
|
||||||
|
title: 'UI/Layout/AnimatedExpandableContainer',
|
||||||
|
component: AnimatedExpandableContainerWithButton,
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
argTypes: {
|
||||||
|
isExpanded: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Controls whether the container is expanded or collapsed',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
dimension: {
|
||||||
|
control: 'radio',
|
||||||
|
options: ['width', 'height'],
|
||||||
|
description: 'The dimension along which the container expands',
|
||||||
|
defaultValue: 'height',
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
control: 'radio',
|
||||||
|
options: ['scroll-height', 'fit-content'],
|
||||||
|
description: 'How the container should calculate its expanded size',
|
||||||
|
defaultValue: 'scroll-height',
|
||||||
|
},
|
||||||
|
animationDurations: {
|
||||||
|
control: 'radio',
|
||||||
|
options: ['default', 'custom'],
|
||||||
|
mapping: {
|
||||||
|
default: 'default',
|
||||||
|
custom: { opacity: 0.3, size: 0.3 },
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Animation durations - either default theme values or custom values',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AnimatedExpandableContainerWithButton>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
isExpanded: false,
|
||||||
|
dimension: 'height',
|
||||||
|
mode: 'scroll-height',
|
||||||
|
animationDurations: 'default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FitContent: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
mode: 'fit-content',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomDurations: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
animationDurations: { opacity: 0.8, size: 1.2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidthAnimation: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
dimension: 'width',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { AnimationDimension } from '@ui/layout/animated-expandable-container/types/AnimationDimension';
|
||||||
|
import { AnimationDurationObject } from '@ui/layout/animated-expandable-container/types/AnimationDurationObject';
|
||||||
|
import { AnimationDurations } from '@ui/layout/animated-expandable-container/types/AnimationDurations';
|
||||||
|
import { AnimationMode } from '@ui/layout/animated-expandable-container/types/AnimationMode';
|
||||||
|
import { AnimationSize } from '@ui/layout/animated-expandable-container/types/AnimationSize';
|
||||||
|
import { getExpandableAnimationConfig } from '@ui/layout/animated-expandable-container/utils/getExpandableAnimationConfig';
|
||||||
|
import { isDefined } from '@ui/utilities';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { ReactNode, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const StyledMotionContainer = styled(motion.div)<{
|
||||||
|
containAnimation: boolean;
|
||||||
|
}>`
|
||||||
|
${({ containAnimation }) =>
|
||||||
|
containAnimation &&
|
||||||
|
`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type AnimatedExpandableContainerProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
isExpanded: boolean;
|
||||||
|
dimension?: AnimationDimension;
|
||||||
|
animationDurations?: AnimationDurations;
|
||||||
|
mode?: AnimationMode;
|
||||||
|
containAnimation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnimatedExpandableContainer = ({
|
||||||
|
children,
|
||||||
|
isExpanded,
|
||||||
|
dimension = 'height',
|
||||||
|
animationDurations = 'default',
|
||||||
|
mode = 'scroll-height',
|
||||||
|
containAnimation = true,
|
||||||
|
}: AnimatedExpandableContainerProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [size, setSize] = useState<AnimationSize>(0);
|
||||||
|
|
||||||
|
const actualDurations: AnimationDurationObject =
|
||||||
|
animationDurations === 'default'
|
||||||
|
? {
|
||||||
|
opacity: theme.animation.duration.normal,
|
||||||
|
size: theme.animation.duration.normal,
|
||||||
|
}
|
||||||
|
: animationDurations;
|
||||||
|
|
||||||
|
const updateSize = () => {
|
||||||
|
if (
|
||||||
|
mode === 'scroll-height' &&
|
||||||
|
dimension === 'height' &&
|
||||||
|
isDefined(contentRef.current)
|
||||||
|
) {
|
||||||
|
setSize(contentRef.current.scrollHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const motionAnimationVariants = getExpandableAnimationConfig(
|
||||||
|
isExpanded,
|
||||||
|
dimension,
|
||||||
|
actualDurations.opacity,
|
||||||
|
actualDurations.size,
|
||||||
|
mode === 'fit-content' ? 'fit-content' : size,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<StyledMotionContainer
|
||||||
|
containAnimation={containAnimation}
|
||||||
|
ref={contentRef}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={motionAnimationVariants}
|
||||||
|
onAnimationStart={updateSize}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledMotionContainer>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export type AnimationDimension = 'width' | 'height';
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export type AnimationDurationObject = {
|
||||||
|
opacity: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import { AnimationDurationObject } from '@ui/layout/animated-expandable-container/types/AnimationDurationObject';
|
||||||
|
|
||||||
|
export type AnimationDurations = AnimationDurationObject | 'default';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export type AnimationMode = 'scroll-height' | 'fit-content';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export type AnimationSize = number | 'fit-content';
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { AnimationDimension } from '@ui/layout/animated-expandable-container/types/AnimationDimension';
|
||||||
|
import { getTransitionValues } from '@ui/layout/animated-expandable-container/utils/getTransitionValues';
|
||||||
|
|
||||||
|
export const getCommonStyles = (
|
||||||
|
dimension: AnimationDimension,
|
||||||
|
opacityDuration: number,
|
||||||
|
sizeDuration: number,
|
||||||
|
) => ({
|
||||||
|
opacity: 0,
|
||||||
|
[dimension]: 0,
|
||||||
|
...getTransitionValues(dimension, opacityDuration, sizeDuration),
|
||||||
|
});
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { AnimationDimension } from '@ui/layout/animated-expandable-container/types/AnimationDimension';
|
||||||
|
import { AnimationSize } from '@ui/layout/animated-expandable-container/types/AnimationSize';
|
||||||
|
import { getCommonStyles } from '@ui/layout/animated-expandable-container/utils/getCommonStyles';
|
||||||
|
import { getTransitionValues } from '@ui/layout/animated-expandable-container/utils/getTransitionValues';
|
||||||
|
|
||||||
|
export const getExpandableAnimationConfig = (
|
||||||
|
isExpanded: boolean,
|
||||||
|
dimension: AnimationDimension,
|
||||||
|
opacityDuration: number,
|
||||||
|
sizeDuration: number,
|
||||||
|
size: AnimationSize,
|
||||||
|
) => ({
|
||||||
|
initial: {
|
||||||
|
...getCommonStyles(dimension, opacityDuration, sizeDuration),
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
[dimension]: isExpanded
|
||||||
|
? size === 'fit-content'
|
||||||
|
? 'fit-content'
|
||||||
|
: dimension === 'width'
|
||||||
|
? '100%'
|
||||||
|
: size
|
||||||
|
: 0,
|
||||||
|
...getTransitionValues(dimension, opacityDuration, sizeDuration),
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
...getCommonStyles(dimension, opacityDuration, sizeDuration),
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { AnimationDimension } from '@ui/layout/animated-expandable-container/types/AnimationDimension';
|
||||||
|
|
||||||
|
export const getTransitionValues = (
|
||||||
|
dimension: AnimationDimension,
|
||||||
|
opacityDuration: number,
|
||||||
|
sizeDuration: number,
|
||||||
|
) => ({
|
||||||
|
transition: {
|
||||||
|
opacity: {
|
||||||
|
duration: opacityDuration,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
[dimension]: {
|
||||||
|
duration: sizeDuration,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import styled from '@emotion/styled';
|
|
||||||
import { isDefined } from '@ui/utilities';
|
|
||||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
const StyledTransitionContainer = styled.div<{
|
|
||||||
isExpanded: boolean;
|
|
||||||
height: number;
|
|
||||||
}>`
|
|
||||||
max-height: ${({ isExpanded, height }) => (isExpanded ? `${height}px` : '0')};
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
transition: max-height
|
|
||||||
${({ theme, isExpanded }) =>
|
|
||||||
`${theme.animation.duration.normal}s ${isExpanded ? 'ease-in' : 'ease-out'}`};
|
|
||||||
`;
|
|
||||||
|
|
||||||
type ExpandableContainerProps = {
|
|
||||||
isExpanded: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ExpandableContainer = ({
|
|
||||||
isExpanded,
|
|
||||||
children,
|
|
||||||
}: ExpandableContainerProps) => {
|
|
||||||
const [contentHeight, setContentHeight] = useState(0);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (isDefined(contentRef.current)) {
|
|
||||||
setContentHeight(contentRef.current.scrollHeight);
|
|
||||||
}
|
|
||||||
}, [isExpanded]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledTransitionContainer isExpanded={isExpanded} height={contentHeight}>
|
|
||||||
<div ref={contentRef}>{children}</div>
|
|
||||||
</StyledTransitionContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpandableContainer;
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import styled from '@emotion/styled';
|
|
||||||
import { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import { ComponentDecorator } from '@ui/testing';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import ExpandableContainer from '../ExpandableContainer';
|
|
||||||
|
|
||||||
const StyledButton = styled.button`
|
|
||||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)};
|
|
||||||
background-color: ${({ theme }) => theme.color.blue50};
|
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
|
||||||
border: none;
|
|
||||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: ${({ theme }) => theme.color.blue40};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledContent = styled.div`
|
|
||||||
background-color: ${({ theme }) => theme.background.primary};
|
|
||||||
height: 200px;
|
|
||||||
padding: ${({ theme }) => theme.spacing(3)};
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
|
||||||
font-size: ${({ theme }) => theme.font.size.md};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ExpandableContainerWithButton = (args: any) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(args.isExpanded);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<StyledButton onClick={() => setIsExpanded(!isExpanded)}>
|
|
||||||
{isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
</StyledButton>
|
|
||||||
<ExpandableContainer isExpanded={isExpanded}>
|
|
||||||
<StyledContent>
|
|
||||||
<p>
|
|
||||||
This is some content inside the ExpandableContainer. It will grow
|
|
||||||
and shrink depending on the expand/collapse state.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Add more text or even other components here to test how the
|
|
||||||
container handles more content.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Feel free to adjust the height and content to see how it affects the
|
|
||||||
expand/collapse behavior.
|
|
||||||
</p>
|
|
||||||
</StyledContent>
|
|
||||||
</ExpandableContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta: Meta<typeof ExpandableContainer> = {
|
|
||||||
title: 'UI/Layout/ExpandableContainer',
|
|
||||||
component: ExpandableContainerWithButton,
|
|
||||||
decorators: [ComponentDecorator],
|
|
||||||
argTypes: {
|
|
||||||
isExpanded: {
|
|
||||||
control: 'boolean',
|
|
||||||
description: 'Controls whether the container is expanded or collapsed',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ExpandableContainerWithButton>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
isExpanded: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
export * from './animated-expandable-container/components/AnimatedExpandableContainer';
|
||||||
export * from './animated-placeholder/components/AnimatedPlaceholder';
|
export * from './animated-placeholder/components/AnimatedPlaceholder';
|
||||||
export * from './animated-placeholder/components/EmptyPlaceholderStyled';
|
export * from './animated-placeholder/components/EmptyPlaceholderStyled';
|
||||||
export * from './animated-placeholder/components/ErrorPlaceholderStyled';
|
export * from './animated-placeholder/components/ErrorPlaceholderStyled';
|
||||||
@ -9,5 +10,4 @@ export * from './card/components/Card';
|
|||||||
export * from './card/components/CardContent';
|
export * from './card/components/CardContent';
|
||||||
export * from './card/components/CardFooter';
|
export * from './card/components/CardFooter';
|
||||||
export * from './card/components/CardHeader';
|
export * from './card/components/CardHeader';
|
||||||
export * from './expandableContainer/components/ExpandableContainer';
|
|
||||||
export * from './section/components/Section';
|
export * from './section/components/Section';
|
||||||
|
|||||||
Reference in New Issue
Block a user