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

Add menu position prop #24

Merged
merged 3 commits into from
Jul 31, 2023
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
9 changes: 6 additions & 3 deletions components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import MenuItem from './MenuItem';
import MenuToggle from './MenuToggle';
import MenuList from './MenuList';
import {MenuContext} from './MenuContext';
import {MenuPosition} from './MenuPosition';

/**
* Props.
Expand All @@ -12,17 +13,19 @@ type MenuProps = {
/** @default false */
initialOpen?: boolean;
children?: ReactNode;
};
} & MenuPosition;

/**
* @param {MenuProps} props Props.
* @returns React component.
*/
export default function Menu({initialOpen = false, children}: MenuProps) {
export default function Menu({initialOpen = false, children, ...position}: MenuProps) {
const [isOpen, setIsOpen] = useState(initialOpen);
useDisableBodyScroll(isOpen);

return <MenuContext.Provider value={{isOpen, setIsOpen}}>{children}</MenuContext.Provider>;
return (
<MenuContext.Provider value={{isOpen, setIsOpen, position}}>{children}</MenuContext.Provider>
);
}

Menu.Item = MenuItem;
Expand Down
2 changes: 2 additions & 0 deletions components/Menu/MenuContext.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {createContext} from 'react';
import {MenuPosition} from './MenuPosition';

/**
* Context type.
*/
type MenuConextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
position: MenuPosition;
};

