Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Label): celebration animation #2032

Merged
merged 6 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
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) =>
shaharzil marked this conversation as resolved.
Show resolved Hide resolved
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({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all just math haha

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 @@ -144,6 +144,12 @@ In case of visual overload, use the secondary label in order to create hirarchy.
<Story of={LabelStories.SecondaryLabel} />
</Canvas>

### 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]} />
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
}
}
};