154 lines
5.9 KiB
Vue
154 lines
5.9 KiB
Vue
<script setup>
|
|
defineProps({
|
|
/** 공개 API `primary` 트리 노드 */
|
|
nodes: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
})
|
|
|
|
const route = useRoute()
|
|
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
|
|
const toggleBranch = inject('sidebarPrimaryNavToggle')
|
|
|
|
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
|
|
const navBarBeforeBase =
|
|
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
|
|
|
|
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
|
|
const navBarBeforeInactive =
|
|
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
|
|
|
|
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
|
|
const navBarBeforeActive =
|
|
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
|
|
|
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
|
const navRowShell =
|
|
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
|
|
|
/**
|
|
* 노드가 펼쳐져 있는지
|
|
* @param {string} id - 노드 id
|
|
* @returns {boolean}
|
|
*/
|
|
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
|
|
|
|
/**
|
|
* 부모 행(이름·행 전체) 클릭으로 하위 접기/펼치기
|
|
* @param {string} id - 노드 id
|
|
* @returns {void}
|
|
*/
|
|
const onBranchClick = (id) => {
|
|
toggleBranch(id)
|
|
}
|
|
|
|
/**
|
|
* 외부 URL 여부
|
|
* @param {string} raw - 네비 URL
|
|
* @returns {boolean}
|
|
*/
|
|
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
|
|
|
|
/**
|
|
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
|
|
* @param {string} raw - 네비 URL
|
|
* @returns {boolean}
|
|
*/
|
|
const isInternalNavActive = (raw) => {
|
|
const u = String(raw || '').trim()
|
|
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
|
|
return false
|
|
}
|
|
if (isExternalUrl(u)) {
|
|
return false
|
|
}
|
|
const path = (route.path || '/').split('?')[0] || '/'
|
|
/**
|
|
* 경로 정규화
|
|
* @param {string} s - 경로
|
|
* @returns {string}
|
|
*/
|
|
const norm = (s) => {
|
|
let x = s || '/'
|
|
if (x.length > 1 && x.endsWith('/')) {
|
|
x = x.slice(0, -1)
|
|
}
|
|
return x || '/'
|
|
}
|
|
return norm(path) === norm(u)
|
|
}
|
|
|
|
/**
|
|
* 리프 `NuxtLink`용 클래스
|
|
* @param {string} url - 노드 URL
|
|
* @returns {string}
|
|
*/
|
|
const navLinkClass = (url) => {
|
|
const active = isInternalNavActive(url)
|
|
const bar = active ? navBarBeforeActive : navBarBeforeInactive
|
|
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<ul class="sidebar-primary-nav-list flex w-full flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
|
<template v-for="node in nodes" :key="node.id">
|
|
<li
|
|
v-if="node.children?.length"
|
|
class="sidebar-primary-nav-list__branch w-full"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
|
|
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
|
:aria-expanded="isExpanded(node.id)"
|
|
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
|
|
@click="onBranchClick(node.id)"
|
|
>
|
|
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
|
<span
|
|
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
|
|
:class="{ '-rotate-180': isExpanded(node.id) }"
|
|
aria-hidden="true"
|
|
>
|
|
<svg class="sidebar-primary-nav-list__chevron-svg h-3.5 w-3.5" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M2.5 4.25L6 7.75L9.5 4.25" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<div
|
|
class="sidebar-primary-nav-list__sub-grid grid min-h-0 w-full max-w-full transition-[grid-template-rows] duration-200 ease-out"
|
|
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
|
>
|
|
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
|
|
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
|
|
<SidebarPrimaryNavList :nodes="node.children" />
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<li
|
|
v-else
|
|
class="sidebar-primary-nav-list__leaf group relative flex w-full max-w-full items-center"
|
|
>
|
|
<NuxtLink
|
|
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
|
|
:class="navLinkClass(node.url)"
|
|
:to="node.url"
|
|
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
|
|
>
|
|
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
|
</NuxtLink>
|
|
<span
|
|
v-else
|
|
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
|
|
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
|
>
|
|
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
|
</span>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</template>
|