Files
web-temp/layers/components/blocks/Lnb2.vue
2025-11-11 16:54:59 +09:00

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>