sub-menu.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. <script lang="ts" setup>
  2. import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
  3. import type { MenuItemRegistered, MenuProvider, SubMenuProps } from '../types';
  4. import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
  5. import { useNamespace } from '@vben-core/composables';
  6. import { VbenHoverCard } from '@vben-core/shadcn-ui';
  7. import {
  8. createSubMenuContext,
  9. useMenu,
  10. useMenuContext,
  11. useMenuStyle,
  12. useSubMenuContext,
  13. } from '../hooks';
  14. import CollapseTransition from './collapse-transition.vue';
  15. import SubMenuContent from './sub-menu-content.vue';
  16. interface Props extends SubMenuProps {
  17. isSubMenuMore?: boolean;
  18. }
  19. defineOptions({ name: 'SubMenu' });
  20. const props = withDefaults(defineProps<Props>(), {
  21. disabled: false,
  22. isSubMenuMore: false,
  23. });
  24. const { parentMenu, parentPaths } = useMenu();
  25. const { b, is } = useNamespace('sub-menu');
  26. const nsMenu = useNamespace('menu');
  27. const rootMenu = useMenuContext();
  28. const subMenu = useSubMenuContext();
  29. const subMenuStyle = useMenuStyle(subMenu);
  30. const mouseInChild = ref(false);
  31. const items = ref<MenuProvider['items']>({});
  32. const subMenus = ref<MenuProvider['subMenus']>({});
  33. const timer = ref<null | ReturnType<typeof setTimeout>>(null);
  34. createSubMenuContext({
  35. addSubMenu,
  36. handleMouseleave,
  37. level: (subMenu?.level ?? 0) + 1,
  38. mouseInChild,
  39. removeSubMenu,
  40. });
  41. const opened = computed(() => {
  42. return rootMenu?.openedMenus.includes(props.path);
  43. });
  44. const isTopLevelMenuSubmenu = computed(
  45. () => parentMenu.value?.type.name === 'Menu',
  46. );
  47. const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
  48. const rounded = computed(() => rootMenu?.props.rounded);
  49. const currentLevel = computed(() => subMenu?.level ?? 0);
  50. const isFirstLevel = computed(() => {
  51. return currentLevel.value === 1;
  52. });
  53. const contentProps = computed((): HoverCardContentProps => {
  54. const isHorizontal = mode.value === 'horizontal';
  55. const side = isHorizontal && isFirstLevel.value ? 'bottom' : 'right';
  56. return {
  57. collisionPadding: { top: 20 },
  58. side,
  59. sideOffset: isHorizontal ? 5 : 10,
  60. };
  61. });
  62. const active = computed(() => {
  63. let isActive = false;
  64. Object.values(items.value).forEach((item) => {
  65. if (item.active) {
  66. isActive = true;
  67. }
  68. });
  69. Object.values(subMenus.value).forEach((subItem) => {
  70. if (subItem.active) {
  71. isActive = true;
  72. }
  73. });
  74. return isActive;
  75. });
  76. function addSubMenu(subMenu: MenuItemRegistered) {
  77. subMenus.value[subMenu.path] = subMenu;
  78. }
  79. function removeSubMenu(subMenu: MenuItemRegistered) {
  80. Reflect.deleteProperty(subMenus.value, subMenu.path);
  81. }
  82. /**
  83. * 点击submenu展开/关闭
  84. */
  85. function handleClick() {
  86. const mode = rootMenu?.props.mode;
  87. if (
  88. // 当前菜单禁用时,不展开
  89. props.disabled ||
  90. (rootMenu?.props.collapse && mode === 'vertical') ||
  91. // 水平模式下不展开
  92. mode === 'horizontal'
  93. ) {
  94. return;
  95. }
  96. rootMenu?.handleSubMenuClick({
  97. active: active.value,
  98. parentPaths: parentPaths.value,
  99. path: props.path,
  100. });
  101. }
  102. function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
  103. if (event.type === 'focus') {
  104. return;
  105. }
  106. if (
  107. (!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
  108. props.disabled
  109. ) {
  110. if (subMenu) {
  111. subMenu.mouseInChild.value = true;
  112. }
  113. return;
  114. }
  115. if (subMenu) {
  116. subMenu.mouseInChild.value = true;
  117. }
  118. timer.value && window.clearTimeout(timer.value);
  119. timer.value = setTimeout(() => {
  120. rootMenu?.openMenu(props.path, parentPaths.value);
  121. }, showTimeout);
  122. parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
  123. }
  124. function handleMouseleave(deepDispatch = false) {
  125. if (
  126. !rootMenu?.props.collapse &&
  127. rootMenu?.props.mode === 'vertical' &&
  128. subMenu
  129. ) {
  130. subMenu.mouseInChild.value = false;
  131. return;
  132. }
  133. timer.value && window.clearTimeout(timer.value);
  134. if (subMenu) {
  135. subMenu.mouseInChild.value = false;
  136. }
  137. timer.value = setTimeout(() => {
  138. !mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
  139. }, 300);
  140. if (deepDispatch) {
  141. subMenu?.handleMouseleave?.(true);
  142. }
  143. }
  144. const menuIcon = computed(() =>
  145. active.value ? props.activeIcon || props.icon : props.icon,
  146. );
  147. const item = reactive({
  148. active,
  149. parentPaths,
  150. path: props.path,
  151. });
  152. onMounted(() => {
  153. subMenu?.addSubMenu?.(item);
  154. rootMenu?.addSubMenu?.(item);
  155. });
  156. onBeforeUnmount(() => {
  157. subMenu?.removeSubMenu?.(item);
  158. rootMenu?.removeSubMenu?.(item);
  159. });
  160. </script>
  161. <template>
  162. <li
  163. :class="[
  164. b(),
  165. is('opened', opened),
  166. is('active', active),
  167. is('disabled', disabled),
  168. ]"
  169. @focus="handleMouseenter"
  170. @mouseenter="handleMouseenter"
  171. @mouseleave="() => handleMouseleave()"
  172. >
  173. <template v-if="rootMenu.isMenuPopup">
  174. <VbenHoverCard
  175. :content-class="[
  176. rootMenu.theme,
  177. nsMenu.e('popup-container'),
  178. is(rootMenu.theme, true),
  179. opened ? '' : 'hidden',
  180. 'overflow-auto',
  181. 'max-h-[calc(var(--radix-hover-card-content-available-height)-20px)]',
  182. ]"
  183. :content-props="contentProps"
  184. :open="true"
  185. :open-delay="0"
  186. >
  187. <template #trigger>
  188. <SubMenuContent
  189. :class="is('active', active)"
  190. :icon="menuIcon"
  191. :is-menu-more="isSubMenuMore"
  192. :is-top-level-menu-submenu="isTopLevelMenuSubmenu"
  193. :level="currentLevel"
  194. :path="path"
  195. @click.stop="handleClick"
  196. >
  197. <template #title>
  198. <slot name="title"></slot>
  199. </template>
  200. </SubMenuContent>
  201. </template>
  202. <div
  203. :class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
  204. @focus="(e) => handleMouseenter(e, 100)"
  205. @mouseenter="(e) => handleMouseenter(e, 100)"
  206. @mouseleave="() => handleMouseleave(true)"
  207. >
  208. <ul
  209. :class="[nsMenu.b(), is('rounded', rounded)]"
  210. :style="subMenuStyle"
  211. >
  212. <slot></slot>
  213. </ul>
  214. </div>
  215. </VbenHoverCard>
  216. </template>
  217. <template v-else>
  218. <SubMenuContent
  219. :class="is('active', active)"
  220. :icon="menuIcon"
  221. :is-menu-more="isSubMenuMore"
  222. :is-top-level-menu-submenu="isTopLevelMenuSubmenu"
  223. :level="currentLevel"
  224. :path="path"
  225. @click.stop="handleClick"
  226. >
  227. <slot name="content"></slot>
  228. <template #title>
  229. <slot name="title"></slot>
  230. </template>
  231. </SubMenuContent>
  232. <CollapseTransition>
  233. <ul
  234. v-show="opened"
  235. :class="[nsMenu.b(), is('rounded', rounded)]"
  236. :style="subMenuStyle"
  237. >
  238. <slot></slot>
  239. </ul>
  240. </CollapseTransition>
  241. </template>
  242. </li>
  243. </template>