[UX] #577 show VPD submenu below primary navigation

This commit is contained in:
devmrko
2026-07-01 09:38:05 +09:00
parent 1218ae1719
commit c52890e721
5 changed files with 93 additions and 109 deletions

View File

@@ -113,10 +113,6 @@ body {
gap: .35rem;
}
.rw-menu-group {
position: relative;
}
.rw-menu-trigger {
background: transparent;
border: 1px solid transparent;
@@ -130,36 +126,38 @@ body {
.rw-menu-trigger:hover,
.rw-menu-trigger:focus,
.rw-menu-group.open .rw-menu-trigger {
.rw-menu-trigger.active {
background: var(--rw-surface-muted);
border-color: var(--rw-border);
color: var(--rw-text);
}
.rw-menu-panel {
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
box-shadow: var(--rw-shadow);
display: none;
left: 0;
min-width: 190px;
padding: .35rem;
position: absolute;
top: calc(100% + .35rem);
z-index: 30;
.rw-submenu-bar {
background: var(--rw-surface-muted);
border-top: 1px solid var(--rw-border);
width: 100%;
}
.rw-menu-group.open .rw-menu-panel {
display: grid;
gap: .15rem;
}
.rw-menu-panel .nav-link {
border-radius: 8px;
color: var(--rw-text);
.rw-submenu-bar .container {
display: block;
padding: .55rem .65rem !important;
}
.rw-submenu {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: .2rem;
min-height: 2.75rem;
padding: .25rem 0;
}
.rw-submenu[hidden] {
display: none;
}
.rw-submenu .nav-link {
color: var(--rw-text);
padding: .4rem .7rem !important;
white-space: nowrap;
}
@@ -181,14 +179,6 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rw-menu-panel {
position: static;
}
.rw-menu-group.open .rw-menu-panel {
display: grid;
}
.architecture-strip {
grid-template-columns: 1fr;
}
@@ -1786,10 +1776,6 @@ body {
width: 100%;
}
.rw-menu-group {
min-width: 0;
}
.rw-menu-trigger {
padding-left: .65rem;
padding-right: .65rem;

View File

@@ -73,45 +73,39 @@ function syncPolicyTemplate(select) {
);
}
function closeMenuGroup(group) {
if (!group) {
function initPersistentMenus() {
const productNav = document.querySelector('[data-product-nav]');
if (!productNav) {
return;
}
group.classList.remove('open');
group.querySelector('.rw-menu-trigger')?.setAttribute('aria-expanded', 'false');
}
const submenuBar = productNav.querySelector('[data-submenu-bar]');
const triggers = Array.from(productNav.querySelectorAll('[data-submenu-trigger]'));
const panels = Array.from(productNav.querySelectorAll('[data-submenu-panel]'));
const path = window.location.pathname;
function closeAllMenuGroups(exceptGroup = null) {
document.querySelectorAll('.rw-menu-group.open').forEach((group) => {
if (group !== exceptGroup) {
closeMenuGroup(group);
}
});
}
function initPersistentMenus() {
document.querySelectorAll('.rw-menu-trigger').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
const group = button.closest('.rw-menu-group');
if (!group) {
return;
}
const nextOpen = !group.classList.contains('open');
closeAllMenuGroups(group);
group.classList.toggle('open', nextOpen);
button.setAttribute('aria-expanded', String(nextOpen));
const activate = (target) => {
const hasTarget = panels.some((panel) => panel.dataset.submenuPanel === target);
submenuBar.hidden = !hasTarget;
triggers.forEach((trigger) => {
const active = trigger.dataset.submenuTrigger === target;
trigger.classList.toggle('active', active);
trigger.setAttribute('aria-expanded', String(active));
});
});
document.addEventListener('click', (event) => {
if (!event.target.closest('.rw-menu')) {
closeAllMenuGroups();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAllMenuGroups();
}
panels.forEach((panel) => {
panel.hidden = panel.dataset.submenuPanel !== target;
});
};
const currentPanel = panels.find((panel) => Array.from(panel.querySelectorAll('a[href]')).some((link) => {
const href = new URL(link.href, window.location.origin).pathname;
return path === href || (href !== '/' && path.startsWith(`${href}/`));
}));
if (currentPanel) {
activate(currentPanel.dataset.submenuPanel);
}
triggers.forEach((trigger) => {
trigger.addEventListener('click', () => activate(trigger.dataset.submenuTrigger));
});
}

View File

@@ -11,49 +11,45 @@
<script defer src="/js/app.js"></script>
</head>
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg">
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container">
<a class="navbar-brand" href="/">VPD 권한 운영</a>
<div class="rw-menu">
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">권한 관리</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">접근 규칙</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">보호 정책 관리</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/objects">보호 대상 관리</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">지식자료 관리</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vector-knowledge">지식자료 등록 및 검색</a>
<a class="nav-link" href="/mcp-chatbot">대화형 검색</a>
<a class="nav-link" href="/mcp-reasoning">검색 해석</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">접근 검증</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/tokens">검증 세션</a>
<a class="nav-link" href="/probe">접근 검증</a>
<a class="nav-link" href="/operation-status">검증 현황</a>
</div>
</div>
<div class="rw-menu" aria-label="주요 메뉴">
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">권한 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호 정책 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="knowledge" aria-controls="submenu-knowledge" aria-expanded="false">지식자료 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="verification" aria-controls="submenu-verification" aria-expanded="false">접근 검증</button>
</div>
<form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
</form>
</div>
<div class="rw-submenu-bar" data-submenu-bar hidden>
<div class="container">
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="권한 관리 메뉴" hidden>
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">접근 규칙</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a>
</div>
<div id="submenu-protection" class="rw-submenu" data-submenu-panel="protection" role="navigation" aria-label="보호 정책 관리 메뉴" hidden>
<a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/objects">보호 대상 관리</a>
</div>
<div id="submenu-knowledge" class="rw-submenu" data-submenu-panel="knowledge" role="navigation" aria-label="지식자료 관리 메뉴" hidden>
<a class="nav-link" href="/vector-knowledge">지식자료 등록 및 검색</a>
<a class="nav-link" href="/mcp-chatbot">대화형 검색</a>
<a class="nav-link" href="/mcp-reasoning">검색 해석</a>
</div>
<div id="submenu-verification" class="rw-submenu" data-submenu-panel="verification" role="navigation" aria-label="접근 검증 메뉴" hidden>
<a class="nav-link" href="/tokens">검증 세션</a>
<a class="nav-link" href="/probe">접근 검증</a>
<a class="nav-link" href="/operation-status">검증 현황</a>
</div>
</div>
</div>
</nav>
</body>