Vue侧边栏Dropdown Menu联动控制需求实现求助
Vue.js 侧边栏下拉菜单行为控制问题
需求
- 打开新的非active状态下拉菜单时,已展开的非active下拉自动折叠
- active状态的下拉菜单不会自动折叠,仅支持手动开关
- 手动关闭active下拉后,已展开的非active下拉保持展开
说明
- active state:当前访问路由处于:active状态
- isOpen:控制下拉菜单展开/折叠的状态变量
- 组件分工:
sidebarContent.vue:侧边栏所有菜单的容器sidebarCollapsibleItem.vue:处理下拉菜单的子项链接sidebarCollapsible.vue:下拉菜单的容器组件,实现展开/折叠逻辑sidebarLink.vue:无下拉的菜单链接组件
当前代码
sidebarCollapsible.vue
<script setup> import { ref, watch } from 'vue'; import { sidebarState } from '@/Composables'; import SidebarLink from '@/Components/Sidebar/SidebarLink.vue'; import { EmptyCircleIcon } from '@/Components/Icons/Outline'; const props = defineProps({ title: { type: String, }, icon: { required: false, }, active: { type: Boolean, default: false, }, }); const { active } = props; const isOpen = ref(active); const beforeEnter = (el) => { el.style.maxHeight = `0px`; }; const enter = (el) => { el.style.maxHeight = `${el.scrollHeight}px`; }; const beforeLeave = (el) => { el.style.maxHeight = `${el.scrollHeight}px`; }; const leave = (el) => { el.style.maxHeight = `0px`; }; watch(() => sidebarState.isHovered, (sideHover) => { if (!sideHover && !sidebarState.isOpen && !active) { isOpen.value = false; }; }); </script> <template> <div class="relative"> <SidebarLink @click="isOpen = !isOpen" :title="title" :active="active"> <template #icon> <slot name="icon"> <EmptyCircleIcon aria-hidden="true" class="flex-shrink-0 w-6 h-6" /> </slot> </template> <template #arrow> <span v-show="sidebarState.isOpen || sidebarState.isHovered" aria-hidden="true" class="relative block w-6 h-6 ml-auto"> <span :class="['absolute right-[9px] bg-gray-400 mt-[-5px] h-2 w-[2px] top-1/2 transition-all duration-200', {'-rotate-45': isOpen, 'rotate-45': !isOpen}]"></span> <span :class="['absolute left-[9px] bg-gray-400 mt-[-5px] h-2 w-[2px] top-1/2 transition-all duration-200', {'rotate-45': isOpen, '-rotate-45': !isOpen}]"></span> </span> </template> </SidebarLink> <transition @before-enter="beforeEnter" @enter="enter" @before-leave="beforeLeave" @leave="leave" appear> <div v-show="isOpen && (sidebarState.isOpen || sidebarState.isHovered)" class="overflow-hidden transition-all duration-200"> <ul class="relative px-0 pt-2 pb-0 ml-5 before:w-0 before:block before:absolute before:inset-y-0 before:left-0 before:border-l-2 before:border-l-gray-200 dark:before:border-l-gray-600"> <slot /> </ul> </div> </transition> </div> </template>
sidebarCollapsibleItem.vue
<script setup> import { defineProps } from 'vue'; import { Link } from '@inertiajs/vue3'; const props = defineProps({ href: String, title: String, active: { type: Boolean, default: false, }, external: { type: Boolean, default: false, }, }); const { external } = props; const Tag = external ? 'a' : Link; </script> <template> <li :class="[ 'relative leading-8 m-0 pl-6', 'before:block before:w-4 before:h-0 before:absolute before:left-0 before:top-4 before:border-t-2 before:border-t-gray-200 before:-mt-0.5', 'last:before:bg-white last:before:h-auto last:before:top-4 last:before:bottom-0', 'dark:last:before:bg-dark-eval-1 dark:before:border-t-gray-600', ]" > <component :is="Tag" :href="href" v-bind="$attrs" :class="[ 'transition-colors hover:text-gray-900 dark:hover:text-gray-100', { 'text-purple-500 dark:text-purple-500 hover:text-purple-600 dark:hover:text-purple-600': active, 'text-gray-500 dark:text-gray-400': !active, }, ]" > {{ title }} </component> </li> </template>
sidebarContent.vue
<script setup> import PerfrectScrollbar from '@/Components/PerfectScrollbar'; import SidebarLink from '@/Components/Sidebar/SidebarLink.vue'; import { DashboardIcon } from '@/Components/Icons/Outline'; import { UserGroupIcon, BuildingOffice2Icon } from '@heroicons/vue/24/outline'; import SidebarCollapsible from '@/Components/Sidebar/SidebarCollapsible.vue'; import SidebarCollapsibleItem from '@/Components/Sidebar/SidebarCollapsibleItem.vue'; </script> <template> <PerfrectScrollbar tagname="nav" aria-label="main" class="relative flex flex-col flex-1 max-h-full gap-4 px-3" > <SidebarLink title="Dashboard" :href="route('dashboard')" :active="route().current('dashboard')" > <template #icon> <DashboardIcon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> </SidebarLink> <SidebarCollapsible title="Management" :active="route().current('users.*') || route().current('teams.*') || route().current('divisions.*') || route().current('departments.*')" > <template #icon> <UserGroupIcon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Users" :href="route('users.index')" :active="route().current('users.*')" /> <SidebarCollapsibleItem title="Roles" :href="route('teams.index')" :active="route().current('#')" /> <SidebarCollapsibleItem :href="route('teams.index')" title="Teams" :active="route().current('teams.*')" /> <SidebarCollapsibleItem title="Departments" :href="route('departments.index')" :active="route().current('departments.*')" /> <SidebarCollapsibleItem title="Divisions" :href="route('divisions.index')" :active="route().current('divisions.*')" /> </SidebarCollapsible> <SidebarCollapsible title="Locations" :active="route().current('externals.*') || route().current('internals.*')" > <template #icon> <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Internals" :href="route('internals.index')" :active="route().current('internals.*')" /> <SidebarCollapsibleItem title="Externals" :href="route('externals.index')" :active="route().current('externals.*')" /> </SidebarCollapsible> <SidebarCollapsible title="test" :active="route().current('api-tokens.*')" > <template #icon> <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Internals" :href="route('api-tokens.index')" :active="route().current('user.*')" /> <SidebarCollapsibleItem title="Externals" :href="route('api-tokens.index')" :active="route().current('api-tokens.*')" /> </SidebarCollapsible> </PerfrectScrollbar> </template>
解决方案
核心思路
将下拉菜单的状态管理提升到父组件sidebarContent.vue,统一控制非active下拉的展开状态,同时保留active下拉的独立手动控制能力。
修改步骤
1. 更新sidebarContent.vue
在父组件中维护所有非active下拉的状态,处理下拉点击事件的折叠逻辑:
<script setup> import PerfrectScrollbar from '@/Components/PerfectScrollbar'; import SidebarLink from '@/Components/Sidebar/SidebarLink.vue'; import { DashboardIcon } from '@/Components/Icons/Outline'; import { UserGroupIcon, BuildingOffice2Icon } from '@heroicons/vue/24/outline'; import SidebarCollapsible from '@/Components/Sidebar/SidebarCollapsible.vue'; import SidebarCollapsibleItem from '@/Components/Sidebar/SidebarCollapsibleItem.vue'; import { ref } from 'vue'; // 维护非active下拉的展开状态,key为下拉标题 const nonActiveDropdowns = ref({ 'Management': false, 'Locations': false, 'test': false, }); // 处理下拉菜单点击事件 const handleDropdownClick = (title, isActive, currentIsOpen) => { if (isActive) return; // 非active下拉:展开时关闭其他所有非active下拉,关闭时仅处理自身 nonActiveDropdowns.value[title] = !currentIsOpen; if (!currentIsOpen) { Object.keys(nonActiveDropdowns.value).forEach(key => { if (key !== title) nonActiveDropdowns.value[key] = false; }); } }; </script> <template> <PerfrectScrollbar tagname="nav" aria-label="main" class="relative flex flex-col flex-1 max-h-full gap-4 px-3" > <SidebarLink title="Dashboard" :href="route('dashboard')" :active="route().current('dashboard')" > <template #icon> <DashboardIcon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> </SidebarLink> <SidebarCollapsible title="Management" :active="route().current('users.*') || route().current('teams.*') || route().current('divisions.*') || route().current('departments.*')" :isOpen="nonActiveDropdowns['Management']" @toggle="handleDropdownClick('Management', $event.active, $event.isOpen)" > <!-- 子项内容保持不变 --> <template #icon> <UserGroupIcon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Users" :href="route('users.index')" :active="route().current('users.*')" /> <SidebarCollapsibleItem title="Roles" :href="route('teams.index')" :active="route().current('#')" /> <SidebarCollapsibleItem title="Teams" :href="route('teams.index')" :active="route().current('teams.*')" /> <SidebarCollapsibleItem title="Departments" :href="route('departments.index')" :active="route().current('departments.*')" /> <SidebarCollapsibleItem title="Divisions" :href="route('divisions.index')" :active="route().current('divisions.*')" /> </SidebarCollapsible> <SidebarCollapsible title="Locations" :active="route().current('externals.*') || route().current('internals.*')" :isOpen="nonActiveDropdowns['Locations']" @toggle="handleDropdownClick('Locations', $event.active, $event.isOpen)" > <!-- 子项内容保持不变 --> <template #icon> <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Internals" :href="route('internals.index')" :active="route().current('internals.*')" /> <SidebarCollapsibleItem title="Externals" :href="route('externals.index')" :active="route().current('externals.*')" /> </SidebarCollapsible> <SidebarCollapsible title="test" :active="route().current('api-tokens.*')" :isOpen="nonActiveDropdowns['test']" @toggle="handleDropdownClick('test', $event.active, $event.isOpen)" > <!-- 子项内容保持不变 --> <template #icon> <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" /> </template> <SidebarCollapsibleItem title="Internals" :href="route('api-tokens.index')" :active="route().current('user.*')" /> <SidebarCollapsibleItem title="Externals" :href="route('api-tokens.index')" :active="route().current('api-tokens.*')" /> </SidebarCollapsible> </PerfrectScrollbar> </template>
2. 更新sidebarCollapsible.vue
接收父组件传递的状态,触发toggle事件,并区分active与非active下拉的状态逻辑:
<script setup> import { ref, watch, computed } from 'vue'; import { sidebarState } from '@/Composables'; import SidebarLink from '@/Components/Sidebar/SidebarLink.vue'; import { EmptyCircleIcon } from '@/Components/Icons/Outline'; const props = defineProps({ title: { type: String }, icon: { required: false }, active: { type: Boolean, default: false }, isOpen: { type: Boolean, default: false }, }); const emit = defineEmits(['toggle']); // active下拉使用本地独立状态,非active使用父组件传递的状态 const localIsOpen = ref(props.active); const currentIsOpen = computed(() => props.active ? localIsOpen.value : props.isOpen); // 动画钩子保持不变 const beforeEnter = (el) => el.style.maxHeight = `0px`; const enter = (el) => el.style.maxHeight = `${el.scrollHeight}px`; const beforeLeave = (el) => el.style.maxHeight = `${el.scrollHeight}px`; const leave = (el) => el.style.maxHeight = `0px`; // 处理点击事件 const handleClick = () => { const newState = props.active ? !localIsOpen.value : !props.isOpen; if (props.active) localIsOpen.value = newState; emit('toggle', { active: props.active, isOpen: newState }); }; // 侧边栏收起/未hover时,关闭非active下拉 watch(() => sidebarState.isHovered, (sideHover) => { if (!sideHover && !sidebarState.isOpen && !props.active) { emit('toggle', { active: props.active, isOpen: false }); } }); </script> <template> <div class="relative"> <SidebarLink @click="handleClick" :title="title" :active="active"> <template #icon> <slot name="icon"> <EmptyCircleIcon aria-hidden="true" class="flex-shrink-0 w-6 h-6" /> </slot> </template> <
相关产品推荐
相关产品推荐

