Remove framer-motion from twenty-ui (#22021)
## What Removes the `framer-motion` dependency from `twenty-ui` and replaces every usage with pure CSS animations, reaching for Base UI primitives where one fits: - **Collapse/expand** (`AnimatedEaseInOut`, `AnimatedExpandableContainer`): rebuilt on Base UI `Collapsible` (CSS-animated `--collapsible-panel-height/width` + transition states). Public props unchanged, so the ~28 call sites are untouched. - **ProgressBar**: rebuilt on Base UI `Progress` (proper `role`/`aria-valuenow`). The snackbar auto-dismiss countdown now uses a CSS keyframe + `animation-play-state` (pause on hover), removing a per-frame React re-render; `useProgressAnimation` is deleted. - The remaining `Animated*` components, the circular spinner, checkmark, and the placeholder pointer parallax move to plain CSS (SCSS modules + the `duration()` helper + theme tokens). - Deletes 3 unused components (`AnimatedTranslation`, `AnimatedTextWord`, `AnimatedFadeOut`). ## Why `twenty-ui` is a publicly published library with a size budget, so dropping framer-motion shrinks what consumers ship. `twenty-front` keeps its own framer-motion; that is out of scope here. ## Notes for reviewers - A few `twenty-ui` components received framer props from `twenty-front` call sites; those were migrated (e.g. `AnimatedLightIconButton` gained a CSS `rotate` prop, and the `EMPTY_PLACEHOLDER_TRANSITION_PROPS` spreads were removed). - Behavior change: Base UI `Collapsible` animates only on open/close transitions, so the old "animate in on first mount while already open" case no longer plays (the `initial` prop is kept for API compatibility). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22021?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+15
-19
@@ -1,11 +1,10 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { differenceInSeconds, endOfDay, format } from 'date-fns';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow';
|
||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||
import { CardContent } from 'twenty-ui/surfaces';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type TimelineCalendarEvent } from '~/generated/graphql';
|
||||
|
||||
type CalendarDayCardContentProps = {
|
||||
@@ -23,6 +22,17 @@ const StyledCardContentContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDayCardContent = styled(CardContent)`
|
||||
@keyframes calendarDayEnded {
|
||||
to {
|
||||
background-color: ${themeCssVariables.background.primary};
|
||||
}
|
||||
}
|
||||
|
||||
animation: calendarDayEnded calc(var(--t-animation-duration-fast) * 1s) ease
|
||||
forwards;
|
||||
`;
|
||||
|
||||
const StyledDayContainer = styled.div`
|
||||
text-align: center;
|
||||
width: ${themeCssVariables.spacing[6]};
|
||||
@@ -54,31 +64,17 @@ export const CalendarDayCardContent = ({
|
||||
calendarEvents,
|
||||
divider,
|
||||
}: CalendarDayCardContentProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const endOfDayDate = endOfDay(getCalendarEventStartDate(calendarEvents[0]));
|
||||
const dayEndsIn = differenceInSeconds(endOfDayDate, Date.now());
|
||||
|
||||
const weekDayLabel = format(endOfDayDate, 'EE');
|
||||
const monthDayLabel = format(endOfDayDate, 'dd');
|
||||
|
||||
const upcomingDayCardContentVariants = {
|
||||
upcoming: {},
|
||||
ended: {
|
||||
backgroundColor: theme.background.primary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledCardContentContainer>
|
||||
<CardContent
|
||||
<StyledDayCardContent
|
||||
divider={divider}
|
||||
initial="upcoming"
|
||||
animate="ended"
|
||||
variants={upcomingDayCardContentVariants}
|
||||
transition={{
|
||||
delay: Math.max(0, dayEndsIn),
|
||||
duration: theme.animation.duration.fast,
|
||||
}}
|
||||
style={{ animationDelay: `${Math.max(0, dayEndsIn)}s` }}
|
||||
>
|
||||
<StyledDayContainer>
|
||||
<StyledWeekDay>{weekDayLabel}</StyledWeekDay>
|
||||
@@ -91,7 +87,7 @@ export const CalendarDayCardContent = ({
|
||||
</StyledEventRowContainer>
|
||||
))}
|
||||
</StyledEvents>
|
||||
</CardContent>
|
||||
</StyledDayCardContent>
|
||||
</StyledCardContentContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-5
@@ -20,7 +20,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -97,10 +96,7 @@ export const CalendarEventsCard = () => {
|
||||
if (!firstQueryLoading && !timelineCalendarEvents?.length) {
|
||||
// TODO: change animated placeholder
|
||||
return (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noMatchRecord" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+1
-5
@@ -8,7 +8,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
|
||||
export const EmptyInboxPlaceholder = () => {
|
||||
@@ -16,10 +15,7 @@ export const EmptyInboxPlaceholder = () => {
|
||||
const { openComposer, loading } = useComposeEmailForTargetRecord();
|
||||
|
||||
return (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="emptyInbox" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -103,10 +102,7 @@ export const FilesCard = () => {
|
||||
onUploadFiles={onUploadFiles}
|
||||
/>
|
||||
) : (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noFile" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
|
||||
const StyledNotesContainer = styled.div`
|
||||
@@ -61,10 +60,7 @@ export const NotesCard = () => {
|
||||
|
||||
if (isNotesEmpty) {
|
||||
return (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noNote" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { AddTaskButton } from './AddTaskButton';
|
||||
import { TaskList } from './TaskList';
|
||||
@@ -71,10 +70,7 @@ export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
|
||||
|
||||
if (isTasksEmpty) {
|
||||
return (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noTask" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+1
-5
@@ -13,7 +13,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
@@ -63,10 +62,7 @@ export const TimelineCard = () => {
|
||||
|
||||
if (isTimelineActivitiesEmpty) {
|
||||
const placeholderContent = (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="emptyTimeline" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+3
-6
@@ -1,6 +1,6 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useCallback } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
|
||||
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
|
||||
@@ -114,8 +114,6 @@ const StyledRecordTableDragAndDropPlaceholderCell = styled.div`
|
||||
`;
|
||||
|
||||
export const RecordTableRecordGroupSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const currentRecordGroupId = useCurrentRecordGroupId();
|
||||
|
||||
const shouldHide = useShouldHideRecordGroup(currentRecordGroupId);
|
||||
@@ -199,8 +197,7 @@ export const RecordTableRecordGroupSection = () => {
|
||||
Icon={IconChevronDown}
|
||||
size="small"
|
||||
accent="secondary"
|
||||
animate={{ rotate: !isRecordGroupTableSectionToggled ? -90 : 0 }}
|
||||
transition={{ duration: theme.animation.duration.normal }}
|
||||
rotate={!isRecordGroupTableSectionToggled ? -90 : 0}
|
||||
/>
|
||||
</StyledAnimatedLightIconButtonContainer>
|
||||
</StyledChevronContainer>
|
||||
|
||||
+1
-5
@@ -16,7 +16,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
|
||||
export const DashboardWidgetPlaceholder = () => {
|
||||
@@ -66,10 +65,7 @@ export const DashboardWidgetPlaceholder = () => {
|
||||
title={t`Add Widget`}
|
||||
isEmpty
|
||||
/>
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noWidgets" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+1
-5
@@ -7,7 +7,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
|
||||
const StyledPlaceholderContainer = styled.div`
|
||||
@@ -24,10 +23,7 @@ const StyledPlaceholderContainer = styled.div`
|
||||
export const StandaloneWidgetPlaceholder = () => {
|
||||
return (
|
||||
<StyledPlaceholderContainer className="widget">
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noWidgets" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+1
-5
@@ -35,7 +35,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { FieldDisplayMode } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -76,10 +75,7 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => {
|
||||
return (
|
||||
<SidePanelProvider value={{ isInSidePanel }}>
|
||||
<StyledContainer>
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noRecord" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+1
-5
@@ -19,7 +19,6 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
|
||||
@@ -108,10 +107,7 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
|
||||
return (
|
||||
<SidePanelProvider value={{ isInSidePanel }}>
|
||||
<StyledContainer>
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholderEmptyContainer>
|
||||
<AnimatedPlaceholder type="noRecord" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
|
||||
+10
-13
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
IconX,
|
||||
} from 'twenty-ui/icon';
|
||||
import { HorizontalSeparator } from 'twenty-ui/layout';
|
||||
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
|
||||
import { ProgressBar } from 'twenty-ui/feedback';
|
||||
import { LightButton, LightIconButton } from 'twenty-ui/input';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import {
|
||||
@@ -156,15 +157,8 @@ export const SnackBar = ({
|
||||
}: SnackBarProps) => {
|
||||
const { i18n, t } = useLingui();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { animation: progressAnimation, value: progressValue } =
|
||||
useProgressAnimation({
|
||||
autoPlay: isUndefined(overrideProgressValue),
|
||||
initialValue: isDefined(overrideProgressValue)
|
||||
? overrideProgressValue
|
||||
: 100,
|
||||
finalValue: 0,
|
||||
options: { duration, onComplete: onClose },
|
||||
});
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const isAutoDismiss = isUndefined(overrideProgressValue);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (isDefined(iconComponent)) {
|
||||
@@ -200,11 +194,11 @@ export const SnackBar = ({
|
||||
}, [iconComponent, variant, i18n, theme.icon.size.md, theme.snackBar]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
progressAnimation?.pause();
|
||||
setIsPaused(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
progressAnimation?.play();
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message);
|
||||
@@ -225,7 +219,10 @@ export const SnackBar = ({
|
||||
<StyledProgressBarContainer>
|
||||
<ProgressBar
|
||||
barColor={theme.snackBar[variant].backgroundColor}
|
||||
value={progressValue}
|
||||
value={overrideProgressValue ?? 100}
|
||||
countdownDurationInMs={isAutoDismiss ? duration : undefined}
|
||||
isCountdownPaused={isPaused}
|
||||
onCountdownComplete={onClose}
|
||||
/>
|
||||
</StyledProgressBarContainer>
|
||||
<StyledHeader>
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ export const WorkflowOutputSchemaBuilder = ({
|
||||
<AnimatedLightIconButton
|
||||
Icon={IconChevronDown}
|
||||
size="small"
|
||||
animate={{ rotate: isExpanded ? -180 : 0 }}
|
||||
rotate={isExpanded ? -180 : 0}
|
||||
/>
|
||||
{showRemoveFieldButton && (
|
||||
<LightIconButton
|
||||
|
||||
@@ -77,8 +77,6 @@
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"framer-motion": "^11.18.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"type-fest": "^4.10.1",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.animatedPath {
|
||||
animation: draw-checkmark var(--animated-checkmark-duration, 0.5s) ease
|
||||
forwards;
|
||||
}
|
||||
|
||||
@keyframes draw-checkmark {
|
||||
from {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare const classNames: {
|
||||
readonly animatedPath: 'animatedPath';
|
||||
};
|
||||
export default classNames;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
export type AnimatedCheckmarkProps = React.ComponentProps<
|
||||
typeof motion.path
|
||||
> & {
|
||||
import styles from './AnimatedCheckmark.module.scss';
|
||||
|
||||
export type AnimatedCheckmarkProps = {
|
||||
isAnimating?: boolean;
|
||||
color?: string;
|
||||
duration?: number;
|
||||
@@ -25,16 +24,20 @@ export const AnimatedCheckmark = ({
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<motion.path
|
||||
<path
|
||||
className={isAnimating ? styles.animatedPath : undefined}
|
||||
fill="none"
|
||||
stroke={color ?? theme.grayScale.gray1}
|
||||
strokeWidth={4}
|
||||
d="M14 27l7.8 7.8L38 14"
|
||||
pathLength="1"
|
||||
strokeDasharray="1"
|
||||
strokeDashoffset={isAnimating ? '1' : '0'}
|
||||
animate={{ strokeDashoffset: isAnimating ? '0' : '1' }}
|
||||
transition={{ duration }}
|
||||
strokeDashoffset={isAnimating ? 0 : 1}
|
||||
style={
|
||||
{
|
||||
'--animated-checkmark-duration': `${duration}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -16,3 +16,17 @@
|
||||
max-height: 245px;
|
||||
max-width: 245px;
|
||||
}
|
||||
|
||||
.movingImage {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
max-width: 130px;
|
||||
max-height: 130px;
|
||||
transform: translate(var(--parallax-x, 0), var(--parallax-y, 0));
|
||||
transition: transform duration(normal) ease-out;
|
||||
}
|
||||
|
||||
.movingImageLarge {
|
||||
max-width: 185px;
|
||||
max-height: 185px;
|
||||
}
|
||||
|
||||
+2
@@ -2,5 +2,7 @@ declare const classNames: {
|
||||
readonly container: 'container';
|
||||
readonly backgroundImage: 'backgroundImage';
|
||||
readonly backgroundImageLarge: 'backgroundImageLarge';
|
||||
readonly movingImage: 'movingImage';
|
||||
readonly movingImageLarge: 'movingImageLarge';
|
||||
};
|
||||
export default classNames;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { animate, motion, useMotionValue, useTransform } from 'framer-motion';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { BACKGROUND } from '@ui/feedback/AnimatedPlaceholder/constants/Background';
|
||||
import { DARK_BACKGROUND } from '@ui/feedback/AnimatedPlaceholder/constants/DarkBackground';
|
||||
@@ -10,8 +9,7 @@ import { useThemeColorScheme } from '@ui/theme-constants';
|
||||
|
||||
import styles from './AnimatedPlaceholder.module.scss';
|
||||
|
||||
const getMovingImageSize = (type: string) =>
|
||||
type === 'error500' || type === 'error404' ? '185px' : '130px';
|
||||
const PARALLAX_OFFSET_IN_PX = 2;
|
||||
|
||||
export type AnimatedPlaceholderType =
|
||||
| keyof typeof BACKGROUND
|
||||
@@ -23,35 +21,35 @@ type AnimatedPlaceholderProps = {
|
||||
|
||||
export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
|
||||
const colorScheme = useThemeColorScheme();
|
||||
|
||||
const x = useMotionValue(window.innerWidth / 2);
|
||||
const y = useMotionValue(window.innerHeight / 2);
|
||||
|
||||
const translateX = useTransform(x, [0, window.innerWidth], [-2, 2]);
|
||||
const translateY = useTransform(y, [0, window.innerHeight], [-2, 2]);
|
||||
const movingImageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const movingImage = movingImageRef.current;
|
||||
if (movingImage === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setParallax = (offsetX: number, offsetY: number) => {
|
||||
movingImage.style.setProperty('--parallax-x', `${offsetX}px`);
|
||||
movingImage.style.setProperty('--parallax-y', `${offsetY}px`);
|
||||
};
|
||||
|
||||
const handleMove = (event: MouseEvent | TouchEvent) => {
|
||||
const clientX =
|
||||
'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||
const clientY =
|
||||
'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||
|
||||
x.set(clientX);
|
||||
y.set(clientY);
|
||||
setParallax(
|
||||
(clientX / window.innerWidth) * 2 * PARALLAX_OFFSET_IN_PX -
|
||||
PARALLAX_OFFSET_IN_PX,
|
||||
(clientY / window.innerHeight) * 2 * PARALLAX_OFFSET_IN_PX -
|
||||
PARALLAX_OFFSET_IN_PX,
|
||||
);
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
animate(x, window.innerWidth / 2, {
|
||||
type: 'spring',
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
});
|
||||
animate(y, window.innerHeight / 2, {
|
||||
type: 'spring',
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
});
|
||||
setParallax(0, 0);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
@@ -63,7 +61,9 @@ export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
|
||||
window.removeEventListener('touchmove', handleMove);
|
||||
window.document.removeEventListener('mouseleave', handleLeave);
|
||||
};
|
||||
}, [x, y]);
|
||||
}, []);
|
||||
|
||||
const isLarge = type === 'error500' || type === 'error404';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -72,24 +72,16 @@ export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
|
||||
alt=""
|
||||
className={clsx(
|
||||
styles.backgroundImage,
|
||||
(type === 'error500' || type === 'error404') &&
|
||||
styles.backgroundImageLarge,
|
||||
isLarge && styles.backgroundImageLarge,
|
||||
)}
|
||||
/>
|
||||
<motion.img
|
||||
<img
|
||||
ref={movingImageRef}
|
||||
src={
|
||||
colorScheme === 'dark' ? DARK_MOVING_IMAGE[type] : MOVING_IMAGE[type]
|
||||
}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
maxWidth: getMovingImageSize(type),
|
||||
maxHeight: getMovingImageSize(type),
|
||||
zIndex: 2,
|
||||
translateX,
|
||||
translateY,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 100, damping: 10 }}
|
||||
className={clsx(styles.movingImage, isLarge && styles.movingImageLarge)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.svg {
|
||||
animation: circularProgressBarRotate 2s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.circle {
|
||||
animation: circularProgressBarDash 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes circularProgressBarRotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circularProgressBarDash {
|
||||
0% {
|
||||
stroke-dasharray: 10 90;
|
||||
}
|
||||
|
||||
25% {
|
||||
stroke-dasharray: 20 80;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 30 70;
|
||||
}
|
||||
|
||||
75% {
|
||||
stroke-dasharray: 20 80;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 10 90;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
declare const classNames: {
|
||||
readonly svg: 'svg';
|
||||
readonly circle: 'circle';
|
||||
};
|
||||
export default classNames;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import styles from './CircularProgressBar.module.scss';
|
||||
|
||||
type CircularProgressBarProps = {
|
||||
size?: number;
|
||||
@@ -11,61 +10,18 @@ export const CircularProgressBar = ({
|
||||
size = 50,
|
||||
barWidth = 5,
|
||||
barColor = 'currentColor',
|
||||
}: CircularProgressBarProps) => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const circumference = useMemo(
|
||||
() => 2 * Math.PI * (size / 2 - barWidth),
|
||||
[size, barWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const animateIndeterminate = async () => {
|
||||
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
|
||||
|
||||
// Adjusted sequence based on baseSegment
|
||||
const dashSequences = [
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
];
|
||||
|
||||
await controls.start({
|
||||
strokeDasharray: dashSequences,
|
||||
rotate: [0, 720],
|
||||
transition: {
|
||||
strokeDasharray: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
rotate: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
animateIndeterminate();
|
||||
}, [circumference, controls]);
|
||||
|
||||
return (
|
||||
<motion.svg width={size} height={size} animate={controls}>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={size / 2 - barWidth}
|
||||
fill="none"
|
||||
stroke={barColor}
|
||||
strokeWidth={barWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
}: CircularProgressBarProps) => (
|
||||
<svg className={styles.svg} width={size} height={size}>
|
||||
<circle
|
||||
className={styles.circle}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={size / 2 - barWidth}
|
||||
fill="none"
|
||||
stroke={barColor}
|
||||
strokeWidth={barWidth}
|
||||
strokeLinecap="round"
|
||||
pathLength={100}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
+11
@@ -7,6 +7,17 @@
|
||||
gap: var(--t-spacing-6);
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
animation: emptyPlaceholderFadeIn duration(fast) ease;
|
||||
}
|
||||
|
||||
@keyframes emptyPlaceholderFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyTextContainer {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { type HTMLMotionProps, motion } from 'framer-motion';
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { isDefined } from '@ui/utilities/utils/isDefined';
|
||||
|
||||
import styles from './EmptyPlaceholderStyled.module.scss';
|
||||
|
||||
type AnimatedPlaceholderEmptyContainerProps = Pick<
|
||||
HTMLMotionProps<'div'>,
|
||||
'initial' | 'animate' | 'transition'
|
||||
> & {
|
||||
type AnimatedPlaceholderEmptyContainerProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
width?: number;
|
||||
@@ -18,31 +14,17 @@ export const AnimatedPlaceholderEmptyContainer = ({
|
||||
children,
|
||||
className,
|
||||
width,
|
||||
initial,
|
||||
animate,
|
||||
transition,
|
||||
}: AnimatedPlaceholderEmptyContainerProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
className={clsx(styles.emptyContainer, className)}
|
||||
style={isDefined(width) ? { width: `${width}px` } : undefined}
|
||||
initial={initial}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EMPTY_PLACEHOLDER_TRANSITION_PROPS = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
},
|
||||
};
|
||||
|
||||
export const AnimatedPlaceholderEmptyTextContainer = ({
|
||||
className,
|
||||
...rest
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
.bar {
|
||||
position: relative;
|
||||
height: var(--t-spacing-2);
|
||||
width: 100%;
|
||||
background-color: var(--progress-bar-background-color, none);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&[data-with-border-radius] {
|
||||
border-radius: var(--t-border-radius-xxl);
|
||||
}
|
||||
}
|
||||
|
||||
.barFilling {
|
||||
background-color: var(--progress-bar-color, var(--t-font-color-primary));
|
||||
border-radius: 0;
|
||||
.track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
height: 100%;
|
||||
background-color: var(--progress-bar-color, var(--t-font-color-primary));
|
||||
border-radius: 0;
|
||||
transition: width duration(normal) linear;
|
||||
|
||||
&[data-with-border-radius] {
|
||||
border-radius: var(--t-border-radius-md);
|
||||
}
|
||||
|
||||
&[data-nonzero] {
|
||||
min-width: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.countdown {
|
||||
animation: progressBarCountdown
|
||||
var(--progress-bar-countdown-duration, #{duration(normal)}) linear forwards;
|
||||
|
||||
&[data-paused] {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressBarCountdown {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
declare const classNames: {
|
||||
readonly bar: 'bar';
|
||||
readonly barFilling: 'barFilling';
|
||||
readonly track: 'track';
|
||||
readonly indicator: 'indicator';
|
||||
readonly countdown: 'countdown';
|
||||
};
|
||||
export default classNames;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Progress } from '@base-ui/react/progress';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
import styles from './ProgressBar.module.scss';
|
||||
@@ -9,16 +10,11 @@ export type ProgressBarProps = {
|
||||
backgroundColor?: string;
|
||||
withBorderRadius?: boolean;
|
||||
ariaLabel?: string;
|
||||
countdownDurationInMs?: number;
|
||||
isCountdownPaused?: boolean;
|
||||
onCountdownComplete?: () => void;
|
||||
};
|
||||
|
||||
export type StyledBarProps = {
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
withBorderRadius?: boolean;
|
||||
};
|
||||
|
||||
const MIN_BAR_WIDTH_PX = 12;
|
||||
|
||||
export const ProgressBar = ({
|
||||
value,
|
||||
className,
|
||||
@@ -26,36 +22,39 @@ export const ProgressBar = ({
|
||||
backgroundColor = 'none',
|
||||
withBorderRadius = false,
|
||||
ariaLabel,
|
||||
}: ProgressBarProps) => (
|
||||
<div
|
||||
className={clsx(styles.bar, className)}
|
||||
data-with-border-radius={withBorderRadius || undefined}
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuenow={Math.ceil(value)}
|
||||
style={
|
||||
{
|
||||
'--progress-bar-background-color': backgroundColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
minWidth: value > 0 ? MIN_BAR_WIDTH_PX : 0,
|
||||
width: `${Math.ceil(value)}%`,
|
||||
transition: 'width 0.3s linear',
|
||||
}}
|
||||
countdownDurationInMs,
|
||||
isCountdownPaused = false,
|
||||
onCountdownComplete,
|
||||
}: ProgressBarProps) => {
|
||||
const isCountdown = countdownDurationInMs !== undefined;
|
||||
|
||||
return (
|
||||
<Progress.Root
|
||||
className={clsx(styles.bar, className)}
|
||||
data-with-border-radius={withBorderRadius || undefined}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
style={
|
||||
{
|
||||
'--progress-bar-background-color': backgroundColor,
|
||||
...(barColor ? { '--progress-bar-color': barColor } : {}),
|
||||
...(isCountdown
|
||||
? {
|
||||
'--progress-bar-countdown-duration': `${countdownDurationInMs}ms`,
|
||||
}
|
||||
: {}),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.barFilling}
|
||||
data-with-border-radius={withBorderRadius || undefined}
|
||||
style={
|
||||
barColor
|
||||
? ({ '--progress-bar-color': barColor } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Progress.Track className={styles.track}>
|
||||
<Progress.Indicator
|
||||
className={clsx(styles.indicator, isCountdown && styles.countdown)}
|
||||
data-with-border-radius={withBorderRadius || undefined}
|
||||
data-nonzero={(value > 0 && !isCountdown) || undefined}
|
||||
data-paused={(isCountdown && isCountdownPaused) || undefined}
|
||||
onAnimationEnd={isCountdown ? onCountdownComplete : undefined}
|
||||
/>
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { useProgressAnimation } from '@ui/feedback/ProgressBar/hooks/useProgressAnimation';
|
||||
import { ComponentDecorator } from '@ui/testing/decorators/ComponentDecorator';
|
||||
import { ProgressBar } from '@ui/feedback/ProgressBar/ProgressBar';
|
||||
|
||||
@@ -24,25 +23,16 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Animated: Story = {
|
||||
export const Countdown: Story = {
|
||||
tags: ['!test'],
|
||||
argTypes: {
|
||||
value: { control: false },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const { value } = useProgressAnimation({
|
||||
autoPlay: true,
|
||||
initialValue: 0,
|
||||
finalValue: 100,
|
||||
options: {
|
||||
duration: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
return <Story args={{ value }} />;
|
||||
},
|
||||
],
|
||||
args: {
|
||||
value: 100,
|
||||
ariaLabel: 'Progress',
|
||||
countdownDurationInMs: 10000,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { millisecondsToSeconds } from 'date-fns';
|
||||
import {
|
||||
animate,
|
||||
type AnimationPlaybackControls,
|
||||
type ValueAnimationTransition,
|
||||
} from 'framer-motion';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isDefined } from '@ui/utilities/utils/isDefined';
|
||||
|
||||
export const useProgressAnimation = ({
|
||||
autoPlay = true,
|
||||
initialValue = 0,
|
||||
finalValue = 100,
|
||||
options,
|
||||
}: {
|
||||
autoPlay?: boolean;
|
||||
initialValue?: number;
|
||||
finalValue?: number;
|
||||
options?: ValueAnimationTransition<number>;
|
||||
}) => {
|
||||
const [animation, setAnimation] = useState<
|
||||
AnimationPlaybackControls | undefined
|
||||
>();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const startAnimation = useCallback(() => {
|
||||
if (isDefined(animation)) return;
|
||||
|
||||
const duration = isDefined(options?.duration)
|
||||
? millisecondsToSeconds(options.duration)
|
||||
: undefined;
|
||||
|
||||
setAnimation(
|
||||
animate(initialValue, finalValue, {
|
||||
...options,
|
||||
duration,
|
||||
onUpdate: (nextValue) => {
|
||||
if (value === nextValue) return;
|
||||
setValue(nextValue);
|
||||
options?.onUpdate?.(nextValue);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [animation, finalValue, initialValue, options, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay && !animation) {
|
||||
startAnimation();
|
||||
}
|
||||
}, [animation, autoPlay, startAnimation]);
|
||||
|
||||
return {
|
||||
animation,
|
||||
startAnimation,
|
||||
value,
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,6 @@ export { Callout } from './Callout/Callout';
|
||||
export { CircularProgressBar } from './CircularProgressBar/CircularProgressBar';
|
||||
export {
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
@@ -35,11 +34,7 @@ export type { InfoAccent, InfoProps } from './Info/Info';
|
||||
export { Info } from './Info/Info';
|
||||
export { InlineBanner } from './InlineBanner/InlineBanner';
|
||||
export { Loader } from './Loader/Loader';
|
||||
export { useProgressAnimation } from './ProgressBar/hooks/useProgressAnimation';
|
||||
export type {
|
||||
ProgressBarProps,
|
||||
StyledBarProps,
|
||||
} from './ProgressBar/ProgressBar';
|
||||
export type { ProgressBarProps } from './ProgressBar/ProgressBar';
|
||||
export { ProgressBar } from './ProgressBar/ProgressBar';
|
||||
export type { SidePanelInformationBannerProps } from './SidePanelInformationBanner/SidePanelInformationBanner';
|
||||
export { SidePanelInformationBanner } from './SidePanelInformationBanner/SidePanelInformationBanner';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { type MotionProps, motion } from 'framer-motion';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -17,13 +16,12 @@ import {
|
||||
|
||||
import styles from './AnimatedButton.module.scss';
|
||||
|
||||
export type AnimatedButtonProps = ButtonProps &
|
||||
Pick<MotionProps, 'animate' | 'transition'> & {
|
||||
animatedSvg: React.ReactNode;
|
||||
soonLabel?: string;
|
||||
// Renders a square icon-only button (width matches the size-based height).
|
||||
square?: boolean;
|
||||
};
|
||||
export type AnimatedButtonProps = ButtonProps & {
|
||||
animatedSvg: React.ReactNode;
|
||||
soonLabel?: string;
|
||||
// Renders a square icon-only button (width matches the size-based height).
|
||||
square?: boolean;
|
||||
};
|
||||
|
||||
type AnimatedButtonDynamicStyles = {
|
||||
background: string;
|
||||
@@ -329,8 +327,6 @@ export const AnimatedButton = ({
|
||||
dataTestId,
|
||||
hotkeys,
|
||||
ariaLabel,
|
||||
animate,
|
||||
transition,
|
||||
dataClickOutsideId,
|
||||
dataGloballyPreventClickOutside,
|
||||
soonLabel = 'Soon',
|
||||
@@ -392,23 +388,11 @@ export const AnimatedButton = ({
|
||||
data-globally-prevent-click-outside={dataGloballyPreventClickOutside}
|
||||
>
|
||||
{Icon && (
|
||||
<motion.div
|
||||
className={styles.motion}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
>
|
||||
<div className={styles.motion}>
|
||||
<Icon size={theme.icon.size.sm} aria-hidden />
|
||||
</motion.div>
|
||||
)}
|
||||
{animatedSvg && (
|
||||
<motion.div
|
||||
className={styles.motion}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
>
|
||||
{animatedSvg}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{animatedSvg && <div className={styles.motion}>{animatedSvg}</div>}
|
||||
{title}
|
||||
{hotkeys && !isMobile && (
|
||||
<>
|
||||
|
||||
+1
@@ -76,4 +76,5 @@
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: transform duration(normal) ease;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { motion, type MotionProps } from 'framer-motion';
|
||||
import { type ComponentProps, type MouseEvent } from 'react';
|
||||
|
||||
import { type IconComponent } from '@ui/icon';
|
||||
@@ -21,22 +20,21 @@ export type AnimatedLightIconButtonProps = {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
rotate?: number;
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'> &
|
||||
Pick<MotionProps, 'animate' | 'transition'>;
|
||||
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'>;
|
||||
|
||||
export const AnimatedLightIconButton = ({
|
||||
'aria-label': ariaLabel,
|
||||
className,
|
||||
testId,
|
||||
animate,
|
||||
transition,
|
||||
Icon,
|
||||
active = false,
|
||||
size = 'small',
|
||||
accent = 'secondary',
|
||||
disabled = false,
|
||||
focus = false,
|
||||
rotate,
|
||||
onClick,
|
||||
title,
|
||||
}: AnimatedLightIconButtonProps) => {
|
||||
@@ -55,10 +53,13 @@ export const AnimatedLightIconButton = ({
|
||||
className={clsx(styles.button, styles[size], className)}
|
||||
title={title}
|
||||
>
|
||||
<motion.div
|
||||
<div
|
||||
className={styles.iconContainer}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
style={
|
||||
rotate !== undefined
|
||||
? { transform: `rotate(${rotate}deg)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
@@ -66,7 +67,7 @@ export const AnimatedLightIconButton = ({
|
||||
aria-hidden={!!ariaLabel}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
-8
@@ -28,13 +28,9 @@ export const Default: Story = {
|
||||
active: false,
|
||||
focus: false,
|
||||
Icon: IconSearch,
|
||||
animate: { scale: 1.2 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
argTypes: {
|
||||
Icon: { control: false },
|
||||
animate: { control: 'object' },
|
||||
transition: { control: 'object' },
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
@@ -43,16 +39,12 @@ export const Catalog: CatalogStory<Story, typeof AnimatedLightIconButton> = {
|
||||
args: {
|
||||
title: 'Filter',
|
||||
Icon: IconSearch,
|
||||
animate: { scale: 1.2 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
argTypes: {
|
||||
accent: { control: false },
|
||||
disabled: { control: false },
|
||||
active: { control: false },
|
||||
focus: { control: false },
|
||||
animate: { control: 'object' },
|
||||
transition: { control: 'object' },
|
||||
},
|
||||
parameters: {
|
||||
a11y: A11Y_DEFER_COLOR_CONTRAST,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { clsx } from 'clsx';
|
||||
import { type AnimationControls } from 'framer-motion';
|
||||
|
||||
import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown';
|
||||
import { Checkmark } from '@ui/data-display/Checkmark/Checkmark';
|
||||
@@ -12,11 +11,8 @@ import { isDefined } from '@ui/utilities/utils/isDefined';
|
||||
|
||||
import styles from './ColorSchemeCard.module.scss';
|
||||
|
||||
// controls is no longer used since the hover animation is CSS-only now, but it
|
||||
// stays in the exported type for backward-compatible public API parity.
|
||||
export type ColorSchemeSegmentProps = {
|
||||
variant: ColorScheme;
|
||||
controls: AnimationControls;
|
||||
className?: string;
|
||||
} & React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
@@ -27,7 +23,7 @@ const ColorSchemeSegment = ({
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: Omit<ColorSchemeSegmentProps, 'controls'>) => {
|
||||
}: ColorSchemeSegmentProps) => {
|
||||
const grayScale = variant === 'Dark' ? GRAY_SCALE_DARK : GRAY_SCALE_LIGHT;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,4 +2,15 @@
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
animation: animatedCircleLoadingSpin duration(slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes animatedCircleLoadingSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
import styles from './AnimatedCircleLoading.module.scss';
|
||||
|
||||
export const AnimatedCircleLoading = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.container}
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{
|
||||
rotate: 360,
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: theme.animation.duration.slow,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
}) => <div className={styles.container}>{children}</div>;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.container {
|
||||
animation: animatedContainerFadeIn duration(fast) ease;
|
||||
transition: transform duration(fast) ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animatedContainerFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
declare const classNames: {
|
||||
readonly container: 'container';
|
||||
readonly word: 'word';
|
||||
};
|
||||
export default classNames;
|
||||
@@ -1,17 +1,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import styles from './AnimatedContainer.module.scss';
|
||||
|
||||
export const AnimatedContainer = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
whileHover={{ scale: 1.04 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}) => <div className={styles.container}>{children}</div>;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.fadeIn {
|
||||
--animated-ease-in-duration: #{duration(normal)};
|
||||
|
||||
animation: animatedEaseInFadeIn var(--animated-ease-in-duration) linear;
|
||||
|
||||
&[data-duration='instant'] {
|
||||
--animated-ease-in-duration: #{duration(instant)};
|
||||
}
|
||||
|
||||
&[data-duration='fast'] {
|
||||
--animated-ease-in-duration: #{duration(fast)};
|
||||
}
|
||||
|
||||
&[data-duration='slow'] {
|
||||
--animated-ease-in-duration: #{duration(slow)};
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animatedEaseInFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare const classNames: {
|
||||
readonly fadeIn: 'fadeIn';
|
||||
};
|
||||
export default classNames;
|
||||
@@ -1,30 +1,17 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { type AnimationDuration } from '@ui/theme';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
type AnimatedEaseInProps = Omit<
|
||||
React.ComponentProps<typeof motion.div>,
|
||||
'initial' | 'animated' | 'transition'
|
||||
> & {
|
||||
import styles from './AnimatedEaseIn.module.scss';
|
||||
|
||||
type AnimatedEaseInProps = {
|
||||
children?: React.ReactNode;
|
||||
duration?: AnimationDuration;
|
||||
};
|
||||
|
||||
export const AnimatedEaseIn = ({
|
||||
children,
|
||||
duration = 'normal',
|
||||
}: AnimatedEaseInProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const initial = { opacity: 0 };
|
||||
const animate = { opacity: 1 };
|
||||
const transition = {
|
||||
ease: 'linear',
|
||||
duration: theme.animation.duration[duration],
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div initial={initial} animate={animate} transition={transition}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
}: AnimatedEaseInProps) => (
|
||||
<div className={styles.fadeIn} data-duration={duration}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.panel {
|
||||
--animated-ease-in-out-duration: #{duration(normal)};
|
||||
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
height: var(--collapsible-panel-height);
|
||||
margin-top: var(--animated-ease-in-out-margin-top, 0);
|
||||
margin-bottom: var(--animated-ease-in-out-margin-bottom, 0);
|
||||
transition:
|
||||
height var(--animated-ease-in-out-duration) ease-in-out,
|
||||
opacity var(--animated-ease-in-out-duration) ease-in-out,
|
||||
margin var(--animated-ease-in-out-duration) ease-in-out;
|
||||
|
||||
&[data-duration='instant'] {
|
||||
--animated-ease-in-out-duration: #{duration(instant)};
|
||||
}
|
||||
|
||||
&[data-duration='fast'] {
|
||||
--animated-ease-in-out-duration: #{duration(fast)};
|
||||
}
|
||||
|
||||
&[data-duration='slow'] {
|
||||
--animated-ease-in-out-duration: #{duration(slow)};
|
||||
}
|
||||
|
||||
&[data-starting-style] {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[data-ending-style] {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare const classNames: {
|
||||
readonly panel: 'panel';
|
||||
};
|
||||
export default classNames;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Collapsible } from '@base-ui/react/collapsible';
|
||||
import { type AnimationDuration } from '@ui/theme';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
import styles from './AnimatedEaseInOut.module.scss';
|
||||
|
||||
type AnimatedEaseInOutProps = {
|
||||
isOpen: boolean;
|
||||
@@ -17,30 +18,19 @@ export const AnimatedEaseInOut = ({
|
||||
marginBottom,
|
||||
marginTop,
|
||||
duration = 'normal',
|
||||
initial = true,
|
||||
}: AnimatedEaseInOutProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={initial}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
marginBottom: marginBottom ?? 0,
|
||||
marginTop: marginTop ?? 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{ height: 'fit-content', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0, marginBottom: 0, marginTop: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration[duration],
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
}: AnimatedEaseInOutProps) => (
|
||||
<Collapsible.Root open={isOpen}>
|
||||
<Collapsible.Panel
|
||||
className={styles.panel}
|
||||
data-duration={duration}
|
||||
style={
|
||||
{
|
||||
'--animated-ease-in-out-margin-top': marginTop ?? '0',
|
||||
'--animated-ease-in-out-margin-bottom': marginBottom ?? '0',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Collapsible.Panel>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
.panel {
|
||||
--animated-expandable-opacity-duration: #{duration(normal)};
|
||||
--animated-expandable-size-duration: #{duration(normal)};
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity var(--animated-expandable-opacity-duration) ease-in-out;
|
||||
|
||||
&[data-dimension='height'] {
|
||||
height: var(--collapsible-panel-height);
|
||||
transition:
|
||||
height var(--animated-expandable-size-duration) ease-in-out,
|
||||
opacity var(--animated-expandable-opacity-duration) ease-in-out;
|
||||
}
|
||||
|
||||
&[data-dimension='width'] {
|
||||
width: var(--collapsible-panel-width);
|
||||
transition:
|
||||
width var(--animated-expandable-size-duration) ease-in-out,
|
||||
opacity var(--animated-expandable-opacity-duration) ease-in-out;
|
||||
}
|
||||
|
||||
&[data-starting-style],
|
||||
&[data-ending-style] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[data-dimension='height'][data-starting-style],
|
||||
&[data-dimension='height'][data-ending-style] {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&[data-dimension='width'][data-starting-style],
|
||||
&[data-dimension='width'][data-ending-style] {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contained {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&[data-dimension='height'] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
declare const classNames: {
|
||||
readonly panel: 'panel';
|
||||
readonly contained: 'contained';
|
||||
};
|
||||
export default classNames;
|
||||
+21
-64
@@ -1,16 +1,14 @@
|
||||
import { Collapsible } from '@base-ui/react/collapsible';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
|
||||
import { type AnimationDurationObject } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDurationObject';
|
||||
import { type AnimationDurations } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDurations';
|
||||
import { type AnimationMode } from '@ui/layout/AnimatedExpandableContainer/types/AnimationMode';
|
||||
import { type AnimationSize } from '@ui/layout/AnimatedExpandableContainer/types/AnimationSize';
|
||||
import { getExpandableAnimationConfig } from '@ui/layout/AnimatedExpandableContainer/utils/getExpandableAnimationConfig';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { isDefined } from '@ui/utilities/utils/isDefined';
|
||||
|
||||
import styles from './AnimatedExpandableContainer.module.scss';
|
||||
|
||||
type AnimatedExpandableContainerProps = {
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
dimension?: AnimationDimension;
|
||||
animationDurations?: AnimationDurations;
|
||||
@@ -24,66 +22,25 @@ export const AnimatedExpandableContainer = ({
|
||||
isExpanded,
|
||||
dimension = 'height',
|
||||
animationDurations = 'default',
|
||||
mode = 'scroll-height',
|
||||
containAnimation = true,
|
||||
initial = true,
|
||||
}: AnimatedExpandableContainerProps) => {
|
||||
const theme = useTheme();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState<AnimationSize>(0);
|
||||
|
||||
const normalDuration = theme.animation.duration.normal;
|
||||
|
||||
const actualDurations: AnimationDurationObject =
|
||||
const durationStyle =
|
||||
animationDurations === 'default'
|
||||
? {
|
||||
opacity: normalDuration,
|
||||
size: normalDuration,
|
||||
}
|
||||
: 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,
|
||||
);
|
||||
? undefined
|
||||
: ({
|
||||
'--animated-expandable-opacity-duration': `${animationDurations.opacity}s`,
|
||||
'--animated-expandable-size-duration': `${animationDurations.size}s`,
|
||||
} as React.CSSProperties);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={initial}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={motionAnimationVariants}
|
||||
onAnimationStart={updateSize}
|
||||
style={
|
||||
containAnimation
|
||||
? {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Collapsible.Root open={isExpanded}>
|
||||
<Collapsible.Panel
|
||||
className={clsx(styles.panel, containAnimation && styles.contained)}
|
||||
data-dimension={dimension}
|
||||
style={durationStyle}
|
||||
>
|
||||
{children}
|
||||
</Collapsible.Panel>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
|
||||
import { getTransitionValues } from '@ui/layout/AnimatedExpandableContainer/utils/getTransitionValues';
|
||||
|
||||
export const getCommonStyles = (
|
||||
dimension: AnimationDimension,
|
||||
opacityDuration: number,
|
||||
sizeDuration: number,
|
||||
) => ({
|
||||
opacity: 0,
|
||||
[dimension]: 0,
|
||||
...getTransitionValues(dimension, opacityDuration, sizeDuration),
|
||||
});
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
|
||||
import { type AnimationSize } from '@ui/layout/AnimatedExpandableContainer/types/AnimationSize';
|
||||
import { getCommonStyles } from '@ui/layout/AnimatedExpandableContainer/utils/getCommonStyles';
|
||||
import { getTransitionValues } from '@ui/layout/AnimatedExpandableContainer/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),
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/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 { AnimatePresence, motion } from 'framer-motion';
|
||||
import { type AnimationDuration } from '@ui/theme';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
type AnimatedFadeOutProps = {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
duration?: AnimationDuration;
|
||||
marginBottom?: string;
|
||||
marginTop?: string;
|
||||
};
|
||||
|
||||
export const AnimatedFadeOut = ({
|
||||
isOpen,
|
||||
children,
|
||||
duration = 'normal',
|
||||
marginBottom,
|
||||
marginTop,
|
||||
}: AnimatedFadeOutProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 1,
|
||||
marginBottom: marginBottom ?? 0,
|
||||
marginTop: marginTop ?? 0,
|
||||
}}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0, marginTop: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration[duration],
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -11,4 +11,17 @@
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
transition:
|
||||
opacity duration(fast) ease-in-out,
|
||||
transform duration(fast) ease-in-out;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
declare const classNames: {
|
||||
readonly container: 'container';
|
||||
readonly layer: 'layer';
|
||||
readonly visible: 'visible';
|
||||
readonly hidden: 'hidden';
|
||||
};
|
||||
export default classNames;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
import { type IconComponent } from '@ui/icon/types/IconComponent';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
@@ -32,34 +31,22 @@ export const AnimatedIconCrossfade = ({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className={styles.layer}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isActive ? 0 : 1,
|
||||
scale: isActive ? 0.85 : 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
<div
|
||||
className={clsx(
|
||||
styles.layer,
|
||||
isActive ? styles.hidden : styles.visible,
|
||||
)}
|
||||
>
|
||||
<InactiveIcon size={iconSize} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={styles.layer}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isActive ? 1 : 0,
|
||||
scale: isActive ? 1 : 0.85,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.layer,
|
||||
isActive ? styles.visible : styles.hidden,
|
||||
)}
|
||||
>
|
||||
<ActiveIcon size={iconSize} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
.container {
|
||||
--animated-rotate-duration: #{duration(fast)};
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
animation: animatedRotateIn var(--animated-rotate-duration) ease;
|
||||
|
||||
&[data-duration='instant'] {
|
||||
--animated-rotate-duration: #{duration(instant)};
|
||||
}
|
||||
|
||||
&[data-duration='normal'] {
|
||||
--animated-rotate-duration: #{duration(normal)};
|
||||
}
|
||||
|
||||
&[data-duration='slow'] {
|
||||
--animated-rotate-duration: #{duration(slow)};
|
||||
}
|
||||
}
|
||||
|
||||
.animateOnHover {
|
||||
transition: transform duration(fast) ease;
|
||||
|
||||
&:hover {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animatedRotateIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
declare const classNames: {
|
||||
readonly container: 'container';
|
||||
readonly animateOnHover: 'animateOnHover';
|
||||
};
|
||||
export default classNames;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { type HTMLMotionProps, motion } from 'framer-motion';
|
||||
import { type AnimationDuration } from '@ui/theme';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
import { clsx } from 'clsx';
|
||||
import { type JSX } from 'react';
|
||||
|
||||
import { type AnimationDuration } from '@ui/theme';
|
||||
|
||||
import styles from './AnimatedRotate.module.scss';
|
||||
|
||||
type AnimatedRotateProps = Omit<
|
||||
HTMLMotionProps<'div'>,
|
||||
'initial' | 'animate' | 'transition' | 'exit'
|
||||
> & {
|
||||
type AnimatedRotateProps = {
|
||||
children?: React.ReactNode;
|
||||
duration?: AnimationDuration;
|
||||
animateOnHover?: boolean;
|
||||
};
|
||||
@@ -17,35 +15,11 @@ export const AnimatedRotate = ({
|
||||
children,
|
||||
duration = 'fast',
|
||||
animateOnHover,
|
||||
}: AnimatedRotateProps): JSX.Element => {
|
||||
const theme = useTheme();
|
||||
|
||||
const initial = { opacity: 0, rotate: -90 };
|
||||
const animate = { opacity: 1, rotate: 0 };
|
||||
const exit = { opacity: 0, rotate: 90 };
|
||||
const transition = {
|
||||
duration: theme.animation.duration[duration],
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.container}
|
||||
initial={initial}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
exit={exit}
|
||||
whileHover={
|
||||
animateOnHover
|
||||
? {
|
||||
rotate: 45,
|
||||
transition: {
|
||||
duration: theme.animation.duration.fast,
|
||||
},
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
}: AnimatedRotateProps): JSX.Element => (
|
||||
<div
|
||||
className={clsx(styles.container, animateOnHover && styles.animateOnHover)}
|
||||
data-duration={duration}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.word {
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import styles from './AnimatedTextWord.module.scss';
|
||||
|
||||
type AnimatedTextWordProps = Omit<
|
||||
React.ComponentProps<typeof motion.div>,
|
||||
'children'
|
||||
> & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const containerAnimation = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: (i = 1) => ({
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
|
||||
}),
|
||||
};
|
||||
|
||||
const childAnimation = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
damping: 12,
|
||||
stiffness: 100,
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
damping: 12,
|
||||
stiffness: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AnimatedTextWord = ({ text = '' }: AnimatedTextWordProps) => {
|
||||
const words = useMemo(() => {
|
||||
const words = text.split(' ');
|
||||
|
||||
return words.map((value, index) =>
|
||||
index === words.length - 1 ? value : value + ' ',
|
||||
);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.container}
|
||||
variants={containerAnimation}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<motion.span
|
||||
className={styles.word}
|
||||
variants={childAnimation}
|
||||
key={index}
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '@ui/theme-constants';
|
||||
|
||||
type AnimatedTranslationProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AnimatedTranslation = ({ children }: AnimatedTranslationProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: theme.animation.duration.normal,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -17,14 +17,8 @@ export type { AnimationDurationObject } from './AnimatedExpandableContainer/type
|
||||
export type { AnimationDurations } from './AnimatedExpandableContainer/types/AnimationDurations';
|
||||
export type { AnimationMode } from './AnimatedExpandableContainer/types/AnimationMode';
|
||||
export type { AnimationSize } from './AnimatedExpandableContainer/types/AnimationSize';
|
||||
export { getCommonStyles } from './AnimatedExpandableContainer/utils/getCommonStyles';
|
||||
export { getExpandableAnimationConfig } from './AnimatedExpandableContainer/utils/getExpandableAnimationConfig';
|
||||
export { getTransitionValues } from './AnimatedExpandableContainer/utils/getTransitionValues';
|
||||
export { AnimatedFadeOut } from './AnimatedFadeOut/AnimatedFadeOut';
|
||||
export { AnimatedIconCrossfade } from './AnimatedIconCrossfade/AnimatedIconCrossfade';
|
||||
export { AnimatedRotate } from './AnimatedRotate/AnimatedRotate';
|
||||
export { AnimatedTextWord } from './AnimatedTextWord/AnimatedTextWord';
|
||||
export { AnimatedTranslation } from './AnimatedTranslation/AnimatedTranslation';
|
||||
export { AutogrowWrapper } from './AutogrowWrapper/AutogrowWrapper';
|
||||
export { HorizontalSeparator } from './HorizontalSeparator/HorizontalSeparator';
|
||||
export { useResizeHandle } from './ResizeHandle/hooks/useResizeHandle';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { type HTMLMotionProps, motion } from 'framer-motion';
|
||||
import { type ReactNode } from 'react';
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
|
||||
import styles from './CardContent.module.scss';
|
||||
|
||||
@@ -10,7 +9,7 @@ type CardContentProps = {
|
||||
divider?: boolean;
|
||||
isClickable?: boolean;
|
||||
hasHoverHighlight?: boolean;
|
||||
} & Omit<HTMLMotionProps<'div'>, 'theme'>;
|
||||
} & ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
export const CardContent = ({
|
||||
children,
|
||||
@@ -21,7 +20,7 @@ export const CardContent = ({
|
||||
...rest
|
||||
}: CardContentProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
className={clsx(styles.cardContent, className)}
|
||||
data-divider={divider || undefined}
|
||||
data-clickable={isClickable || undefined}
|
||||
@@ -30,6 +29,6 @@ export const CardContent = ({
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53629,9 +53629,7 @@ __metadata:
|
||||
"@vitejs/plugin-react-swc": "npm:^4.3.1"
|
||||
"@vitest/browser-playwright": "npm:^4.1.0"
|
||||
clsx: "npm:^2.1.1"
|
||||
date-fns: "npm:^4.4.0"
|
||||
esbuild: "npm:^0.28.1"
|
||||
framer-motion: "npm:^11.18.0"
|
||||
glob: "npm:^11.1.0"
|
||||
jest: "npm:29.7.0"
|
||||
jest-environment-jsdom: "npm:30.0.0-beta.3"
|
||||
|
||||
Reference in New Issue
Block a user