HorizontalMenu.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. <script setup lang="ts">
  2. import { useElementSize, watchThrottled } from '@vueuse/core'
  3. import { isFunction, isEmpty } from 'lodash-es'
  4. import { NDropdown } from 'naive-ui'
  5. import { h, computed, ref, watch, nextTick, onBeforeUnmount } from 'vue'
  6. import { useInjection } from '@/composables'
  7. import { headerLayoutInjectionKey } from '@/injection'
  8. import router from '@/router'
  9. import { useUserStore } from '@/stores'
  10. import type { DropdownProps, MenuProps } from 'naive-ui'
  11. import type { ComponentPublicInstance } from 'vue'
  12. type Key = string | number | undefined
  13. const MENU_SIZE = {
  14. MORE_BUTTON: 44,
  15. ITEM_COLUMN_GAP: 4,
  16. BOUNDARY_OFFSET: 1,
  17. }
  18. let rafId: number | null = null
  19. let initialized = false
  20. const userStore = useUserStore()
  21. const { navigationContainerElement } = useInjection(headerLayoutInjectionKey)
  22. const { width: containerWidth, stop: stopObserveContainerWidth } = useElementSize(
  23. navigationContainerElement,
  24. )
  25. const navigationWrapperRef = ref<HTMLElement | null>(null)
  26. const menuActiveKey = ref('')
  27. const menuRightBoundMap = new Map<Key, number>()
  28. const partiallyVisibleMenuKeys = ref<Set<Key>>(new Set())
  29. const moreDropdownOptions = computed<DropdownProps['options']>(() => {
  30. return (userStore.menuList as NonNullable<MenuProps['options']>).filter((item) =>
  31. partiallyVisibleMenuKeys.value.has(item.key),
  32. )
  33. })
  34. const renderIcon: DropdownProps['renderIcon'] = (option) => {
  35. return isFunction(option.icon)
  36. ? h(option.icon, {
  37. class: 'ml-1.5 size-5',
  38. })
  39. : null
  40. }
  41. function forwardRef(key: Key, ref: Element | ComponentPublicInstance | null) {
  42. if (!key || !ref || menuRightBoundMap.has(key)) return
  43. nextTick(() => {
  44. const rect = (ref as HTMLElement).getBoundingClientRect()
  45. menuRightBoundMap.set(
  46. key,
  47. rect.right - (navigationWrapperRef.value?.getBoundingClientRect().left ?? 0),
  48. )
  49. scheduleUpdateMenuVisibility()
  50. })
  51. }
  52. function updateMenuVisibility(containerWidth: number) {
  53. if (containerWidth <= 0) return
  54. for (const [key, rightBound] of menuRightBoundMap.entries()) {
  55. if (
  56. rightBound + MENU_SIZE.ITEM_COLUMN_GAP >
  57. containerWidth - MENU_SIZE.MORE_BUTTON + MENU_SIZE.BOUNDARY_OFFSET
  58. ) {
  59. partiallyVisibleMenuKeys.value.add(key)
  60. } else {
  61. partiallyVisibleMenuKeys.value.delete(key)
  62. }
  63. }
  64. }
  65. function scheduleUpdateMenuVisibility() {
  66. if (rafId != null) return
  67. rafId = requestAnimationFrame(() => {
  68. rafId = null
  69. updateMenuVisibility(containerWidth.value)
  70. if (!initialized) initialized = true
  71. })
  72. }
  73. function hasActiveChild(children: any[]): boolean {
  74. const stack = [...children]
  75. while (stack.length) {
  76. const node = stack.pop()
  77. if (node?.key === menuActiveKey.value) return true
  78. if (Array.isArray(node?.children)) {
  79. stack.push(...node.children)
  80. }
  81. }
  82. return false
  83. }
  84. watch(
  85. () => router.currentRoute.value,
  86. (newRoute) => {
  87. menuActiveKey.value = newRoute.name as string
  88. },
  89. {
  90. immediate: true,
  91. },
  92. )
  93. watchThrottled(
  94. containerWidth,
  95. (width) => {
  96. if (!initialized) return
  97. updateMenuVisibility(width)
  98. },
  99. {
  100. throttle: 100,
  101. },
  102. )
  103. onBeforeUnmount(() => {
  104. stopObserveContainerWidth()
  105. })
  106. </script>
  107. <template>
  108. <div
  109. ref="navigationWrapperRef"
  110. class="relative flex items-center overflow-hidden"
  111. :style="{
  112. columnGap: `${MENU_SIZE.ITEM_COLUMN_GAP}px`,
  113. }"
  114. >
  115. <template
  116. v-for="{ disabled, key, type, label, icon, children } in userStore.menuList"
  117. :key="key"
  118. >
  119. <div
  120. v-if="!type"
  121. :ref="(ref) => forwardRef(key, ref)"
  122. v-show="!partiallyVisibleMenuKeys.has(key)"
  123. class="shrink-0 rounded-naive transition-[background-color,color]"
  124. :class="[
  125. {
  126. 'relative flex items-center px-2.5 py-2': isEmpty(children),
  127. },
  128. disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
  129. menuActiveKey === key || (Array.isArray(children) && hasActiveChild(children))
  130. ? 'bg-primary/15 text-primary'
  131. : 'hover:bg-neutral-150 dark:hover:bg-neutral-800',
  132. ]"
  133. >
  134. <template v-if="Array.isArray(children) && !isEmpty(children)">
  135. <NDropdown
  136. :options="children"
  137. :value="menuActiveKey"
  138. :disabled="disabled"
  139. :render-icon="renderIcon"
  140. >
  141. <div class="flex items-center py-2 pr-2 pl-2.5">
  142. <component
  143. v-if="icon"
  144. :is="icon"
  145. class="mr-2 size-5"
  146. />
  147. <span class="leading-4">{{ label }}</span>
  148. <span class="ml-1.5 iconify ph--caret-down" />
  149. </div>
  150. </NDropdown>
  151. </template>
  152. <template v-else>
  153. <component
  154. v-if="icon"
  155. :is="icon"
  156. class="mr-2 size-5"
  157. />
  158. <component
  159. v-if="isFunction(label)"
  160. :is="label"
  161. class="leading-4 before:absolute before:inset-0 before:size-full before:content-['']"
  162. />
  163. <span
  164. v-else
  165. class="leading-4"
  166. >{{ label }}</span
  167. >
  168. </template>
  169. </div>
  170. </template>
  171. <NDropdown
  172. :options="moreDropdownOptions"
  173. :value="menuActiveKey"
  174. :disabled="isEmpty(moreDropdownOptions)"
  175. :render-icon="renderIcon"
  176. size="large"
  177. >
  178. <div
  179. v-show="!isEmpty(partiallyVisibleMenuKeys)"
  180. class="flex shrink-0 cursor-pointer items-center rounded-naive px-3 py-2 leading-4 font-medium transition-[background-color,color]"
  181. :class="[
  182. Array.isArray(moreDropdownOptions) && hasActiveChild(moreDropdownOptions)
  183. ? 'bg-primary/15 text-primary'
  184. : 'hover:bg-neutral-150 dark:hover:bg-neutral-800',
  185. ]"
  186. >
  187. <span class="iconify size-5 ph--dots-three-circle-vertical" />
  188. </div>
  189. </NDropdown>
  190. </div>
  191. </template>