Skip to content

Commit

Permalink
feat: dock menu (#30)
Browse files Browse the repository at this point in the history
* oval shape

* 여유좌표, overflow처리

* circular

* scale opacity

* content setup

* direction

* chore: minWidth 760

* refactor: oval offsetPath

* rotate logic

* offset rotate

* fix: rotate logic

* fix: duration tune

* refactor: DockContent

* gradient utils

* fix: dock content transition, content bg

* fix: initial exit animation

AnimatePresence custom property

* fix: content

* fix: static image

* fine tuing

* svg path save
  • Loading branch information
SoYoung210 authored Mar 3, 2024
1 parent 7df7eb6 commit d5780cf
Show file tree
Hide file tree
Showing 19 changed files with 732 additions and 0 deletions.
35 changes: 35 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"animejs": "^3.2.1",
"babel-preset-gatsby": "^3.6.0",
"canvas-confetti": "^1.6.0",
"chroma-js": "^2.4.2",
"cmdk": "^0.1.20",
"easing-coordinates": "^2.0.2",
"framer-motion": "^8.1.9",
"gatsby": "^5.11.0",
"gatsby-plugin-gtag": "^1.0.13",
Expand Down Expand Up @@ -67,6 +69,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/animejs": "^3.1.7",
"@types/canvas-confetti": "^1.6.0",
"@types/chroma-js": "^2.4.3",
"@types/gatsbyjs__reach-router": "^2.0.0",
"@types/jest": "^29.5.3",
"@types/node": "^20.4.10",
Expand Down
158 changes: 158 additions & 0 deletions src/components/content/menu-dock/DockContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// like radix-ui Tab.Content

import { AnimatePresence, motion, Variants } from 'framer-motion';
import { forwardRef, ReactElement } from 'react';

import { styled } from '../../../../stitches.config';

import { useMenuDockContext } from './context';

// index -> value is better
export interface DockContentProps {
children: React.ReactNode;
index: number;
bottomAddon?: ReactElement;
}
const xAmount = 290;
const yAmount = 40;
const yRotate = 18;
const x = {
left: -1.08 * xAmount,
right: xAmount,
};

const rotateY = {
right: yRotate,
left: -1 * yRotate,
};
interface AnimateParams {
direction: number;
}

const variants: Variants = {
enter: ({ direction }: AnimateParams) => {
return {
x: direction > 0 ? x.left : x.right,
rotateY: direction > 0 ? rotateY.left : rotateY.right,
y: yAmount,
opacity: 0,
scale: 0.85,
transformPerspective: 400,
};
},
center: {
zIndex: 1,
x: 0,
y: 0,
rotateY: 0,
opacity: 1,
scale: 1,
transformPerspective: 400,
},
exit: ({ direction }: AnimateParams) => {
return {
zIndex: 0,
x: direction > 0 ? x.right : x.left,
y: yAmount * 1.1,
rotateY: direction < 0 ? rotateY.left : rotateY.right,
opacity: 0,
scale: 0.85,
transformPerspective: 400,
};
},
};
export const DockContentImpl = forwardRef<HTMLDivElement, DockContentProps>(
(props, ref) => {
const { children, index, bottomAddon, ...restProps } = props;
const { activeIndex, direction } = useMenuDockContext('DockContent');
const visible = activeIndex === index;
const animationDirection = direction === 'clockwise' ? 1 : -1;

return (
<AnimatePresence
initial={false}
custom={{ direction: animationDirection }}
>
{visible ? (
<MotionRoot
ref={ref}
key={index}
custom={{ direction: animationDirection }}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
ease: [0.45, 0, 0.55, 1],
duration: 0.5,
opacity: { duration: 0.3 },
}}
{...restProps}
>
{children}
<Fog>{bottomAddon}</Fog>
</MotionRoot>
) : null}
</AnimatePresence>
);
}
);

const BottomAddonRoot = styled('div', {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
height: '100%',
padding: '0 0 26px 26px',
color: 'rgb(29,29,31)',
fontSize: '14px',
});

const Title = styled('div', {
fontWeight: 600,
});

const Caption = styled('div', {
marginTop: 4,
});

const Fog = styled('div', {
width: '100%',
height: '46%',
zIndex: 1,

position: 'absolute',
bottom: 0,
left: 0,
borderRadius: '0px 0px 32px 32px',

background:
'linear-gradient(transparent, rgba(255, 255, 255, 0.416) 58%, rgb(255, 255, 255, 0.52) 100%)',
});

