212 lines
6.1 KiB
Vue
212 lines
6.1 KiB
Vue
<script setup lang="ts">
|
|
import type { PageDataLnbMenu } from '#layers/types/api/pageData'
|
|
|
|
const HEADER_HEIGHT = 64
|
|
|
|
const { y: windowY } = useWindowScroll({ behavior: 'smooth' })
|
|
const pageDataStore = usePageDataStore()
|
|
const breakpoints = useResponsiveBreakpoints()
|
|
|
|
const { pageData } = storeToRefs(pageDataStore)
|
|
|
|
const activeSection = ref<string>('')
|
|
const observerRef = shallowRef<IntersectionObserver | null>(null)
|
|
|
|
/** 1뎁스 LNB 배열로 정규화 */
|
|
const lnbRoot = computed<PageDataLnbMenu[]>(() =>
|
|
Object.values(pageData.value?.lnb_menus || {}).filter(Boolean)
|
|
)
|
|
const isShowLnb = computed(() => {
|
|
const pageDataUseLnb = pageData.value?.use_lnb ?? false
|
|
const isDesktop = breakpoints.value.isDesktop
|
|
const lnbRootLength = lnbRoot.value.length
|
|
return Boolean(pageDataUseLnb && isDesktop && lnbRootLength)
|
|
})
|
|
const activeColor = computed(
|
|
() => pageData.value?.lnb_text_color_code_active || 'var(--text-primary)'
|
|
)
|
|
const disableColor = computed(
|
|
() => pageData.value?.lnb_text_color_code_deactive || 'var(--text-secondary)'
|
|
)
|
|
|
|
const getChildren = (item?: PageDataLnbMenu) =>
|
|
item?.children ? Object.values(item.children) : []
|
|
|
|
/** 1뎁스 활성 판정(자신 또는 자식) */
|
|
const is1DepthActive = (lnbItem: PageDataLnbMenu): boolean => {
|
|
if (!lnbItem) return false
|
|
if (activeSection.value === lnbItem.page_ver_tmpl_name_en) return true
|
|
return getChildren(lnbItem).some(
|
|
c => activeSection.value === c.page_ver_tmpl_name_en
|
|
)
|
|
}
|
|
|
|
/** 스크롤 이동 */
|
|
const scrollToLnb = async (id: string) => {
|
|
if (import.meta.server) return
|
|
await nextTick()
|
|
|
|
const el = document.getElementById(id)
|
|
if (!el) return
|
|
|
|
const rect = el.getBoundingClientRect()
|
|
const targetY = window.scrollY + rect.top - HEADER_HEIGHT
|
|
|
|
windowY.value = targetY
|
|
}
|
|
|
|
/** 관찰 대상 id 리스트(1뎁스 + 2뎁스) */
|
|
const sectionIds = computed<string[]>(() => {
|
|
const ids: string[] = []
|
|
for (const item of lnbRoot.value) {
|
|
if (item?.page_ver_tmpl_name_en) ids.push(item.page_ver_tmpl_name_en)
|
|
for (const c of getChildren(item)) {
|
|
if (c?.page_ver_tmpl_name_en) ids.push(c.page_ver_tmpl_name_en)
|
|
}
|
|
}
|
|
// 중복 제거
|
|
return Array.from(new Set(ids))
|
|
})
|
|
|
|
const createObserver = () => {
|
|
if (observerRef.value) observerRef.value.disconnect()
|
|
|
|
observerRef.value = new IntersectionObserver(
|
|
entries => {
|
|
// 보이는 섹션 중 화면 상단에 가장 가까운 요소를 활성화
|
|
const visibles = entries
|
|
.filter(e => e.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
|
|
if (visibles.length > 0) {
|
|
activeSection.value = (visibles[0].target as HTMLElement).id
|
|
return
|
|
}
|
|
|
|
// 가끔 빠르게 스크롤 시 entries가 비어있을 수 있으므로
|
|
// 현재 모든 섹션 중에서 '상단 기준으로 가장 가까운' 것을 폴백 계산
|
|
const candidates: Array<{ id: string; top: number }> = []
|
|
for (const id of sectionIds.value) {
|
|
const el = document.getElementById(id)
|
|
if (!el) continue
|
|
const top = el.getBoundingClientRect().top - HEADER_HEIGHT
|
|
candidates.push({ id, top })
|
|
}
|
|
// 0에 가장 가까운 양수/음수 모두 고려(위/아래 가장 가까운)
|
|
candidates.sort((a, b) => Math.abs(a.top) - Math.abs(b.top))
|
|
if (candidates.length) activeSection.value = candidates[0].id
|
|
},
|
|
{
|
|
root: null,
|
|
// 상단 20%, 하단 60% 마진은 유지하되 헤더 보정치 반영
|
|
rootMargin: `-${Math.max(HEADER_HEIGHT, 20)}px 0px -60% 0px`,
|
|
threshold: 0,
|
|
}
|
|
)
|
|
}
|
|
|
|
const handleLnbClick = (lnbItem: PageDataLnbMenu) => {
|
|
if (!lnbItem?.page_ver_tmpl_name_en) return
|
|
scrollToLnb(lnbItem.page_ver_tmpl_name_en)
|
|
}
|
|
|
|
const observeSections = () => {
|
|
if (import.meta.server) return
|
|
if (!observerRef.value) createObserver()
|
|
const obs = observerRef.value!
|
|
obs.disconnect() // 기존 관찰 해제
|
|
|
|
// DOM 렌더 후 관찰 등록
|
|
requestAnimationFrame(() => {
|
|
for (const id of sectionIds.value) {
|
|
const el = document.getElementById(id)
|
|
if (el) obs.observe(el)
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (import.meta.server) return
|
|
createObserver()
|
|
observeSections()
|
|
})
|
|
|
|
// lnb 데이터/DOM이 바뀌면 재관찰
|
|
watchEffect(async () => {
|
|
if (!import.meta.client) return
|
|
if (isShowLnb.value && sectionIds.value.length) {
|
|
await nextTick()
|
|
observeSections()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
observerRef.value?.disconnect()
|
|
observerRef.value = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="isShowLnb"
|
|
class="lnb-wrap"
|
|
:style="{
|
|
'--lnb-active-color': activeColor,
|
|
'--lnb-disable-color': disableColor,
|
|
}"
|
|
>
|
|
<ul class="main-list">
|
|
<li v-for="lnbItem in lnbRoot" :key="lnbItem.path_code">
|
|
<button
|
|
v-dompurify-html="lnbItem.menu_name"
|
|
type="button"
|
|
:class="['btn-1depth', { 'is-active': is1DepthActive(lnbItem) }]"
|
|
@click="handleLnbClick(lnbItem)"
|
|
></button>
|
|
|
|
<ul v-if="getChildren(lnbItem).length > 0" class="sub-list">
|
|
<li v-for="subItem in getChildren(lnbItem)" :key="subItem.path_code">
|
|
<button
|
|
v-dompurify-html="subItem.menu_name"
|
|
type="button"
|
|
:class="[
|
|
'btn-2depth',
|
|
{
|
|
'is-active': activeSection === subItem.page_ver_tmpl_name_en,
|
|
},
|
|
]"
|
|
@click="handleLnbClick(subItem)"
|
|
></button>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.lnb-wrap {
|
|
@apply fixed top-1/2 right-0 py-12 pr-4 text-right -translate-y-1/2 bg-[radial-gradient(100%_50%_at_100%_50%,rgba(0,0,0,0.5)_25%,rgba(0,0,0,0)_100%)] z-50;
|
|
}
|
|
.main-list {
|
|
@apply flex flex-col gap-4 items-end;
|
|
}
|
|
.btn-1depth {
|
|
@apply text-[15px] leading-[26px] tracking-[-0.54px];
|
|
}
|
|
.sub-list {
|
|
@apply flex flex-col gap-2 items-end mt-4 mb-1 pr-[46px];
|
|
}
|
|
.btn-2depth {
|
|
@apply text-[14px] leading-[20px] tracking-[-0.42px];
|
|
}
|
|
|
|
button {
|
|
@apply font-[500] text-[var(--lnb-disable-color)] transition-all duration-300 ease-in-out;
|
|
}
|
|
button:hover,
|
|
button.is-active {
|
|
@apply text-[var(--lnb-active-color)];
|
|
}
|
|
</style>
|