feat: smooth scroll animation for catalog header
All checks were successful
Build Docker Image / build (push) Successful in 4m41s

- Dynamic top positioning based on scrollY
- Chevron inside SearchBar for expand/collapse
- All layers position:fixed with calculated offsets
This commit is contained in:
Ruslan Bakiev
2026-01-14 22:03:27 +07:00
parent c10c085b70
commit 6f16c862f4
2 changed files with 91 additions and 91 deletions

View File

@@ -1,55 +1,66 @@
/** /**
* Composable for collapsing header on scroll * Composable for smooth header collapse on scroll
* Returns isCollapsed state that becomes true when scroll > threshold * Returns pixel positions for all fixed layers
* User can manually override the collapsed state
*/ */
export const useScrollCollapse = (threshold = 50) => { export const useScrollCollapse = (headerHeight = 112, searchBarHeight = 48) => {
const isCollapsed = ref(false)
const scrollY = ref(0) const scrollY = ref(0)
const manualOverride = ref(false)
// Layer positions in pixels
// Layers 1+2 (MainNav + SubNav): slide up as user scrolls
const headerOffset = computed(() => -Math.min(scrollY.value, headerHeight))
// Layer 3 (SearchBar): slides down to top:0
const searchBarTop = computed(() => Math.max(0, headerHeight - scrollY.value))
// Layer 4 (Map): follows SearchBar, minimum is searchBarHeight
const mapTop = computed(() => Math.max(searchBarHeight, headerHeight + searchBarHeight - scrollY.value))
// Content padding-top
const contentPaddingTop = computed(() => Math.max(searchBarHeight, headerHeight + searchBarHeight - scrollY.value))
// Is header fully collapsed?
const isCollapsed = computed(() => scrollY.value >= headerHeight)
// Expand - scroll to top
const expand = () => {
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Collapse - scroll down to hide header
const collapse = () => {
if (import.meta.client) {
window.scrollTo({ top: headerHeight, behavior: 'smooth' })
}
}
const onScroll = () => { const onScroll = () => {
if (import.meta.server) return if (import.meta.client) {
scrollY.value = window.scrollY scrollY.value = window.scrollY
// Only auto-collapse if user hasn't manually expanded
if (!manualOverride.value) {
isCollapsed.value = scrollY.value > threshold
} }
// Reset manual override when scrolling back to top
if (scrollY.value <= threshold) {
manualOverride.value = false
isCollapsed.value = false
}
}
// Manual expand (user clicked chevron down)
const expand = () => {
isCollapsed.value = false
manualOverride.value = true
}
// Manual collapse (user clicked chevron up)
const collapse = () => {
isCollapsed.value = true
manualOverride.value = false
} }
onMounted(() => { onMounted(() => {
if (import.meta.client) {
window.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true })
// Initial check onScroll() // Initial value
onScroll() }
}) })
onUnmounted(() => { onUnmounted(() => {
if (import.meta.client) {
window.removeEventListener('scroll', onScroll) window.removeEventListener('scroll', onScroll)
}
}) })
return { return {
isCollapsed: readonly(isCollapsed),
scrollY: readonly(scrollY), scrollY: readonly(scrollY),
headerOffset,
searchBarTop,
mapTop,
contentPaddingTop,
isCollapsed,
expand, expand,
collapse collapse
} }

View File

@@ -1,22 +1,9 @@
<template> <template>
<div class="min-h-screen bg-base-200"> <div class="min-h-screen bg-base-200">
<!-- Layer 1: Collapsed bar (when scrolled) --> <!-- Layer 1+2: MainNavigation + SubNavigation (slides up on scroll) -->
<div <div
v-if="isHeaderCollapsed" class="fixed left-0 right-0 z-50"
class="fixed top-0 left-0 right-0 z-50 bg-base-200 border-b border-base-300 h-8 flex items-center px-4 lg:px-6" :style="{ top: `${headerOffset}px` }"
>
<button
class="btn btn-ghost btn-xs btn-circle"
@click="expandHeader"
>
<Icon name="lucide:chevron-down" size="16" />
</button>
</div>
<!-- Layer 1: MainNavigation (fixed) -->
<div
v-show="!isHeaderCollapsed"
class="fixed top-0 left-0 right-0 z-50"
> >
<MainNavigation <MainNavigation
class="bg-base-200" class="bg-base-200"
@@ -33,22 +20,10 @@
@sign-in="signIn()" @sign-in="signIn()"
@switch-team="switchToTeam" @switch-team="switchToTeam"
/> />
</div>
<!-- Layer 2: SubNavigation (fixed) --> <!-- SubNavigation -->
<div <div class="bg-base-200 border-b border-base-300">
v-show="!isHeaderCollapsed"
class="fixed top-16 left-0 right-0 z-40 bg-base-200 border-b border-base-300"
>
<div class="flex items-center gap-1 py-2 px-4 lg:px-6"> <div class="flex items-center gap-1 py-2 px-4 lg:px-6">
<!-- Collapse button -->
<button
class="btn btn-ghost btn-xs btn-circle mr-1 flex-shrink-0"
@click="collapseHeader"
>
<Icon name="lucide:chevron-up" size="16" />
</button>
<NuxtLink <NuxtLink
v-for="item in subNavItems" v-for="item in subNavItems"
:key="item.path" :key="item.path"
@@ -60,25 +35,35 @@
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</div>
<!-- Layer 3: SearchBar (fixed) - teleport target --> <!-- Layer 3: SearchBar (fixed, slides to top:0) - teleport target -->
<div <div
id="catalog-searchbar" id="catalog-searchbar"
class="fixed left-0 right-0 z-30 bg-base-100 border-b border-base-300" class="fixed left-0 right-0 z-40 bg-base-100 border-b border-base-300 flex items-center"
:class="isHeaderCollapsed ? 'top-8' : 'top-[6.75rem]'" :style="{ top: `${searchBarTop}px` }"
/> >
<!-- Chevron button inside SearchBar -->
<button
class="btn btn-ghost btn-xs btn-circle ml-4 flex-shrink-0"
@click="isCollapsed ? expand() : collapse()"
>
<Icon :name="isCollapsed ? 'lucide:chevron-down' : 'lucide:chevron-up'" size="16" />
</button>
<!-- SearchBar content teleported here -->
</div>
<!-- Layer 4: Map (fixed, right side, to bottom) - teleport target --> <!-- Layer 4: Map (fixed, right side, to bottom) - teleport target -->
<div <div
id="catalog-map" id="catalog-map"
class="fixed right-0 bottom-0 w-3/5 z-20 hidden lg:block" class="fixed right-0 bottom-0 w-3/5 z-20 hidden lg:block"
:class="isHeaderCollapsed ? 'top-[5.75rem]' : 'top-[9.75rem]'" :style="{ top: `${mapTop}px` }"
/> />
<!-- Content area (left side, with padding for fixed header) --> <!-- Content area (left side, with dynamic padding for fixed header) -->
<div <div
class="lg:w-2/5 min-h-screen" class="lg:w-2/5 min-h-screen"
:class="isHeaderCollapsed ? 'pt-[6.75rem]' : 'pt-[10.75rem]'" :style="{ paddingTop: `${contentPaddingTop}px` }"
> >
<div class="px-4 lg:px-6 py-4"> <div class="px-4 lg:px-6 py-4">
<slot /> <slot />
@@ -110,7 +95,7 @@
v-if="mobileView === 'map'" v-if="mobileView === 'map'"
id="catalog-map-mobile" id="catalog-map-mobile"
class="lg:hidden fixed inset-0 z-20" class="lg:hidden fixed inset-0 z-20"
:class="isHeaderCollapsed ? 'top-[5.75rem]' : 'top-[9.75rem]'" :style="{ top: `${mapTop}px` }"
/> />
</div> </div>
</template> </template>
@@ -123,12 +108,16 @@ const route = useRoute()
const localePath = useLocalePath() const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
// Collapsible header // Smooth collapsible header - headerHeight = 112px (64 + 48), searchBarHeight = 48px
const { isCollapsed, expand, collapse } = useScrollCollapse(50) const {
const isHeaderCollapsed = computed(() => isCollapsed.value) headerOffset,
searchBarTop,
const expandHeader = () => expand() mapTop,
const collapseHeader = () => collapse() contentPaddingTop,
isCollapsed,
expand,
collapse
} = useScrollCollapse(112, 48)
// Mobile view toggle // Mobile view toggle
const mobileView = ref<'list' | 'map'>('list') const mobileView = ref<'list' | 'map'>('list')
@@ -180,7 +169,7 @@ const isSubNavActive = (path: string) => {
} }
// Provide collapsed state to children // Provide collapsed state to children
provide('headerCollapsed', isHeaderCollapsed) provide('headerCollapsed', isCollapsed)
// Avatar generation // Avatar generation
const generateUserAvatar = async (seed: string) => { const generateUserAvatar = async (seed: string) => {