feat. 해더 기획 기능 구현 (해상도에 맞춰 리스트 더보기 생성)

This commit is contained in:
clkim
2025-10-01 21:38:12 +09:00
parent 517d2b233b
commit a70b3c8795
5 changed files with 253 additions and 247 deletions

View File

@@ -8,6 +8,7 @@
--foreground-reversal-6: rgba(0, 0, 0, 0.06);
--foreground-reversal-8: rgba(0, 0, 0, 0.08);
--foreground-reversal-10: rgba(0, 0, 0, 0.1);
--foreground-reversal-15: rgba(0, 0, 0, 0.15);
--foreground-reversal-30: #ebebeb; /* gray-80 */
}
@@ -21,6 +22,7 @@
--foreground-reversal-6: rgba(255, 255, 255, 0.06);
--foreground-reversal-8: rgba(255, 255, 255, 0.08);
--foreground-reversal-10: rgba(255, 255, 255, 0.1);
--foreground-reversal-15: rgba(255, 255, 255, 0.15);
--foreground-reversal-30: #404040; /* gray-750 */
}

View File

@@ -1,5 +1,5 @@
<template>
<div id="header-stove" class="relative z-[5]" />
<div id="stove-wrapper" class="relative z-[5]" />
</template>
<script setup lang="ts">
@@ -26,7 +26,7 @@ function loadGnb(locale: string) {
locale = locale.toLowerCase()
const gnbOption = {
wrapper: '#header-stove',
wrapper: '#stove-wrapper',
isResponsive: true,
skin: gnbData?.skin_type || 'gnb-dark-mini',
widget: {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import { useScrollStore } from '#layers/stores/useScrollStore'
import { useWindowScroll, onClickOutside } from '@vueuse/core'
import { useWindowScroll, onClickOutside, useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type {
GameDataValue,
@@ -11,15 +11,21 @@ import type {
const route = useRoute()
const { y: windowY } = useWindowScroll()
const gameDataStore = useGameDataStore()
const scrollStore = useScrollStore()
const _breakpoints = useResponsiveBreakpointsReliable()
const { isPassedStoveGnb } = storeToRefs(scrollStore)
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const gnbList = (gameData?.gnb?.menus ?? []) as GameDataMenuChildren
const gnbList = (gameData?.gnb?.menus ?? {}) as GameDataMenuChildren
const isMenuOpen = ref(false)
const contentNavRef = ref<HTMLElement>()
const navAreaRef = ref<HTMLElement>()
const startRef = ref<HTMLElement>()
const navWidth = ref(0)
const startWidth = ref(0)
const officialItemWidths = ref<number[]>([])
const overflowNam = ref<number>(0)
const currentPath = computed(() => formatPathWithoutLocale(route.path))
@@ -29,12 +35,12 @@ const pathMatches = (base: string, current: string) => {
}
/** header overlay 높이 계산 (re-compute 최소화) */
const scrollPositionPX = computed(() => {
const scrollPositionHeight = computed(() => {
const gnbHeight = scrollStore.stoveGnbHeight
const y = windowY.value
if (y === 0) return `${gnbHeight}px`
if (y >= gnbHeight) return '0px'
return `${gnbHeight - y}px`
return `${gnbHeight - y}`
})
/** 자식 중 활성 링크 존재 여부 */
@@ -60,46 +66,135 @@ const isNavItemActive = (gnbItem: GameDataMenu): boolean => {
const handleMenuOpen = () => (isMenuOpen.value = true)
const handleMenuClose = () => (isMenuOpen.value = false)
onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
// navAreaRef의 넓이를 구하는 함수
const calculateNavWidth = () => {
if (!navAreaRef.value) return 0
const navAreaWidth = navAreaRef.value.offsetWidth
return navAreaWidth
}
// startRef의 넓이를 구하는 함수
const calculateStartWidth = () => {
if (!startRef.value) return 0
const startWidth = startRef.value.offsetWidth
return startWidth + 40
}
// official 자식들의 넓이를 구하는 함수 (뒤에서부터 순서대로)
const calculateOfficialItemWidths = () => {
if (!navAreaRef.value) return
const officialItems = navAreaRef.value.querySelectorAll('.official .nav-item')
const widths: number[] = []
// 뒤에서부터 순서대로 넓이 계산
for (let i = officialItems.length - 1; i >= 0; i--) {
const item = officialItems[i] as HTMLElement
if (item) {
widths.push(item.offsetWidth)
}
}
officialItemWidths.value = widths
// 해상도 체크 및 오버플로우 계산
calculateOverflow()
}
console.log(0, Object.keys(gnbList).length)
// 오버플로우 계산 함수
const calculateOverflow = () => {
if (!navAreaRef.value) return
const totalNavWidth = navWidth.value + startWidth.value
const screenWidth = width.value
console.log('calculateOverflow called:', {
screenWidth,
totalNavWidth,
navWidth: navWidth.value,
startWidth: startWidth.value,
officialItemWidths: officialItemWidths.value,
})
// 모바일(1024px 미만)에서는 overflowNam을 0으로 설정
if (screenWidth < 1024) {
overflowNam.value = 0
console.log('Mobile view - overflowNam set to 0')
return
}
// 해상도가 navWidth + startWidth보다 작은 경우
if (screenWidth < totalNavWidth) {
let removedCount = 0
let currentTotal = totalNavWidth
// officialItemWidths를 하나씩 빼면서 해상도보다 작아지는지 확인
for (let i = 0; i < officialItemWidths.value.length; i++) {
currentTotal -= officialItemWidths.value[i]
removedCount++
// 해상도보다 작아지면 중단
if (currentTotal <= screenWidth) {
break
}
}
overflowNam.value = removedCount
console.log('Overflow calculated:', overflowNam.value)
} else {
overflowNam.value = 0
console.log('No overflow needed, setting to 0')
}
}
// 컴포넌트 마운트 후 한 번만 계산
onMounted(() => {
// 초기화
overflowNam.value = 0
console.log('onMounted - overflowNam 초기화:', overflowNam.value)
nextTick(() => {
if (navAreaRef.value && startRef.value) {
navWidth.value = calculateNavWidth()
startWidth.value = calculateStartWidth()
calculateOfficialItemWidths()
}
})
})
// 화면 크기 변경 시 오버플로우 재계산
const { width } = useWindowSize()
watch(width, () => {
calculateOverflow()
})
onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
</script>
<template>
<header class="header">
<BlocksStoveGnb class="min-h-[48px]" />
<div class="header-game" :class="{ 'game-fixed': isPassedStoveGnb }">
<!-- 로고 -->
<div class="header-logo">
<AtomsLocaleLink to="/brand">
<img
:src="gameData?.gnb?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
</div>
<button
class="btn-open"
aria-controls="site-menu"
:aria-expanded="isMenuOpen"
@click="handleMenuOpen"
>
<div class="game-wrapper" :class="{ 'is-fixed': isPassedStoveGnb }">
<AtomsLocaleLink to="/brand" class="mx-auto md:hidden">
<img
:src="gameData?.gnb?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
<button class="btn-open" @click="handleMenuOpen">
<span class="sr-only">menu open</span>
</button>
<div
class="header-content"
:class="{ 'menu-open': isMenuOpen }"
:style="{ '--scroll-position': scrollPositionPX }"
:class="['gnb-game', { 'is-open': isMenuOpen }]"
:style="{ '--scroll-position': scrollPositionHeight }"
>
<div id="site-menu" ref="contentNavRef" class="content-nav">
<button
class="btn-close"
aria-controls="site-menu"
:aria-expanded="isMenuOpen"
@click="handleMenuClose"
>
<span class="sr-only">menu close</span>
</button>
<div ref="navAreaRef" class="nav-area">
<div class="nav-logo">
<AtomsLocaleLink to="/brand">
<img
@@ -109,23 +204,27 @@ onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
/>
</AtomsLocaleLink>
</div>
<nav class="nav-main">
<div v-if="gnbList" class="main-official">
<nav class="nav-list">
<div v-if="gnbList" class="official">
<div
v-for="(gnbItem, key) in gnbList"
:key="key"
class="nav-item"
:class="{
'is-hidden':
overflowNam > 0 &&
Number(key) >= Object.keys(gnbList).length - overflowNam,
}"
>
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`item-1depth ${isNavItemActive(gnbItem) ? 'active' : ''}`"
:class="`nav-1depth ${isNavItemActive(gnbItem) ? 'active' : ''}`"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsArrowDown v-if="gnbItem.children" />
</BlocksHybridLink>
<div v-if="gnbItem.children" class="item-2depth">
<div v-if="gnbItem.children" class="nav-2depth">
<ul>
<li
v-for="child in gnbItem.children"
@@ -145,19 +244,68 @@ onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
</div>
</div>
</div>
<div class="main-event">
<a href="#" class="item-1depth text-gradient-pink">
<AtomsIconsStar />
<span>이벤트</span>
<AtomsIconsStar />
</a>
<div v-if="gnbList && overflowNam > 0" class="more">
<button class="btn-more">
<span class="sr-only">more</span>
</button>
<div class="more-list">
<div
v-for="(gnbItem, key) in gnbList"
:key="key"
:class="{
hidden:
Number(key) < Object.keys(gnbList).length - overflowNam,
}"
>
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsArrowDown v-if="gnbItem.children" />
</BlocksHybridLink>
<div v-if="gnbItem.children">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<BlocksHybridLink
:to="child.url_path"
:target="child.link_target"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsLinkOut
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="event">
<div class="nav-item">
<BlocksHybridLink
:to="'/event'"
:target="'_self'"
class="nav-1depth text-gradient-pink"
>
<span>이벤트</span>
</BlocksHybridLink>
</div>
</div>
</nav>
<div class="btn-start">
<div ref="startRef" class="btn-start">
<AtomsButton size="small" class="w-full md:w-auto">
게임 시작
</AtomsButton>
</div>
<button class="btn-close" @click="handleMenuClose">
<span class="sr-only">menu close</span>
</button>
</div>
</div>
</div>
@@ -168,14 +316,14 @@ onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
.header {
@apply bg-theme-foreground text-theme-foreground-reversal relative z-50;
}
.header-game {
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[64px] bg-theme-foreground md:h-16 md:px-[40px]
.game-wrapper {
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[64px] bg-theme-foreground md:h-16 md:pl-0 md:pr-[40px]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-theme-foreground-reversal-6;
}
.game-fixed {
.game-wrapper.is-fixed {
@apply fixed top-0 left-0;
}
.header-logo {
.game-logo {
@apply mx-auto shrink-0 md:mx-0;
}
@@ -190,52 +338,38 @@ onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
@apply top-[11px] left-[12px];
}
.btn-start {
@apply relative mt-2 px-5 md:ml-auto md:mt-0 md:px-0;
@apply relative mt-2 px-5 md:absolute md:right-0 md:mt-0 md:px-0;
}
.btn-more {
@apply w-[40px] h-[40px] bg-[red];
}
.header-content {
@apply overflow-hidden absolute top-0 left-0 w-0 md:overflow-visible md:static md:w-full md:h-full;
.gnb-game {
@apply absolute top-0 left-0 w-0 md:relative md:w-full md:!h-full;
height: calc(100vh - var(--scroll-position));
}
.header-content.menu-open {
.gnb-game.is-open {
@apply w-full;
}
.header-content.menu-open::before {
.gnb-game.is-open::before {
@apply content-[''] absolute inset-0 w-[100vw] h-full bg-[rgba(0,0,0,0.6)] md:hidden;
}
.header-content.menu-open .content-nav {
@apply relative h-full translate-x-0 transition-transform duration-300 md:translate-x-0;
.gnb-game.is-open .nav-area {
@apply h-full translate-x-0 transition-transform duration-300 md:translate-x-0;
}
.content-nav {
@apply relative flex flex-col w-[360px] bg-theme-foreground-10 translate-x-[-100%]
md:flex-row md:w-full md:items-center md:h-full md:bg-transparent md:translate-x-0;
}
.nav-main {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2
md:overflow-visible md:flex-row md:order-none md:h-full md:my-0 md:mx-10 md:px-0;
}
.main-official {
@apply overflow-y-auto pb-2 md:overflow-visible md:flex md:items-center md:space-x-8 md:pb-0;
}
.main-event {
@apply relative flex flex-col mb-2 md:flex-row md:mb-0 md:ml-16;
}
.main-event::before {
@apply content-[''] hidden absolute top-[23px] left-[-32px] w-px h-4 bg-theme-foreground-reversal-30 md:block;
}
.main-event::after {
@apply order-[-1] content-[''] h-px mb-2 mx-3 bg-theme-foreground-reversal-8
md:mb-0 md:mx-0 md:absolute md:bottom-0 md:left-0 md:w-full md:h-[2px] md:bg-theme-foreground-reversal md:opacity-0;
}
.main-event:hover::after {
@apply opacity-100;
.nav-area {
@apply flex flex-col w-[360px] bg-theme-foreground-10 translate-x-[-100%]
md:inline-flex md:flex-row md:w-auto md:h-full md:pl-[40px] md:items-center md:bg-transparent transform-none;
}
.nav-logo {
@apply flex items-center justify-center shrink-0 h-[62px] md:hidden;
@apply flex items-center justify-center shrink-0 h-[62px];
}
.nav-list {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2
md:flex-row md:order-none md:h-full md:my-0 md:mx-10 md:px-0 md:overflow-visible;
}
.nav-item {
@@ -247,39 +381,52 @@ onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
.nav-item::after {
@apply content-[''] h-px my-2 mx-3 bg-theme-foreground-reversal-8 md:hidden;
}
.nav-item:hover .item-1depth::after,
.item-1depth.active::after {
.nav-item:hover .nav-1depth::after,
.nav-1depth.active::after {
@apply opacity-100 md:transition-opacity;
}
.nav-item:hover .item-2depth {
@apply md:opacity-100 md:visible md:pointer-events-auto md:transition-opacity md:duration-200;
.nav-item:hover .nav-2depth {
@apply md:block;
}
.item-1depth {
.nav-1depth {
@apply flex items-center py-[9px] px-3 gap-1 rounded-[12px] md:h-full md:py-0 md:px-0;
}
.item-1depth::after {
.nav-1depth::after {
@apply md:content-[''] md:absolute md:bottom-0 md:left-0 md:w-full md:h-[2px] md:bg-theme-foreground-reversal md:opacity-0;
}
.item-1depth.active {
.nav-1depth.active {
@apply bg-theme-foreground-reversal-8 md:bg-transparent;
}
.item-2depth {
@apply block text-[15px] md:opacity-0 md:absolute md:top-full md:left-[-28px] md:min-w-[190px] md:pt-1 md:pointer-events-none;
.nav-2depth {
@apply block text-[15px] md:hidden md:absolute md:top-[64px] md:left-[-28px] md:min-w-[190px] md:pt-1 md:z-50;
}
.item-2depth ul {
.nav-2depth ul {
@apply bg-theme-foreground-10 rounded-[20px] md:shadow-lg md:p-3;
}
.item-2depth a {
.nav-2depth a {
@apply flex items-center gap-1 px-5 py-[9px] rounded-[12px] transition-colors
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10
md:px-4 md:py-[11px];
}
@media (min-width: 1024px) {
.header-content {
height: 100%;
}
.official {
@apply overflow-x-hidden overflow-y-auto pb-2 md:flex md:items-center md:space-x-8 md:pb-0 md:overflow-visible;
}
.more {
@apply relative hidden md:block;
}
.more-list {
@apply absolute;
}
.event {
@apply ml-[100px];
}
.is-hidden {
@apply hidden;
}
</style>

View File

@@ -1,144 +0,0 @@
// composables/useHeaderNavOverflow.ts
import { ref, computed, onMounted, nextTick, watch, type Ref } from 'vue'
import { useResizeObserver } from '@vueuse/core'
type NavItem = { id: string | number; label: string; href?: string }
interface Options {
itemSelector?: string // nav 내부 개별 아이템 선택자
headerGap?: number // header 수평 gap(px)
navGap?: number // nav 아이템 간 gap(px)
}
export function useHeaderNavOverflow(
items: Ref<NavItem[]>,
headerRef: Ref<HTMLElement | null>,
logoRef: Ref<HTMLElement | null>,
trailingRefs: Ref<HTMLElement | null>[], // 우측 버튼/아이콘 등
moreButtonRef: Ref<HTMLElement | null>, // "More" 버튼 참조 (실측용)
options: Options = {}
) {
const itemSelector = options.itemSelector ?? '[data-nav-item]'
const headerGap = options.headerGap ?? 0
const navGap = options.navGap ?? 0
const containerWidth = ref(0)
const logoWidth = ref(0)
const trailingWidth = ref(0)
const moreBtnWidth = ref(0) // 오버플로우 발생 시 필요한 "More" 버튼 너비
const itemWidths = ref<number[]>([])
const splitIndex = ref(items.value.length)
const measureStatic = () => {
const header = headerRef.value
if (!header) return
containerWidth.value = header.clientWidth
// 로고/트레일링(우측 버튼들) 폭 합
logoWidth.value = Math.ceil(
logoRef.value?.getBoundingClientRect().width ?? 0
)
trailingWidth.value = trailingRefs.reduce((sum, r) => {
const w = Math.ceil(r.value?.getBoundingClientRect().width ?? 0)
return sum + w
}, 0)
// "More" 버튼 실측 (display:none이면 0이므로, visibility:hidden 상태로 자리 차지 측정 추천)
moreBtnWidth.value = Math.ceil(
moreButtonRef.value?.getBoundingClientRect().width ?? 0
)
}
const measureItems = (navEl: HTMLElement | null) => {
if (!navEl) return
const nodes = Array.from(navEl.querySelectorAll<HTMLElement>(itemSelector))
itemWidths.value = nodes.map(n =>
Math.ceil(n.getBoundingClientRect().width)
)
}
const computeSplit = (navEl: HTMLElement | null) => {
if (!navEl) return
const available =
containerWidth.value - logoWidth.value - trailingWidth.value - headerGap // header 자체 gap 여유분(필요 시)
if (available <= 0) {
splitIndex.value = 0
return
}
// 1차: 모두 넣을 수 있는지 시도
let sum = 0
let idx = items.value.length
for (let i = 0; i < items.value.length; i++) {
const w = itemWidths.value[i] ?? 0
const extra = i === 0 ? 0 : navGap
if (sum + w + extra <= available) {
sum += w + extra
} else {
idx = i
break
}
}
// 오버플로우가 생기면 "More" 버튼 자리도 고려해서 재계산
if (idx < items.value.length && moreBtnWidth.value > 0) {
const available2 = available - moreBtnWidth.value - navGap // more와의 간격
let sum2 = 0
let idx2 = 0
for (let i = 0; i < items.value.length; i++) {
const w = itemWidths.value[i] ?? 0
const extra = i === 0 ? 0 : navGap
if (sum2 + w + extra <= available2) {
sum2 += w + extra
idx2 = i + 1
} else {
break
}
}
splitIndex.value = idx2
} else {
splitIndex.value = idx
}
}
const visibleItems = computed(() => items.value.slice(0, splitIndex.value))
const overflowItems = computed(() => items.value.slice(splitIndex.value))
const measureAll = async (navEl: HTMLElement | null) => {
await nextTick()
measureStatic()
measureItems(navEl)
computeSplit(navEl)
}
const observe = (el: HTMLElement | null, navEl: HTMLElement | null) => {
if (!el) return
useResizeObserver(el, () => {
containerWidth.value = el.clientWidth
computeSplit(navEl)
})
}
const init = (navEl: Ref<HTMLElement | null>) => {
onMounted(() => {
measureAll(navEl.value)
observe(headerRef.value, navEl.value)
// 폰트/i18n 변경 같은 경우에 수동 호출할 수 있도록 measureAll 노출
})
}
// 아이템 배열 변경 시 재측정
watch(items, () => measureAll(currentNav.value), { deep: true })
// nav 엘리먼트를 외부에서 넘기기 위해 보조 ref
const currentNav = ref<HTMLElement | null>(null)
return {
visibleItems,
overflowItems,
splitIndex,
measureAll,
currentNav, // 템플릿에서 ref 바인딩
init,
}
}

View File

@@ -20,6 +20,7 @@ export default {
'theme-foreground-reversal-6': 'var(--foreground-reversal-6)',
'theme-foreground-reversal-8': 'var(--foreground-reversal-8)',
'theme-foreground-reversal-10': 'var(--foreground-reversal-10)',
'theme-foreground-reversal-15': 'var(--foreground-reversal-15)',
'theme-foreground-reversal-30': 'var(--foreground-reversal-30)',
},
},