feat: assemble responsive app shell navigation
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
|
||||
import { useSession } from "../providers/session-provider.jsx";
|
||||
|
||||
const SESSION_LABELS = Object.freeze({
|
||||
authenticated: "인증됨",
|
||||
unauthenticated: "로그인 전",
|
||||
"recovery-pending": "복구 대기",
|
||||
"integration-failed": "연동 필요",
|
||||
});
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const { sessionState, beginSignIn, signOut, recover } = useSession();
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const [sessionActionPending, setSessionActionPending] = useState(false);
|
||||
const [sessionActionFailed, setSessionActionFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNavigationOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigationOpen) return undefined;
|
||||
/** @param {KeyboardEvent} event */
|
||||
const closeOnEscape = (event) => {
|
||||
if (event.key === "Escape") setNavigationOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [navigationOpen]);
|
||||
|
||||
async function runSessionAction() {
|
||||
setSessionActionPending(true);
|
||||
setSessionActionFailed(false);
|
||||
try {
|
||||
if (sessionState === "authenticated") {
|
||||
await signOut();
|
||||
} else if (sessionState === "recovery-pending") {
|
||||
await recover();
|
||||
} else {
|
||||
await beginSignIn(
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setSessionActionFailed(true);
|
||||
} finally {
|
||||
setSessionActionPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionActionLabel =
|
||||
sessionState === "authenticated"
|
||||
? "로그아웃"
|
||||
: sessionState === "recovery-pending"
|
||||
? "세션 복구"
|
||||
: "로그인";
|
||||
const integrationAvailable = sessionState !== "integration-failed";
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<header className="app-shell__header">
|
||||
<button
|
||||
className="app-shell__menu-button"
|
||||
type="button"
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
onClick={() => setNavigationOpen((open) => !open)}
|
||||
>
|
||||
<span aria-hidden="true">☰</span>
|
||||
<span>메뉴</span>
|
||||
</button>
|
||||
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
|
||||
Frontend Skeleton
|
||||
</NavLink>
|
||||
<div className="app-shell__session">
|
||||
<span className="session-status" data-state={sessionState}>
|
||||
{SESSION_LABELS[sessionState]}
|
||||
</span>
|
||||
{integrationAvailable ? (
|
||||
<button
|
||||
className="ui-button ui-button--compact"
|
||||
type="button"
|
||||
disabled={sessionActionPending}
|
||||
onClick={() => void runSessionAction()}
|
||||
>
|
||||
{sessionActionPending ? "처리 중…" : sessionActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{sessionActionFailed ? (
|
||||
<p className="app-shell__session-error" role="alert">
|
||||
세션 작업을 완료하지 못했습니다.
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
<aside
|
||||
className="app-shell__sidebar"
|
||||
data-open={navigationOpen}
|
||||
aria-label="사이드바"
|
||||
>
|
||||
<nav id="primary-navigation" aria-label="주요 탐색">
|
||||
<ul className="app-navigation">
|
||||
{NAVIGATION_ROUTES.map((definition) => (
|
||||
<li key={definition.routeId}>
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`app-navigation__link${isActive ? " is-active" : ""}`
|
||||
}
|
||||
end={definition.path === "/"}
|
||||
to={definition.path}
|
||||
>
|
||||
{definition.navigationLabel}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
{navigationOpen ? (
|
||||
<button
|
||||
className="app-shell__scrim"
|
||||
type="button"
|
||||
aria-label="메뉴 닫기"
|
||||
onClick={() => setNavigationOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<main className="app-shell__content" id="main-content" tabIndex={-1}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user