feat: collapsible header on scroll for catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 4m24s
All checks were successful
Build Docker Image / build (push) Successful in 4m24s
- Add useScrollCollapse composable to track scroll and collapse state - Update topnav.vue to show collapsed bar with chevron when scrolled - Add collapse button (chevron up) to SubNavigation - Make SubNavigation sticky below MainNavigation - Update CatalogPage map/searchbar positions based on header state
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav v-if="items.length > 0" class="bg-base-100 border-b border-base-300">
|
<nav v-if="items.length > 0" class="sticky top-16 z-30 bg-base-100 border-b border-base-300">
|
||||||
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
|
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
|
||||||
|
<!-- Collapse button (chevron up) -->
|
||||||
|
<button
|
||||||
|
v-if="showCollapseButton"
|
||||||
|
class="btn btn-ghost btn-xs btn-circle mr-1 flex-shrink-0"
|
||||||
|
@click="emit('collapse')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:chevron-up" size="16" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
@@ -17,6 +26,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
section: 'catalog' | 'orders' | 'seller' | 'settings'
|
section: 'catalog' | 'orders' | 'seller' | 'settings'
|
||||||
|
showCollapseButton?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
collapse: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Search bar slot (sticky third bar - like navigation) -->
|
<!-- Search bar slot (sticky third bar - like navigation) -->
|
||||||
<div v-if="$slots.searchBar" class="sticky top-0 z-20 -mx-3 lg:-mx-6 px-3 lg:px-6 py-2 bg-base-100 border-b border-base-300">
|
<div v-if="$slots.searchBar" class="sticky z-20 -mx-3 lg:-mx-6 px-3 lg:px-6 py-2 bg-base-100 border-b border-base-300" :class="searchBarTopClass">
|
||||||
<slot name="searchBar" :displayed-count="displayItems.length" :total-count="totalCount" />
|
<slot name="searchBar" :displayed-count="displayItems.length" :total-count="totalCount" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,13 +213,37 @@ const props = withDefaults(defineProps<{
|
|||||||
totalCount: 0
|
totalCount: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map positioning - dynamic based on search bar presence
|
// Inject header collapsed state from layout
|
||||||
// Without searchBar: MainNav (4rem) + SubNav (3rem) + padding (1rem) = 8rem
|
const headerCollapsed = inject<Ref<boolean>>('headerCollapsed', ref(false))
|
||||||
// With searchBar: 8rem + SearchBar (3rem) = 11rem
|
|
||||||
|
// Map positioning - dynamic based on search bar presence and header collapsed state
|
||||||
|
// Expanded: MainNav (4rem) + SubNav (3rem) = 7rem, with SearchBar = 10rem
|
||||||
|
// Collapsed: CollapsedBar (2rem), with SearchBar = 5rem
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
const hasSearchBar = computed(() => !!slots.searchBar)
|
const hasSearchBar = computed(() => !!slots.searchBar)
|
||||||
const mapTopClass = computed(() => hasSearchBar.value ? 'top-[11rem]' : 'top-32')
|
|
||||||
const mapHeightClass = computed(() => hasSearchBar.value ? 'h-[calc(100vh-12rem)]' : 'h-[calc(100vh-9rem)]')
|
// SearchBar position: below header (sticky)
|
||||||
|
const searchBarTopClass = computed(() => {
|
||||||
|
if (headerCollapsed.value) {
|
||||||
|
return 'top-8' // 2rem collapsed bar
|
||||||
|
}
|
||||||
|
return 'top-[7rem]' // 4rem MainNav + 3rem SubNav
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map position: below header + searchbar (fixed)
|
||||||
|
const mapTopClass = computed(() => {
|
||||||
|
if (headerCollapsed.value) {
|
||||||
|
return hasSearchBar.value ? 'top-[5rem]' : 'top-8' // collapsed bar + searchbar
|
||||||
|
}
|
||||||
|
return hasSearchBar.value ? 'top-[10rem]' : 'top-[7rem]' // full header + searchbar
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapHeightClass = computed(() => {
|
||||||
|
if (headerCollapsed.value) {
|
||||||
|
return hasSearchBar.value ? 'h-[calc(100vh-6rem)]' : 'h-[calc(100vh-3rem)]'
|
||||||
|
}
|
||||||
|
return hasSearchBar.value ? 'h-[calc(100vh-11rem)]' : 'h-[calc(100vh-8rem)]'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select': [item: MapItem]
|
'select': [item: MapItem]
|
||||||
|
|||||||
56
app/composables/useScrollCollapse.ts
Normal file
56
app/composables/useScrollCollapse.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Composable for collapsing header on scroll
|
||||||
|
* Returns isCollapsed state that becomes true when scroll > threshold
|
||||||
|
* User can manually override the collapsed state
|
||||||
|
*/
|
||||||
|
export const useScrollCollapse = (threshold = 50) => {
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
const scrollY = ref(0)
|
||||||
|
const manualOverride = ref(false)
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (import.meta.server) return
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
// Initial check
|
||||||
|
onScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCollapsed: readonly(isCollapsed),
|
||||||
|
scrollY: readonly(scrollY),
|
||||||
|
expand,
|
||||||
|
collapse
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col bg-base-200">
|
<div class="min-h-screen flex flex-col bg-base-200">
|
||||||
|
<!-- Collapsed header bar (only on catalog pages when scrolled) -->
|
||||||
|
<div
|
||||||
|
v-if="showCollapsedBar"
|
||||||
|
class="sticky top-0 z-40 bg-base-100 border-b border-base-300 h-8 flex items-center px-4 lg:px-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
|
@click="expandHeader"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:chevron-down" size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Navigation (Logo + Tabs + User) -->
|
<!-- Main Navigation (Logo + Tabs + User) -->
|
||||||
<MainNavigation
|
<MainNavigation
|
||||||
|
v-show="!isHeaderCollapsed"
|
||||||
:session-checked="sessionChecked"
|
:session-checked="sessionChecked"
|
||||||
:logged-in="isLoggedIn"
|
:logged-in="isLoggedIn"
|
||||||
:user-avatar-svg="userAvatarSvg"
|
:user-avatar-svg="userAvatarSvg"
|
||||||
@@ -20,7 +34,13 @@
|
|||||||
<GlobalSearchBar v-if="showSearch" class="border-b border-base-300" />
|
<GlobalSearchBar v-if="showSearch" class="border-b border-base-300" />
|
||||||
|
|
||||||
<!-- Sub Navigation (section-specific tabs) - hidden on home page -->
|
<!-- Sub Navigation (section-specific tabs) - hidden on home page -->
|
||||||
<SubNavigation v-if="!isHomePage" :section="currentSection" />
|
<SubNavigation
|
||||||
|
v-if="!isHomePage"
|
||||||
|
v-show="!isHeaderCollapsed"
|
||||||
|
:section="currentSection"
|
||||||
|
:show-collapse-button="canCollapse"
|
||||||
|
@collapse="collapseHeader"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6 py-4">
|
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6 py-4">
|
||||||
@@ -35,6 +55,9 @@ const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
|
|||||||
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Collapsible header for catalog pages
|
||||||
|
const { isCollapsed, expand, collapse } = useScrollCollapse(50)
|
||||||
|
|
||||||
// Theme state
|
// Theme state
|
||||||
const theme = useState<'default' | 'night'>('theme', () => 'default')
|
const theme = useState<'default' | 'night'>('theme', () => 'default')
|
||||||
|
|
||||||
@@ -88,6 +111,17 @@ const isHomePage = computed(() => {
|
|||||||
// Show search bar only on main page
|
// Show search bar only on main page
|
||||||
const showSearch = computed(() => isHomePage.value)
|
const showSearch = computed(() => isHomePage.value)
|
||||||
|
|
||||||
|
// Collapsible header logic - only for catalog pages (not home page)
|
||||||
|
const canCollapse = computed(() => !isHomePage.value)
|
||||||
|
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
|
||||||
|
const showCollapsedBar = computed(() => isHeaderCollapsed.value)
|
||||||
|
|
||||||
|
const expandHeader = () => expand()
|
||||||
|
const collapseHeader = () => collapse()
|
||||||
|
|
||||||
|
// Provide collapsed state to child components (CatalogPage needs it for map positioning)
|
||||||
|
provide('headerCollapsed', isHeaderCollapsed)
|
||||||
|
|
||||||
// Avatar generation
|
// Avatar generation
|
||||||
const generateUserAvatar = async (seed: string) => {
|
const generateUserAvatar = async (seed: string) => {
|
||||||
if (!seed) return
|
if (!seed) return
|
||||||
|
|||||||
Reference in New Issue
Block a user