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:
nitin
2025-01-08 19:36:49 +05:30
committed by GitHub
parent bec7911d59
commit 973ec83e71
19 changed files with 419 additions and 333 deletions

View File

@ -22,6 +22,7 @@ import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import {
AnimatedExpandableContainer,
IconFolder,
IconFolderOpen,
IconHeartOff,
@ -158,7 +159,12 @@ export const CurrentWorkspaceMemberFavorites = ({
</FavoritesDroppable>
)}
{isOpen && (
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
>
<Droppable droppableId={`folder-${folder.folderId}`}>
{(provided) => (
<div
@ -202,7 +208,7 @@ export const CurrentWorkspaceMemberFavorites = ({
</div>
)}
</Droppable>
)}
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
{createPortal(

View File

@ -9,7 +9,7 @@ import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-dr
import { View } from '@/views/types/View';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
import { useLocation } from 'react-router-dom';
import { useIcons } from 'twenty-ui';
import { AnimatedExpandableContainer, useIcons } from 'twenty-ui';
export type NavigationDrawerItemForObjectMetadataItemProps = {
objectMetadataItem: ObjectMetadataItem;
@ -66,8 +66,14 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
Icon={getIcon(objectMetadataItem.icon)}
active={isActive}
/>
{shouldSubItemsBeDisplayed &&
sortedObjectMetadataViews.map((view, index) => (
<AnimatedExpandableContainer
isExpanded={shouldSubItemsBeDisplayed}
dimension="height"
mode="fit-content"
containAnimation
>
{sortedObjectMetadataViews.map((view, index) => (
<NavigationDrawerSubItem
label={view.name}
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
@ -81,6 +87,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
key={view.id}
/>
))}
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
);
};

View File

@ -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 styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import { useRecoilValue } from 'recoil';
import { IconPoint, MAIN_COLORS } from 'twenty-ui';
import { AnimatedExpandableContainer, IconPoint, MAIN_COLORS } from 'twenty-ui';
const StyledAdvancedWrapper = styled.div`
position: relative;
width: 100%;
`;
const StyledIconContainer = styled.div`
const StyledIconContainer = styled.div<{ navigationDrawerItem: boolean }>`
display: flex;
height: 100%;
left: ${({ theme }) => theme.spacing(-4)};
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`
width: 100%;
`;
const StyledIconPoint = styled(IconPoint)`
margin-right: 0;
`;
@ -29,43 +40,37 @@ type AdvancedSettingsWrapperProps = {
children: React.ReactNode;
dimension?: 'width' | 'height';
hideIcon?: boolean;
navigationDrawerItem?: boolean;
};
export const AdvancedSettingsWrapper = ({
children,
dimension = 'height',
hideIcon = false,
navigationDrawerItem = false,
}: AdvancedSettingsWrapperProps) => {
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const { contentRef, motionAnimationVariants } = useExpandedAnimation(
isAdvancedModeEnabled,
dimension,
);
return (
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
ref={contentRef}
initial="initial"
animate="animate"
exit="exit"
variants={motionAnimationVariants}
>
<StyledAdvancedWrapper>
{!hideIcon && (
<StyledIconContainer>
<StyledIconPoint
size={12}
color={MAIN_COLORS.yellow}
fill={MAIN_COLORS.yellow}
/>
</StyledIconContainer>
)}
<StyledContent>{children}</StyledContent>
</StyledAdvancedWrapper>
</motion.div>
)}
</AnimatePresence>
<AnimatedExpandableContainer
isExpanded={isAdvancedModeEnabled}
dimension={dimension}
animationDurations={ADVANCED_SETTINGS_ANIMATION_DURATION}
mode="scroll-height"
containAnimation={false}
>
<StyledAdvancedWrapper>
{!hideIcon && (
<StyledIconContainer navigationDrawerItem={navigationDrawerItem}>
<StyledIconPoint
size={12}
color={MAIN_COLORS.yellow}
fill={MAIN_COLORS.yellow}
/>
</StyledIconContainer>
)}
<StyledContent>{children}</StyledContent>
</StyledAdvancedWrapper>
</AnimatedExpandableContainer>
);
};

View File

@ -12,20 +12,18 @@ import {
IconHierarchy2,
IconKey,
IconMail,
IconPoint,
IconRocket,
IconServer,
IconSettings,
IconUserCircle,
IconUsers,
MAIN_COLORS,
} from 'twenty-ui';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { useExpandedAnimation } from '@/settings/hooks/useExpandedAnimation';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import {
@ -35,11 +33,8 @@ import {
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
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 { 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 { FeatureFlagKey } from '~/generated/graphql';
@ -51,32 +46,7 @@ type SettingsNavigationItem = {
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 = () => {
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const {
contentRef: securityRef,
motionAnimationVariants: securityAnimationVariants,
} = useExpandedAnimation(isAdvancedModeEnabled);
const {
contentRef: developersRef,
motionAnimationVariants: developersAnimationVariants,
} = useExpandedAnimation(isAdvancedModeEnabled);
const { signOut } = useAuth();
const billing = useRecoilValue(billingState);
@ -198,81 +168,36 @@ export const SettingsNavigationDrawerItems = () => {
Icon={IconCode}
/>
)}
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
ref={securityRef}
initial="initial"
animate="animate"
exit="exit"
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>
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label="Security"
path={SettingsPath.Security}
Icon={IconKey}
/>
</AdvancedSettingsWrapper>
</NavigationDrawerSection>
<AnimatePresence>
{isAdvancedModeEnabled && (
<motion.div
ref={developersRef}
initial="initial"
animate="animate"
exit="exit"
variants={developersAnimationVariants}
>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Developers" />
<StyledContainer>
<StyledIconContainer>
<StyledIconPoint
size={12}
color={MAIN_COLORS.yellow}
fill={MAIN_COLORS.yellow}
/>
</StyledIconContainer>
<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>
<NavigationDrawerSection>
<AdvancedSettingsWrapper hideIcon>
<NavigationDrawerSectionTitle label="Developers" />
</AdvancedSettingsWrapper>
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label="API & Webhooks"
path={SettingsPath.Developers}
Icon={IconCode}
/>
</AdvancedSettingsWrapper>
{isFunctionSettingsEnabled && (
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label="Functions"
path={SettingsPath.ServerlessFunctions}
Icon={IconFunction}
/>
</AdvancedSettingsWrapper>
)}
</AnimatePresence>
</NavigationDrawerSection>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Other" />
{isAdminPageEnabled && (

View File

@ -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,
),
};
};

View File

@ -5,7 +5,7 @@ import { Column } from '@/spreadsheet-import/steps/components/MatchColumnsStep/M
import { Fields } from '@/spreadsheet-import/types';
import styled from '@emotion/styled';
import { useState } from 'react';
import { ExpandableContainer, isDefined } from 'twenty-ui';
import { AnimatedExpandableContainer, isDefined } from 'twenty-ui';
const getExpandableContainerTitle = <T extends string>(
fields: Fields<T>,
@ -59,7 +59,12 @@ export const UnmatchColumn = <T extends string>({
buttonOnClick={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
/>
<ExpandableContainer isExpanded={isExpanded}>
<AnimatedExpandableContainer
isExpanded={isExpanded}
dimension="height"
mode="scroll-height"
containAnimation
>
<StyledContentWrapper>
{column.matchedOptions.map((option) => (
<SubMatchingSelect
@ -71,7 +76,7 @@ export const UnmatchColumn = <T extends string>({
/>
))}
</StyledContentWrapper>
</ExpandableContainer>
</AnimatedExpandableContainer>
</StyledContainer>
);
};

View File

@ -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',
},
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export type AnimationDimension = 'width' | 'height';

View File

@ -0,0 +1,4 @@
export type AnimationDurationObject = {
opacity: number;
size: number;
};

View File

@ -0,0 +1,3 @@
import { AnimationDurationObject } from '@ui/layout/animated-expandable-container/types/AnimationDurationObject';
export type AnimationDurations = AnimationDurationObject | 'default';

View File

@ -0,0 +1 @@
export type AnimationMode = 'scroll-height' | 'fit-content';

View File

@ -0,0 +1 @@
export type AnimationSize = number | 'fit-content';

View File

@ -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),
});

View File

@ -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),
},
});

View File

@ -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',
},
},
});

View File

@ -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;

View File

@ -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,
},
};

View File

@ -1,3 +1,4 @@
export * from './animated-expandable-container/components/AnimatedExpandableContainer';
export * from './animated-placeholder/components/AnimatedPlaceholder';
export * from './animated-placeholder/components/EmptyPlaceholderStyled';
export * from './animated-placeholder/components/ErrorPlaceholderStyled';
@ -9,5 +10,4 @@ export * from './card/components/Card';
export * from './card/components/CardContent';
export * from './card/components/CardFooter';
export * from './card/components/CardHeader';
export * from './expandableContainer/components/ExpandableContainer';
export * from './section/components/Section';