Initial commit
This commit is contained in:
48
src/components/navbar/Logo.tsx
Normal file
48
src/components/navbar/Logo.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
brandName?: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const Logo = memo<LogoProps>(function Logo({
|
||||
logoSrc,
|
||||
logoAlt = "",
|
||||
brandName = "Webild",
|
||||
className = "",
|
||||
imageClassName = "",
|
||||
textClassName = ""
|
||||
}) {
|
||||
if (logoSrc) {
|
||||
return (
|
||||
<div className={cls("relative h-[var(--text-xl)] w-auto", className)}>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={logoAlt}
|
||||
width={100}
|
||||
height={24}
|
||||
className={cls("h-full w-auto object-contain", imageClassName)}
|
||||
unoptimized={logoSrc.startsWith('http') || logoSrc.startsWith('//')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<h2 className={cls("text-xl font-medium text-foreground", textClassName)}>
|
||||
{brandName}
|
||||
</h2>
|
||||
);
|
||||
});
|
||||
|
||||
Logo.displayName = "Logo";
|
||||
|
||||
export default Logo;
|
||||
82
src/components/navbar/NavbarLayoutFloatingInline.tsx
Normal file
82
src/components/navbar/NavbarLayoutFloatingInline.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { memo } from "react";
|
||||
import Button from "../button/Button";
|
||||
import ButtonTextUnderline from "../button/ButtonTextUnderline";
|
||||
import Logo from "./Logo";
|
||||
import { NavItem } from "@/types/navigation";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig } from "@/types/button";
|
||||
|
||||
interface NavbarLayoutFloatingInlineProps {
|
||||
navItems: NavItem[];
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
brandName?: string;
|
||||
button: ButtonConfig;
|
||||
className?: string;
|
||||
navItemClassName?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const NavbarLayoutFloatingInline = memo<NavbarLayoutFloatingInlineProps>(
|
||||
function NavbarLayoutFloatingInline({
|
||||
navItems,
|
||||
logoSrc,
|
||||
logoAlt = "",
|
||||
brandName = "Webild",
|
||||
button,
|
||||
className = "",
|
||||
navItemClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
className={cls(
|
||||
"fixed z-[100] top-6 w-full",
|
||||
"transition-all duration-500 ease-in-out",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cls(
|
||||
"w-content-width mx-auto",
|
||||
"flex items-center justify-between",
|
||||
"card rounded-theme",
|
||||
"p-3 pl-6 h-fit relative"
|
||||
)}>
|
||||
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 hidden md:flex gap-6 items-center">
|
||||
{navItems.map((item, index) => (
|
||||
<ButtonTextUnderline
|
||||
key={index}
|
||||
text={item.name}
|
||||
href={item.id}
|
||||
className={cls("!text-base", navItemClassName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
{...getButtonProps(
|
||||
button,
|
||||
0,
|
||||
theme.defaultButtonVariant,
|
||||
buttonClassName,
|
||||
buttonTextClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default NavbarLayoutFloatingInline;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
import { memo } from "react";
|
||||
import ExpandingMenu from "../expandingMenu/ExpandingMenu";
|
||||
import Button from "../../button/Button";
|
||||
import Logo from "../Logo";
|
||||
import { useScrollDetection } from "./useScrollDetection";
|
||||
import { useMenuAnimation } from "./useMenuAnimation";
|
||||
import { useResponsive } from "./useResponsive";
|
||||
import type { NavItem } from "@/types/navigation";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig } from "@/types/button";
|
||||
|
||||
interface NavbarLayoutFloatingOverlayProps {
|
||||
navItems: NavItem[];
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
brandName?: string;
|
||||
button: ButtonConfig;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const NavbarLayoutFloatingOverlay = memo<NavbarLayoutFloatingOverlayProps>(
|
||||
function NavbarLayoutFloatingOverlay({
|
||||
navItems,
|
||||
logoSrc,
|
||||
logoAlt = "",
|
||||
brandName = "Webild",
|
||||
button,
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const isScrolled = useScrollDetection(50);
|
||||
const { menuOpen, buttonZIndex, handleMenuToggle } =
|
||||
useMenuAnimation();
|
||||
const isMobile = useResponsive(768);
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
className={cls(
|
||||
"fixed z-[100] top-6 w-full",
|
||||
"transition-all duration-500 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"w-content-width mx-auto",
|
||||
"flex items-center justify-between",
|
||||
"card rounded-theme backdrop-blur-xs",
|
||||
"px-6 md:pr-3"
|
||||
)}
|
||||
style={{
|
||||
height: "calc(var(--vw-0_75) + var(--vw-0_75) + var(--height-9))",
|
||||
}}
|
||||
>
|
||||
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
|
||||
<div
|
||||
className="flex items-center transition-all duration-500 ease-in-out"
|
||||
style={{ paddingRight: "calc(var(--height-9) + var(--vw-0_75))" }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="hidden md:flex">
|
||||
<Button
|
||||
{...getButtonProps(
|
||||
button,
|
||||
0,
|
||||
theme.defaultButtonVariant,
|
||||
cls(buttonZIndex, buttonClassName),
|
||||
buttonTextClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ExpandingMenu
|
||||
isOpen={menuOpen}
|
||||
onToggle={handleMenuToggle}
|
||||
navItems={navItems}
|
||||
isScrolled={isScrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default NavbarLayoutFloatingOverlay;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export const useMenuAnimation = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [buttonZIndex, setButtonZIndex] = useState('z-[100]');
|
||||
const [animationTimeoutId, setAnimationTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
const isOpening = !menuOpen;
|
||||
setMenuOpen(prev => !prev);
|
||||
|
||||
if (animationTimeoutId) {
|
||||
clearTimeout(animationTimeoutId);
|
||||
}
|
||||
|
||||
if (isOpening) {
|
||||
setButtonZIndex('z-0');
|
||||
} else {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setButtonZIndex('z-[100]');
|
||||
}, 800);
|
||||
setAnimationTimeoutId(timeoutId);
|
||||
}
|
||||
}, [menuOpen, animationTimeoutId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationTimeoutId) {
|
||||
clearTimeout(animationTimeoutId);
|
||||
}
|
||||
};
|
||||
}, [animationTimeoutId]);
|
||||
|
||||
return {
|
||||
menuOpen,
|
||||
buttonZIndex,
|
||||
handleMenuToggle,
|
||||
setMenuOpen
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
|
||||
export const useResponsive = (breakpoint: number = 768) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = throttle(() => {
|
||||
setIsMobile(window.innerWidth < breakpoint);
|
||||
}, 150);
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
|
||||
export const useScrollDetection = (threshold: number = 50) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = throttle(() => {
|
||||
setIsScrolled(window.scrollY > threshold);
|
||||
}, 100);
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [threshold]);
|
||||
|
||||
return isScrolled;
|
||||
};
|
||||
86
src/components/navbar/NavbarStyleApple/NavbarStyleApple.tsx
Normal file
86
src/components/navbar/NavbarStyleApple/NavbarStyleApple.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import { useState, memo, useCallback } from "react";
|
||||
import MobileMenu from "../mobileMenu/MobileMenu";
|
||||
import ButtonTextUnderline from "@/components/button/ButtonTextUnderline";
|
||||
import Logo from "../Logo";
|
||||
import { Plus } from "lucide-react";
|
||||
import { NavbarProps } from "@/types/navigation";
|
||||
import { useScrollState } from "./useScrollState";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const SCROLL_THRESHOLD = 50;
|
||||
|
||||
const NavbarStyleApple = ({
|
||||
navItems,
|
||||
logoSrc,
|
||||
logoAlt = "",
|
||||
brandName = "Webild",
|
||||
}: NavbarProps) => {
|
||||
const isScrolled = useScrollState(SCROLL_THRESHOLD);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleMobileNavClick = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cls(
|
||||
"fixed z-[1000] top-0 left-0 w-full transition-all duration-500 ease-in-out",
|
||||
isScrolled
|
||||
? "bg-background/80 backdrop-blur-sm h-15"
|
||||
: "bg-background/0 backdrop-blur-0 h-20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-full w-content-width mx-auto">
|
||||
<div className="flex items-center transition-all duration-500 ease-in-out">
|
||||
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden md:flex items-center gap-6 transition-all duration-500 ease-in-out"
|
||||
role="navigation"
|
||||
>
|
||||
{navItems.map((item, index) => (
|
||||
<ButtonTextUnderline
|
||||
key={index}
|
||||
text={item.name}
|
||||
href={item.id}
|
||||
className="!text-base"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex md:hidden shrink-0 h-8 aspect-square rounded-theme bg-foreground items-center justify-center cursor-pointer"
|
||||
onClick={handleMenuToggle}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<Plus
|
||||
className={cls(
|
||||
"w-1/2 h-1/2 text-background transition-transform duration-300",
|
||||
menuOpen ? "rotate-45" : "rotate-0"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MobileMenu
|
||||
menuOpen={menuOpen}
|
||||
onMenuToggle={handleMenuToggle}
|
||||
navItems={navItems}
|
||||
onNavClick={handleMobileNavClick}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NavbarStyleApple);
|
||||
28
src/components/navbar/NavbarStyleApple/useScrollState.ts
Normal file
28
src/components/navbar/NavbarStyleApple/useScrollState.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export const useScrollState = (threshold: number = 50) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const rafRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setIsScrolled(window.scrollY > threshold);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
return isScrolled;
|
||||
};
|
||||
63
src/components/navbar/NavbarStyleMinimal.tsx
Normal file
63
src/components/navbar/NavbarStyleMinimal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import { memo } from "react";
|
||||
import Button from "../button/Button";
|
||||
import Logo from "./Logo";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig } from "@/types/button";
|
||||
|
||||
interface NavbarStyleMinimalProps {
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
brandName?: string;
|
||||
button: ButtonConfig;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const NavbarStyleMinimal = memo<NavbarStyleMinimalProps>(
|
||||
function NavbarStyleMinimal({
|
||||
logoSrc,
|
||||
logoAlt = "",
|
||||
brandName = "Webild",
|
||||
button,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
className={cls(
|
||||
"fixed z-[100] top-6 w-full",
|
||||
"transition-all duration-500 ease-in-out",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cls(
|
||||
"w-content-width mx-auto",
|
||||
"flex items-center justify-between"
|
||||
)}>
|
||||
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
|
||||
|
||||
<Button
|
||||
{...getButtonProps(
|
||||
button,
|
||||
0,
|
||||
theme.defaultButtonVariant,
|
||||
buttonClassName,
|
||||
buttonTextClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default NavbarStyleMinimal;
|
||||
147
src/components/navbar/expandingMenu/ExpandingMenu.tsx
Normal file
147
src/components/navbar/expandingMenu/ExpandingMenu.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import { useCallback, memo } from 'react';
|
||||
import { useResponsiveMenuWidth } from './useResponsiveMenuWidth';
|
||||
import { useButtonClick } from '@/components/button/useButtonClick';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ExpandingMenuProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
navItems: NavItem[];
|
||||
isScrolled?: boolean;
|
||||
}
|
||||
|
||||
const ExpandingMenu = memo<ExpandingMenuProps>(function ExpandingMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
navItems,
|
||||
isScrolled = false
|
||||
}) {
|
||||
const { isMounted, menuWidth } = useResponsiveMenuWidth();
|
||||
|
||||
const handleNavClick = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-theme-capped absolute top-3 right-3
|
||||
transition-[top] duration-500 ease-in-out
|
||||
${isScrolled ? '' : ''}
|
||||
`}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`
|
||||
primary-button
|
||||
backdrop-blur-xs
|
||||
transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
|
||||
bg-foreground rounded-theme-capped absolute top-0 right-0
|
||||
${isOpen
|
||||
? 'w-full h-full'
|
||||
: 'h-9 w-[var(--height-9)]'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
|
||||
<div className={`
|
||||
relative p-6 flex flex-col gap-6
|
||||
transition-all duration-500 ease-[cubic-bezier(0.5,0.5,0,1)]
|
||||
pointer-events-auto origin-[100%_0]
|
||||
${isOpen
|
||||
? 'scale-100 opacity-100 visible'
|
||||
: 'scale-[0.15] opacity-0 invisible'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
transition: 'all 0.5s cubic-bezier(0.5, 0.5, 0, 1), transform 0.7s cubic-bezier(0.5, 0.5, 0, 1)',
|
||||
width: isMounted ? menuWidth : 'var(--width-20)'
|
||||
}}
|
||||
>
|
||||
<p className="text-xl text-background" aria-hidden="true">Menu</p>
|
||||
<ul
|
||||
role="menu"
|
||||
className="relative list-none flex flex-col gap-3 m-0 p-0"
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const MenuButton = () => {
|
||||
const handleClick = useButtonClick(item.id, handleNavClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`Navigate to ${item.name}`}
|
||||
className={`
|
||||
text-background flex justify-between items-center
|
||||
no-underline bg-none border-none cursor-pointer w-full
|
||||
font-inherit group
|
||||
`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="text-base">
|
||||
{item.name}
|
||||
</span>
|
||||
<div className="bg-current rounded-theme-capped shrink-0 h-2 aspect-square" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
role="menuitem"
|
||||
className="m-0 p-0 list-none"
|
||||
>
|
||||
<MenuButton />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label={isOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="navigation-menu"
|
||||
className={`
|
||||
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
|
||||
pointer-events-auto cursor-pointer rounded-theme-capped
|
||||
flex justify-center items-center
|
||||
h-9 w-[var(--height-9)] aspect-square absolute top-0 right-0
|
||||
bg-transparent border-none
|
||||
${isOpen
|
||||
? '-translate-x-3 translate-y-3'
|
||||
: 'translate-x-0 translate-y-0'
|
||||
}
|
||||
`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`
|
||||
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
|
||||
bg-background w-[40%] h-0.25 absolute
|
||||
${isOpen
|
||||
? 'translate-y-0 rotate-45'
|
||||
: '-translate-y-1 hover:translate-y-1'
|
||||
}
|
||||
`} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`
|
||||
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
|
||||
bg-background w-[40%] h-0.25 absolute
|
||||
${isOpen
|
||||
? 'translate-y-0 -rotate-45'
|
||||
: 'translate-y-1 hover:-translate-y-1'
|
||||
}
|
||||
`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExpandingMenu;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useResponsiveMenuWidth = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [menuWidth, setMenuWidth] = useState('var(--width-20)');
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const handleResize = () => {
|
||||
setMenuWidth(
|
||||
window.innerWidth >= 768
|
||||
? 'var(--width-20)'
|
||||
: 'calc(var(--width-80) - var(--vw-0_75) * 2)'
|
||||
);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return { isMounted, menuWidth };
|
||||
};
|
||||
79
src/components/navbar/mobileMenu/MobileMenu.tsx
Normal file
79
src/components/navbar/mobileMenu/MobileMenu.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Fragment } from 'react';
|
||||
import { ArrowRight, Plus } from 'lucide-react';
|
||||
import { NavItem } from '@/types/navigation';
|
||||
import { useMenuAnimation } from './useMenuAnimation';
|
||||
import { useButtonClick } from '@/components/button/useButtonClick';
|
||||
|
||||
interface MobileMenuProps {
|
||||
menuOpen: boolean;
|
||||
onMenuToggle: () => void;
|
||||
navItems: NavItem[];
|
||||
onNavClick: (id: string) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const MobileMenu = ({
|
||||
menuOpen,
|
||||
onMenuToggle,
|
||||
navItems,
|
||||
onNavClick,
|
||||
children
|
||||
}: MobileMenuProps) => {
|
||||
const menuRef = useMenuAnimation(menuOpen);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className="md:hidden z-10 fixed top-3 left-1/2 -translate-x-1/2 h-fit rounded-theme-capped card p-6 flex flex-col gap-6 opacity-0"
|
||||
style={{ width: 'calc(100vw - var(--vw-0_75) * 2)' }}
|
||||
ref={menuRef}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation menu"
|
||||
>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<p className="text-xl text-foreground">Menu</p>
|
||||
<button
|
||||
className="shrink-0 h-8 aspect-square rounded-theme bg-foreground flex items-center justify-center cursor-pointer"
|
||||
onClick={onMenuToggle}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<Plus className="w-1/2 h-1/2 text-background rotate-45" strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{navItems.map((item, index) => {
|
||||
const NavButton = () => {
|
||||
const handleClick = useButtonClick(item.id, () => onNavClick(item.id));
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full h-fit flex justify-between items-center cursor-pointer"
|
||||
onClick={handleClick}
|
||||
aria-label={`Navigate to ${item.name}`}
|
||||
>
|
||||
<p className="text-base font-medium">{item.name}</p>
|
||||
<ArrowRight strokeWidth={1.5} className="h-[var(--text-base)] w-auto text-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<NavButton />
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="w-full h-px bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="flex gap-3 items-center">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MobileMenu);
|
||||
40
src/components/navbar/mobileMenu/useMenuAnimation.ts
Normal file
40
src/components/navbar/mobileMenu/useMenuAnimation.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
export const useMenuAnimation = (menuOpen: boolean) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
if (menuOpen) {
|
||||
gsap.to(menuRef.current, {
|
||||
y: "0%",
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
duration: 0.8,
|
||||
ease: "power3.out"
|
||||
});
|
||||
} else {
|
||||
gsap.to(menuRef.current, {
|
||||
y: "-135%",
|
||||
opacity: 1,
|
||||
pointerEvents: "none",
|
||||
duration: 0.8,
|
||||
ease: "power3.inOut"
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
gsap.set(menuRef.current, {
|
||||
y: "-135%",
|
||||
opacity: 1,
|
||||
pointerEvents: "none"
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return menuRef;
|
||||
};
|
||||
Reference in New Issue
Block a user