diff --git a/packages/core/src/components/Label/Label.module.scss b/packages/core/src/components/Label/Label.module.scss index 3fd88972a6..536efb0fe4 100644 --- a/packages/core/src/components/Label/Label.module.scss +++ b/packages/core/src/components/Label/Label.module.scss @@ -4,6 +4,11 @@ .clickable { @include clickable.clickable; + + &:active { + transform: scale(0.95) translate3d(0, 0, 0); + transition: var(--motion-productive-short) transform; + } } .label { diff --git a/packages/core/src/components/Label/Label.tsx b/packages/core/src/components/Label/Label.tsx index 9d28acdff6..9a6004d848 100644 --- a/packages/core/src/components/Label/Label.tsx +++ b/packages/core/src/components/Label/Label.tsx @@ -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"; @@ -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 { /** @@ -27,6 +28,7 @@ export interface LabelProps extends VibeComponentProps { isAnimationDisabled?: boolean; isLegIncluded?: boolean; onClick?: (event: React.MouseEvent) => void; + celebrationAnimation?: boolean; } const Label: VibeComponent & { @@ -45,12 +47,14 @@ const Label: VibeComponent & { isLegIncluded = false, id, "data-testid": dataTestId, - onClick + onClick, + celebrationAnimation }, ref ) => { const labelRef = useRef(null); const mergedRef = useMergeRef(ref, labelRef); + const [isCelebrationAnimation, setIsCelebrationAnimation] = useState(celebrationAnimation); const overrideClassName = backwardCompatibilityForProperties([className, wrapperClassName]) as string; const isClickable = Boolean(onClick); @@ -62,12 +66,13 @@ const Label: VibeComponent & { 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( @@ -91,21 +96,55 @@ const Label: VibeComponent & { labelRef ); - return ( - - - - {text} + useEffect(() => { + setIsCelebrationAnimation(celebrationAnimation); + }, [celebrationAnimation]); + + const label = useMemo(() => { + return ( + + + + {text} + + {isLegIncluded ? : null} - {isLegIncluded ? : null} - - - ); + + ); + }, [ + isClickable, + clickableProps, + overrideClassName, + dataTestId, + id, + mergedRef, + classNames, + isCelebrationAnimation, + text, + isLegIncluded + ]); + + // Celebration animation is applied only for line kind + if (isCelebrationAnimation && kind === "line") { + return ( + setIsCelebrationAnimation(false)}> + {label} + + ); + } + + return label; } ); diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.module.scss b/packages/core/src/components/Label/LabelCelebrationAnimation.module.scss new file mode 100644 index 0000000000..759a87323e --- /dev/null +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.module.scss @@ -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; + } +} diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx new file mode 100644 index 0000000000..eaa599eaff --- /dev/null +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -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(); + const childRef = useRef(); + + const [path, setPath] = useState(); + + 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 ( +
+ + + + + + + +
+ ); +} + +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; +} diff --git a/packages/core/src/components/Label/__stories__/label.mdx b/packages/core/src/components/Label/__stories__/label.mdx index 8526d32e2d..ccf41421bd 100644 --- a/packages/core/src/components/Label/__stories__/label.mdx +++ b/packages/core/src/components/Label/__stories__/label.mdx @@ -134,6 +134,12 @@ In case of visual overload, use the secondary label in order to create hirarchy. +### Celebration + +To celebrate new feature, outline label can be highlighted by adding celebrate animation. + + + ## Related components diff --git a/packages/core/src/components/Label/__stories__/label.stories.tsx b/packages/core/src/components/Label/__stories__/label.stories.tsx index 7af9b0a4d7..66dd05f0fc 100644 --- a/packages/core/src/components/Label/__stories__/label.stories.tsx +++ b/packages/core/src/components/Label/__stories__/label.stories.tsx @@ -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, @@ -130,3 +132,29 @@ export const SecondaryLabel = { } } }; + +export const Celebration = { + render: () => { + const [animate, setAnimate] = useState(false); + + useEffect(() => { + setTimeout(() => { + setAnimate(false); + }, 500); + }, [animate]); + + return ( + <> +