1- import type { ReactNode } from "react" ;
1+ import { type ReactNode , useState } from "react" ;
22import "../styles/shell.css" ;
33
44export type RouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password" ;
@@ -18,6 +18,21 @@ interface AppShellProps {
1818// 추후 모듈 로더에서 주입될 목록 (현재는 mock)
1919const INSTALLED_MODULES : { id : string ; label : string ; icon : string } [ ] = [ ] ;
2020
21+ // nav list 내 ArrowUp/ArrowDown 키보드 탐색
22+ function handleNavKeyDown ( e : React . KeyboardEvent < HTMLUListElement > ) {
23+ if ( e . key !== 'ArrowDown' && e . key !== 'ArrowUp' ) return ;
24+ const items = Array . from (
25+ e . currentTarget . querySelectorAll < HTMLButtonElement > ( 'button.shell-nav-item' ) ,
26+ ) ;
27+ const idx = items . indexOf ( document . activeElement as HTMLButtonElement ) ;
28+ if ( idx === - 1 ) return ;
29+ e . preventDefault ( ) ;
30+ const next = e . key === 'ArrowDown'
31+ ? items [ ( idx + 1 ) % items . length ]
32+ : items [ ( idx - 1 + items . length ) % items . length ] ;
33+ next ?. focus ( ) ;
34+ }
35+
2136export function AppShell ( {
2237 installMode,
2338 route,
@@ -30,25 +45,52 @@ export function AppShell({
3045 children,
3146} : AppShellProps ) {
3247 const userInitial = currentUser ?. email . charAt ( 0 ) . toUpperCase ( ) ?? "?" ;
48+ const [ isMobileOpen , setIsMobileOpen ] = useState ( false ) ;
49+
50+ const closeMobileMenu = ( ) => setIsMobileOpen ( false ) ;
51+ const navAndClose = ( target : RouteKey ) => { onNavigate ( target ) ; closeMobileMenu ( ) ; } ;
52+
3353 return (
3454 < div className = "shell" >
55+ { /* ── 모바일 오버레이 ──────────────────────────────── */ }
56+ { isMobileOpen && (
57+ < div
58+ className = "shell-drawer-overlay"
59+ onClick = { closeMobileMenu }
60+ aria-hidden = "true"
61+ />
62+ ) }
63+
3564 { /* ── Sidebar ─────────────────────────────────────── */ }
36- < aside className = "shell-sidebar" aria-label = "사이드바 네비게이션" >
65+ < aside
66+ className = "shell-sidebar"
67+ data-mobile-open = { isMobileOpen ? "" : undefined }
68+ aria-label = "사이드바 네비게이션"
69+ >
3770 < div className = "shell-brand" >
3871 < div className = "shell-brand-logo" aria-hidden = "true" > FS</ div >
3972 < span className = "shell-brand-name" > Fieldstack</ span >
73+ { /* 모바일: 닫기 버튼 */ }
74+ < button
75+ type = "button"
76+ className = "shell-drawer-close"
77+ onClick = { closeMobileMenu }
78+ aria-label = "메뉴 닫기"
79+ >
80+ ✕
81+ </ button >
4082 </ div >
4183
4284 < nav className = "shell-nav" aria-label = "주 메뉴" >
4385 { /* Workspace */ }
4486 < p className = "shell-nav-label" aria-hidden = "true" > Workspace</ p >
45- < ul className = "shell-nav-list" >
87+ < ul className = "shell-nav-list" onKeyDown = { handleNavKeyDown } >
4688 < li >
4789 < button
4890 type = "button"
4991 className = "shell-nav-item"
5092 aria-current = { route === "home" ? "page" : undefined }
51- onClick = { ( ) => onNavigate ( "home" ) }
93+ onClick = { ( ) => navAndClose ( "home" ) }
5294 >
5395 < span className = "shell-nav-icon" aria-hidden = "true" > ⊞</ span >
5496 Home
@@ -59,7 +101,7 @@ export function AppShell({
59101 type = "button"
60102 className = "shell-nav-item"
61103 aria-current = { route === "marketplace" ? "page" : undefined }
62- onClick = { ( ) => onNavigate ( "marketplace" ) }
104+ onClick = { ( ) => navAndClose ( "marketplace" ) }
63105 >
64106 < span className = "shell-nav-icon" aria-hidden = "true" > ⬡</ span >
65107 Marketplace
@@ -69,14 +111,14 @@ export function AppShell({
69111
70112 { /* Modules */ }
71113 < p className = "shell-nav-label" aria-hidden = "true" > Modules</ p >
72- < ul className = "shell-nav-list" aria-label = "설치된 모듈" >
114+ < ul className = "shell-nav-list" aria-label = "설치된 모듈" onKeyDown = { handleNavKeyDown } >
73115 { INSTALLED_MODULES . length > 0 ? (
74116 INSTALLED_MODULES . map ( ( mod ) => (
75117 < li key = { mod . id } >
76118 < button
77119 type = "button"
78120 className = "shell-nav-item"
79- onClick = { ( ) => { window . location . hash = mod . id ; } }
121+ onClick = { ( ) => { window . location . hash = mod . id ; closeMobileMenu ( ) ; } }
80122 >
81123 < span className = "shell-nav-icon" aria-hidden = "true" > { mod . icon } </ span >
82124 { mod . label }
@@ -105,7 +147,7 @@ export function AppShell({
105147 DEV BYPASS
106148 </ div >
107149 ) }
108- < button type = "button" className = "shell-nav-item" onClick = { onOpenSettings } >
150+ < button type = "button" className = "shell-nav-item" onClick = { ( ) => { onOpenSettings ( ) ; closeMobileMenu ( ) ; } } >
109151 < span className = "shell-nav-icon" aria-hidden = "true" > ⚙</ span >
110152 Settings
111153 </ button >
@@ -114,7 +156,7 @@ export function AppShell({
114156 type = "button"
115157 className = "shell-nav-item"
116158 aria-current = { route === "admin" ? "page" : undefined }
117- onClick = { ( ) => onNavigate ( "admin" ) }
159+ onClick = { ( ) => navAndClose ( "admin" ) }
118160 >
119161 < span className = "shell-nav-icon" aria-hidden = "true" > ⚡</ span >
120162 Admin
@@ -123,16 +165,31 @@ export function AppShell({
123165 < button
124166 type = "button"
125167 className = "shell-nav-item shell-nav-item-danger"
126- onClick = { onLogout }
168+ onClick = { ( ) => { onLogout ( ) ; closeMobileMenu ( ) ; } }
127169 >
128- < span className = "shell-nav-icon" aria-hidden = "true" > ↪ </ span >
170+ < span className = "shell-nav-icon" aria-hidden = "true" > → </ span >
129171 Sign Out
130172 </ button >
131173 </ div >
132174 </ aside >
133175
134176 { /* ── Body ─────────────────────────────────────────── */ }
135177 < div className = "shell-body" >
178+ { /* 모바일 상단 바 */ }
179+ < div className = "shell-mobile-topbar" >
180+ < button
181+ type = "button"
182+ className = "shell-hamburger"
183+ onClick = { ( ) => setIsMobileOpen ( true ) }
184+ aria-label = "메뉴 열기"
185+ aria-expanded = { isMobileOpen }
186+ >
187+ < span aria-hidden = "true" > ☰</ span >
188+ </ button >
189+ < div className = "shell-brand-logo" aria-hidden = "true" > FS</ div >
190+ < span className = "shell-brand-name" > Fieldstack</ span >
191+ </ div >
192+
136193 { notice && (
137194 < div className = "shell-notice" role = "status" aria-live = "polite" >
138195 < span className = "shell-notice-dot" aria-hidden = "true" />
0 commit comments