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: menu dock #30

Merged
merged 20 commits into from
Mar 3, 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
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
Loading