|
@@ -1,25 +1,80 @@
|
|
|
<script setup lang="ts">
|
|
|
+import { useElementSize } from '@vueuse/core'
|
|
|
import { isFunction, isEmpty } from 'lodash-es'
|
|
|
import { NDropdown } from 'naive-ui'
|
|
|
-import { h, ref, watch } from 'vue'
|
|
|
+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 } from 'naive-ui'
|
|
|
+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,
|
|
|
+}
|
|
|
|
|
|
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-2 size-5',
|
|
|
+ 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,
|
|
|
+ )
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+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 hasActiveChild(children: any[]): boolean {
|
|
|
const stack = [...children]
|
|
|
while (stack.length) {
|
|
@@ -41,61 +96,98 @@ watch(
|
|
|
immediate: true,
|
|
|
},
|
|
|
)
|
|
|
+
|
|
|
+watch(containerWidth, (width) => {
|
|
|
+ updateMenuVisibility(width)
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ stopObserveContainerWidth()
|
|
|
+})
|
|
|
</script>
|
|
|
<template>
|
|
|
- <div class="relative flex w-full items-center gap-x-1 truncate">
|
|
|
+ <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 && isEmpty(children)"
|
|
|
- :data-key="key"
|
|
|
- class="relative flex items-center rounded-naive px-2.5 py-2 transition-[background-color,color]"
|
|
|
+ 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
|
|
|
- ? 'menu-active bg-primary/15 text-primary'
|
|
|
+ menuActiveKey === key || (Array.isArray(children) && hasActiveChild(children))
|
|
|
+ ? 'bg-primary/15 text-primary'
|
|
|
: 'hover:bg-neutral-150 dark:hover:bg-neutral-800',
|
|
|
]"
|
|
|
>
|
|
|
- <component
|
|
|
- v-if="isFunction(icon)"
|
|
|
- :is="icon()"
|
|
|
- class="mr-2 size-5"
|
|
|
- />
|
|
|
- <component
|
|
|
- v-if="isFunction(label)"
|
|
|
- :is="label()"
|
|
|
- class="before:absolute before:inset-0 before:size-full before:content-['']"
|
|
|
- />
|
|
|
- <span v-else>{{ label }}</span>
|
|
|
- </div>
|
|
|
- <NDropdown
|
|
|
- v-if="!type && Array.isArray(children) && !isEmpty(children)"
|
|
|
- :options="children"
|
|
|
- :value="menuActiveKey"
|
|
|
- :render-icon="renderIcon"
|
|
|
- >
|
|
|
- <div
|
|
|
- :data-key="key"
|
|
|
- class="flex items-center rounded-naive py-2 pr-2 pl-2.5 transition-[background-color,color]"
|
|
|
- :class="[
|
|
|
- disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
|
|
- hasActiveChild(children)
|
|
|
- ? 'menu-active 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="isFunction(icon)"
|
|
|
- :is="icon()"
|
|
|
+ v-if="icon"
|
|
|
+ :is="icon"
|
|
|
class="mr-2 size-5"
|
|
|
/>
|
|
|
- <span>{{ label }}</span>
|
|
|
- <span class="ml-1.5 iconify ph--caret-down" />
|
|
|
- </div>
|
|
|
- </NDropdown>
|
|
|
+ <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>
|