/**
Expand Down
2 changes: 1 addition & 1 deletion components/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const variants = {
},
};

const itemCn = clsx('text-4xl', 'm-2', 'text-center');
const itemCn = clsx('text-5xl', 'm-2', 'text-center');

/**
* @param {MenuItemProps} props Props.
Expand Down
46 changes: 38 additions & 8 deletions components/Menu/MenuList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import clsx from 'clsx';
import useScroll from '@/lib/shared/useScroll';
import useWindowDimensions from '@/lib/shared/useWindowDimensions';
import {MenuContext} from './MenuContext';
import {MenuPosition} from './MenuPosition';
import menuButtonSize from './menuButtonSize';

/**
* Props.
Expand All @@ -13,25 +15,53 @@ type MenuListProps = {
children: ReactNode;
};

// eslint-disable-next-line jsdoc/require-jsdoc
function getClipPath({height, position}: {height?: number; position: MenuPosition}) {
let coordinates = '';

const {top, bottom, left, right} = position;

if (left !== undefined) {
coordinates += `${left + menuButtonSize / 2}px`;
}

if (right !== undefined) {
coordinates += `calc(100% - ${right + menuButtonSize / 2}px)`;
}

if (top !== undefined) {
coordinates += ' ' + `${top + menuButtonSize / 2}px`;
}

if (bottom !== undefined) {
coordinates += ' ' + `calc(100% - ${bottom + menuButtonSize / 2}px)`;
}

const size = height ? `${height * 2 + 200}px` : '0px';

return `circle(${size} at ${coordinates}`;
}

const navVariants = {
// eslint-disable-next-line jsdoc/require-jsdoc
open: (height: number) => ({
clipPath: `circle(${height * 2 + 200}px at 95% 5%)`,
open: ({height, position}: {height: number; position: MenuPosition}) => ({
clipPath: getClipPath({height, position}),
transition: {
type: 'spring',
stiffness: 20,
restDelta: 2,
},
}),
closed: {
clipPath: 'circle(30px at 95% 5%)',
// eslint-disable-next-line jsdoc/require-jsdoc
closed: ({position}: {height: number; position: MenuPosition}) => ({
clipPath: getClipPath({position}),
transition: {
delay: 0.5,
type: 'spring',
stiffness: 400,
damping: 40,
},
},
}),
};

const navCn = clsx(
Expand All @@ -41,7 +71,7 @@ const navCn = clsx(
'w-full',
'h-full',
'z-10',
'bg-alternate',
'bg-gradient-to-bl from-accent0 to-alternate',
'flex',
'flex-col',
'items-center',
Expand All @@ -53,7 +83,7 @@ const navCn = clsx(
* @returns React component.
*/
export default function MenuList({children}: MenuListProps) {
const {isOpen, setIsOpen} = useContext(MenuContext);
const {isOpen, setIsOpen, position} = useContext(MenuContext);
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
useHotkeys('esc', close);
useScroll(close);
Expand All @@ -71,7 +101,7 @@ export default function MenuList({children}: MenuListProps) {
<m.nav
initial={false}
animate={isOpen ? 'open' : 'closed'}
custom={height}
custom={{height, position}}
className={navCn}
variants={navVariants}
onBlur={onBlur}
Expand Down
41 changes: 41 additions & 0 deletions components/Menu/MenuPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type {XOR} from 'ts-xor';

/**
* Position using top and right props.
*/
type PositionTopRight = {
top: number;
right: number;
};

/**
* Position using top and left props.
*/
type PositionTopLeft = {
top: number;
left: number;
};

/**
* Position using bottom and right props.
*/
type PositionBottomRight = {
bottom: number;
right: number;
};

/**
* Position using bottom and left props.
*/
type PositionBottomLeft = {
bottom: number;
left: number;
};

/**
* Position of menu using style position props.
*/
export type MenuPosition = XOR<
PositionTopRight,
XOR<PositionTopLeft, XOR<PositionBottomRight, PositionBottomLeft>>
>;
47 changes: 11 additions & 36 deletions components/Menu/MenuToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import {SVGMotionProps, Variants, m} from 'framer-motion';
import {Variants, m} from 'framer-motion';
import {forwardRef, memo, useContext} from 'react';
import clsx from 'clsx';
import {twMerge} from 'tailwind-merge';
import {MenuContext} from './MenuContext';
import menuButtonSize from './menuButtonSize';
import Button from '../Button/Button';
import MenuToggleSvgContent from './MenuToggleSvgContent';

/**
* Props.
Expand All @@ -20,29 +21,18 @@ const menuVariants: Variants = {
hidden: {opacity: 0},
};

const containerCn = clsx('absolute', 'top-16', 'right-16', 'z-20');
const btnCn = clsx('rounded-full w-24 h-24');

// eslint-disable-next-line jsdoc/require-jsdoc
const Path = ({variants}: SVGMotionProps<SVGPathElement>) => (
<m.path
fill="white"
strokeWidth="4"
stroke="white"
strokeLinecap="round"
variants={variants}
/>
);
const containerCn = clsx('absolute', 'z-20');
const btnCn = clsx('rounded-full');

/**
* @param {MenuToggleProps} props Props.
* @returns React component.
*/
const MenuToggle = forwardRef<HTMLButtonElement, MenuToggleProps>(function MenuToggle(
{onToggle, className},
{onToggle},
ref,
) {
const {setIsOpen, isOpen} = useContext(MenuContext);
const {setIsOpen, isOpen, position} = useContext(MenuContext);
// eslint-disable-next-line jsdoc/require-jsdoc
const onClick = () => {
setIsOpen(!isOpen);
Expand All @@ -57,32 +47,17 @@ const MenuToggle = forwardRef<HTMLButtonElement, MenuToggleProps>(function MenuT
transition={{duration: 0.75, delay: 1}}
variants={menuVariants}
className={containerCn}
style={position}
animate={isOpen ? 'open' : 'closed'}
>
<Button
onClick={onClick}
className={twMerge(btnCn, className)}
className={btnCn}
ref={ref}
variant="alternate"
style={{width: menuButtonSize, height: menuButtonSize}}
>
<svg
width="23"
height="23"
viewBox="0 0 23 23"
>
<Path
variants={{
closed: {d: 'M 2 2.5 L 20 2.5'},
open: {d: 'M 3 16.5 L 17 2.5'},
}}
/>
<Path
variants={{
closed: {d: 'M 2 16.346 L 20 16.346'},
open: {d: 'M 3 2.5 L 17 16.346'},
}}
/>
</svg>
<MenuToggleSvgContent />
</Button>
</m.div>
);
Expand Down
59 changes: 59 additions & 0 deletions components/Menu/MenuToggleSvgContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {SVGMotionProps, m} from 'framer-motion';
import clsx from 'clsx';
import menuButtonSize from './menuButtonSize';

const pathCn = clsx('fill-white', 'stroke-white');

// eslint-disable-next-line jsdoc/require-jsdoc
const Path = ({variants}: SVGMotionProps<SVGPathElement>) => (
<m.path
strokeWidth={menuButtonSize / 18}
strokeLinecap="round"
variants={variants}
className={pathCn}
/>
);

/**
* @returns React component.
*/
export default function MenuToggleSvgContent() {
const svgViewBoxSize = menuButtonSize / 2;
const svgElSize = svgViewBoxSize / 1.5;
const svgElOffset = (svgViewBoxSize - svgElSize) / 2;

return (
<svg
width={svgViewBoxSize}
height={svgViewBoxSize}
viewBox={`0 0 ${svgViewBoxSize} ${svgViewBoxSize}`}
>
<Path
variants={{
closed: {
d: `M ${svgElOffset},${svgElOffset} L ${svgElSize + svgElOffset},${svgElOffset}`,
},
open: {
d: `M ${svgElOffset},${svgElOffset} L ${svgElSize + svgElOffset},${
svgElSize + svgElOffset
}`,
},
}}
/>
<Path
variants={{
closed: {
d: `M ${svgElOffset},${svgElSize + svgElOffset} L ${svgElSize + svgElOffset},${
svgElSize + svgElOffset
}`,
},
open: {
d: `M ${svgElOffset},${svgElSize + svgElOffset} L ${
svgElSize + svgElOffset
},${svgElOffset}`,
},
}}
/>
</svg>
);
}
3 changes: 3 additions & 0 deletions components/Menu/menuButtonSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const menuButtonSize = 80;

export default menuButtonSize;
Loading