+
+
+
+
+
+
+
+
+
+
+
@@ -294,7 +332,12 @@ export default function AppShell({ children, posts }: AppShellProps) {
-
+
);
}
diff --git a/src/shared/layout/Header/MobileBottomNav.test.tsx b/src/shared/layout/Header/MobileBottomNav.test.tsx
index d82815b9..e9de5bd2 100644
--- a/src/shared/layout/Header/MobileBottomNav.test.tsx
+++ b/src/shared/layout/Header/MobileBottomNav.test.tsx
@@ -1,5 +1,5 @@
import { act, render, screen } from '@testing-library/react';
-import { createElement, type ReactNode } from 'react';
+import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import MobileBottomNav from './MobileBottomNav';
@@ -20,29 +20,6 @@ vi.mock('next/link', () => ({
),
}));
-vi.mock('framer-motion', () => ({
- motion: {
- nav: ({
- children,
- ...props
- }: {
- children: ReactNode;
- initial?: unknown;
- animate?: unknown;
- transition?: unknown;
- [key: string]: unknown;
- }) => {
- const {
- initial: _initial,
- animate: _animate,
- transition: _transition,
- ...domProps
- } = props;
- return createElement('nav', domProps, children);
- },
- },
-}));
-
vi.mock('@/shared/analytics/lib/analytics', () => {
return {
AnalyticsEvents: {
@@ -55,93 +32,83 @@ vi.mock('@/shared/analytics/lib/analytics', () => {
});
describe('MobileBottomNav', () => {
- it('renders four mobile nav links', () => {
- render(
);
-
- expect(screen.getByRole('link', { name: '홈' })).toBeInTheDocument();
- expect(
- screen.getByRole('link', { name: 'Engineering' })
- ).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'Life' })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'Resume' })).toBeInTheDocument();
+ it('renders drawer links when open', () => {
+ render(
);
+
+ expect(screen.getByRole('link', { name: /Home/ })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Tech/ })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Life/ })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Resume/ })).toBeInTheDocument();
});
it('marks active item based on exact home path', async () => {
- render(
);
+ render(
);
- const home = await screen.findByRole('link', { name: '홈' });
+ const home = await screen.findByRole('link', { name: /Home/ });
expect(home).toHaveAttribute('aria-current', 'page');
- const engineering = await screen.findByRole('link', {
- name: 'Engineering',
- });
- expect(engineering).not.toHaveAttribute('aria-current');
+ const tech = await screen.findByRole('link', { name: /Tech/ });
+ expect(tech).not.toHaveAttribute('aria-current');
});
it('marks active item for nested engineering path', async () => {
- render(
);
+ render(
+
+ );
- const engineering = await screen.findByRole('link', {
- name: 'Engineering',
- });
- expect(engineering).toHaveAttribute('aria-current', 'page');
+ const tech = await screen.findByRole('link', { name: /Tech/ });
+ expect(tech).toHaveAttribute('aria-current', 'page');
});
- it('applies inactive hover style token for non-active items', async () => {
- render(
);
-
- const life = await screen.findByRole('link', { name: 'Life' });
- const lifeContent = life.querySelector('div');
+ it('renders drawer shell when open', () => {
+ render(
);
- expect(lifeContent).toHaveClass('border-transparent');
- expect(lifeContent).toHaveClass('hover:bg-[var(--mobile-nav-hover-bg)]');
- expect(lifeContent).toHaveClass(
- 'group-active:bg-[var(--mobile-nav-hover-bg)]'
- );
- expect(lifeContent).toHaveClass('min-h-14');
+ expect(screen.getByLabelText('모바일 네비게이션')).toBeInTheDocument();
+ expect(screen.getByText('블로그 섹션과 외부 링크')).toBeInTheDocument();
});
- it('applies focus and touch token classes on each nav control', async () => {
- render(
);
+ it('calls close handler when backdrop is clicked', () => {
+ const handleOpenChange = vi.fn();
- const home = await screen.findByRole('link', { name: '홈' });
- expect(home).toHaveClass(
- 'focus-visible:ring-[var(--mobile-nav-focus-ring)]'
+ render(
+
);
- expect(home).toHaveClass(
- 'focus-visible:ring-offset-[var(--mobile-nav-focus-offset)]'
- );
- expect(home).toHaveClass('touch-manipulation');
- expect(home).toHaveClass('active:scale-[0.97]');
+
+ screen.getAllByRole('button', { name: '메뉴 닫기' })[0].click();
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
});
it('tracks analytics on tab click', async () => {
mockTrackEvent.mockClear();
await act(async () => {
- render(
);
+ render(
);
await Promise.resolve();
});
- const resume = screen.getByRole('link', { name: 'Resume' });
+ const resume = screen.getByRole('link', { name: /Resume/ });
await act(async () => {
await Promise.resolve();
resume.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(mockTrackEvent).toHaveBeenCalledWith('click', {
- target: 'mobile_bottom_nav',
+ target: 'mobile_nav_drawer',
destination: '/resume',
});
});
- it('applies motion offset class when hidden', () => {
- render(
);
+ it('hides pointer events when closed', () => {
+ render(
);
- const nav = screen.getByLabelText('모바일 하단 네비게이션');
- expect(nav).toHaveClass('fixed');
- expect((nav as HTMLDivElement).getAttribute('aria-label')).toBe(
- '모바일 하단 네비게이션'
- );
+ const drawerRoot =
+ screen.getAllByRole('button', { name: '메뉴 닫기', hidden: true })[0]
+ .parentElement;
+ expect(drawerRoot).toHaveClass('pointer-events-none');
});
});
diff --git a/src/shared/layout/Header/MobileBottomNav.tsx b/src/shared/layout/Header/MobileBottomNav.tsx
index 8516bb35..8b7d8697 100644
--- a/src/shared/layout/Header/MobileBottomNav.tsx
+++ b/src/shared/layout/Header/MobileBottomNav.tsx
@@ -1,8 +1,7 @@
'use client';
-import { useMemo } from 'react';
+import { useEffect, useMemo } from 'react';
import Link from 'next/link';
-import { motion } from 'framer-motion';
import { clsx } from 'clsx';
import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics';
import { AppSectionIcon } from '@/shared/ui/icons/AppSectionIcon';
@@ -10,6 +9,8 @@ import { AppSectionIcon } from '@/shared/ui/icons/AppSectionIcon';
interface MobileBottomNavProps {
pathname: string;
visible: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
}
interface MobileNavItem {
@@ -21,6 +22,8 @@ interface MobileNavItem {
export default function MobileBottomNav({
pathname,
visible,
+ open = false,
+ onOpenChange,
}: MobileBottomNavProps) {
const navItems = useMemo
(
() => [
@@ -48,73 +51,200 @@ export default function MobileBottomNav({
[]
);
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onOpenChange?.(false);
+ }
+ };
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, onOpenChange]);
+
return (
-
-
-
+
-
+
+
+ );
+}
+
+function getDescription(id: MobileNavItem['id']) {
+ switch (id) {
+ case 'home':
+ return '전체 피드와 최근 글';
+ case 'engineering':
+ return '기술 글과 시리즈';
+ case 'life':
+ return '에세이와 회고';
+ case 'resume':
+ return '경력과 프로젝트';
+ default:
+ return '';
+ }
+}
+
+function ExternalLink({
+ href,
+ label,
+ onNavigate,
+}: {
+ href: string;
+ label: string;
+ onNavigate: () => void;
+}) {
+ return (
+
+ {label}
+ 열기
+
);
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 4ff9f44e..7adc8e71 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -9,8 +9,8 @@
@import 'tailwindcss';
:root {
- --mobile-bottom-nav-height: 84px;
- --mobile-bottom-nav-offset: var(--mobile-bottom-nav-height);
+ --mobile-bottom-nav-height: 0px;
+ --mobile-bottom-nav-offset: 0px;
}
/* ===== Local Fonts ===== */
@@ -248,12 +248,6 @@
}
}
-@media (max-width: 767px) {
- body {
- padding-bottom: calc(var(--mobile-bottom-nav-offset) + env(safe-area-inset-bottom));
- }
-}
-
/* ===== Focus Styles (Accessibility) ===== */
:focus-visible {
outline: 2px solid var(--color-toss-blue);