스킵 네비게이션

This commit is contained in:
2026-10-02 21:51:56 +09:00
parent 49000c6140
commit ff45d1a875
8 changed files with 166 additions and 9 deletions
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import { MenuApi } from "@/entities/menu";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore } from "@/shared/lib";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import { useQuery } from "@tanstack/react-query";
@@ -11,6 +14,9 @@ export default function PostLayout({
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const { bbsInfo, currentMenuInfo } = useMenuStore((state) => state);
const { data } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, currentMenuInfo?.menuCd],
@@ -18,11 +24,78 @@ export default function PostLayout({
MenuApi.getMenuInfo(currentMenuInfo?.menuCd.toString() ?? ""),
enabled: !!currentMenuInfo,
});
const isRegisterPage =
pathname?.endsWith("/register") || pathname?.endsWith("/answer");
const isModifyPage = pathname?.endsWith("/modify");
const requiredAuthority = isRegisterPage
? "regAuthrtYn"
: isModifyPage
? "mdfcnAuthrtYn"
: undefined;
const {
data: pageAuthorities,
isError: isAuthorityError,
isLoading: isAuthorityLoading,
} = useQuery({
queryKey: ["page-menu-authority", currentMenuInfo?.menuCd],
queryFn: () =>
MenuAuthorityApi.getPageMenuAuthority(currentMenuInfo!.menuCd),
enabled: Boolean(requiredAuthority && currentMenuInfo?.menuCd),
});
const hasRequiredAuthority =
!requiredAuthority ||
pageAuthorities?.[0]?.[requiredAuthority] === "Y";
const canRenderChildren =
!requiredAuthority || (hasRequiredAuthority && !isAuthorityLoading);
const handledDeniedRoute = useRef("");
useEffect(() => {
if (!requiredAuthority) {
handledDeniedRoute.current = "";
return;
}
if (
!pathname ||
!currentMenuInfo?.menuCd ||
isAuthorityLoading ||
(!isAuthorityError && hasRequiredAuthority)
) {
return;
}
const deniedRouteKey = `${pathname}:${currentMenuInfo.menuCd}:${requiredAuthority}`;
if (handledDeniedRoute.current === deniedRouteKey) return;
handledDeniedRoute.current = deniedRouteKey;
const listPath = pathname.replace(
/\/(?:register|[^/]+\/(?:modify|answer))\/?$/,
"",
);
const message = isAuthorityError
? "권한 정보를 확인할 수 없습니다."
: requiredAuthority === "regAuthrtYn"
? "등록 권한이 없습니다."
: "수정 권한이 없습니다.";
alert(message, () => router.replace(listPath));
}, [
alert,
currentMenuInfo?.menuCd,
hasRequiredAuthority,
isAuthorityError,
isAuthorityLoading,
pathname,
requiredAuthority,
router,
]);
const menuPaths = data?.menuPath?.split(">").map((path) => path.trim());
return (
<PageLayout title={bbsInfo?.bbsNm ?? ""} menuPaths={menuPaths ?? []}>
{children}
{canRenderChildren && children}
</PageLayout>
);
}
@@ -270,7 +270,11 @@ export function Header({
}, []);
return (
<header className="sticky top-0 z-30 box-border flex h-[6rem] w-full flex-col items-center justify-between border-b border-solid border-border bg-background sm:flex-row">
<header
id="gnb"
tabIndex={-1}
className="sticky top-0 z-30 box-border flex h-[6rem] w-full flex-col items-center justify-between border-b border-solid border-border bg-background sm:flex-row"
>
<div className="flex w-full items-center justify-between px-2 sm:w-auto sm:justify-start sm:px-4 md:px-6">
<div className="flex items-center gap-2 sm:gap-4">
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
@@ -7,6 +7,7 @@ import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "../footer";
import { SkipNavigation } from "./skip-navigation";
import "./common.css";
export function BaseLayout({
@@ -30,6 +31,7 @@ export function BaseLayout({
<PopupProvider>
<SidebarProvider>
<div className="flex-1">
<SkipNavigation />
<Header
authService={authService}
tokenManager={authService ? undefined : tokenManager}
@@ -40,7 +42,9 @@ export function BaseLayout({
handleMenuClick={handleMenuClick}
/>
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col">
<div className="flex-1">{children}</div>
<main id="main-content" tabIndex={-1} className="flex-1">
{children}
</main>
<Footer />
</div>
</div>
@@ -5,6 +5,7 @@ import type { MenuItem } from "../../api";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { SkipNavigation } from "./skip-navigation";
import "./common.css";
import type { AuthService } from "@common/auth";
@@ -26,6 +27,7 @@ export function NoFooterLayout({
<PopupProvider>
<SidebarProvider>
<div className="flex-1">
<SkipNavigation />
<Header authService={authService} />
<div className="flex">
<LNB
@@ -33,7 +35,9 @@ export function NoFooterLayout({
handleMenuClick={handleMenuClick}
/>
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
<main id="main-content" tabIndex={-1} className="flex-1">
{children}
</main>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ import type { MenuItem } from "../../api";
import { SidebarProvider } from "../sidebar";
import { Header } from "../header";
import { Footer } from "../footer";
import { SkipNavigation } from "./skip-navigation";
import "./common.css";
export function NoLNBLayout({
@@ -24,10 +25,13 @@ export function NoLNBLayout({
<PopupProvider>
<SidebarProvider>
<div className="flex-1">
<SkipNavigation includeLnb={false} />
<Header authService={authService} />
<div className="flex">
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
<main id="main-content" tabIndex={-1} className="flex-1">
{children}
</main>
<Footer />
</div>
</div>
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef } from "react";
import { useSidebar } from "../sidebar";
type SkipNavigationProps = {
includeLnb?: boolean;
};
const linkClassName =
"sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-sm focus:bg-background focus:px-4 focus:py-3 focus:text-foreground focus:shadow-lg focus:outline focus:outline-2 focus:outline-primary";
export function SkipNavigation({
includeLnb = true,
}: SkipNavigationProps) {
const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar();
const shouldFocusLnb = useRef(false);
useEffect(() => {
const isLnbOpen = isMobile ? openMobile : open;
if (!shouldFocusLnb.current || !isLnbOpen) return;
document.getElementById("lnb")?.focus();
shouldFocusLnb.current = false;
}, [isMobile, open, openMobile]);
const handleLnbClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const isLnbOpen = isMobile ? openMobile : open;
if (isLnbOpen) {
document.getElementById("lnb")?.focus();
return;
}
shouldFocusLnb.current = true;
if (isMobile) {
setOpenMobile(true);
} else {
setOpen(true);
}
};
return (
<nav aria-label="건너뛰기">
<a className={linkClassName} href="#main-content">
본문 바로가기
</a>
<a className={linkClassName} href="#gnb">
주메뉴 바로가기
</a>
{includeLnb && (
<a className={linkClassName} href="#lnb" onClick={handleLnbClick}>
부메뉴 바로가기
</a>
)}
</nav>
);
}
@@ -3,6 +3,7 @@ import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "../footer";
import { SkipNavigation } from "./skip-navigation";
import "./common.css";
import type { QueryClient } from "@tanstack/react-query";
@@ -18,13 +19,18 @@ export function VBaseLayout({
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<SkipNavigation />
<Header viewOnly />
<div className="flex h-[calc(100vh-50px)]">
<LNB viewOnly />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
<main
id="main-content"
tabIndex={-1}
className="max-h-[calc(100%-50px)] flex-1 overflow-auto"
>
{children}
</div>
</main>
<Footer />
</div>
</div>
@@ -187,7 +187,6 @@ export function LNB({
}) {
const {
open: isSidebarOpen,
setOpen,
isMobile: isMobileView,
setOpenMobile: closeMobileSidebar,
} = useSidebar();
@@ -348,6 +347,10 @@ export function LNB({
)}
>
<SidebarContent
id="lnb"
role="navigation"
aria-label="부메뉴"
tabIndex={-1}
className="bg-background"
onKeyDown={handleSidebarKeyDown}
>