diff --git a/packages/core/src/components/Label/Label.tsx b/packages/core/src/components/Label/Label.tsx index 9d28acdff6..e5bced2c3e 100644 --- a/packages/core/src/components/Label/Label.tsx +++ b/packages/core/src/components/Label/Label.tsx @@ -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; + celebration?: boolean; } const Label: VibeComponent & { @@ -45,7 +47,8 @@ const Label: VibeComponent & { isLegIncluded = false, id, "data-testid": dataTestId, - onClick + onClick, + celebration }, ref ) => { @@ -62,12 +65,12 @@ const Label: VibeComponent & { getStyle(styles, camelCase("kind" + "-" + kind)), getStyle(styles, camelCase("color" + "-" + color)), { - [styles.withAnimation]: !isAnimationDisabled, + [styles.withAnimation]: !isAnimationDisabled && !celebration, [styles.withLeg]: isLegIncluded }, labelClassName ), - [kind, color, isAnimationDisabled, isLegIncluded, labelClassName] + [kind, color, isAnimationDisabled, isLegIncluded, labelClassName, celebration] ); const onClickCallback = useCallback( @@ -92,19 +95,27 @@ const Label: VibeComponent & { ); return ( - - - - {text} + + + + + {text} + + {isLegIncluded ? : null} - {isLegIncluded ? : null} - - + + ); } ); 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..b769d79c05 --- /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: #ffcc00; + animation: fade 200ms linear forwards; + animation-delay: 80ms; + stroke-dasharray: 0; + stroke-dashoffset: 0; + opacity: 0; + } + + &.first { + stroke: #00ca72; + animation-delay: 80ms; + animation-duration: 320ms; + } + + &.second { + stroke: #fb275d; + 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%, + #fb275d 40%, + #00ca72 60%, + #ffcc00 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..62759e8f31 --- /dev/null +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -0,0 +1,112 @@ +import React, { cloneElement, forwardRef, useCallback, useEffect, 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; + active: boolean; +} + +function LabelCelebrationAnimation({ children, active: isActive }: LabelCelebrationAnimationProps) { + const wrapperRef = useRef(); + const childRef = useRef(); + + const [active, setActive] = useState(isActive); + const [path, setPath] = useState(); + + useEffect(() => { + setActive(isActive); + }, [isActive]); + + const resizeObserverCallback = useCallback( + ({ borderBoxSize }: { borderBoxSize: { blockSize: number; inlineSize: number } }) => { + const { blockSize: height, inlineSize: width } = borderBoxSize || {}; + + if (wrapperRef.current && active) { + const d = getPath({ width, height }); + setPath(d); + + const perimeter = getPerimeter({ width, height }); + wrapperRef.current.style.setProperty("--container-perimeter", String(perimeter)); + } + }, + [active] + ); + + useResizeObserver({ + ref: wrapperRef, + callback: resizeObserverCallback, + debounceTime: 0 + }); + + const ChildComponentWithRef = forwardRef((_props, ref) => + cloneElement(children, { + ref + }) + ); + + if (!active) { + return children; + } + + return ( +
+ + + + + { + setActive(false); + }} + /> + + +
+ ); +} + +export default LabelCelebrationAnimation; + +function getPath({ + width, + height, + borderRadius = DEFAULT_BORDER_RADIUS, + strokeWidth = DEFAULT_STROKE_WIDTH +}: { + width: number; + height: number; + borderRadius?: number; + strokeWidth?: number; +}) { + return `M ${width - strokeWidth / 2}, ${borderRadius} V ${ + height - borderRadius + } A ${borderRadius} ${borderRadius} 0 0 1 ${width - borderRadius} ${ + height - strokeWidth / 2 + } H ${borderRadius} A ${borderRadius} ${borderRadius} 0 0 1 ${strokeWidth / 2} ${ + height - borderRadius + } V ${borderRadius} 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; +}