Compare commits
3
Commits
24bba484ca
...
68a800cdfc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a800cdfc | ||
|
|
ff45d1a875 | ||
|
|
49000c6140 |
@@ -78,6 +78,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin:focus-visible) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin:focus-visible) :global(path) {
|
||||
stroke: #111827;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin) :global(path),
|
||||
.svgContainer :global(svg) :global(.basin) :global(polygon),
|
||||
.svgContainer :global(svg) :global(.basin) :global(polyline) {
|
||||
|
||||
@@ -265,20 +265,44 @@ export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] |
|
||||
};
|
||||
|
||||
groups.forEach((group) => {
|
||||
addListener(group, "mouseenter", () => {
|
||||
group.setAttribute("role", "button");
|
||||
group.setAttribute("tabindex", "0");
|
||||
group.setAttribute(
|
||||
"aria-label",
|
||||
`${resolveBasinName(group)} 행정지원 현황 보기`,
|
||||
);
|
||||
|
||||
const activateGroup = () => {
|
||||
clearActive?.();
|
||||
group.classList.add("active");
|
||||
revealGroup(group);
|
||||
};
|
||||
const clearGroupIfInactive = () => {
|
||||
if (!group.matches(":hover") && document.activeElement !== group) {
|
||||
clearActive?.();
|
||||
}
|
||||
};
|
||||
|
||||
addListener(group, "mouseenter", () => {
|
||||
activateGroup();
|
||||
});
|
||||
addListener(group, "mousemove", () => {
|
||||
if (group.classList.contains("active")) revealGroup(group);
|
||||
});
|
||||
addListener(group, "mouseleave", () => clearActive?.());
|
||||
addListener(group, "mouseleave", clearGroupIfInactive);
|
||||
addListener(group, "focus", activateGroup);
|
||||
addListener(group, "blur", clearGroupIfInactive);
|
||||
addListener(group, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
clearActive?.();
|
||||
group.classList.add("active");
|
||||
revealGroup(group);
|
||||
activateGroup();
|
||||
});
|
||||
addListener(group, "keydown", (event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
if (keyboardEvent.key !== "Enter" && keyboardEvent.key !== " ") {
|
||||
return;
|
||||
}
|
||||
keyboardEvent.preventDefault();
|
||||
activateGroup();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi, PostPageGuard, type PostParams } from "@/entities/post";
|
||||
@@ -40,6 +43,7 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
keyword: "",
|
||||
});
|
||||
const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]);
|
||||
const [expandedFaqs, setExpandedFaqs] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: postList,
|
||||
@@ -174,6 +178,8 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
{isFetched ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedFaqs}
|
||||
onValueChange={setExpandedFaqs}
|
||||
className="secondary flex flex-col gap-4"
|
||||
>
|
||||
{postList?.content.map((item) => (
|
||||
@@ -191,17 +197,30 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
value={item.pstNo.toString()}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionTrigger>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-primary">Q</span>
|
||||
<span>{item.pstTtl}</span>
|
||||
</div>
|
||||
<span className="px-4 text-[1.2rem] font-normal text-label">
|
||||
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AccordionTrigger
|
||||
aria-expanded={expandedFaqs.includes(
|
||||
item.pstNo.toString(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-primary">Q</span>
|
||||
<span>{item.pstTtl}</span>
|
||||
</div>
|
||||
<span className="px-4 text-[1.2rem] font-normal text-label">
|
||||
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{expandedFaqs.includes(item.pstNo.toString())
|
||||
? "접기"
|
||||
: "펼치기"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AccordionContent>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-[1.4rem] font-bold text-label">
|
||||
|
||||
@@ -58,8 +58,16 @@ function DataTable<T>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onRowClick?.(row.original);
|
||||
}}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer hover:bg-accent",
|
||||
onRowClick &&
|
||||
"cursor-pointer hover:bg-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary",
|
||||
isSelectedRow && "!bg-primary-transparent",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user