Skip to content

Commit

Permalink
feat(Label): celebration animation (#2032)
Browse files Browse the repository at this point in the history
  • Loading branch information
talkor authored Mar 27, 2024
1 parent 59299d9 commit 1af527c
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 18 deletions.
5 changes: 5 additions & 0 deletions packages/core/src/components/Label/Label.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

.clickable {
@include clickable.clickable;

&:active {
transform: scale(0.95) translate3d(0, 0, 0);
transition: var(--motion-productive-short) transform;
}
}

.label {
Expand Down
75 changes: 57 additions & 18 deletions packages/core/src/components/Label/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { camelCase } from "lodash-es";
import cx from "classnames";
import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils";
import { getStyle } from "../../helpers/typesciptCssModulesHelper";
import React, { forwardRef, useCallback, useMemo, useRef } from "react";
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { backwardCompatibilityForProperties } from "../../helpers/backwardCompatibilityForProperties";
import Text from "../Text/Text";
import Leg from "./Leg";
Expand All @@ -11,6 +11,7 @@ import { VibeComponent, VibeComponentProps, withStaticProps } from "../../types"
import useClickableProps from "../../hooks/useClickableProps/useClickableProps";
import useMergeRef from "../../hooks/useMergeRef";
import styles from "./Label.module.scss";
import LabelCelebrationAnimation from "./LabelCelebrationAnimation";

export interface LabelProps extends VibeComponentProps {
/**
Expand All @@ -27,6 +28,7 @@ export interface LabelProps extends VibeComponentProps {
isAnimationDisabled?: boolean;
isLegIncluded?: boolean;
onClick?: (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => void;
celebrationAnimation?: boolean;
}

const Label: VibeComponent<LabelProps> & {
Expand All @@ -45,12 +47,14 @@ const Label: VibeComponent<LabelProps> & {
isLegIncluded = false,
id,
"data-testid": dataTestId,
onClick
onClick,
celebrationAnimation
},
ref
) => {
const labelRef = useRef<HTMLSpanElement>(null);
const mergedRef = useMergeRef(ref, labelRef);
const [isCelebrationAnimation, setIsCelebrationAnimation] = useState(celebrationAnimation);

const overrideClassName = backwardCompatibilityForProperties([className, wrapperClassName]) as string;
const isClickable = Boolean(onClick);
Expand All @@ -62,12 +66,13 @@ const Label: VibeComponent<LabelProps> & {
getStyle(styles, camelCase("kind" + "-" + kind)),
getStyle(styles, camelCase("color" + "-" + color)),
{
[styles.withAnimation]: !isAnimationDisabled,
// When celebrationAnimation is active it wins over the default animation
[styles.withAnimation]: !isAnimationDisabled && !isCelebrationAnimation,
[styles.withLeg]: isLegIncluded
},
labelClassName
),
[kind, color, isAnimationDisabled, isLegIncluded, labelClassName]
[kind, color, isAnimationDisabled, isLegIncluded, labelClassName, isCelebrationAnimation]
);

const onClickCallback = useCallback(
Expand All @@ -91,21 +96,55 @@ const Label: VibeComponent<LabelProps> & {
labelRef
);

return (
<span
{...(isClickable && clickableProps)}
className={cx({ [styles.clickable]: isClickable }, overrideClassName)}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.LABEL, id)}
ref={mergedRef}
>
<Text element="span" type={Text.types.TEXT2} className={classNames} color={Text.colors.ON_INVERTED}>
<Text element="span" type={Text.types.TEXT2} color={Text.colors.INHERIT}>
{text}
useEffect(() => {
setIsCelebrationAnimation(celebrationAnimation);
}, [celebrationAnimation]);

const label = useMemo(() => {
return (
<span
{...(isClickable && clickableProps)}
className={cx({ [styles.clickable]: isClickable }, overrideClassName)}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.LABEL, id)}
ref={mergedRef}
>
<Text
element="span"
type={Text.types.TEXT2}
className={classNames}
color={Text.colors.ON_INVERTED}
data-celebration-text={isCelebrationAnimation}
>
<Text element="span" type={Text.types.TEXT2} color={Text.colors.INHERIT}>
{text}
</Text>
<span className={cx(styles.legWrapper)}>{isLegIncluded ? <Leg /> : null}</span>
</Text>
<span className={cx(styles.legWrapper)}>{isLegIncluded ? <Leg /> : null}</span>
</Text>
</span>
);
</span>
);
}, [
isClickable,
clickableProps,
overrideClassName,
dataTestId,
id,
mergedRef,
classNames,
isCelebrationAnimation,
text,
isLegIncluded
]);

// Celebration animation is applied only for line kind
if (isCelebrationAnimation && kind === "line") {
return (
<LabelCelebrationAnimation onAnimationEnd={() => setIsCelebrationAnimation(false)}>
{label}
</LabelCelebrationAnimation>
);
}

return label;
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
.celebration {
// Fallback perimeter, changes according to the path length
--container-perimeter: 840;
position: relative;

.svg {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;

.stroke {
fill: none;
stroke-width: 1;
stroke-linecap: round;
stroke-linejoin: round;
animation: stroke-rotate cubic-bezier(0.33, 0, 0.67, 1) forwards;
stroke-dasharray: var(--container-perimeter);
stroke-dashoffset: var(--container-perimeter);

&.base {
stroke: var(--color-egg_yolk);
animation: fade 200ms linear forwards;
animation-delay: 80ms;
stroke-dasharray: 0;
stroke-dashoffset: 0;
opacity: 0;
}

&.first {
stroke: var(--color-done-green);
animation-delay: 80ms;
animation-duration: 320ms;
}

&.second {
stroke: var(--color-stuck-red);
animation-delay: 200ms;
animation-duration: 320ms;
}

&.third {
stroke: var(--primary-color);
animation-delay: 360ms;
animation-duration: 320ms;
}
}
}

[data-celebration-text] {
background-size: 300% 100%;
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: gradient-text-fill 680ms linear forwards;
background-image: linear-gradient(
to right,
var(--primary-color) 30%,
var(--color-stuck-red) 40%,
var(--color-done-green) 60%,
var(--color-egg_yolk) 85%,
transparent 90%
);
}
}

@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes stroke-rotate {
to {
stroke-dashoffset: 0;
}
}

@keyframes gradient-text-fill {
from {
background-position: 150% 0;
}
to {
background-position: 0% 0;
}
}
99 changes: 99 additions & 0 deletions packages/core/src/components/Label/LabelCelebrationAnimation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { cloneElement, forwardRef, useCallback, useRef, useState } from "react";
import cx from "classnames";
import useResizeObserver from "../../hooks/useResizeObserver";
import styles from "./LabelCelebrationAnimation.module.scss";

const DEFAULT_BORDER_RADIUS = 4;
const DEFAULT_STROKE_WIDTH = 1;

export interface LabelCelebrationAnimationProps {
children: React.ReactElement;
onAnimationEnd: () => void;
}

function LabelCelebrationAnimation({ children, onAnimationEnd }: LabelCelebrationAnimationProps) {
const wrapperRef = useRef<HTMLDivElement>();
const childRef = useRef<HTMLDivElement>();

const [path, setPath] = useState<string>();

const resizeObserverCallback = useCallback(
({ borderBoxSize }: { borderBoxSize: { blockSize: number; inlineSize: number } }) => {
const { blockSize: height, inlineSize: width } = borderBoxSize || {};

if (wrapperRef.current) {
const d = getPath({ width, height });
setPath(d);

const perimeter = getPerimeter({ width, height });
wrapperRef.current.style.setProperty("--container-perimeter", String(perimeter));
}
},
[]
);

useResizeObserver({
ref: wrapperRef,
callback: resizeObserverCallback,
debounceTime: 0
});

const ChildComponentWithRef = forwardRef((_props, ref) =>
cloneElement(children, {
ref
})
);

return (
<div className={styles.celebration} ref={wrapperRef}>
<svg className={styles.svg}>
<path className={cx(styles.stroke, styles.base)} d={path} />
<path className={cx(styles.stroke, styles.first)} d={path} />
<path className={cx(styles.stroke, styles.second)} d={path} />
<path className={cx(styles.stroke, styles.third)} d={path} onAnimationEnd={onAnimationEnd} />
</svg>
<ChildComponentWithRef ref={childRef} />
</div>
);
}

export default LabelCelebrationAnimation;

function getPath({
width,
height,
borderRadius = DEFAULT_BORDER_RADIUS,
strokeWidth = DEFAULT_STROKE_WIDTH
}: {
width: number;
height: number;
borderRadius?: number;
strokeWidth?: number;
}) {
const offset = strokeWidth / 2;

return `M ${width - strokeWidth / 2}, ${borderRadius} V ${
height - borderRadius
} A ${borderRadius} ${borderRadius} 0 0 1 ${width - borderRadius} ${height - strokeWidth / 2} H ${
borderRadius + offset
} A ${borderRadius} ${borderRadius} 0 0 1 ${strokeWidth / 2} ${height - borderRadius} V ${
borderRadius + offset
} A ${borderRadius} ${borderRadius} 0 0 1 ${borderRadius} ${strokeWidth / 2} L ${width - borderRadius}, ${
strokeWidth / 2
} A ${borderRadius} ${borderRadius} 0 0 1 ${width - strokeWidth / 2} ${borderRadius} Z`;
}

function getPerimeter({
width,
height,
borderRadius = DEFAULT_BORDER_RADIUS
}: {
width: number;
height: number;
borderRadius?: number;
}) {
const straightWidth = width - 2 * borderRadius;
const straightHeight = height - 2 * borderRadius;
const cornerCircumference = 2 * Math.PI * borderRadius;
return cornerCircumference + 2 * straightWidth + 2 * straightHeight;
}
6 changes: 6 additions & 0 deletions packages/core/src/components/Label/__stories__/label.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ In case of visual overload, use the secondary label in order to create hirarchy.

<Canvas of={LabelStories.SecondaryLabel} />

### Celebration

To celebrate new feature, outline label can be highlighted by adding celebrate animation.

<Canvas of={LabelStories.Celebration} />

## Related components

<RelatedComponents componentsNames={[TOOLTIP, COUNTER, CHIP]} />
28 changes: 28 additions & 0 deletions packages/core/src/components/Label/__stories__/label.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Label from "../Label";
import Button from "../../Button/Button";
import { createStoryMetaSettingsDecorator } from "../../../storybook";
import { NOOP } from "../../../utils/function-utils";
import { createComponentTemplate, MultipleStoryElementsWrapper } from "vibe-storybook-components";
import "./label.stories.scss";
import { useEffect, useState } from "react";

const metaSettings = createStoryMetaSettingsDecorator({
component: Label,
Expand Down Expand Up @@ -130,3 +132,29 @@ export const SecondaryLabel = {
}
}
};

export const Celebration = {
render: () => {
const [animate, setAnimate] = useState(false);

useEffect(() => {
setTimeout(() => {
setAnimate(false);
}, 500);
}, [animate]);

return (
<>
<Label text="New" kind={Label.kinds.LINE} celebrationAnimation={animate} isAnimationDisabled />
<Button size={Button.sizes.SMALL} kind={Button.kinds.TERTIARY} onClick={() => setAnimate(true)}>
Click to celebrate
</Button>
</>
);
},
parameters: {
chromatic: {
pauseAnimationAtEnd: true
}
}
};

0 comments on commit 1af527c

Please sign in to comment.