Skip to content
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
4 changes: 3 additions & 1 deletion posts/베트남-여행-후기/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
![](/assets/posts/2026-02-05-travel-vietnam-review/food3.jpeg)
</ImageGrid>

유튜브로 미리 찾아본 식당들 전부 만족했어요. 그중에서도 가장 추천하는 식당은 [Beni 2](https://share.google/1wtyxMhTXyT2QvGCO)예요. ⭐⭐⭐⭐⭐
유튜브로 미리 찾아본 식당들은 전부 만족스러웠어요. 그중에서 가장 추천하는 곳은 [Beni 2](https://share.google/1wtyxMhTXyT2QvGCO)예요.

체감 평점으로는 ⭐⭐⭐⭐⭐ 정도였습니다.

- 해산물 맛집이예요. 랍스터랑 세트메뉴로 주문해서 먹었는데 하나같이 다 맛있었어요.
- 찐로컬 분위기로 현지인들도 많았어요.
Expand Down
57 changes: 50 additions & 7 deletions src/shared/layout/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useKBar } from 'kbar';
import { clsx } from 'clsx';
import type { FeedData } from '@/domains/post/model/types';
Expand Down Expand Up @@ -212,19 +212,24 @@ function SearchButton() {

export default function AppShell({ children, posts }: AppShellProps) {
const pathname = usePathname();
const [mobileNavOpen, setMobileNavOpen] = useState(false);

const activeSection = useMemo(
() => resolveSection(pathname, posts),
[pathname, posts]
);

useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);

return (
<div className="min-h-screen bg-[var(--color-grey-50)] text-[var(--color-text-primary)] md:flex md:h-screen md:overflow-hidden">
<ThemeTransitionWash />

<aside className="hidden h-screen w-16 shrink-0 border-r border-[var(--color-grey-200)] bg-[var(--color-bg-primary)] md:sticky md:top-0 md:flex">
<div className="flex h-full w-full flex-col justify-between">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
<div className="flex flex-col items-center gap-1 px-1 py-3">
{RAIL_NAV_ITEMS.map((item) => {
const isActive = item.id === activeSection;

Expand All @@ -234,23 +239,23 @@ export default function AppShell({ children, posts }: AppShellProps) {
href={item.href}
aria-label={item.label}
className={clsx(
'flex w-full flex-col items-center gap-1 rounded-2xl px-1 py-1.5 transition-colors',
'flex w-full flex-col items-center gap-1 rounded-2xl px-1 py-2 transition-colors',
isActive
? 'text-[var(--color-toss-blue)]'
: 'text-[var(--color-grey-500)] hover:text-[var(--color-grey-900)]'
)}
>
<span
className={clsx(
'flex h-9 w-9 items-center justify-center rounded-xl border transition-colors',
'flex h-10 w-10 items-center justify-center rounded-xl border transition-colors',
isActive
? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-white shadow-sm'
: 'border-transparent hover:border-[var(--color-grey-100)] hover:bg-[var(--color-bg-primary)]'
)}
>
{item.icon}
</span>
<span className="text-center text-xs leading-none tracking-tight">
<span className="text-center text-xs font-medium leading-none tracking-tight">
{item.label}
</span>
</Link>
Expand All @@ -275,7 +280,40 @@ export default function AppShell({ children, posts }: AppShellProps) {

<div className="min-w-0 flex-1 bg-[var(--color-bg-primary)] md:flex md:h-screen md:flex-col md:overflow-hidden">
<header className="sticky top-0 z-[var(--z-sticky)] border-b border-[var(--color-grey-200)] bg-[var(--color-bg-primary)]/95 backdrop-blur-md">
<div className="grid h-14 grid-cols-[1fr_minmax(0,560px)_1fr] items-center gap-3 px-4 md:px-6">
<div className="flex h-14 items-center gap-3 px-4 md:hidden">
<button
type="button"
aria-label={mobileNavOpen ? '메뉴 닫기' : '메뉴 열기'}
aria-expanded={mobileNavOpen}
aria-controls="mobile-nav-drawer"
onClick={() => setMobileNavOpen((current) => !current)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--color-grey-200)] bg-[var(--color-grey-50)] text-[var(--color-grey-500)] transition-colors hover:border-[var(--color-grey-300)] hover:text-[var(--color-grey-900)]"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="17" x2="20" y2="17" />
</svg>
</button>

<div className="min-w-0 flex-1">
<SearchButton />
</div>

<ThemeToggle />
</div>

<div className="hidden h-14 grid-cols-[1fr_minmax(0,560px)_1fr] items-center gap-3 px-6 md:grid">
<div />
<div className="flex justify-center">
<div className="w-full max-w-xl">
Comment on lines +316 to 319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Render only one SearchButton instance

This change mounts SearchButton twice (mobile and desktop variants) and relies on CSS (md:hidden / hidden md:grid) to hide one of them, but hidden components still mount. Because SearchButton registers a global / key handler in its useEffect, both listeners fire and call query.toggle() twice, which can immediately close the command palette after opening and make the keyboard shortcut appear broken across breakpoints.

Useful? React with 👍 / 👎.

Expand All @@ -294,7 +332,12 @@ export default function AppShell({ children, posts }: AppShellProps) {
</div>
</div>

<MobileBottomNav pathname={pathname} visible />
<MobileBottomNav
pathname={pathname}
visible
open={mobileNavOpen}
onOpenChange={setMobileNavOpen}
/>
</div>
);
}
117 changes: 42 additions & 75 deletions src/shared/layout/Header/MobileBottomNav.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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: {
Expand All @@ -55,93 +32,83 @@ vi.mock('@/shared/analytics/lib/analytics', () => {
});

describe('MobileBottomNav', () => {
it('renders four mobile nav links', () => {
render(<MobileBottomNav pathname="/" visible />);

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(<MobileBottomNav pathname="/" visible open />);

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(<MobileBottomNav pathname="/" visible />);
render(<MobileBottomNav pathname="/" visible open />);

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(<MobileBottomNav pathname="/engineering/how-to-test" visible />);
render(
<MobileBottomNav pathname="/engineering/how-to-test" visible open />
);

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(<MobileBottomNav pathname="/" visible />);

const life = await screen.findByRole('link', { name: 'Life' });
const lifeContent = life.querySelector('div');
it('renders drawer shell when open', () => {
render(<MobileBottomNav pathname="/" visible open />);

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(<MobileBottomNav pathname="/" visible />);
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(
<MobileBottomNav
pathname="/"
visible
open
onOpenChange={handleOpenChange}
/>
);
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(<MobileBottomNav pathname="/engineering" visible />);
render(<MobileBottomNav pathname="/engineering" visible open />);
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(<MobileBottomNav pathname="/" visible={false} />);
it('hides pointer events when closed', () => {
render(<MobileBottomNav pathname="/" visible open={false} />);

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');
});
});
Loading
Loading