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 = ({
-
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
;
+ return (
+
+ );
};
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 (
-
- -
-
+
+ -
-
- {APP_ROUTES_LABEL.social}
-
-
- -
-
+
+ {APP_ROUTES_LABEL.social}
+
+
+
+ -
-
- {APP_ROUTES_LABEL.library}
-
-
-
+
+
+ {APP_ROUTES_LABEL.library}
+
+
+
+ -
+ {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 ? (
+
+ ) : (
+
+ )}
+
+ }
+ >
+
+ {
+ menuItemRef.current[0] = el;
+ }}
+ href={`${APP_ROUTES.mypage}`}
+ tabIndex={0}
+ role="menuitem"
+ onClick={() => closeDropdown()}
+ className="block w-full rounded-xl px-4 py-4 text-left text-sm font-medium hover:bg-gray-100"
+ >
+ 마이페이지
+
+ }
+ />
+ {
+ menuItemRef.current[1] = el;
+ }}
+ role="menuitem"
+ tabIndex={0}
+ className="block w-full rounded-xl px-4 py-4 text-left text-sm font-medium hover:bg-gray-100"
+ >
+ 로그아웃
+
+ }
+ />
+
+
+ );
+};
+
+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 }),
+}));