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:
@ -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/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';
|
||||
|
||||
Reference in New Issue
Block a user