123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- <script setup lang="ts">
- import { useElementSize, watchThrottled } from '@vueuse/core'
- import { isFunction, isEmpty } from 'lodash-es'
- import { NDropdown } from 'naive-ui'
- import { h, computed, ref, watch, nextTick, onBeforeUnmount } from 'vue'
- import { useInjection } from '@/composables'
- import { headerLayoutInjectionKey } from '@/injection'
- import router from '@/router'
- import { useUserStore } from '@/stores'
- import type { DropdownProps, MenuProps } from 'naive-ui'
- import type { ComponentPublicInstance } from 'vue'
- type Key = string | number | undefined
- const MENU_SIZE = {
- MORE_BUTTON: 44,
- ITEM_COLUMN_GAP: 4,
- BOUNDARY_OFFSET: 1,
- }
- let rafId: number | null = null
- let initialized = false
- const userStore = useUserStore()
- const { navigationContainerElement } = useInjection(headerLayoutInjectionKey)
- const { width: containerWidth, stop: stopObserveContainerWidth } = useElementSize(
- navigationContainerElement,
- )
- const navigationWrapperRef = ref<HTMLElement | null>(null)
- const menuActiveKey = ref('')
- const menuRightBoundMap = new Map<Key, number>()
- const partiallyVisibleMenuKeys = ref<Set<Key>>(new Set())
- const moreDropdownOptions = computed<DropdownProps['options']>(() => {
- return (userStore.menuList as NonNullable<MenuProps['options']>).filter((item) =>
- partiallyVisibleMenuKeys.value.has(item.key),
- )
- })
- const renderIcon: DropdownProps['renderIcon'] = (option) => {
- return isFunction(option.icon)
- ? h(option.icon, {
- class: 'ml-1.5 size-5',
- })
- : null
- }
- function forwardRef(key: Key, ref: Element | ComponentPublicInstance | null) {
- if (!key || !ref || menuRightBoundMap.has(key)) return
- nextTick(() => {
- const rect = (ref as HTMLElement).getBoundingClientRect()
- menuRightBoundMap.set(
- key,
- rect.right - (navigationWrapperRef.value?.getBoundingClientRect().left ?? 0),
- )
- scheduleUpdateMenuVisibility()
- })
- }
- function updateMenuVisibility(containerWidth: number) {
- if (containerWidth <= 0) return
- for (const [key, rightBound] of menuRightBoundMap.entries()) {
- if (
- rightBound + MENU_SIZE.ITEM_COLUMN_GAP >
- containerWidth - MENU_SIZE.MORE_BUTTON + MENU_SIZE.BOUNDARY_OFFSET
- ) {
- partiallyVisibleMenuKeys.value.add(key)
- } else {
- partiallyVisibleMenuKeys.value.delete(key)
- }
- }
- }
- function scheduleUpdateMenuVisibility() {
- if (rafId != null) return
- rafId = requestAnimationFrame(() => {
- rafId = null
- updateMenuVisibility(containerWidth.value)
- if (!initialized) initialized = true
- })
- }
- function hasActiveChild(children: any[]): boolean {
- const stack = [...children]
- while (stack.length) {
- const node = stack.pop()
- if (node?.key === menuActiveKey.value) return true
- if (Array.isArray(node?.children)) {
- stack.push(...node.children)
- }
- }
- return false
- }
- watch(
- () => router.currentRoute.value,
- (newRoute) => {
- menuActiveKey.value = newRoute.name as string
- },
- {
- immediate: true,
- },
- )
- watchThrottled(
- containerWidth,
- (width) => {
- if (!initialized) return
- updateMenuVisibility(width)
- },
- {
- throttle: 100,
- },
- )
- onBeforeUnmount(() => {
- stopObserveContainerWidth()
- })
- </script>
- <template>
- <div
- ref="navigationWrapperRef"
- class="relative flex items-center overflow-hidden"
- :style="{
- columnGap: `${MENU_SIZE.ITEM_COLUMN_GAP}px`,
- }"
- >
- <template
- v-for="{ disabled, key, type, label, icon, children } in userStore.menuList"
- :key="key"
- >
- <div
- v-if="!type"
- :ref="(ref) => forwardRef(key, ref)"
- v-show="!partiallyVisibleMenuKeys.has(key)"
- class="shrink-0 rounded-naive transition-[background-color,color]"
- :class="[
- {
- 'relative flex items-center px-2.5 py-2': isEmpty(children),
- },
- disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
- menuActiveKey === key || (Array.isArray(children) && hasActiveChild(children))
- ? 'bg-primary/15 text-primary'
- : 'hover:bg-neutral-150 dark:hover:bg-neutral-800',
- ]"
- >
- <template v-if="Array.isArray(children) && !isEmpty(children)">
- <NDropdown
- :options="children"
- :value="menuActiveKey"
- :disabled="disabled"
- :render-icon="renderIcon"
- >
- <div class="flex items-center py-2 pr-2 pl-2.5">
- <component
- v-if="icon"
- :is="icon"
- class="mr-2 size-5"
- />
- <span class="leading-4">{{ label }}</span>
- <span class="ml-1.5 iconify ph--caret-down" />
- </div>
- </NDropdown>
- </template>
- <template v-else>
- <component
- v-if="icon"
- :is="icon"
- class="mr-2 size-5"
- />
- <component
- v-if="isFunction(label)"
- :is="label"
- class="leading-4 before:absolute before:inset-0 before:size-full before:content-['']"
- />
- <span
- v-else
- class="leading-4"
- >{{ label }}</span
- >
- </template>
- </div>
- </template>
- <NDropdown
- :options="moreDropdownOptions"
- :value="menuActiveKey"
- :disabled="isEmpty(moreDropdownOptions)"
- :render-icon="renderIcon"
- size="large"
- >
- <div
- v-show="!isEmpty(partiallyVisibleMenuKeys)"
- class="flex shrink-0 cursor-pointer items-center rounded-naive px-3 py-2 leading-4 font-medium transition-[background-color,color]"
- :class="[
- Array.isArray(moreDropdownOptions) && hasActiveChild(moreDropdownOptions)
- ? 'bg-primary/15 text-primary'
- : 'hover:bg-neutral-150 dark:hover:bg-neutral-800',
- ]"
- >
- <span class="iconify size-5 ph--dots-three-circle-vertical" />
- </div>
- </NDropdown>
- </div>
- </template>
|