menu.vue 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <script lang="ts" setup>
  2. import type {
  3. MenuItemClicked,
  4. MenuItemRegistered,
  5. MenuProps,
  6. MenuProvider,
  7. } from '../interface';
  8. import {
  9. type VNodeArrayChildren,
  10. computed,
  11. nextTick,
  12. reactive,
  13. ref,
  14. toRef,
  15. useSlots,
  16. watch,
  17. watchEffect,
  18. } from 'vue';
  19. import { IcRoundMoreHoriz } from '@vben-core/iconify';
  20. import { isHttpUrl, useNamespace } from '@vben-core/toolkit';
  21. import { UseResizeObserverReturn, useResizeObserver } from '@vueuse/core';
  22. import {
  23. createMenuContext,
  24. createSubMenuContext,
  25. useMenuStyle,
  26. } from '../hooks';
  27. import { flattedChildren } from '../utils';
  28. import SubMenu from './sub-menu.vue';
  29. interface Props extends MenuProps {}
  30. defineOptions({ name: 'Menu' });
  31. const props = withDefaults(defineProps<Props>(), {
  32. accordion: true,
  33. collapse: false,
  34. mode: 'vertical',
  35. rounded: true,
  36. theme: 'dark',
  37. });
  38. const emit = defineEmits<{
  39. close: [string, string[]];
  40. open: [string, string[]];
  41. select: [string, string[]];
  42. }>();
  43. const { b, is } = useNamespace('menu');
  44. const menuStyle = useMenuStyle();
  45. const slots = useSlots();
  46. const menu = ref<HTMLUListElement>();
  47. const sliceIndex = ref(-1);
  48. const openedMenus = ref<MenuProvider['openedMenus']>(
  49. props.defaultOpeneds && !props.collapse ? [...props.defaultOpeneds] : [],
  50. );
  51. const activePath = ref<MenuProvider['activePath']>(props.defaultActive);
  52. const items = ref<MenuProvider['items']>({});
  53. const subMenus = ref<MenuProvider['subMenus']>({});
  54. const mouseInChild = ref(false);
  55. const defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
  56. const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
  57. return (
  58. props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
  59. );
  60. });
  61. const getSlot = computed(() => {
  62. const originalSlot = flattedChildren(defaultSlots) as VNodeArrayChildren;
  63. const slotDefault =
  64. sliceIndex.value === -1
  65. ? originalSlot
  66. : originalSlot.slice(0, sliceIndex.value);
  67. const slotMore =
  68. sliceIndex.value === -1 ? [] : originalSlot.slice(sliceIndex.value);
  69. return { showSlotMore: slotMore.length > 0, slotDefault, slotMore };
  70. });
  71. watch(
  72. () => props.collapse,
  73. (value) => {
  74. if (value) openedMenus.value = [];
  75. },
  76. );
  77. watch(items.value, initMenu);
  78. watch(
  79. () => props.defaultActive,
  80. (currentActive = '') => {
  81. if (!items.value[currentActive]) {
  82. activePath.value = '';
  83. }
  84. updateActiveName(currentActive);
  85. },
  86. );
  87. let resizeStopper: UseResizeObserverReturn['stop'];
  88. watchEffect(() => {
  89. if (props.mode === 'horizontal') {
  90. resizeStopper = useResizeObserver(menu, handleResize).stop;
  91. } else {
  92. resizeStopper?.();
  93. }
  94. });
  95. // 注入上下文
  96. createMenuContext(
  97. reactive({
  98. activePath,
  99. addMenuItem,
  100. addSubMenu,
  101. closeMenu,
  102. handleMenuItemClick,
  103. handleSubMenuClick,
  104. isMenuPopup,
  105. openMenu,
  106. openedMenus,
  107. props,
  108. removeMenuItem,
  109. removeSubMenu,
  110. subMenus,
  111. theme: toRef(props, 'theme'),
  112. items,
  113. }),
  114. );
  115. createSubMenuContext({
  116. addSubMenu,
  117. level: 1,
  118. mouseInChild,
  119. removeSubMenu,
  120. });
  121. function calcMenuItemWidth(menuItem: HTMLElement) {
  122. const computedStyle = getComputedStyle(menuItem);
  123. const marginLeft = Number.parseInt(computedStyle.marginLeft, 10);
  124. const marginRight = Number.parseInt(computedStyle.marginRight, 10);
  125. return menuItem.offsetWidth + marginLeft + marginRight || 0;
  126. }
  127. function calcSliceIndex() {
  128. if (!menu.value) {
  129. return -1;
  130. }
  131. const items = [...(menu.value?.childNodes ?? [])].filter(
  132. (item) =>
  133. // remove comment type node #12634
  134. item.nodeName !== '#comment' &&
  135. (item.nodeName !== '#text' || item.nodeValue),
  136. ) as HTMLElement[];
  137. const moreItemWidth = 46;
  138. const computedMenuStyle = getComputedStyle(menu?.value);
  139. const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10);
  140. const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10);
  141. const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight;
  142. let calcWidth = 0;
  143. let sliceIndex = 0;
  144. items.forEach((item, index) => {
  145. calcWidth += calcMenuItemWidth(item);
  146. if (calcWidth <= menuWidth - moreItemWidth) {
  147. sliceIndex = index + 1;
  148. }
  149. });
  150. return sliceIndex === items.length ? -1 : sliceIndex;
  151. }
  152. function debounce(fn: () => void, wait = 33.34) {
  153. let timer: ReturnType<typeof setTimeout> | null;
  154. return () => {
  155. timer && clearTimeout(timer);
  156. timer = setTimeout(() => {
  157. fn();
  158. }, wait);
  159. };
  160. }
  161. let isFirstTimeRender = true;
  162. function handleResize() {
  163. if (sliceIndex.value === calcSliceIndex()) {
  164. return;
  165. }
  166. const callback = () => {
  167. sliceIndex.value = -1;
  168. nextTick(() => {
  169. sliceIndex.value = calcSliceIndex();
  170. });
  171. };
  172. callback();
  173. // // execute callback directly when first time resize to avoid shaking
  174. isFirstTimeRender ? callback() : debounce(callback)();
  175. isFirstTimeRender = false;
  176. }
  177. function getActivePaths() {
  178. const activeItem = activePath.value && items.value[activePath.value];
  179. if (!activeItem || props.mode === 'horizontal' || props.collapse) {
  180. return [];
  181. }
  182. return activeItem.parentPaths;
  183. }
  184. // 默认展开菜单
  185. function initMenu() {
  186. const parentPaths = getActivePaths();
  187. // 展开该菜单项的路径上所有子菜单
  188. // expand all subMenus of the menu item
  189. parentPaths.forEach((path) => {
  190. const subMenu = subMenus.value[path];
  191. subMenu && openMenu(path, subMenu.parentPaths);
  192. });
  193. }
  194. function updateActiveName(val: string) {
  195. const itemsInData = items.value;
  196. const item =
  197. itemsInData[val] ||
  198. (activePath.value && itemsInData[activePath.value]) ||
  199. itemsInData[props.defaultActive || ''];
  200. activePath.value = item ? item.path : val;
  201. }
  202. function handleMenuItemClick(data: MenuItemClicked) {
  203. const { collapse, mode } = props;
  204. if (mode === 'horizontal' || collapse) {
  205. openedMenus.value = [];
  206. }
  207. const { parentPaths, path } = data;
  208. if (!path || !parentPaths) {
  209. return;
  210. }
  211. if (!isHttpUrl(path)) {
  212. activePath.value = path;
  213. }
  214. emit('select', path, parentPaths);
  215. }
  216. function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
  217. const isOpened = openedMenus.value.includes(path);
  218. if (isOpened) {
  219. closeMenu(path, parentPaths);
  220. } else {
  221. openMenu(path, parentPaths);
  222. }
  223. }
  224. function close(path: string) {
  225. const i = openedMenus.value.indexOf(path);
  226. if (i !== -1) {
  227. openedMenus.value.splice(i, 1);
  228. }
  229. }
  230. /**
  231. * 关闭、折叠菜单
  232. */
  233. function closeMenu(path: string, parentPaths: string[]) {
  234. if (props.accordion) {
  235. openedMenus.value = subMenus.value[path]?.parentPaths;
  236. }
  237. close(path);
  238. emit('close', path, parentPaths);
  239. }
  240. /**
  241. * 点击展开菜单
  242. */
  243. function openMenu(path: string, parentPaths: string[]) {
  244. if (openedMenus.value.includes(path)) {
  245. return;
  246. }
  247. // 手风琴模式菜单
  248. if (props.accordion) {
  249. const activeParentPaths = getActivePaths();
  250. if (activeParentPaths.includes(path)) {
  251. parentPaths = activeParentPaths;
  252. }
  253. openedMenus.value = openedMenus.value.filter((path: string) =>
  254. parentPaths.includes(path),
  255. );
  256. }
  257. openedMenus.value.push(path);
  258. emit('open', path, parentPaths);
  259. }
  260. function addMenuItem(item: MenuItemRegistered) {
  261. items.value[item.path] = item;
  262. }
  263. function addSubMenu(subMenu: MenuItemRegistered) {
  264. subMenus.value[subMenu.path] = subMenu;
  265. }
  266. function removeSubMenu(subMenu: MenuItemRegistered) {
  267. Reflect.deleteProperty(subMenus.value, subMenu.path);
  268. }
  269. function removeMenuItem(item: MenuItemRegistered) {
  270. Reflect.deleteProperty(items.value, item.path);
  271. }
  272. </script>
  273. <template>
  274. <ul
  275. ref="menu"
  276. :class="[
  277. b(),
  278. is(mode, true),
  279. is(theme, true),
  280. is('rounded', rounded),
  281. is('collapse', collapse),
  282. ]"
  283. :style="menuStyle"
  284. role="menu"
  285. >
  286. <template v-if="mode === 'horizontal' && getSlot.showSlotMore">
  287. <template v-for="item in getSlot.slotDefault" :key="item.key">
  288. <component :is="item" />
  289. </template>
  290. <SubMenu is-sub-menu-more path="sub-menu-more">
  291. <template #title>
  292. <IcRoundMoreHoriz />
  293. </template>
  294. <template v-for="item in getSlot.slotMore" :key="item.key">
  295. <component :is="item" />
  296. </template>
  297. </SubMenu>
  298. </template>
  299. <template v-else>
  300. <slot></slot>
  301. </template>
  302. </ul>
  303. </template>