const MotionRoot = styled(motion.div, {
height: 240,
width: 240,
position: 'absolute',
borderRadius: 32,
display: 'flex',
alignItems: 'center',
boxShadow: '0 0 8px rgba(177, 177, 177, 0.25)',

'&::after': {
content: '',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
boxShadow: 'inset 0 0 1px rgba(0,0,0,0.12)',
borderRadius: 'inherit',
},
});

export const DockContent = Object.assign(DockContentImpl, {
BottomAddonRoot,
Title,
Caption,
});
121 changes: 121 additions & 0 deletions src/components/content/menu-dock/DockItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { composeEventHandlers } from '@radix-ui/primitive';
import { motion } from 'framer-motion';
import { ButtonHTMLAttributes, ReactNode, useMemo } from 'react';

import { styled } from '../../../../stitches.config';
import { usePrevious } from '../../../hooks/usePrevious';

import { useMenuDockContext } from './context';

export interface DockItemProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'onDrag' | 'onDragEnd' | 'onDragStart' | 'onAnimationStart'
> {
children: ReactNode;
index: number;
}

const origin = [0, 1, 2, 3, 4];
const pathLength = [7, 17, 27, 37, 47];
const midIndex = Math.floor(pathLength.length / 2);
// 개수는 5개 기준으로 작성
export function DockItem({
children,
index,
onClick,
...restProps
}: DockItemProps) {
const { onActiveIndexChange, activeIndex, direction, onDirectionChange } =
useMenuDockContext('DockItem');
const offset = Math.abs(activeIndex - midIndex);
const orderedIndex =
offset === 0
? origin
: activeIndex > 2
? //next
[...origin.slice(offset), ...origin.slice(0, offset)]
: //prev
[
...origin.slice(origin.length - offset),
...origin.slice(0, origin.length - offset),
];

const order = orderedIndex.findIndex(i => i === index);

const nextPathLengthValueCandidate = pathLength[order];
const prevPathLengthValue = usePrevious(pathLength[order]);

const nextPathLengthValue = useMemo(() => {
if (direction === 'counterclockwise') {
return nextPathLengthValueCandidate > prevPathLengthValue
? nextPathLengthValueCandidate - 100
: nextPathLengthValueCandidate;
}

return nextPathLengthValueCandidate < prevPathLengthValue
? 100 + nextPathLengthValueCandidate
: nextPathLengthValueCandidate;
}, [direction, nextPathLengthValueCandidate, prevPathLengthValue]);

return (
<Item
onClick={composeEventHandlers(onClick, () => {
const direction = order < 2 ? 'clockwise' : 'counterclockwise';
onDirectionChange(direction);
onActiveIndexChange(index);
})}
initial={false}
animate={{
offsetDistance: `${nextPathLengthValue}%`,
scale: order === 2 ? 1.3 : 1 - Math.abs(offset) * 0.05,
opacity: activeIndex === index ? 1 : 1 - Math.abs(offset) * 0.1,
transitionEnd: {
offsetDistance: `${adjustInRange(nextPathLengthValue, 0, 100)}%`,
},
}}
transition={{
ease: [0.45, 0, 0.55, 1],
duration: 0.6,
scale: {
duration: 0.3,
},
}}
{...restProps}
>
{children}
</Item>
);
}

function adjustInRange(number: number, min: number, max: number): number {
if (number < min) {
// 주어진 숫자가 최소값보다 작으면 최대값을 더해줌
return number + max;
} else if (number > max) {
// 주어진 숫자가 최대값보다 크면 최대값을 뺌
return number - max;
} else {
// 아무 조정이 필요 없는 경우
return number;
}
}

const Item = styled(motion.button, {
position: 'absolute',
top: 0,
left: 0,
offsetPath:
'path("M80.5 93.0032V68.2395C102.179 50.8165 127.503 33.912 163.6 21.3472C199.805 8.74474 246.872 0.5 312 0.5C442.114 0.5 510.116 28.4174 551.5 68.2127V95.9968L80.5 93.0032Z")',
offsetRotate: '0deg',
background: 'transparent',
borderRadius: 12,
border: '1px solid rgba(255, 255, 255, 0.9)',
height: 48,
width: 48,
filter: 'saturate(0.9) brightness(0.9)',
transition: 'transform 0.25s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
Loading

0 comments on commit d5780cf

Please sign in to comment.