diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 31c7e8eb..2347218c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,11 +3,12 @@ import { Hanuman } from 'next/font/google'; import type { Metadata, Viewport } from 'next'; import '@/styles/globals.css'; import QueryProviders from '@/providers/queryProviders'; -import { GNB } from '@/components/layout/GNB/GNB'; +import GNB from '@/components/layout/GNB/GNB'; import LayoutWrapper from '@/components/layout/LayoutWrapper'; import AuthProvider from '@/providers/auth-provider/AuthProvider'; import { ReactNode } from 'react'; import { ToastProvider } from '@/providers/ToastProvider'; +import SideDrawer from '@/components/layout/SideDrawer/SideDrawer'; const pretendard = localFont({ src: '../fonts/PretendardVariable.woff2', @@ -45,13 +46,14 @@ const RootLayout = ({ -
+
{children}
+ diff --git a/src/components/common/Dropdown/Dropdown.tsx b/src/components/common/Dropdown/Dropdown.tsx index 1055d51b..b1cd6b77 100644 --- a/src/components/common/Dropdown/Dropdown.tsx +++ b/src/components/common/Dropdown/Dropdown.tsx @@ -22,9 +22,14 @@ export const DropdownContent = ({ export const DropdownContainer = ({ children, className = '', + ...rest }: DropdownContainerProps) => { const defaultOptionContainerStyle = `overflow-hidden rounded-md bg-white mt-2 ${className}`; - return
    {children}
; + return ( +
    + {children} +
+ ); }; const Dropdown = ({ @@ -34,7 +39,7 @@ const Dropdown = ({ className = '', onClose, }: DropdownProps) => { - const outSideRef = useClickOutside(() => { + const outSideRef = useClickOutside(() => { if (isOpen && onClose) { onClose(); } diff --git a/src/components/common/Dropdown/type.ts b/src/components/common/Dropdown/type.ts index ad24d96c..a00221b4 100644 --- a/src/components/common/Dropdown/type.ts +++ b/src/components/common/Dropdown/type.ts @@ -1,4 +1,4 @@ -import { MouseEventHandler, ReactNode } from 'react'; +import { MouseEventHandler, ReactNode, HTMLAttributes } from 'react'; export interface DropdownProps { trigger?: ReactNode; @@ -14,7 +14,6 @@ export interface DropdownContentProps { className?: string; } -export interface DropdownContainerProps { - className?: string; +export type DropdownContainerProps = HTMLAttributes & { children: ReactNode; -} +}; diff --git a/src/components/layout/GNB/GNB.tsx b/src/components/layout/GNB/GNB.tsx index cb613ec7..6a9c5553 100644 --- a/src/components/layout/GNB/GNB.tsx +++ b/src/components/layout/GNB/GNB.tsx @@ -1,65 +1,15 @@ -'use client'; - -import LoginSection from './LoginSection'; -import SideDrawer from '../SideDrawer/SideDrawer'; import LogoButton from '@/components/layout/GNB/LogoButton'; import MenuGroups from '@/components/layout/GNB/MenuGroups'; -import { APP_ROUTES, APP_ROUTES_LABEL } from '@/constants/appRoutes'; -import useBoolean from '@/hooks/useBoolean'; -import { Hamburger } from '@public/assets/icons'; - -// 메뉴 항목 -const MENU_ITEMS = [ - { label: APP_ROUTES_LABEL.mypage, href: APP_ROUTES.mypage }, - { label: APP_ROUTES_LABEL.social, href: APP_ROUTES.social }, - { label: APP_ROUTES_LABEL.library, href: APP_ROUTES.library }, -]; - -export const GNB = () => { - const { - value: isDrawerOpen, - setTrue: setIsDrawerOpen, - setFalse: setIsDrawerClose, - } = useBoolean(); +const GNB = () => { return ( - <> - - {/* 오버레이 */} - {isDrawerOpen && ( -
setIsDrawerClose()} - /> - )} - {/* 시아드 드로어 (모바일 화면에 표시) */} - setIsDrawerClose()} - menuItems={MENU_ITEMS} - /> - + ); }; + +export default GNB; diff --git a/src/components/layout/GNB/LoginSection.tsx b/src/components/layout/GNB/LoginSection.tsx deleted file mode 100644 index 35ad9e05..00000000 --- a/src/components/layout/GNB/LoginSection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import { APP_ROUTES } from '../../../constants/appRoutes'; -import { DefaultProfileImage } from '@public/assets/icons'; -import useBoolean from '@/hooks/useBoolean'; -import useClickOutside from '@/hooks/useClickOutside'; -import { usePostSignout } from '@/hooks/api/auth/usePostSignout'; -import UserDropdown from '@/components/layout/GNB/UserDropdown'; -import Link from 'next/link'; -import { useAuth } from '@/providers/auth-provider/AuthProvider.client'; -import useReferer from '@/hooks/useReferer'; - -const LoginSection = () => { - const { mutate: signOut } = usePostSignout(); - const { isSignIn, myInfo } = useAuth(); - const { refererParam } = useReferer(); - - const { - value: isDropdownOpen, - setTrue: openDropdown, - setFalse: closeDropdown, - } = useBoolean(); - const ref = useClickOutside(closeDropdown); - - const handleSignOut = () => { - signOut(); - closeDropdown(); - }; - - return ( -
- {myInfo && isSignIn ? ( - - ) : ( - - 로그인 - - )} - {isDropdownOpen && ( -
- -
- )} -
- ); -}; - -export default LoginSection; diff --git a/src/components/layout/GNB/LogoButton.tsx b/src/components/layout/GNB/LogoButton.tsx index d220788a..3dd28ea2 100644 --- a/src/components/layout/GNB/LogoButton.tsx +++ b/src/components/layout/GNB/LogoButton.tsx @@ -4,11 +4,15 @@ import Link from 'next/link'; const LogoButton = ({ onClick }: LogoButtonProps) => { return ( -

- + +
WeWrite - -

+
+ ); }; export default LogoButton; diff --git a/src/components/layout/GNB/MenuGroups.tsx b/src/components/layout/GNB/MenuGroups.tsx index b9740402..462e48f5 100644 --- a/src/components/layout/GNB/MenuGroups.tsx +++ b/src/components/layout/GNB/MenuGroups.tsx @@ -1,44 +1,98 @@ +'use client'; + import { APP_ROUTES, APP_ROUTES_LABEL } from '@/constants/appRoutes'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Users, BookOpen } from 'lucide-react'; +import useClickOutside from '@/hooks/useClickOutside'; +import useBoolean from '@/hooks/useBoolean'; +import { useAuth } from '@/providers/auth-provider/AuthProvider.client'; +import useReferer from '@/hooks/useReferer'; +import UserProfileDropdown from '@/components/layout/GNB/UserProfileDropdown'; +import { Hamburger } from '@public/assets/icons'; +import { useSideDrawerStore } from '@/lib/store/useSideDrawerStore'; const MenuGroups = () => { const pathname = usePathname(); + const { refererParam } = useReferer(); + const { isSignIn, myInfo } = useAuth(); + const { openDrawer } = useSideDrawerStore(); + + const { + value: isDropdownOpen, + toggle: toggleDropDown, + setFalse: closeDropdown, + } = useBoolean(); + + const ref = useClickOutside(closeDropdown); return ( -
    -
  • - +
      +
    • -
    • -
    • - +
    • + +
    • -
    • -
    + +
  • + +
  • + {myInfo && isSignIn ? ( + + ) : ( + + 로그인 + + )} +
  • +
+ + ); }; diff --git a/src/components/layout/GNB/UserDropdown.tsx b/src/components/layout/GNB/UserDropdown.tsx deleted file mode 100644 index bc29e73f..00000000 --- a/src/components/layout/GNB/UserDropdown.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; -import { useRouter, usePathname } from 'next/navigation'; -import { APP_ROUTES } from '@/constants/appRoutes'; -import { UserDropdownProps } from './type'; - -const UserDropdown = ({ onSignOut, onClose }: UserDropdownProps) => { - const router = useRouter(); - const pathname = usePathname(); - const handleSignOut = async () => { - await onSignOut(); - onClose(); - if (pathname === APP_ROUTES.mypage) { - await router.push(APP_ROUTES.signin); - } - }; - const gotoMyPage = () => { - router.push(APP_ROUTES.mypage); - onClose(); - }; - - return ( -
- - -
- ); -}; - -export default UserDropdown; diff --git a/src/components/layout/GNB/UserProfileDropdown.tsx b/src/components/layout/GNB/UserProfileDropdown.tsx new file mode 100644 index 00000000..86e6bdf1 --- /dev/null +++ b/src/components/layout/GNB/UserProfileDropdown.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useRouter, usePathname } from 'next/navigation'; +import { APP_ROUTES } from '@/constants/appRoutes'; +import { UserProfileDropdownProps } from '@/components/layout/GNB/type'; +import Dropdown from '@/components/common/Dropdown/Dropdown'; +import Image from 'next/image'; +import { DefaultProfileImage } from '@public/assets/icons'; +import { usePostSignout } from '@/hooks/api/auth/usePostSignout'; +import Link from 'next/link'; +import { useEffect, useRef, useState, KeyboardEvent } from 'react'; +import useEscapeKey from '@/hooks/useEscapeKey'; + +const UserProfileDropdown = ({ + isDropdownOpen, + toggleDropDown, + closeDropdown, + userName, + profileImage, +}: UserProfileDropdownProps) => { + const router = useRouter(); + const pathname = usePathname(); + const menuItemRef = useRef<(HTMLDivElement | HTMLAnchorElement | null)[]>([]); + const [focusedIndex, setFocusedIndex] = useState(0); + const { mutate: signOut } = usePostSignout(); + + useEscapeKey({ callback: () => closeDropdown(), active: isDropdownOpen }); + + useEffect(() => { + if (isDropdownOpen) { + setFocusedIndex(0); + setTimeout(() => { + menuItemRef.current[0]?.focus(); + }, 0); + } + }, [isDropdownOpen]); + + const handleSignOut = () => { + signOut(); + + closeDropdown(); + + if (pathname === APP_ROUTES.mypage) { + router.push(APP_ROUTES.signin); + } + }; + + const handleMenuKeyDown = (e: KeyboardEvent) => { + if (!isDropdownOpen) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const nextIndex = (focusedIndex + 1) % menuItemRef.current.length; + setFocusedIndex(nextIndex); + menuItemRef.current[nextIndex]?.focus(); + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + const prevIndex = + (focusedIndex - 1 + menuItemRef.current.length) % + menuItemRef.current.length; + setFocusedIndex(prevIndex); + menuItemRef.current[prevIndex]?.focus(); + } + }; + + return ( + + {profileImage && userName ? ( + + ) : ( + + ); +}; + +export default UserProfileDropdown; diff --git a/src/components/layout/GNB/type.ts b/src/components/layout/GNB/type.ts index 4a2d4d2c..0beaa986 100644 --- a/src/components/layout/GNB/type.ts +++ b/src/components/layout/GNB/type.ts @@ -1,4 +1,7 @@ -export interface UserDropdownProps { - onSignOut: () => void; - onClose: () => void; +export interface UserProfileDropdownProps { + isDropdownOpen: boolean; + toggleDropDown: () => void; + closeDropdown: () => void; + userName: string | null; + profileImage: string | null; } diff --git a/src/components/layout/SideDrawer/SideDrawer.tsx b/src/components/layout/SideDrawer/SideDrawer.tsx index e72e583d..b289cafb 100644 --- a/src/components/layout/SideDrawer/SideDrawer.tsx +++ b/src/components/layout/SideDrawer/SideDrawer.tsx @@ -2,87 +2,146 @@ import LogoButton from '@/components/layout/GNB/LogoButton'; import Link from 'next/link'; -import { SideDrawerProps } from '@/components/layout/SideDrawer/type'; import { usePathname, useRouter } from 'next/navigation'; -import { APP_ROUTES } from '@/constants/appRoutes'; +import { APP_ROUTES, APP_ROUTES_LABEL } from '@/constants/appRoutes'; import { usePostSignout } from '@/hooks/api/auth/usePostSignout'; import { useAuth } from '@/providers/auth-provider/AuthProvider.client'; import useReferer from '@/hooks/useReferer'; +import { useSideDrawerStore } from '@/lib/store/useSideDrawerStore'; +import { X } from 'lucide-react'; +import { useEffect, useRef } from 'react'; -const SideDrawer = ({ isOpen, closeDrawer, menuItems }: SideDrawerProps) => { +const MENU_ITEMS = [ + { label: APP_ROUTES_LABEL.mypage, href: APP_ROUTES.mypage }, + { label: APP_ROUTES_LABEL.social, href: APP_ROUTES.social }, + { label: APP_ROUTES_LABEL.library, href: APP_ROUTES.library }, +]; + +const SideDrawer = () => { const { isSignIn, myInfo } = useAuth(); const pathname = usePathname(); const router = useRouter(); const { mutate: signOut } = usePostSignout(); const { refererParam } = useReferer(); + const drawerRef = useRef(null); + + const { isDrawerOpen, closeDrawer } = useSideDrawerStore(); + + useEffect(() => { + if (isDrawerOpen && drawerRef.current) { + drawerRef.current.focus(); + } + }, [isDrawerOpen]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isDrawerOpen) { + closeDrawer(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isDrawerOpen, closeDrawer]); const handleSignIn = () => { - router.push(`${APP_ROUTES.signin}?${refererParam} -`); + router.push(`${APP_ROUTES.signin}?${refererParam}`); closeDrawer(); }; - const handleSignOut = async () => { - await signOut(); + const handleSignOut = () => { + signOut(); closeDrawer(); if (pathname === APP_ROUTES.mypage) { - await router.push(APP_ROUTES.signin); + router.push(APP_ROUTES.signin); } }; const isMenuActive = (item: { href: string }) => pathname === item.href; return ( -
-
- - -
- {menuItems - .filter((item) => - [APP_ROUTES.social, APP_ROUTES.library].includes(item.href) - ) - .map((item) => ( - +
closeDrawer()} + aria-hidden="true" + /> + +
+

+ 메뉴 사이드 드로어 +

+
+ + -
+
+ +
+ ); }; export default SideDrawer; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts index e6ebdc8e..ffb25af2 100644 --- a/src/hooks/useClickOutside.ts +++ b/src/hooks/useClickOutside.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; -const useClickOutside = (handler: () => void) => { - const ref = useRef(null); +const useClickOutside = (handler: () => void) => { + const ref = useRef(null); useEffect(() => { const listener = (event: MouseEvent | TouchEvent) => { diff --git a/src/hooks/useEscapeKey.ts b/src/hooks/useEscapeKey.ts new file mode 100644 index 00000000..30896285 --- /dev/null +++ b/src/hooks/useEscapeKey.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +interface UseEscapeKeyParams { + callback: () => void; + active: boolean; +} + +const useEscapeKey = ({ callback, active }: UseEscapeKeyParams) => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && active) { + callback(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [callback, active]); +}; + +export default useEscapeKey; diff --git a/src/lib/store/useSideDrawerStore.ts b/src/lib/store/useSideDrawerStore.ts new file mode 100644 index 00000000..f053c66d --- /dev/null +++ b/src/lib/store/useSideDrawerStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface SideDrawerStore { + isDrawerOpen: boolean; + openDrawer: () => void; + closeDrawer: () => void; +} + +export const useSideDrawerStore = create((set) => ({ + isDrawerOpen: false, + openDrawer: () => set({ isDrawerOpen: true }), + closeDrawer: () => set({ isDrawerOpen: false }), +}));