diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index 9e23f62..04b8d86 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -8,6 +8,10 @@ import { } from "solid-js"; import { ANIM_CURVE_STD } from "./theme"; import "./Menu.css"; +import { + animateGrowFromTopRight, + animateShrinkToTopRight, +} from "../platform/anim"; type Props = { open?: boolean; @@ -51,23 +55,7 @@ const Menu: ParentComponent = (props) => { top, }); - const overflow = root.style.overflow; - root.style.overflow = "hidden"; - const duration = (rend.height / 1600) * 1000; - const easing = ANIM_CURVE_STD; - const animation = root.animate( - { - height: [`${rend.height / 2}px`, `${rend.height}px`], - }, - { - duration, - easing, - }, - ); - animation.addEventListener( - "finish", - () => (root.style.overflow = overflow), - ); + animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); } else { openAnimationOrigin = "lt"; setAnchorPos({ left, top }); @@ -119,19 +107,10 @@ const Menu: ParentComponent = (props) => { root.close(); }); } else { - const overflow = root.style.overflow; - root.style.overflow = "hidden"; - const animation = root.animate( - { - height: [`${rend.height}px`, `${rend.height / 2}px`], - }, - { - duration: (rend.height / 2 / 1600) * 1000, - easing: ANIM_CURVE_STD, - }, - ); + const animation = animateShrinkToTopRight(root, { + easing: ANIM_CURVE_STD, + }); animation.addEventListener("finish", () => { - root.style.overflow = overflow; root.close(); }); } diff --git a/src/material/theme.css b/src/material/theme.css index e7cb4d9..09cc746 100644 --- a/src/material/theme.css +++ b/src/material/theme.css @@ -117,6 +117,7 @@ --tutu-shadow-e24: 0px 24px 48px 0px var(--tutu-color-shadow-l2); + /* curves are also hard-coded in theme.ts */ --tutu-anim-curve-std: cubic-bezier(0.4, 0, 0.2, 1); --tutu-anim-curve-deceleration: cubic-bezier(0, 0, 0.2, 1); --tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1); diff --git a/src/platform/anim.ts b/src/platform/anim.ts index 87549d0..3dd0ead 100644 --- a/src/platform/anim.ts +++ b/src/platform/anim.ts @@ -53,3 +53,145 @@ export function useHeroSignal( return [() => undefined, () => undefined]; } } + +export function animateRollOutFromTop( + root: HTMLElement, + options?: Omit, +) { + const overflow = root.style.overflow; + root.style.overflow = "hidden"; + + const { height } = root.getBoundingClientRect(); + + const opts = Object.assign( + { + duration: Math.floor((height / 1600) * 1000), + }, + options, + ); + + const animation = root.animate( + { + height: ["0px", `${height}px`], + }, + opts, + ); + + const restore = () => (root.style.overflow = overflow); + + animation.addEventListener("finish", restore); + animation.addEventListener("cancel", restore); + + return animation; +} + + +export function animateRollInFromBottom( + root: HTMLElement, + options?: Omit, +) { + const overflow = root.style.overflow; + root.style.overflow = "hidden"; + + const { height } = root.getBoundingClientRect(); + + const opts = Object.assign( + { + duration: Math.floor((height / 1600) * 1000), + }, + options, + ); + + const animation = root.animate( + { + height: [`${height}px`, "0px"], + }, + opts, + ); + + const restore = () => (root.style.overflow = overflow); + + animation.addEventListener("finish", restore); + animation.addEventListener("cancel", restore); + + return animation; +} + +export function animateGrowFromTopRight( + root: HTMLElement, + options?: KeyframeAnimationOptions, +) { + const transformOrigin = root.style.transformOrigin; + root.style.transformOrigin = "top right"; + + const { width, height } = root.getBoundingClientRect(); + + const durationX = Math.floor((height / 1600) * 1000); + const durationY = Math.floor((width / 1600) * 1000); + + // finds the offset for the center frame, + // it will stops at the (minDuration / maxDuration)% + const minDuration = Math.min(durationX, durationY); + const maxDuration = Math.max(durationX, durationY); + + const centerOffset = minDuration / maxDuration; + + const keyframes = [ + { transform: "scaleX(0)", opacity: 0, height: "0px", offset: 0 }, + { + transform: `scaleX(${minDuration === durationX ? "1" : centerOffset})`, + height: `${(minDuration === durationY ? 1 : centerOffset) * height}px`, + offset: centerOffset, + opacity: 1, + }, + { transform: "scaleX(1)", height: `${height}px`, offset: 1 }, + ]; + + const animation = root.animate( + keyframes, + { ...options, duration: maxDuration }, + ); + + const restore = () => { + root.style.transformOrigin = transformOrigin; + }; + + animation.addEventListener("cancel", restore); + animation.addEventListener("finish", restore); + + return animation; +} + +export function animateShrinkToTopRight( + root: HTMLElement, + options?: KeyframeAnimationOptions, +) { + const overflow = root.style.overflow; + root.style.overflow = "hidden"; + const transformOrigin = root.style.transformOrigin; + root.style.transformOrigin = "top right"; + + const { width, height } = root.getBoundingClientRect(); + + const duration = Math.floor( + Math.max((width / 1600) * 1000, (height / 1600) * 1000), + ); + + const animation = root.animate( + { + transform: ["scale(1)", "scale(0.5)"], + opacity: [1, 0], + }, + { ...options, duration }, + ); + + const restore = () => { + root.style.overflow = overflow; + root.style.transformOrigin = transformOrigin; + }; + + animation.addEventListener("cancel", restore); + animation.addEventListener("finish", restore); + + return animation; +}